diff --git a/.claude/commands/check-ci.md b/.claude/commands/check-ci.md index ba785ccca..62b628140 100644 --- a/.claude/commands/check-ci.md +++ b/.claude/commands/check-ci.md @@ -1,7 +1,7 @@ Run all CI checks locally, in order. Stop and report if any step fails. 1. `cargo fmt --all -- --check` -2. `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare` +2. `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && scripts/lint-wasm-clock.sh` 3. `cargo test-fastly && cargo test-axum && cargo test-cloudflare` 4. `cargo check-cloudflare` (wasm32-unknown-unknown target check, mirrors CI) 5. `cd crates/trusted-server-js/lib && npx vitest run` diff --git a/.claude/skills/deploying-trusted-server-to-fastly/SKILL.md b/.claude/skills/deploying-trusted-server-to-fastly/SKILL.md index 08609e192..681952b32 100644 --- a/.claude/skills/deploying-trusted-server-to-fastly/SKILL.md +++ b/.claude/skills/deploying-trusted-server-to-fastly/SKILL.md @@ -40,9 +40,18 @@ accounts and harmless for single-service ones, so prefer it by default. - **Always `ts config push --dry-run` first.** It prints the exact target physical store; confirm it is the service-scoped one, not a shared default, before writing. -- **Always pass `--service-id=`** to `fastly compute publish`. A checked-in - `fastly.toml` may pin a different (or dead) `service_id`; without the flag, - publish targets that one. +- **Never deploy with `fastly compute publish`.** This app declares Config, KV, + and Secret Stores, and only EdgeZero's managed deploy links each selected + physical store to the service version under its logical ID. Publishing with + the Fastly CLI clones the active version's links, so the new version is + missing the links the runtime opens: app config fails to load and every + publisher route returns 500 while `/health` still answers 200. Deploy with + `ts deploy --adapter fastly --service-id= --application-release `, + with `EDGEZERO_MANIFEST` set to the release's `edgezero.toml`, or EdgeZero's + `deploy-fastly` action. +- **Always pass `--service-id=`** to any deploy or publish command. A + checked-in `fastly.toml` may pin a different (or dead) `service_id`; without + the flag, the command targets that one. - **`ts config push` only upserts entries**; the target store must already exist (`fastly config-store create`) before pushing. - **Verify a shared store stayed untouched** by recording its `updated` diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index 841d8d5bd..c57a9a58b 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -34,16 +34,23 @@ outputs: runs: using: composite steps: - - name: Retrieve Rust version - id: rust-version - shell: bash - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" - - name: Retrieve Node.js version id: node-version shell: bash run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" + # Pin Node before anything compiles trusted-server-core: its dependency on + # trusted-server-js runs the TSJS build with the `npm` on PATH. That covers + # the Rust builds below and the callers' later cargo steps, and callers use + # this Node directly too (e.g. `npm install -g wrangler`), so keep the step + # unconditional. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json + - name: Retrieve Viceroy version id: viceroy-version if: ${{ inputs.install-viceroy == 'true' }} @@ -52,11 +59,15 @@ runs: run: echo "viceroy-version=$(grep '^viceroy ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" - name: Set up Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: ./.github/actions/setup-rust with: - toolchain: ${{ steps.rust-version.outputs.rust-version }} target: wasm32-wasip1 - cache-shared-key: cargo-${{ runner.os }} + # Restore the axum job's main cache: it builds the same axum debug and + # Fastly release WASM artifacts. Restore only: test-axum owns this key, + # and rust-cache never replaces an exact-match key, so a partial cache + # saved by a workflow_dispatch run on main would stick. + cache-key: cargo-axum + save-cache: "false" - name: Cache Viceroy binary if: ${{ inputs.install-viceroy == 'true' }} @@ -96,8 +107,11 @@ runs: - name: Build WordPress test container if: ${{ inputs.build-test-images == 'true' }} shell: bash + # Docker's official ECR mirror avoids Docker Hub anonymous pull limits. run: | - docker build -t test-wordpress:latest \ + docker build \ + --build-arg DOCKER_LIBRARY=public.ecr.aws/docker/library \ + -t test-wordpress:latest \ crates/trusted-server-integration-tests/fixtures/frameworks/wordpress/ - name: Build Next.js test container @@ -106,6 +120,7 @@ runs: run: | docker build \ --build-arg NODE_VERSION=${{ steps.node-version.outputs.node-version }} \ + --build-arg DOCKER_LIBRARY=public.ecr.aws/docker/library \ -t test-nextjs:latest \ crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/ diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 000000000..60c12f482 --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -0,0 +1,45 @@ +name: Set up pinned Rust +description: Install the .tool-versions Rust toolchain with a per-job cache that only main saves. + +inputs: + cache-key: + description: Per-job rust-cache key prefix; the runner OS is appended. + required: true + target: + description: Comma-separated extra targets. + required: false + default: "" + components: + description: Comma-separated extra components. + required: false + default: "" + save-cache: + description: Set to "false" for restore-only callers. + required: false + default: "true" + +runs: + using: composite + steps: + - name: Retrieve Rust version + id: rust-version + shell: bash + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Remove runner-image Rust toolchain + # rust-cache hashes every installed toolchain into its key, so the image's + # preinstalled stable would rotate the key on each runner-image rollout. + # Unsetting the default stops rustup from silently reinstalling stable + # when cargo runs outside the checkout. + shell: bash + run: if command -v rustup >/dev/null; then rustup toolchain uninstall stable && rustup default none; fi + + - name: Set up Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + target: ${{ inputs.target }} + components: ${{ inputs.components }} + cache-shared-key: ${{ inputs.cache-key }}-${{ runner.os }} + # PR caches are scoped to their ref and only evict main's shared caches. + cache-save-if: ${{ inputs.save-cache == 'true' && github.ref == 'refs/heads/main' }} diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 3fa0da9b6..99d73b0dd 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -32,12 +32,12 @@ jobs: id: node-version run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + # No dependency cache: the docs-proposal workflow runs generated content + # on the main ref, where it could save a poisoned entry under this key. - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ steps.node-version.outputs.node-version }} - cache: "npm" - cache-dependency-path: docs/package-lock.json - name: Setup Pages uses: actions/configure-pages@v4 diff --git a/.github/workflows/docs-proposal.yml b/.github/workflows/docs-proposal.yml new file mode 100644 index 000000000..4d8f4aecc --- /dev/null +++ b/.github/workflows/docs-proposal.yml @@ -0,0 +1,187 @@ +name: Documentation proposal + +# Proposes documentation edits with Copilot CLI after code merges to main and +# opens one reviewable pull request per merge. See +# docs/superpowers/specs/2026-10-01-docs-proposal-automation-design.md. +on: + push: + branches: [main] + paths: + - "crates/**" + - "trusted-server.example.toml" + - "edgezero.toml" + - "fastly.toml" + - ".cargo/config.toml" + - "scripts/**" + - "!scripts/docs-proposal/**" + workflow_dispatch: + inputs: + sha: + description: Full SHA of a commit on main to propose documentation for + required: true + type: string + base: + description: >- + Full SHA of main before the push that added the commit, when that + push added several commits; defaults to the commit's first parent + required: false + type: string + +permissions: {} + +# Sequential merges are proposed one at a time, in order. The default queue +# keeps only one pending run and cancels older ones, so keep them all. Every +# job has a timeout so a hung run cannot hold the queue for hours. +concurrency: + group: docs-proposal + cancel-in-progress: false + queue: max + +# Each job runs the proposal scripts from the workflow's own revision in +# `tools/` against a checkout of the merge commit in `source/`, so retrying a +# merge that predates this tooling, or a later fix to it, still works. A push +# that adds several commits is inspected from the previous main head; a manual +# retry of such a push needs that head as its `base` input. +env: + MERGE_SHA: ${{ inputs.sha || github.sha }} + BASE_SHA: ${{ github.event_name == 'push' && github.event.before || inputs.base }} + +jobs: + # Copilot runs here with a read-only token; it cannot push or open PRs. + # Copilot can write files that later steps execute, so treat this job as + # untrusted: it uses no dependency cache that later runs would restore. + propose: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + copilot-requests: write + steps: + - name: Checkout tools + uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + path: tools + persist-credentials: false + + - name: Checkout merge commit + uses: actions/checkout@v4 + with: + ref: ${{ env.MERGE_SHA }} + path: source + fetch-depth: 0 + persist-credentials: false + + - name: Retrieve Node.js version + id: node-version + run: echo "node-version=$(grep '^nodejs ' tools/.tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + + - name: Install Copilot CLI + run: npm install -g @github/copilot@1.0.90 + + - name: Propose documentation edits + working-directory: source + env: + GITHUB_TOKEN: ${{ github.token }} + run: ../tools/scripts/docs-proposal/propose.sh "$MERGE_SHA" .docs-proposal "$BASE_SHA" + + - name: Upload proposal + uses: actions/upload-artifact@v4 + with: + name: docs-proposal + path: source/.docs-proposal/ + include-hidden-files: true + if-no-files-found: error + + # VitePress executes Vue in Markdown during the build, so the docs gates run + # here with no write token, persisted credentials, or dependency cache. + validate: + needs: propose + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout tools + uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + path: tools + persist-credentials: false + + - name: Checkout merge commit + uses: actions/checkout@v4 + with: + ref: ${{ env.MERGE_SHA }} + path: source + fetch-depth: 0 + persist-credentials: false + + - name: Retrieve Node.js version + id: node-version + run: echo "node-version=$(grep '^nodejs ' tools/.tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + + - name: Download proposal + uses: actions/download-artifact@v4 + with: + name: docs-proposal + path: source/.docs-proposal + + - name: Validate proposal + working-directory: source + run: ../tools/scripts/docs-proposal/validate.sh "$MERGE_SHA" .docs-proposal + + # Copilot does not run here, and neither does any proposal content: this job + # re-checks the path allowlist on a fresh checkout and publishes the patch. + publish: + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout tools + uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + path: tools + persist-credentials: false + + - name: Checkout merge commit + uses: actions/checkout@v4 + with: + ref: ${{ env.MERGE_SHA }} + path: source + fetch-depth: 0 + + - name: Retrieve Node.js version + id: node-version + run: echo "node-version=$(grep '^nodejs ' tools/.tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + + - name: Download proposal + uses: actions/download-artifact@v4 + with: + name: docs-proposal + path: source/.docs-proposal + + - name: Publish proposal pull request + working-directory: source + env: + GH_TOKEN: ${{ github.token }} + run: ../tools/scripts/docs-proposal/publish.sh "$MERGE_SHA" .docs-proposal diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 6a8c7b08b..1dc0aedc2 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -39,24 +39,38 @@ jobs: exit 1 fi + check-ci-node-pin: + name: CI Node pin guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Every job that compiles trusted-server-core also runs the TSJS build, so + # it must set up the `.tool-versions` Node first (see issue #1204). + - name: Verify jobs pin Node before compiling Rust + run: python3 scripts/check-ci-node-pin.py + format-rust: name: cargo fmt runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Retrieve Rust version - id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT - shell: bash - - name: Set up rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: ./.github/actions/setup-rust with: - toolchain: ${{ steps.rust-version.outputs.rust-version }} target: wasm32-wasip1,wasm32-unknown-unknown components: "clippy, rustfmt" - cache-shared-key: cargo-${{ runner.os }} + cache-key: cargo-clippy + + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json - name: Run cargo fmt uses: actions-rust-lang/rustfmt@v1 @@ -73,6 +87,9 @@ jobs: - name: Run cargo clippy (Cloudflare — wasm32-unknown-unknown) run: cargo clippy-cloudflare-wasm + - name: Check wasm-safe clock usage in the Cloudflare build (wasm32-unknown-unknown) + run: scripts/lint-wasm-clock.sh + - name: Run cargo clippy (Spin — native) run: cargo clippy-spin-native @@ -162,3 +179,28 @@ jobs: - name: Build with VitePress (fails on dead links) run: npm run build + + docs-proposal-scripts: + name: docs-proposal scripts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Node.js version + id: node-version + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + + - name: Run shellcheck + run: shellcheck scripts/docs-proposal/*.sh + + - name: Test hunk review builder + run: node --test scripts/docs-proposal/hunks.test.mjs + + - name: Test proposal scripts + run: scripts/docs-proposal/test.sh diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 157a3582d..d640f7f59 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,4 +1,4 @@ -name: "Integration Tests" +name: 'Integration Tests' permissions: contents: read @@ -30,11 +30,14 @@ jobs: uses: ./.github/actions/setup-integration-test-env with: origin-port: ${{ env.ORIGIN_PORT }} - install-viceroy: "false" - build-cloudflare: "true" + install-viceroy: 'false' + build-cloudflare: 'true' - name: Generate integration Viceroy configs - run: ./scripts/generate-integration-viceroy-configs.sh + run: | + ./scripts/generate-integration-viceroy-configs.sh + INTEGRATION_APP_CONFIG_PATH=crates/trusted-server-integration-tests/fixtures/configs/trusted-server.trace.toml INTEGRATION_BIDDER_ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" ARTIFACTS_DIR="$ARTIFACTS_DIR/trace" ./scripts/generate-integration-viceroy-configs.sh + INTEGRATION_APP_CONFIG_PATH=crates/trusted-server-integration-tests/fixtures/configs/trusted-server.trace-auth.toml ARTIFACTS_DIR="$ARTIFACTS_DIR/trace-auth" ./scripts/generate-integration-viceroy-configs.sh env: INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} @@ -68,10 +71,10 @@ jobs: 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" + install-viceroy: 'true' + build-wasm: 'false' + build-axum: 'false' + build-test-images: 'false' - name: Download integration test artifacts uses: actions/download-artifact@v4 @@ -90,11 +93,6 @@ jobs: - name: Load integration test Docker images run: docker load --input "$DOCKER_ARTIFACT_PATH" - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ steps.shared-setup.outputs.node-version }} - - name: Install wrangler run: npm install -g wrangler @@ -105,6 +103,7 @@ jobs: --target x86_64-unknown-linux-gnu -- --include-ignored --skip test_wordpress_fastly --skip test_nextjs_fastly + --skip trace_browser_workflow --skip trace_runtime_boundary --test-threads=1 env: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} @@ -127,9 +126,9 @@ jobs: uses: ./.github/actions/setup-integration-test-env with: origin-port: ${{ env.ORIGIN_PORT }} - install-viceroy: "true" - build-wasm: "false" - build-test-images: "false" + install-viceroy: 'true' + build-wasm: 'false' + build-test-images: 'false' - name: Download integration test artifacts uses: actions/download-artifact@v4 @@ -152,6 +151,84 @@ jobs: VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml RUST_LOG: info + trace-runtime-tests: + name: trace runtime and browser acceptance + needs: prepare-artifacts + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Set up trace 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' + + - name: Download integration test artifacts + uses: actions/download-artifact@v4 + with: + name: integration-test-artifacts + path: ${{ env.ARTIFACTS_DIR }} + + - name: Restore runtime binaries and framework images + run: | + chmod +x "$AXUM_ARTIFACT_PATH" + mkdir -p crates/trusted-server-adapter-cloudflare/build + cp -r "$CF_BUILD_ARTIFACT_PATH/." crates/trusted-server-adapter-cloudflare/build/ + docker load --input "$DOCKER_ARTIFACT_PATH" + + - 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: Install browser dependencies + run: | + npm ci --prefix crates/trusted-server-js/lib + npm ci --prefix crates/trusted-server-integration-tests/browser + cd crates/trusted-server-integration-tests/browser + npx playwright install --with-deps chromium + npm install -g wrangler@4.83.0 + + - name: Install the verified Spin runtime + run: | + trace_spin_dir="$RUNNER_TEMP/trace-spin" + mkdir -p "$trace_spin_dir" + curl --fail --location --silent --show-error https://github.com/spinframework/spin/releases/download/v4.0.0/spin-v4.0.0-linux-amd64.tar.gz --output "$trace_spin_dir/spin.tar.gz" + echo "e705c9bfd9484a9175f392a116856680862a40f31d1a85618bed331f34ffccfa $trace_spin_dir/spin.tar.gz" | sha256sum --check --strict + tar -xzf "$trace_spin_dir/spin.tar.gz" -C "$trace_spin_dir" spin + echo "$trace_spin_dir" >> "$GITHUB_PATH" + + - name: Build the production Spin component + run: cargo build --package trusted-server-adapter-spin --target wasm32-wasip1 --features spin --release + + - name: Run trace acceptance against prepared runtimes + run: | + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --target x86_64-unknown-linux-gnu --test integration trace_runtime_boundary -- --ignored --test-threads=1 --nocapture + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --target x86_64-unknown-linux-gnu --test integration trace_browser_workflow -- --ignored --test-threads=1 --nocapture + env: + WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} + AXUM_BINARY_PATH: ${{ env.AXUM_ARTIFACT_PATH }} + CLOUDFLARE_WRANGLER_DIR: ${{ github.workspace }}/crates/trusted-server-adapter-cloudflare + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + + - name: Upload trace browser evidence + uses: actions/upload-artifact@v4 + if: always() + with: + name: trace-runtime-browser-evidence + path: crates/trusted-server-integration-tests/browser/test-results/trace-runtime-*/ + retention-days: 7 + browser-tests: name: browser integration tests needs: prepare-artifacts @@ -165,9 +242,9 @@ jobs: uses: ./.github/actions/setup-integration-test-env with: origin-port: ${{ env.ORIGIN_PORT }} - install-viceroy: "true" - build-wasm: "false" - build-test-images: "false" + install-viceroy: 'true' + build-wasm: 'false' + build-test-images: 'false' - name: Download integration test artifacts uses: actions/download-artifact@v4 @@ -204,6 +281,10 @@ jobs: working-directory: crates/trusted-server-integration-tests/browser run: node --test initial-render/pages.test.cjs + - name: Test trace fixture cleanup + working-directory: crates/trusted-server-integration-tests/browser + run: node --test trace-fixture.test.cjs + - name: Test initial render ownership working-directory: crates/trusted-server-integration-tests/browser # Let the runner build its Rubicon + Shared ID artifact; the bidder-only @@ -225,6 +306,8 @@ jobs: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml + TRACE_VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/trace/configs/viceroy.toml + TRACE_AUTH_VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/trace-auth/configs/viceroy.toml TEST_FRAMEWORK: nextjs PLAYWRIGHT_HTML_REPORT: playwright-report-nextjs run: npx playwright test @@ -244,6 +327,8 @@ jobs: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml + TRACE_VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/trace/configs/viceroy.toml + TRACE_AUTH_VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/trace-auth/configs/viceroy.toml TEST_FRAMEWORK: wordpress PLAYWRIGHT_HTML_REPORT: playwright-report-wordpress run: npx playwright test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 95654ecdd..106d99222 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,11 +16,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Retrieve Rust version - id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT - shell: bash - - name: Retrieve Viceroy version id: viceroy-version # `.tool-versions` is the single source of truth so this workflow and @@ -34,11 +29,10 @@ jobs: shell: bash - name: Set up Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: ./.github/actions/setup-rust with: - toolchain: ${{ steps.rust-version.outputs.rust-version }} target: wasm32-wasip1 - cache-shared-key: cargo-${{ runner.os }} + cache-key: cargo-fastly - name: Cache Viceroy binary id: cache-viceroy @@ -51,7 +45,9 @@ jobs: if: steps.cache-viceroy.outputs.cache-hit != 'true' run: cargo install viceroy --version "${{ steps.viceroy-version.outputs.viceroy-version }}" --locked --force - - name: Use Node.js for the served-seam contract + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build and the served-seam contract uses: actions/setup-node@v4 with: node-version: ${{ steps.node-version.outputs.node-version }} @@ -59,6 +55,11 @@ jobs: - name: Run tests run: cargo test-fastly + - name: Run trace emitted-script regressions + run: | + cargo test -p trusted-server-core --target x86_64-unknown-linux-gnu --lib trace_document_skipped_emitted_script_leaves_ad_state_untouched -- --ignored + cargo test -p trusted-server-core --target x86_64-unknown-linux-gnu --lib trace_document_emitted_script_deep_freezes_owned_context_in_node -- --ignored + - name: Run tests (reusable-sandbox feature) run: cargo test-fastly-reuse @@ -77,26 +78,23 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Retrieve Rust version - id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT - shell: bash - - name: Set up Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: ./.github/actions/setup-rust with: - toolchain: ${{ steps.rust-version.outputs.rust-version }} # wasm32-wasip1 is required by the "Verify Fastly WASM release # build" step below; the axum build and tests are native. target: wasm32-wasip1 - cache-shared-key: cargo-${{ runner.os }} + cache-key: cargo-axum - - name: Read pinned Node.js version for CLI fixtures + - name: Read pinned Node.js version id: node-version shell: bash run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v4 + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build and the CLI fixtures + uses: actions/setup-node@v4 with: node-version: ${{ steps.node-version.outputs.version }} @@ -131,17 +129,20 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Retrieve Rust version - id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT - shell: bash - - name: Set up Rust toolchain (native + wasm32-unknown-unknown) - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: ./.github/actions/setup-rust with: - toolchain: ${{ steps.rust-version.outputs.rust-version }} target: wasm32-unknown-unknown - cache-shared-key: cargo-${{ runner.os }} + cache-key: cargo-cloudflare + + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json - name: Check Cloudflare adapter (native host) run: cargo check -p trusted-server-adapter-cloudflare @@ -158,17 +159,20 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Retrieve Rust version - id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT - shell: bash - - name: Set up Rust toolchain (native + wasm32-wasip1) - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: ./.github/actions/setup-rust with: - toolchain: ${{ steps.rust-version.outputs.rust-version }} target: wasm32-wasip1 - cache-shared-key: cargo-${{ runner.os }} + cache-key: cargo-spin + + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json - name: Check Spin adapter (native host) run: cargo check -p trusted-server-adapter-spin @@ -197,17 +201,20 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Retrieve Rust version - id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT - shell: bash - - name: Set up Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: ./.github/actions/setup-rust with: - toolchain: ${{ steps.rust-version.outputs.rust-version }} components: clippy, rustfmt - cache-shared-key: cargo-${{ runner.os }} + cache-key: cargo-parity + + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json - name: Format check (parity test crate) run: cargo fmt --manifest-path crates/trusted-server-integration-tests/Cargo.toml -- --check @@ -228,24 +235,21 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Retrieve Rust version - id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT - shell: bash - - name: Set up Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: ./.github/actions/setup-rust with: - toolchain: ${{ steps.rust-version.outputs.rust-version }} components: "clippy, rustfmt" - cache-shared-key: cargo-cli-${{ runner.os }} + cache-key: cargo-cli - - name: Read pinned Node.js version for CLI fixtures + - name: Read pinned Node.js version id: node-version shell: bash run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v4 + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build and the CLI fixtures + uses: actions/setup-node@v4 with: node-version: ${{ steps.node-version.outputs.version }} @@ -292,6 +296,45 @@ jobs: cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$TARGET" \ --test proxy_trust_linux -- --ignored + lint-domains: + # Server-side counterpart to the `ts dev install-hooks` pre-commit hook, + # which `--no-verify` skips and fresh clones never install: fail the PR + # when an added line introduces a non-allowlisted URL host. PR-only, so + # direct pushes to main are not linted. + name: ts dev lint domains (changed lines) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + # A PR checkout is GitHub's merge commit; depth 2 also fetches its + # parents, so `HEAD^1` is the exact base commit the PR merges into. + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Rust toolchain + uses: ./.github/actions/setup-rust + with: + cache-key: cargo-lint-domains + + - name: Read pinned Node.js version + id: node-version + shell: bash + run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.version }} + + # The `ts` CLI is host-target only; the workspace default is wasm32-wasip1. + - name: Lint URL hosts on lines added by this PR + run: | + cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \ + --target "$(rustc -vV | sed -n 's/host: //p')" --bin ts -- \ + dev lint domains --changed-vs HEAD^1 + test-typescript: name: vitest runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 24b9e06aa..d9f19e5e6 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,10 @@ src/*.html # leftover local build artifacts (node_modules, target, dist) that remain on disk. /crates/js/ /crates/integration-tests/ + +# Wrangler config generated by the Cloudflare integration-test harness from +# wrangler.toml at run time; regenerated on every run. +wrangler.integration.generated.toml + +# Documentation proposal workflow scratch space +/.docs-proposal/ diff --git a/AGENTS.md b/AGENTS.md index 2685d164e..8c08e2e35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,8 +58,9 @@ cargo build --package trusted-server-adapter-fastly --release --target wasm32-wa # Run locally with Fastly simulator fastly compute serve -# Deploy to Fastly -fastly compute publish +# Deploy to Fastly (managed path; links logical stores to the version) +EDGEZERO_MANIFEST="/edgezero.toml" \ + ts deploy --adapter fastly --service-id --application-release "" # Run Axum dev server (native — no Viceroy) cargo run -p trusted-server-adapter-axum @@ -362,6 +363,9 @@ Every PR must pass: 7. Docs format (`cd docs && npm run format`) 8. Markdown format outside `docs/` (requires `cd docs && npm ci` first): `docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md"`; fix with `--write` in place of `--check` 9. Native core build-digest test and lint (`cargo test -p trusted-server-core --test template_build_digest` and `cargo clippy -p trusted-server-core --test template_build_digest -- -D warnings`) +10. URL-host linter on lines the PR adds (`lint-domains` job, PRs only); run it locally with `cargo run --manifest-path crates/trusted-server-cli/Cargo.toml --target --bin ts -- dev lint domains --changed-vs origin/main` +11. Documentation proposal scripts: `shellcheck scripts/docs-proposal/*.sh && node --test scripts/docs-proposal/hunks.test.mjs && scripts/docs-proposal/test.sh` +12. Wasm clock lint: `scripts/lint-wasm-clock.sh` (crates built into the Cloudflare Worker must use `web_time`, not the `std::time::{Instant,SystemTime}` `now`/`elapsed` methods, which panic there) --- diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a0eb1ec..a8ac66537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Explicit TS Console activation with `?ts_console=1` now sets a 30-minute diagnostics cookie instead of a browser-session cookie, independently of the mobile trace flag. Ordinary requests do not refresh it; end and explicitly enable diagnostics again to adopt the lifetime for an existing session. +- Publisher documents with diagnostics active now use private, no-store responses and strip conditional and range request headers before fetching the origin. An unexpected origin `304` becomes a private `502` because it cannot supply an instrumented document, including when mobile tracing is disabled. - **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header. - **Breaking:** `ts prebid bundle` is now `ts prebid client`, alongside the new `ts prebid server` namespace. Update scripts and runbooks to use `ts prebid client` with the same arguments. The old `bundle` spelling is no longer accepted and has no compatibility alias. - The S2S `/_ts/api/v1/batch-sync` endpoint now validates the full batch and calls the CAS-protected update path once per distinct normalized EC ID. The last valid UID wins within a group, and infrastructure failures reject the failing and each unprocessed group, so accepted and `kv_unavailable` input indexes may interleave. @@ -28,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Reserve the application-visible `/_ts/trace*` prefix locally on every adapter, including when mobile tracing is disabled. Existing authentication runs first; disabled or unknown trace routes then return a local `404` instead of forwarding to the publisher. +- Stop deriving the public host and scheme from unauthenticated `Forwarded`, `X-Forwarded-Host` and `X-Forwarded-Proto` fields on every adapter, and strip those fields before routing. Fastly and Spin already removed them; Axum and Cloudflare previously let a client-supplied value steer first-party URL rewriting. A deployment behind a TLS-terminating or Host-rewriting proxy, including `ts dev proxy` against Axum, must now configure the opt-in `[trusted_forwarder]` section and send its matching credential to preserve the public origin. The section authenticates one publisher-domain-bounded `X-Forwarded-Host`/`X-Forwarded-Proto` pair with a secret-store-resolved shared secret; without it, first-party URLs use the transport host and scheme. Axum uses `http://` even when the proxy's browser-facing connection uses HTTPS. - `/first-party/sign` now rejects valid targets outside `proxy.allowed_domains` before minting a proxy token. The creative runtime keeps image and iframe assignments blocked after this `403` policy response instead of loading the rejected URL directly; fetch-time checks still cover the initial target and every redirect. - Reserved the complete admin namespace at the publisher-fallback boundary. Percent-encoded separators (`/_ts/admin%2Fec`, `%2f`, and double-encoded forms) matched the `^/_ts/admin` Basic-auth handler but escaped the literal-slash namespace check, so an authenticated request fell through to publisher fallback and forwarded its `Authorization` header and body to the publisher origin. The reservation now spans the whole `/_ts/admin` prefix plus the retired `/admin/keys` aliases — including trailing, descendant, and encoded-separator forms — evaluated on the raw path and on each of its bounded percent-decodings, so multi-encoded separators such as `/admin%252Fkeys/rotate` cannot survive to fallback for a proxy or origin to decode again, and applies to every adapter. - Validate synthetic ID format on inbound values from the `x-synthetic-id` header and `synthetic_id` cookie; values that do not match the expected format (`64-hex-hmac.6-alphanumeric-suffix`) are discarded and a fresh ID is generated rather than forwarded to response headers, cookies, or third-party APIs @@ -35,15 +39,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Image Optimizer configuration now rejects profile and profile-set names within the same map that collide after trimming, even when their values match, instead of silently overwriting an entry. Empty names continue to be discarded. +- Publisher responses now map origin URLs in `Location`, `Content-Location`, `Refresh`, and `Link` headers to the serving host, and each CSP source naming the origin gains a serving-host source beside it (#1128). A redirect to the current URL under a different scheme, or a cookieless `GET`/`HEAD` redirect to the current URL, is left unchanged to avoid a loop. `` refresh and CSP tags in the page body are not rewritten. See "Origin URLs in proxied response headers" in the configuration guide. - Browser integration bundles now share context providers and the configured log level with TSJS core (#1196). With Permutive enabled and cohorts available, `tsjs.requestAds()` now sends `config.permutive_segments` to `/auction`; the server retains that key only when `auction.allowed_context_keys` includes `"permutive_segments"`. This restores a previously inactive data flow, not a consent gate: review publisher consent handling before relying on it. Integration logs now follow `tsjs.setConfig` and `tsjs.log.setLevel`, and creative no longer raises the default `warn` level to `info`. The `?tsdebug=1` query parameter or `localStorage['tsdebug'] = '1'` intentionally enables `debug` logging across all bundles when creative installs, overriding any log level set earlier through `tsjs.setConfig` or `tsjs.log.setLevel`. Later calls to either API can change the level again. +- `rewrite.exclude_domains` matching is now case-insensitive and ignores surrounding whitespace. Entries written with uppercase letters previously never matched and now take effect, so those hosts stop being proxied and click-wrapped, and `/first-party/sign` now rejects them with `502`; it used to sign them, or return `403` when the host was also outside `proxy.allowed_domains`. Empty and bare `"*"` `exclude_domains` entries, which never matched a host, are dropped at load with a warning, and other entries that can never match a host are logged with a warning. Absolute `http(s)` creative URLs that cannot be parsed (for example, a host containing a space) are now left untouched; previously the trimmed value was written back re-quoted (with `https:` prepended to `//` input, so `src=" //exa mple.example/x "` became `https://exa mple.example/x`), an unparseable quoted URL in `
AdExcludedMail"#; + const MATRIX_FIXTURE_FRAGMENT: &str = r#"
AdExcludedMail"#; + const INLINE_ORIGIN: &str = "https://www.example.com"; + + #[derive(Debug, Clone, Copy)] + enum CreativePath { + Auction, + Inline, + } + + fn process_for_path(settings: &Settings, path: CreativePath, html: &str) -> String { + match path { + CreativePath::Auction => process_auction_creative(settings, html), + CreativePath::Inline => { + super::process_inline_auction_creative(settings, INLINE_ORIGIN, html) + } + } + } + + fn matrix_settings(assets: bool, clicks: Option) -> Settings { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.auction.sanitize_creatives = false; + settings.auction.rewrite_creatives = assets; + settings.auction.rewrite_clicks = clicks; + settings.rewrite.exclude_domains = vec!["excluded.example.com".to_owned()]; + settings + } + fn rewrite_srcset_attr(attr_name: &str, attr_value: &str) -> String { let settings = crate::test_support::tests::create_test_settings(); let html = format!(r#""#); @@ -1697,43 +1862,213 @@ mod tests { } #[test] - fn to_abs_conversions() { - let settings = crate::test_support::tests::create_test_settings(); - assert_eq!( - to_abs(&settings, "//cdn.example/x"), - Some("https://cdn.example/x".to_owned()) - ); - assert_eq!( - to_abs(&settings, "HTTPS://cdn.example/x"), - Some("HTTPS://cdn.example/x".to_owned()) - ); - assert_eq!( - to_abs(&settings, "http://cdn.example/x"), - Some("http://cdn.example/x".to_owned()) - ); - assert_eq!(to_abs(&settings, "/local/x"), None); - assert_eq!( - to_abs(&settings, " //cdn.example/y "), - Some("https://cdn.example/y".to_owned()) - ); - assert_eq!(to_abs(&settings, "data:image/png;base64,abcd"), None); - assert_eq!(to_abs(&settings, "javascript:alert(1)"), None); - assert_eq!(to_abs(&settings, "mailto:test@example.com"), None); + fn asset_and_click_switches_combine_on_auction_and_inline_paths() { + for path in [CreativePath::Auction, CreativePath::Inline] { + for (fixture, has_body) in [ + (MATRIX_FIXTURE_WITH_BODY, true), + (MATRIX_FIXTURE_FRAGMENT, false), + ] { + for (assets, clicks) in [(true, true), (true, false), (false, true), (false, false)] + { + let settings = matrix_settings(assets, Some(clicks)); + let label = format!("{path:?} assets={assets} clicks={clicks} body={has_body}"); + let prefix = match path { + CreativePath::Auction => "", + CreativePath::Inline => INLINE_ORIGIN, + }; + + let out = process_for_path(&settings, path, fixture); + + let expected_proxied = match (assets, has_body) { + (false, _) => 0, + (true, true) => 4, + (true, false) => 3, + }; + assert_eq!( + out.matches("/first-party/proxy?tsurl=").count(), + expected_proxied, + "{label}: proxied asset URL count: {out}" + ); + assert_eq!( + out.contains(&format!("src=\"{prefix}/first-party/proxy?tsurl=")), + assets, + "{label}: image proxied only when assets are on: {out}" + ); + assert_eq!( + out.contains(r#"src="https://cdn.example.com/ad.png""#), + !assets, + "{label}: image raw only when assets are off: {out}" + ); + + // Each wrapped `` and `` carries the click URL in + // href and data-tsclick. + let expected_click_urls = if clicks { 4 } else { 0 }; + assert_eq!( + out.matches("/first-party/click?tsurl=").count(), + expected_click_urls, + "{label}: click URL count: {out}" + ); + assert_eq!( + out.contains(&format!("href=\"{prefix}/first-party/click?tsurl=")), + clicks, + "{label}: anchor wrapped only when clicks are on: {out}" + ); + assert_eq!( + out.contains(r#"href="https://landing.example.com/page""#), + !clicks, + "{label}: anchor raw only when clicks are off: {out}" + ); + assert_eq!( + out.contains("data-tsclick"), + clicks, + "{label}: data-tsclick only when clicks are on: {out}" + ); + assert!( + out.contains(r#"href="https://excluded.example.com/page""#), + "{label}: excluded anchor always stays raw: {out}" + ); + assert!( + out.contains(r#"href="mailto:ads@example.com""#), + "{label}: mailto anchor always stays raw: {out}" + ); + + assert_eq!( + out.contains(" without target removed whenever either switch is on: {out}" + ); + let expected_tsjs = + usize::from(matches!(path, CreativePath::Auction) && (assets || clicks)); + assert_eq!( + out.matches("/static/tsjs=").count(), + expected_tsjs, + "{label}: TSJS injected once on /auction when either switch is on: {out}" + ); + if !assets && !clicks { + assert_eq!( + out, fixture, + "{label}: should pass through byte for byte with both switches off" + ); + } + } + } + } } #[test] - fn to_abs_preserves_port_in_protocol_relative() { - let settings = crate::test_support::tests::create_test_settings(); - assert_eq!( - to_abs(&settings, "//cdn.example.com:8080/asset.js"), - Some("https://cdn.example.com:8080/asset.js".to_owned()), - "should preserve port 8080 in protocol-relative URL" - ); - assert_eq!( - to_abs(&settings, "//cdn.example.com:9443/img.png"), - Some("https://cdn.example.com:9443/img.png".to_owned()), - "should preserve port 9443 in protocol-relative URL" - ); + fn unset_rewrite_clicks_preserves_existing_output() { + for path in [CreativePath::Auction, CreativePath::Inline] { + let unset = matrix_settings(true, None); + let explicit = matrix_settings(true, Some(true)); + assert_eq!( + process_for_path(&unset, path, MATRIX_FIXTURE_WITH_BODY), + process_for_path(&explicit, path, MATRIX_FIXTURE_WITH_BODY), + "{path:?}: unset rewrite_clicks with rewrite_creatives = true should match full rewriting" + ); + + let disabled = matrix_settings(false, None); + assert_eq!( + process_for_path(&disabled, path, MATRIX_FIXTURE_WITH_BODY), + MATRIX_FIXTURE_WITH_BODY, + "{path:?}: unset rewrite_clicks with rewrite_creatives = false should pass through" + ); + } + } + + // Captured from `main` before `rewrite_clicks` existed: default settings must + // keep producing these bytes on both auction paths. + const MATRIX_DEFAULT_AUCTION_OUTPUT: &str = r#"
AdExcludedMail"#; + const MATRIX_DEFAULT_INLINE_OUTPUT: &str = r#"
AdExcludedMail"#; + + #[test] + fn base_target_is_kept_when_the_base_url_is_dropped() { + let fixture = r#"Ad"#; + for path in [CreativePath::Auction, CreativePath::Inline] { + for (assets, clicks) in [(true, true), (true, false), (false, true), (false, false)] { + let settings = matrix_settings(assets, Some(clicks)); + let label = format!("{path:?} assets={assets} clicks={clicks}"); + + let out = process_for_path(&settings, path, fixture); + + if assets || clicks { + assert!( + out.contains(r#""#), + "{label}: should keep base target without href: {out}" + ); + assert!( + !out.contains("base.example.com"), + "{label}: should drop the bidder base URL: {out}" + ); + } else { + assert_eq!( + out, fixture, + "{label}: should pass through byte for byte with both switches off" + ); + } + assert!( + out.contains(r#""#), + "{label}: relative image should stay raw: {out}" + ); + assert!( + out.contains(r#""#), + "{label}: relative anchor should stay raw: {out}" + ); + } + } + } + + #[test] + fn default_settings_rewrite_matrix_fixture_byte_for_byte_as_before() { + let settings = matrix_settings(true, None); + + for (path, expected) in [ + (CreativePath::Auction, MATRIX_DEFAULT_AUCTION_OUTPUT), + (CreativePath::Inline, MATRIX_DEFAULT_INLINE_OUTPUT), + ] { + assert_eq!( + process_for_path(&settings, path, MATRIX_FIXTURE_WITH_BODY), + expected, + "{path:?}: default settings should match the pre-rewrite_clicks output exactly" + ); + } + } + + fn normalized(raw: &str) -> Option { + normalize_creative_url(raw).map(|url| url.as_str().to_owned()) + } + + #[test] + fn normalize_creative_url_conversions() { + for (raw, expected) in [ + ("//cdn.example/x", Some("https://cdn.example/x")), + ("HTTPS://cdn.example/x", Some("https://cdn.example/x")), + ("http://cdn.example/x", Some("http://cdn.example/x")), + (" //cdn.example/y ", Some("https://cdn.example/y")), + (" https://cdn.example/a ", Some("https://cdn.example/a")), + ( + "//cdn.example.com:8080/asset.js", + Some("https://cdn.example.com:8080/asset.js"), + ), + ( + "//cdn.example.com:9443/img.png", + Some("https://cdn.example.com:9443/img.png"), + ), + ("/local/x", None), + ("", None), + ("data:image/png;base64,abcd", None), + ("javascript:alert(1)", None), + ("mailto:test@example.com", None), + ("blob:xyz", None), + ("tel:+123", None), + ("about:blank", None), + ("https://exa mple.example/x", None), + ] { + assert_eq!( + normalized(raw).as_deref(), + expected, + "should normalize `{raw}` to {expected:?}" + ); + } } #[test] @@ -3416,18 +3751,6 @@ b{background:url(\"https://cdn.example/c.png\")}"; } } - #[test] - fn to_abs_additional_cases() { - let settings = crate::test_support::tests::create_test_settings(); - assert_eq!( - to_abs(&settings, " https://cdn.example/a "), - Some("https://cdn.example/a".to_owned()) - ); - assert_eq!(to_abs(&settings, "blob:xyz"), None); - assert_eq!(to_abs(&settings, "tel:+123"), None); - assert_eq!(to_abs(&settings, "about:blank"), None); - } - #[test] fn rewrites_lazy_img_data_src_and_data_srcset() { let settings = crate::test_support::tests::create_test_settings(); @@ -3443,65 +3766,341 @@ b{background:url(\"https://cdn.example/c.png\")}"; } #[test] - fn to_abs_respects_exclude_domains() { + fn proxy_if_abs_respects_exclude_domains() { let mut settings = crate::test_support::tests::create_test_settings(); - settings.rewrite.exclude_domains = vec!["trusted-cdn.example.com".to_owned()]; + settings.rewrite.exclude_domains = vec![ + "trusted-cdn.example.com".to_owned(), + "*.example.org".to_owned(), + ]; - // Excluded domain should return None (not proxied) - assert_eq!( - to_abs(&settings, "https://trusted-cdn.example.com/lib.js"), - None - ); + for excluded in [ + "https://trusted-cdn.example.com/lib.js", + "//trusted-cdn.example.com/lib.js", + "https://example.org/cdn.js", + "//cdnjs.example.org/lib.js", + ] { + assert_eq!( + proxy_if_abs(&settings, excluded, ""), + None, + "should leave excluded URL `{excluded}` unproxied" + ); + } + for proxied in [ + "https://other-cdn.example.com/lib.js", + "//other-cdn.example.com/lib.js", + "https://notexample.org/lib.js", + ] { + assert!( + proxy_if_abs(&settings, proxied, "") + .is_some_and(|url| url.starts_with("/first-party/proxy?tsurl=")), + "should proxy non-excluded URL `{proxied}`" + ); + } + } - assert_eq!( - to_abs(&settings, "//trusted-cdn.example.com/lib.js"), - None, - "should exclude a protocol-relative URL by exact domain" + #[test] + fn unparseable_absolute_url_is_left_byte_identical() { + let settings = crate::test_support::tests::create_test_settings(); + let html = ""; + + let out = rewrite_creative_html(&settings, html); + + assert!( + out.contains(html), + "should leave an unparseable absolute URL untouched, including its quoting: {out}" ); + } - // Non-excluded domain should return Some - assert_eq!( - to_abs(&settings, "https://other-cdn.example.com/lib.js"), - Some("https://other-cdn.example.com/lib.js".to_owned()) + #[test] + fn unparseable_absolute_click_url_is_left_byte_identical() { + let settings = crate::test_support::tests::create_test_settings(); + let html = "x"; + + let out = rewrite_creative_html(&settings, html); + + assert!( + out.contains(html), + "should leave an unparseable click URL untouched, including its quoting: {out}" ); - assert_eq!( - to_abs(&settings, "//other-cdn.example.com/lib.js"), - Some("https://other-cdn.example.com/lib.js".to_owned()), - "should normalize a non-excluded protocol-relative URL" + assert!( + !out.contains("data-tsclick"), + "should not mark an unparseable link for the click guard: {out}" ); } #[test] - fn to_abs_respects_wildcard_domains() { + fn exclude_domains_match_case_insensitively_in_the_rewrite_pass() { let mut settings = crate::test_support::tests::create_test_settings(); - settings.rewrite.exclude_domains = vec!["*.cloudflare.com".to_owned()]; + settings + .rewrite + .exclude_domains + .extend(["Landing.Example.com", "CDN.example.com"].map(str::to_owned)); + let html = r#"x"#; - // Should exclude base domain - assert_eq!(to_abs(&settings, "https://cloudflare.com/cdn.js"), None); + let out = rewrite_creative_html(&settings, html); - // Should exclude subdomain - assert_eq!( - to_abs(&settings, "https://cdnjs.cloudflare.com/lib.js"), - None + assert!( + out.contains(r#""#), + "should leave a link raw when its host matches a mixed-case entry: {out}" ); + assert!( + out.contains(r#""#), + "should leave an asset raw when its host matches a mixed-case entry: {out}" + ); + } + + const ALLOWLIST_LISTED: &[&str] = &[ + "https://assets.example.com/a.css", + "https://assets.example.com/p1.png", + "https://assets.example.com/p1-2x.png", + "https://assets.example.com/a.js", + "https://assets.example.com/i.css", + "https://assets.example.com/bg.png", + "https://assets.example.com/set.png", + "https://assets.example.com/img.png", + "https://assets.example.com/lazy.png", + "https://assets.example.com/s1.png", + "https://assets.example.com/v.mp4", + "https://assets.example.com/o.swf", + "https://assets.example.com/e.swf", + "https://assets.example.com/btn.png", + "https://assets.example.com/icon.svg", + "https://assets.example.com/sprite.svg", + "https://assets.example.com/frame.html", + "https://assets.example.com/inline.png", + ]; + + const ALLOWLIST_OFF_LIST: &[&str] = &[ + "https://cdn.example.net/a.css", + "https://cdn.example.net/p2.png", + "https://cdn.example.net/a.js", + "https://cdn.example.net/i.css", + "https://cdn.example.net/bg.png", + "https://cdn.example.net/set.png", + "https://cdn.example.net/img.png", + "https://cdn.example.net/lazy.png", + "https://cdn.example.net/s2.png", + "https://cdn.example.net/v.mp4", + "https://cdn.example.net/o.swf", + "https://cdn.example.net/e.swf", + "https://cdn.example.net/btn.png", + "https://cdn.example.net/icon.svg", + "https://cdn.example.net/sprite.svg", + "https://cdn.example.net/frame.html", + "https://cdn.example.net/inline.png", + ]; + + const ALLOWLIST_CREATIVE: &str = r#" + + + + + + + + + + + + + + + + + + + + + +
+
+
Offer +Area +"#; + + fn encoded_tsurl(url: &str) -> String { + format!( + "tsurl={}", + url::form_urlencoded::byte_serialize(url.as_bytes()).collect::() + ) + } + + fn allowlist_settings() -> crate::settings::Settings { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.rewrite.include_domains = vec!["*.example.com".to_owned()]; + settings + } + + fn assert_allowlist_applied(label: &str, out: &str) { + for listed in ALLOWLIST_LISTED { + assert!( + out.contains(&encoded_tsurl(listed)), + "{label}: should proxy listed URL `{listed}`: {out}" + ); + } + for off_list in ALLOWLIST_OFF_LIST { + assert!( + !out.contains(&encoded_tsurl(off_list)), + "{label}: should not proxy off-list URL `{off_list}`: {out}" + ); + assert!( + out.contains(off_list), + "{label}: should keep off-list URL `{off_list}` raw: {out}" + ); + } + for landing in [ + "https://landing.example.net/offer", + "https://landing.example.org/area", + ] { + assert!( + out.contains(&format!("/first-party/click?{}", encoded_tsurl(landing))), + "{label}: should wrap click `{landing}` regardless of include_domains: {out}" + ); + } assert_eq!( - to_abs(&settings, "//cloudflare.com/cdn.js"), - None, - "should exclude a protocol-relative wildcard base domain" + out.matches("data-tsclick=").count(), + 2, + "{label}: should mark both click-through links" ); - assert_eq!( - to_abs(&settings, "//cdnjs.cloudflare.com/lib.js"), - None, - "should exclude a protocol-relative wildcard subdomain" + } + + #[test] + fn include_domains_limit_asset_rewriting_on_every_html_path() { + let settings = allowlist_settings(); + + assert_allowlist_applied( + "auction", + &rewrite_creative_html(&settings, ALLOWLIST_CREATIVE), + ); + assert_allowlist_applied( + "inline", + &rewrite_inline_creative_html(&settings, "https://www.example.com", ALLOWLIST_CREATIVE), + ); + assert_allowlist_applied( + "proxied", + &rewrite_proxied_html(&settings, ALLOWLIST_CREATIVE), ); + } - // Should not exclude different domain + #[test] + fn include_domains_limit_css_body_rewriting() { + let settings = allowlist_settings(); + let css = r#"@import "https://assets.example.com/i.css"; +@import "https://cdn.example.net/i.css"; +.a { background: url(https://assets.example.com/bg.png); } +.b { background: url(https://cdn.example.net/bg.png); }"#; + + let out = rewrite_css_body(&settings, css).expect("should rewrite CSS body"); + + for listed in [ + "https://assets.example.com/i.css", + "https://assets.example.com/bg.png", + ] { + assert!( + out.contains(&encoded_tsurl(listed)), + "should proxy listed CSS URL `{listed}`: {out}" + ); + } + for off_list in [ + "https://cdn.example.net/i.css", + "https://cdn.example.net/bg.png", + ] { + assert!( + out.contains(off_list) && !out.contains(&encoded_tsurl(off_list)), + "should keep off-list CSS URL `{off_list}` raw: {out}" + ); + } + } + + // `imagesrcset` is rewritten only by the `[imagesrcset]` handler. When the + // `link[href]` handler also rewrote it, the inline path's absolute proxy + // URLs were proxied a second time. + #[test] + fn inline_link_imagesrcset_is_proxied_once() { + let settings = crate::test_support::tests::create_test_settings(); + let html = r#""#; + + let out = rewrite_inline_creative_html(&settings, "https://publisher.example.com", html); + + let imagesrcset = out + .split("imagesrcset=\"") + .nth(1) + .and_then(|rest| rest.split('"').next()) + .expect("should keep an imagesrcset attribute"); assert_eq!( - to_abs(&settings, "https://notcloudflare.com/lib.js"), - Some("https://notcloudflare.com/lib.js".to_owned()) + imagesrcset.matches("tsurl=").count(), + 1, + "should proxy the imagesrcset candidate exactly once: {imagesrcset}" ); } + #[test] + fn root_relative_link_imagesrcset_is_proxied_once() { + let settings = crate::test_support::tests::create_test_settings(); + let html = r#""#; + + for (label, out) in [ + ("auction", rewrite_creative_html(&settings, html)), + ("proxied", rewrite_proxied_html(&settings, html)), + ] { + let imagesrcset = out + .split("imagesrcset=\"") + .nth(1) + .and_then(|rest| rest.split('"').next()) + .expect("should keep an imagesrcset attribute"); + assert_eq!( + imagesrcset.matches("tsurl=").count(), + 1, + "{label}: should proxy the imagesrcset candidate exactly once: {imagesrcset}" + ); + } + } + + #[test] + fn host_in_both_lists_is_left_alone() { + let mut settings = allowlist_settings(); + settings.rewrite.exclude_domains = vec!["assets.example.com".to_owned()]; + let html = ALLOWLIST_CREATIVE.replace( + "", + r#"Excluded"#, + ); + + for (label, out) in [ + ("auction", rewrite_creative_html(&settings, &html)), + ( + "inline", + rewrite_inline_creative_html(&settings, "https://www.example.com", &html), + ), + ("proxied", rewrite_proxied_html(&settings, &html)), + ] { + assert!( + !out.contains("tsurl=https%3A%2F%2Fassets.example.com"), + "{label}: should not rewrite any URL on a host in both lists: {out}" + ); + for listed in ALLOWLIST_LISTED { + assert!( + out.contains(listed), + "{label}: should keep `{listed}` raw when its host is also excluded: {out}" + ); + } + assert!( + out.contains(r#"Excluded"#), + "{label}: should leave an excluded click-through link raw: {out}" + ); + assert_eq!( + out.matches("data-tsclick=").count(), + 2, + "{label}: should wrap only the two links on hosts that are not excluded: {out}" + ); + } + } + #[test] fn rewrite_html_excludes_blacklisted_domains() { let mut settings = crate::test_support::tests::create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index e21fab702..aad4d4ed6 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -6,9 +6,7 @@ //! attach, so operators can debug KV-to-auction propagation without KV //! console access. //! -//! Also serves `GET /_ts/admin/eids`, which echoes the request's `ts-eids` -//! and `sharedId` cookies with an ingestion preview — the client-side half -//! of EID propagation that is never stored server-side. +//! The retired `GET /_ts/admin/eids` diagnostic stays locally denied after authentication. //! //! Authentication is enforced by the `^/_ts/admin` basic-auth handler //! configuration; startup validation rejects configs that leave these paths @@ -17,7 +15,6 @@ //! internal detail (raw consent strings, partner UIDs, parse errors). use std::borrow::Cow; -use std::collections::BTreeMap; use http::{HeaderValue, Method, Request, Response, StatusCode, header}; use serde::Serialize; @@ -26,8 +23,6 @@ use serde_json::Value as JsonValue; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt as _}; -use crate::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; -use crate::cookies::extract_cookie_value; use crate::error::TrustedServerError; use crate::openrtb::Eid; @@ -37,9 +32,6 @@ use super::kv::KvIdentityGraph; use super::kv_backend::EcKvLookup; use super::kv_types::{KvEntry, KvMetadata}; use super::log_id; -use super::prebid_eids::{ - analyze_prebid_eids_cookie, collect_sharedid_update, dedupe_partner_updates, is_valid_eid_uid, -}; use super::registry::PartnerRegistry; /// Route prefix shared by the cookie-based and explicit-ID lookup routes. @@ -482,13 +474,22 @@ fn iso_timestamp(unix_seconds: u64) -> Option { /// Derives the auction EID view for a valid entry, mirroring the filters in /// [`resolve_partner_ids`] and reporting why each stored ID was skipped. fn build_auction_view(registry: &PartnerRegistry, entry: &KvEntry) -> AuctionEidsView { - let resolved = resolve_partner_ids(registry, entry); + let now = super::checked_current_timestamp(); + let resolved = resolve_partner_ids(registry, entry, now); let eids = to_eids(&resolved); let mut skipped = Vec::new(); for (source_domain, partner_uid) in &entry.ids { - let reason = if partner_uid.uid.is_empty() { + let reason = if !entry.consent.ok { + "consent_withdrawn" + } else if partner_uid.uid.is_empty() { "empty_uid" + } else if !partner_uid.is_usable(now) { + if now.is_some() { + "expired" + } else { + "expiry_unverifiable" + } } else { match registry.get(source_domain) { None => "not_in_registry", @@ -505,157 +506,14 @@ fn build_auction_view(registry: &PartnerRegistry, entry: &KvEntry) -> AuctionEid AuctionEidsView { eids, skipped } } -/// Admin EIDs echo payload. -#[derive(Debug, Serialize)] -struct AdminEidsResponse { - /// Whether a `ts-eids` cookie was present on the request. - cookie_present: bool, - /// EIDs parsed from the `ts-eids` cookie. Absent when the cookie is - /// missing or failed to parse. - #[serde(skip_serializing_if = "Option::is_none")] - eids: Option>, - /// Parse failure detail when the `ts-eids` cookie could not be decoded. - #[serde(skip_serializing_if = "Option::is_none")] - parse_error: Option, - /// Whether a `sharedId` cookie was present on the request. - sharedid_present: bool, - /// Number of partners configured in the registry. - partners_configured: usize, - /// Preview of what cookie ingestion would write into the EC entry's - /// `ids` map on a navigation carrying these cookies. - ingest: IngestPreview, -} - -/// What cookie ingestion would store, and what it would drop. -#[derive(Debug, Serialize)] -struct IngestPreview { - /// Cookie sources matched to a configured partner, with the UID that - /// would be stored (deduplicated exactly like the ingestion path). - matched: Vec, - /// `ts-eids` sources dropped on ingestion, with the reason. - unmatched: Vec, -} - -/// A cookie-derived partner UID that ingestion would store. -#[derive(Debug, Serialize)] -struct MatchedPartnerId { - /// Partner namespace key in the EC entry's `ids` map. - source_domain: String, - /// The UID that would be stored. - uid: String, -} - -#[derive(Debug, Serialize)] -struct DroppedEidSource { - /// EID source from the cookie. - source: String, - /// Why ingestion would drop the source. - reason: DroppedEidReason, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -enum DroppedEidReason { - NoPartner, - NoValidUid, -} - -/// Handles `GET /_ts/admin/eids`. -/// -/// Echoes the request's `ts-eids` and `sharedId` cookies: the parsed EID -/// list plus a preview of what cookie ingestion would write into the EC -/// entry's `ids` map given the configured partner registry. Pure request -/// inspection — no KV access — so it works on every adapter. -/// -/// Always responds `200 OK`; missing or malformed cookies are reported in -/// the payload instead of as errors. -/// -/// # Errors -/// -/// Returns [`TrustedServerError::Configuration`] only when the response -/// payload fails JSON serialization. -pub fn handle_admin_eids_lookup( - registry: &PartnerRegistry, - req: &Request, -) -> Result, Report> { - let eids_cookie = extract_cookie_value(req, COOKIE_TS_EIDS); - let sharedid_cookie = extract_cookie_value(req, COOKIE_SHAREDID); - - let (eids, parse_error, diagnostic_sources, mut updates) = match &eids_cookie { - None => (None, None, Vec::new(), Vec::new()), - Some(value) => match analyze_prebid_eids_cookie(value, registry) { - Ok(analysis) => ( - Some(analysis.eids), - None, - analysis.diagnostic_sources, - analysis.updates, - ), - Err(error) => ( - None, - Some(format!("failed to parse ts-eids cookie: {error}")), - Vec::new(), - Vec::new(), - ), - }, - }; - - // Collect matches from both cookies, then dedupe the same way as response - // finalization. The preview is cookie-only and ungated: it ignores the - // `/auction` request-body EIDs, the consent gate on identity-graph writes, - // and UIDs already stored in KV, so it lists candidate matches rather than - // exactly what a request would store. - if let Some(value) = &sharedid_cookie - && let Some(update) = collect_sharedid_update(value, registry) - { - updates.push(update); - } - let matched = dedupe_partner_updates(updates) - .into_iter() - .map(|update| MatchedPartnerId { - source_domain: update.partner_id, - uid: update.uid, - }) - .collect(); - - let mut source_has_valid_uid = BTreeMap::new(); - for diagnostic_source in diagnostic_sources { - let has_valid_uid = diagnostic_source - .uids - .iter() - .any(|uid| is_valid_eid_uid(uid)); - source_has_valid_uid - .entry(diagnostic_source.source) - .and_modify(|source_has_valid_uid| *source_has_valid_uid |= has_valid_uid) - .or_insert(has_valid_uid); - } - let unmatched = source_has_valid_uid - .into_iter() - .filter_map(|(source, has_valid_uid)| { - let reason = if registry.find_by_source_domain(&source).is_none() { - DroppedEidReason::NoPartner - } else if !has_valid_uid { - DroppedEidReason::NoValidUid - } else { - return None; - }; - Some(DroppedEidSource { source, reason }) - }) - .collect(); - - let payload = AdminEidsResponse { - cookie_present: eids_cookie.is_some(), - eids, - parse_error, - sharedid_present: sharedid_cookie.is_some(), - partners_configured: registry.len(), - ingest: IngestPreview { matched, unmatched }, - }; - - let body = - serde_json::to_string(&payload).change_context(TrustedServerError::Configuration { - message: "failed to serialize admin EIDs response".to_owned(), - })?; - Ok(json_response(StatusCode::OK, body)) +/// Locally denies the retired EID-cookie diagnostic after adapter authentication. +/// Never fall through to a publisher origin with admin credentials. +#[must_use] +pub fn admin_eids_lookup_retired() -> Response { + json_error( + StatusCode::GONE, + "EID cookie diagnostic retired; use /_ts/admin/ec", + ) } fn json_error(status: StatusCode, message: &str) -> Response { @@ -677,8 +535,6 @@ fn json_response(status: StatusCode, body: String) -> Response { mod tests { use std::time::Duration; - use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; - use crate::ec::kv::TombstoneOutcome; use super::*; @@ -694,6 +550,7 @@ mod tests { fn make_test_partner(source_domain: &str, bidstream_enabled: bool) -> EcPartner { EcPartner { + identity_owner: None, name: format!("Partner {source_domain}"), source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), @@ -780,12 +637,14 @@ mod tests { "disabled.example".to_owned(), KvPartnerId { uid: "uid-disabled".to_owned(), + ..Default::default() }, ); entry.ids.insert( "unknown.example".to_owned(), KvPartnerId { uid: "uid-unknown".to_owned(), + ..Default::default() }, ); entry @@ -1341,196 +1200,20 @@ mod tests { assert!(result.is_err(), "should propagate KV read failures"); } - fn eids_cookie_for(entries: &serde_json::Value) -> String { - BASE64.encode(entries.to_string()) - } - #[test] - fn eids_lookup_without_cookies_returns_empty_payload() { - let req = get_request("/_ts/admin/eids"); - - let response = - handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::X_CONTENT_TYPE_OPTIONS), - Some(&HeaderValue::from_static("nosniff")) - ); - let json = response_json(response); - assert_eq!(json["cookie_present"], false); - assert_eq!(json["sharedid_present"], false); - assert_eq!(json["partners_configured"], 2); - assert!( - json["ingest"]["matched"] - .as_array() - .expect("should have matched list") - .is_empty(), - "should preview no matches without cookies" - ); - } - - #[test] - fn eids_lookup_parses_cookie_and_previews_ingestion() { - let cookie = eids_cookie_for(&serde_json::json!([ - { - "source": "bidstream.example", - "uids": [{ "id": "uid-configured", "atype": 1 }] - }, - { - "source": "unknown.example", - "uids": [{ "id": "uid-unknown", "atype": 1 }] - } - ])); - let req = get_request_with_cookie("/_ts/admin/eids", &format!("ts-eids={cookie}")); - - let response = - handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); - - assert_eq!(response.status(), StatusCode::OK); - let json = response_json(response); - assert_eq!(json["cookie_present"], true); - assert_eq!( - json["eids"] - .as_array() - .expect("should have parsed eids") - .len(), - 2, - "should echo both parsed EID sources" - ); - - let matched = json["ingest"]["matched"] - .as_array() - .expect("should have matched list"); - assert_eq!(matched.len(), 1, "should match only the configured partner"); - assert_eq!(matched[0]["source_domain"], "bidstream.example"); - assert_eq!(matched[0]["uid"], "uid-configured"); - - let unmatched = json["ingest"]["unmatched"] - .as_array() - .expect("should have unmatched list"); - assert_eq!(unmatched.len(), 1, "should report the unregistered source"); - assert_eq!(unmatched[0]["source"], "unknown.example"); - assert_eq!(unmatched[0]["reason"], "no_partner"); - } - - #[test] - fn eids_lookup_reports_configured_source_without_valid_uid() { - let oversized_uid = "x".repeat(513); - let cookie = eids_cookie_for(&serde_json::json!([{ - "source": "bidstream.example", - "uids": [ - { "id": "", "atype": 1 }, - { "id": " ", "atype": 1 }, - { "id": oversized_uid, "atype": 1 } - ] - }])); - let req = get_request_with_cookie("/_ts/admin/eids", &format!("ts-eids={cookie}")); - - let response = - handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); - let json = response_json(response); - - assert!( - json["ingest"]["matched"] - .as_array() - .expect("should have matched list") - .is_empty(), - "invalid UIDs should not be matched" - ); - let unmatched = json["ingest"]["unmatched"] - .as_array() - .expect("should have unmatched list"); - assert_eq!(unmatched.len(), 1, "should report one dropped source"); - assert_eq!(unmatched[0]["source"], "bidstream.example"); - assert_eq!(unmatched[0]["reason"], "no_valid_uid"); - } - - #[test] - fn eids_lookup_does_not_drop_duplicate_source_with_valid_uid() { - let cookie = eids_cookie_for(&serde_json::json!([ - { - "source": "bidstream.example", - "uids": [{ "id": " ", "atype": 1 }] - }, - { - "source": "bidstream.example", - "uids": [{ "id": "uid-valid", "atype": 1 }] - } - ])); - let req = get_request_with_cookie("/_ts/admin/eids", &format!("ts-eids={cookie}")); - - let response = - handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); - let json = response_json(response); - - let matched = json["ingest"]["matched"] - .as_array() - .expect("should have matched list"); - assert_eq!(matched.len(), 1, "should match the valid duplicate source"); - assert_eq!(matched[0]["uid"], "uid-valid"); - assert!( - json["ingest"]["unmatched"] - .as_array() - .expect("should have unmatched list") - .is_empty(), - "a valid duplicate should suppress no_valid_uid" - ); - } - - #[test] - fn eids_lookup_reports_parse_error() { - let req = get_request_with_cookie("/_ts/admin/eids", "ts-eids=!!!not-base64!!!"); - - let response = - handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); - - assert_eq!( - response.status(), - StatusCode::OK, - "malformed cookies should be reported, not errored" - ); - let json = response_json(response); - assert_eq!(json["cookie_present"], true); - assert!( - json["parse_error"] - .as_str() - .expect("should have parse_error") - .contains("ts-eids"), - "should describe the parse failure" - ); - assert!(json.get("eids").is_none(), "should omit unparsed eids"); - assert!( - json["ingest"]["matched"] - .as_array() - .expect("should have matched list") - .is_empty(), - "unparseable cookie should preview no matches" - ); - } - - #[test] - fn eids_lookup_includes_sharedid_match() { - let registry = PartnerRegistry::from_config(&[ - make_test_partner("bidstream.example", true), - make_test_partner("sharedid.org", true), - ]) - .expect("should build sharedid test registry"); - let req = get_request_with_cookie("/_ts/admin/eids", "sharedId=shared-uid-123"); - - let response = - handle_admin_eids_lookup(®istry, &req).expect("should handle eids lookup"); - - assert_eq!(response.status(), StatusCode::OK); - let json = response_json(response); - assert_eq!(json["cookie_present"], false); - assert_eq!(json["sharedid_present"], true); - - let matched = json["ingest"]["matched"] - .as_array() - .expect("should have matched list"); - assert_eq!(matched.len(), 1, "should match the sharedid partner"); - assert_eq!(matched[0]["source_domain"], "sharedid.org"); - assert_eq!(matched[0]["uid"], "shared-uid-123"); + fn retired_eids_diagnostic_denies_locally_without_echoing_cookies() { + for cookie in ["ts-eids=secret; sharedId=secret", "ts-eids=!!!", ""] { + let _req = get_request_with_cookie("/_ts/admin/eids", cookie); + let response = admin_eids_lookup_retired(); + assert_eq!(response.status(), StatusCode::GONE); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!( + response.headers()[header::X_CONTENT_TYPE_OPTIONS], + "nosniff" + ); + let payload = response_json(response); + assert!(payload.get("eids").is_none()); + assert!(!payload.to_string().contains("secret")); + } } } diff --git a/crates/trusted-server-core/src/ec/auth.rs b/crates/trusted-server-core/src/ec/auth.rs index b7db6f1fa..9f7f6bd7e 100644 --- a/crates/trusted-server-core/src/ec/auth.rs +++ b/crates/trusted-server-core/src/ec/auth.rs @@ -59,6 +59,7 @@ mod tests { pull_sync_allowed_domains: vec![], pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), + identity_owner: None, ts_pull_token: None, } } diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index 4903589b2..598efeb31 100644 --- a/crates/trusted-server-core/src/ec/batch_sync.rs +++ b/crates/trusted-server-core/src/ec/batch_sync.rs @@ -131,6 +131,14 @@ fn handle_batch_sync_with_writer( return Ok(error_response(StatusCode::UNAUTHORIZED, "invalid_token")); }; + // Managed ownership keeps this token read-only for identify. + if partner.identity_owner.is_some() { + return Ok(error_response( + StatusCode::FORBIDDEN, + "identity_source_owned", + )); + } + // 2. Rate limit (per-partner, per-minute via batch_rate_limit) let rate_key = format!("batch:{}", partner.source_domain); if rate_limiter.exceeded_per_minute(&rate_key, partner.batch_rate_limit)? { @@ -306,11 +314,16 @@ fn error_response(status: StatusCode, reason: &str) -> Response { #[cfg(test)] mod tests { use super::*; + use std::cell::Cell; use std::collections::VecDeque; + use crate::consent::jurisdiction::Jurisdiction; + use crate::consent::types::{ConsentContext, ConsentSource}; + use crate::ec::kv_types::KvEntry; use crate::error::TrustedServerError; use crate::redacted::Redacted; use crate::settings::EcPartner; + use crate::test_support::tests::create_test_settings; // EC ID validation tests are in generation.rs (is_valid_ec_id). // Verify the import works here with a basic smoke test. @@ -408,6 +421,7 @@ mod tests { pull_sync_allowed_domains: vec![], pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), + identity_owner: None, ts_pull_token: None, } } @@ -1074,4 +1088,99 @@ mod tests { "should stop after the failing group and accept A's later duplicate" ); } + + #[test] + fn owned_partner_token_is_read_only_for_batch_sync_but_still_authenticates_identify() { + struct CountingRateLimiter(Cell); + + impl RateLimiter for CountingRateLimiter { + fn exceeded( + &self, + _key: &str, + _hourly_limit: u32, + ) -> Result> { + self.0.set(self.0.get() + 1); + Ok(false) + } + + fn exceeded_per_minute( + &self, + _key: &str, + _per_minute_limit: u32, + ) -> Result> { + self.0.set(self.0.get() + 1); + Ok(false) + } + } + + const TOKEN: &str = "test-token-32-bytes-minimum-value"; + let source = "owned-source.identity.test"; + let mut partner = make_test_partner(source, TOKEN); + partner.identity_owner = Some("lockr".to_owned()); + let registry = PartnerRegistry::from_config(&[partner]) + .expect("should retain an owned partner with an API token"); + + let writer = MockWriter::new(vec![]); + let limiter = CountingRateLimiter(Cell::new(0)); + let response = handle_batch_sync_with_writer( + &writer, + ®istry, + &limiter, + authorized_batch_request("not-json"), + ) + .expect("should return the owned-source denial"); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "an owned source token must not write through batch-sync" + ); + assert_eq!( + limiter.0.get(), + 0, + "ownership should reject before consuming rate-limit budget" + ); + assert!( + writer.calls().is_empty(), + "ownership should reject before KV work" + ); + + let settings = create_test_settings(); + let graph = KvIdentityGraph::in_memory("owned-identify-store"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let entry = KvEntry::minimal(source, "owned-source-uid", 1_000); + graph.create(&ec_id, &entry).expect("should seed owned UID"); + let identify_request = Request::builder() + .method("GET") + .uri("https://publisher.example.com/_ts/api/v1/identify") + .header("authorization", format!("Bearer {TOKEN}")) + .body(EdgeBody::empty()) + .expect("should build identify request"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + source: ConsentSource::Cookie, + ..ConsentContext::default() + }; + let ec_context = crate::ec::EcContext::new_for_test(Some(ec_id), consent); + + let identify_response = crate::ec::identify::handle_identify( + &settings, + &graph, + ®istry, + &identify_request, + &ec_context, + ) + .expect("should allow identify through the same owned partner token"); + assert_eq!( + identify_response.status(), + StatusCode::OK, + "ownership should preserve read-only identify authentication" + ); + let body = identify_response + .into_body() + .into_bytes() + .expect("should read identify response"); + let json: serde_json::Value = + serde_json::from_slice(&body).expect("should decode identify response"); + assert_eq!(json["uid"], "owned-source-uid"); + } } diff --git a/crates/trusted-server-core/src/ec/eids.rs b/crates/trusted-server-core/src/ec/eids.rs index 1359f7509..a1a380200 100644 --- a/crates/trusted-server-core/src/ec/eids.rs +++ b/crates/trusted-server-core/src/ec/eids.rs @@ -1,43 +1,44 @@ //! Shared EID resolution and formatting helpers. //! //! Used by both `/_ts/api/v1/identify` and `/auction` to resolve source-domain -//! keyed IDs from KV entries, convert them to `OpenRTB` EID structures, and -//! build base64-encoded response headers. +//! keyed IDs from KV entries and convert them to `OpenRTB` EID structures. -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; -use error_stack::{Report, ResultExt as _}; - -use crate::error::TrustedServerError; use crate::openrtb::{Eid, Uid}; use super::kv_types::KvEntry; use super::registry::PartnerRegistry; -/// Maximum size (in bytes) for the base64-encoded `x-ts-eids` header value. -pub const MAX_EIDS_HEADER_BYTES: usize = 4096; - /// A source-domain keyed ID resolved from a KV entry against the partner registry. /// -/// Only includes partners with `bidstream_enabled = true` and a non-empty UID. +/// Only includes usable records from live entries and bidstream-enabled partners. pub struct ResolvedPartnerId { /// The partner's identity source domain and EC KV `ids` key. pub source_domain: String, /// The synced user ID value. pub uid: String, + /// Persisted acquisition writer, never inferred from configured ownership. + pub writer: Option, /// `OpenRTB` agent type for this partner's identifiers. pub openrtb_atype: i32, } /// Resolves source-domain keyed IDs from a KV entry against the partner registry. /// -/// Filters to partners with `bidstream_enabled = true` and non-empty UIDs, -/// sorted deterministically by source domain. +/// Filters to live, consented, bidstream-enabled records with a usable UID at +/// the supplied checked time, sorted deterministically by source domain. #[must_use] -pub fn resolve_partner_ids(registry: &PartnerRegistry, entry: &KvEntry) -> Vec { +pub fn resolve_partner_ids( + registry: &PartnerRegistry, + entry: &KvEntry, + now: Option, +) -> Vec { let mut resolved = Vec::new(); + if !entry.consent.ok { + return resolved; + } for (source_domain, partner_uid) in &entry.ids { - if partner_uid.uid.is_empty() { + if !partner_uid.is_usable(now) { continue; } @@ -51,6 +52,7 @@ pub fn resolve_partner_ids(registry: &PartnerRegistry, entry: &KvEntry) -> Vec Vec Vec { resolved .iter() - .map(|item| Eid { - source: item.source_domain.clone(), - uids: vec![Uid { - id: item.uid.clone(), - atype: Some(item.openrtb_atype), - ext: None, - }], + .map(|item| { + let mut eid = Eid { + source: item.source_domain.clone(), + uids: vec![Uid { + id: item.uid.clone(), + atype: Some(item.openrtb_atype), + ext: None, + }], + ..Eid::default() + }; + crate::integrations::lockr::apply_eid_provenance(&mut eid, item.writer.as_deref()); + eid }) .collect() } -/// Builds a base64-encoded EID header value, truncating if needed. -/// -/// Returns `(encoded_value, was_truncated)`. If the full set of EIDs exceeds -/// [`MAX_EIDS_HEADER_BYTES`] after base64 encoding, partners are removed -/// from the end of the deterministic partner ordering until it fits. -/// -/// # Errors -/// -/// Returns an error if JSON serialization fails. -pub fn build_eids_header( - resolved: &[ResolvedPartnerId], -) -> Result<(String, bool), Report> { - let eids = to_eids(resolved); - encode_eids_header(&eids) -} - -/// Encodes a pre-built EID slice into a base64 header value with truncation. -/// -/// Like [`build_eids_header`] but operates on already-constructed `Eid` values -/// (e.g., from `UserInfo.eids` in the auction response path). -/// -/// Returns `(encoded_value, was_truncated)`. -/// -/// # Errors -/// -/// Returns an error if JSON serialization fails. -pub fn encode_eids_header(eids: &[Eid]) -> Result<(String, bool), Report> { - let try_encode = |size: usize| -> Result> { - let json = serde_json::to_vec(&eids[..size]).change_context( - TrustedServerError::Configuration { - message: "Failed to serialize eids header payload".to_owned(), - }, - )?; - Ok(BASE64.encode(json)) - }; - - // Fast path: try the full slice first (common case — no truncation). - let encoded = try_encode(eids.len())?; - if encoded.len() <= MAX_EIDS_HEADER_BYTES { - return Ok((encoded, false)); - } - - // Binary search for the largest count that fits within the limit. - // Invariant: lo always fits, hi never fits. - let mut lo: usize = 0; - let mut hi: usize = eids.len(); - - while lo + 1 < hi { - let mid = lo + (hi - lo) / 2; - let encoded = try_encode(mid)?; - if encoded.len() <= MAX_EIDS_HEADER_BYTES { - lo = mid; - } else { - hi = mid; - } - } - - // `lo` is the largest size that fits. Re-encode it for the final value. - if lo == 0 && !eids.is_empty() { - log::warn!( - "encode_eids_header: no EIDs fit within {MAX_EIDS_HEADER_BYTES}B; emitting empty truncated header" - ); - } - let encoded = try_encode(lo)?; - Ok((encoded, true)) -} - #[cfg(test)] mod tests { use super::*; @@ -150,6 +92,7 @@ mod tests { fn make_test_partner(source_domain: &str) -> EcPartner { EcPartner { + identity_owner: None, name: format!("Partner {source_domain}"), source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), @@ -167,6 +110,63 @@ mod tests { } } + #[test] + fn resolve_partner_ids_omits_expired_identity() { + let registry = PartnerRegistry::from_config(&[make_test_partner("ids.example.com")]) + .expect("should build registry"); + let mut entry = KvEntry::minimal("ids.example.com", "expired-uid", 1_000); + entry + .ids + .get_mut("ids.example.com") + .expect("should contain record") + .expires_at = Some(1); + + assert!( + resolve_partner_ids(®istry, &entry, Some(2)).is_empty(), + "should omit a known-expired identity without deleting its record" + ); + } + + #[test] + fn resolve_partner_ids_checks_clock_boundaries_and_retains_legacy_compatibility() { + let registry = PartnerRegistry::from_config(&[make_test_partner("ids.example.com")]) + .expect("should build registry"); + let mut entry = KvEntry::minimal("ids.example.com", "example-uid", 1_000); + entry + .ids + .get_mut("ids.example.com") + .expect("should contain record") + .expires_at = Some(2_000); + assert_eq!( + resolve_partner_ids(®istry, &entry, Some(1_999)).len(), + 1, + "should resolve before expiry" + ); + assert!( + resolve_partner_ids(®istry, &entry, Some(2_000)).is_empty(), + "should omit at expiry" + ); + assert!( + resolve_partner_ids(®istry, &entry, None).is_empty(), + "should fail closed for known expiry when time is unavailable" + ); + entry + .ids + .get_mut("ids.example.com") + .expect("should contain record") + .expires_at = None; + assert_eq!( + resolve_partner_ids(®istry, &entry, None).len(), + 1, + "should retain unknown-expiry compatibility" + ); + entry.consent.ok = false; + assert!( + resolve_partner_ids(®istry, &entry, Some(1_000)).is_empty(), + "should never deliver a withdrawn row's records" + ); + } + #[test] fn resolve_partner_ids_sorts_by_source_domain() { let partners = vec![ @@ -181,16 +181,18 @@ mod tests { "zeta.example.com".to_owned(), super::super::kv_types::KvPartnerId { uid: "uid-z".to_owned(), + ..Default::default() }, ); entry.ids.insert( "alpha.example.com".to_owned(), super::super::kv_types::KvPartnerId { uid: "uid-a".to_owned(), + ..Default::default() }, ); - let resolved = resolve_partner_ids(®istry, &entry); + let resolved = resolve_partner_ids(®istry, &entry, Some(1_000)); let source_domains: Vec<&str> = resolved .iter() .map(|item| item.source_domain.as_str()) @@ -203,21 +205,68 @@ mod tests { ); } + #[test] + fn lockr_provenance_requires_the_stored_writer_and_id5_source() { + for source in ["id5-sync.com", "ids.example.com"] { + let mut partner = make_test_partner(source); + partner.identity_owner = Some("lockr".to_owned()); + let registry = + PartnerRegistry::from_config(&[partner]).expect("should build source registry"); + for writer in [ + None, + Some("browser"), + Some("legacy_pull"), + Some("other"), + Some("lockr"), + ] { + let mut entry = KvEntry::minimal(source, "synthetic-uid", 1_000); + let record = entry.ids.get_mut(source).expect("should contain source"); + record.writer = writer.map(str::to_owned); + record.expires_at = Some(2_000); + let resolved = resolve_partner_ids(®istry, &entry, Some(1_999)); + let payload = serde_json::to_value(to_eids(&resolved)) + .expect("should serialize resolved EIDs"); + let eid = &payload[0]; + assert_eq!(eid["source"], source); + assert_eq!(eid["uids"][0]["id"], "synthetic-uid"); + if source == "id5-sync.com" && writer == Some("lockr") { + assert_eq!(eid["inserter"], "Lockr-For-Publishers"); + assert_eq!(eid["matcher"], ""); + assert_eq!(eid["mm"], 2); + } else { + for field in ["inserter", "matcher", "mm"] { + assert!( + eid.get(field).is_none(), + "should not infer provenance from owner or source alone" + ); + } + } + assert!(resolve_partner_ids(®istry, &entry, Some(2_000)).is_empty()); + assert!(resolve_partner_ids(®istry, &entry, None).is_empty()); + entry.consent.ok = false; + assert!(resolve_partner_ids(®istry, &entry, Some(1_999)).is_empty()); + } + } + } + #[test] fn to_eids_maps_resolved_ids_correctly() { let resolved = vec![ ResolvedPartnerId { uid: "LR_xyz".to_owned(), + writer: None, source_domain: "liveramp.com".to_owned(), openrtb_atype: 3, }, ResolvedPartnerId { uid: "ID5_abc".to_owned(), + writer: None, source_domain: "id5-sync.com".to_owned(), openrtb_atype: 1, }, ResolvedPartnerId { uid: "pair-id".to_owned(), + writer: None, source_domain: "google.com".to_owned(), openrtb_atype: 571187, }, @@ -240,40 +289,4 @@ mod tests { "should preserve PAIR vendor-specific atype" ); } - - #[test] - fn build_eids_header_truncates_when_too_large() { - let mut resolved = Vec::new(); - for idx in 0..64 { - resolved.push(ResolvedPartnerId { - uid: format!("uid_{}", "x".repeat(100)), - source_domain: format!("partner-{idx}.example.com"), - openrtb_atype: 3, - }); - } - - let (encoded, truncated) = - build_eids_header(&resolved).expect("should build truncated header"); - - assert!(truncated, "should report truncation for large payload"); - assert!( - encoded.len() <= MAX_EIDS_HEADER_BYTES, - "should cap encoded header bytes" - ); - } - - #[test] - fn build_eids_header_fits_without_truncation() { - let resolved = vec![ResolvedPartnerId { - uid: "u1".to_owned(), - source_domain: "ssp.com".to_owned(), - openrtb_atype: 3, - }]; - - let (encoded, truncated) = - build_eids_header(&resolved).expect("should build header without truncation"); - - assert!(!truncated, "should not truncate small payload"); - assert!(!encoded.is_empty(), "should produce non-empty value"); - } } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 019ef2aa4..1e210f02e 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -20,7 +20,7 @@ use super::kv::{ apply_partner_id_updates, }; use super::kv_types::KvEntry; -use super::prebid_eids::collect_eid_updates; +use super::prebid_eids::collect_browser_eid_updates; use super::pull_sync_marker::{expire_marker, reconcile_marker}; use super::registry::PartnerRegistry; use super::{EcKvSnapshot, EidSyncSource, current_timestamp, log_id}; @@ -42,20 +42,23 @@ const EC_RESPONSE_HEADERS: &[&str] = &[ /// immediately and the EC identity-graph KV tombstone is the authoritative /// revocation marker. There is no separate consent KV store to clean up. /// -/// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted -/// from the request *before* routing consumes it. +/// Staged auction body updates and sharedId are combined into one browser mutation. pub fn ec_finalize_response( settings: &Settings, ec_context: &mut EcContext, kv: Option<&KvIdentityGraph>, registry: &PartnerRegistry, - eids_cookie: Option<&str>, sharedid_cookie: Option<&str>, response: &mut Response, ) { ec_context.validate_pull_sync_marker(settings, registry); - let consent_allows_ec = ec_consent_granted(ec_context.consent()); - let consent_withdrawn = ec_consent_withdrawn(ec_context.consent()); + let captured_withdrawal = response + .extensions() + .get::() + .is_some_and(|effects| effects.withdraws(ec_context)); + let consent_allows_ec = ec_consent_granted(ec_context.consent()) && !captured_withdrawal; + let consent_withdrawn = ec_consent_withdrawn(ec_context.consent()) || captured_withdrawal; + let body_updates = ec_context.take_browser_eid_updates(); if !consent_allows_ec { // Expire the request-local marker independently of the EC cookie: a @@ -72,6 +75,7 @@ pub fn ec_finalize_response( consent_withdrawn, response, ); + super::identity_handoff::finalize_response(settings, registry, ec_context, response); return; } @@ -79,16 +83,12 @@ pub fn ec_finalize_response( if ec_context.ec_was_present() && !ec_context.ec_generated() && consent_allows_ec { if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value().map(str::to_owned)) { let source = ec_context.eid_sync_source(); - let collected = source + let mut collected = source .map(|_| { - collect_eid_updates( - eids_cookie, - sharedid_cookie, - ec_context.client_eids(), - registry, - ) + collect_browser_eid_updates(body_updates.clone(), sharedid_cookie, registry) }) .unwrap_or_default(); + protect_pending_capture_updates(&mut collected, ec_context, response); let had_eid_updates = !collected.is_empty(); let updates = gate_eid_updates_by_consent(collected, ec_context.consent()); // `sync_eid_cookie_updates` early-returns without refreshing the @@ -123,8 +123,9 @@ pub fn ec_finalize_response( reconcile_pull_sync_marker(settings, registry, ec_context, response); - // Ordinary returning-user page views no longer refresh the browser - // cookie, emit the EC header, or update KV TTL. + // Capture handoff reconciliation is separate from browser EID ingestion. + super::identity_handoff::finalize_response(settings, registry, ec_context, response); + // Ordinary returning-user page views do not refresh the EC cookie. return; } @@ -139,12 +140,7 @@ pub fn ec_finalize_response( }; let updates = gate_eid_updates_by_consent( - collect_eid_updates( - eids_cookie, - sharedid_cookie, - ec_context.client_eids(), - registry, - ), + collect_browser_eid_updates(body_updates, sharedid_cookie, registry), ec_context.consent(), ); sync_eid_cookie_updates(graph, ec_context, &ec_id, &updates, EidSyncSource::NewEc); @@ -156,6 +152,21 @@ pub fn ec_finalize_response( } reconcile_pull_sync_marker(settings, registry, ec_context, response); + super::identity_handoff::finalize_response(settings, registry, ec_context, response); +} + +fn protect_pending_capture_updates( + updates: &mut Vec, + context: &EcContext, + response: &Response, +) { + let capture = response + .extensions() + .get::(); + updates.retain(|update| { + !super::identity_handoff::protects_source(context, &update.partner_id) + && !capture.is_some_and(|effects| effects.protects_source(&update.partner_id)) + }); } fn sync_eid_cookie_updates( @@ -526,12 +537,9 @@ where mod tests { use http::HeaderValue; - use base64::Engine as _; - use super::*; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; - use crate::openrtb::{Eid, Uid}; use crate::redacted::Redacted; use crate::settings::EcPartner; use crate::test_support::tests::{create_test_settings, purpose_one_only_consent}; @@ -603,6 +611,7 @@ mod tests { fn make_partner(source_domain: &str) -> EcPartner { EcPartner { + identity_owner: None, name: format!("Partner {source_domain}"), source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), @@ -620,6 +629,131 @@ mod tests { } } + #[test] + fn captured_withdrawal_precedes_browser_enrichment_and_is_retained_for_after_send() { + let settings = create_test_settings(); + let ec_id = sample_ec_id("captwd"); + let graph = KvIdentityGraph::in_memory("captured-withdrawal"); + let mut live = KvEntry::minimal("id5-sync.com", "stored-id", current_timestamp()); + live.consent.ok = true; + graph.create(&ec_id, &live).expect("should seed live row"); + let registry = PartnerRegistry::from_config(&[ + make_partner("id5-sync.com"), + make_partner("sharedid.org"), + ]) + .expect("should build registry"); + let mut context = make_context( + Some(&ec_id), + Some(&ec_id), + true, + false, + Jurisdiction::NonRegulated, + ); + context.set_kv_snapshot(graph.load_snapshot(&ec_id)); + context.set_eid_sync_source(EidSyncSource::Auction); + context.stage_browser_eid_updates(vec![PartnerIdUpdate::new("id5-sync.com", "body-id")]); + let effects = super::super::identity::stage_capture( + "lockr", + &["id5-sync.com"], + super::super::identity::IdentityObservation { + outcomes: vec![super::super::identity::IdentityOutcome::ConsentWithdrawn], + consent: Default::default(), + }, + &context, + ®istry, + &settings, + ) + .expect("should stage approved global withdrawal"); + let mut response = empty_response(); + response.extensions_mut().insert(effects.clone()); + set_header(&mut response, "x-ts-ec", &ec_id); + set_header(&mut response, "x-ts-eids", "retired-value"); + ec_finalize_response( + &settings, + &mut context, + Some(&graph), + ®istry, + Some("shared-id"), + &mut response, + ); + assert!( + response.headers()[http::header::SET_COOKIE] + .to_str() + .expect("cookie header") + .contains("Max-Age=0") + ); + assert!(response.headers().get("x-ts-ec").is_none()); + assert!(response.headers().get("x-ts-eids").is_none()); + let snapshot = context + .kv_snapshot() + .entry_for(&ec_id) + .expect("should retain withdrawn snapshot"); + assert!(!snapshot.consent.ok); + assert!( + snapshot.ids.is_empty(), + "withdrawal cannot seed body or sharedId" + ); + let (_, generation) = graph + .get(&ec_id) + .expect("should read tombstone") + .expect("tombstone exists"); + effects.apply(&graph, ®istry, &mut context); + let (stored, after_generation) = graph + .get(&ec_id) + .expect("should read tombstone") + .expect("tombstone exists"); + assert_eq!( + generation, after_generation, + "after-send must not duplicate finalization tombstone" + ); + assert!(stored.ids.is_empty()); + } + + #[test] + fn captured_withdrawal_for_another_full_ec_id_is_ignored() { + let settings = create_test_settings(); + let ec_id = sample_ec_id("captwd"); + let other_id = sample_ec_id("other1"); + let registry = PartnerRegistry::empty(); + let old_context = make_context( + Some(&ec_id), + Some(&ec_id), + true, + false, + Jurisdiction::NonRegulated, + ); + let effects = super::super::identity::stage_capture( + "lockr", + &[], + super::super::identity::IdentityObservation { + outcomes: vec![super::super::identity::IdentityOutcome::ConsentWithdrawn], + consent: Default::default(), + }, + &old_context, + ®istry, + &settings, + ) + .expect("should stage withdrawal"); + let mut context = make_context( + Some(&other_id), + Some(&other_id), + true, + false, + Jurisdiction::NonRegulated, + ); + let mut response = empty_response(); + response.extensions_mut().insert(effects); + ec_finalize_response( + &settings, + &mut context, + None, + ®istry, + None, + &mut response, + ); + assert!(response.headers().get(http::header::SET_COOKIE).is_none()); + } + #[test] fn withdrawal_ec_ids_returns_cookie_ec_only_when_active_missing() { let cookie_ec = sample_ec_id("cook1e"); @@ -764,7 +898,6 @@ mod tests { Some(&kv), ®istry, None, - None, &mut response, ); @@ -816,7 +949,6 @@ mod tests { Some(&kv), ®istry, None, - None, &mut response, ); @@ -867,7 +999,6 @@ mod tests { Some(&kv), ®istry, None, - None, &mut response, ); @@ -912,7 +1043,6 @@ mod tests { None, &test_registry, None, - None, &mut response, ); @@ -964,7 +1094,6 @@ mod tests { None, &test_registry, None, - None, &mut response, ); @@ -998,7 +1127,6 @@ mod tests { None, &test_registry, None, - None, &mut response, ); @@ -1032,7 +1160,6 @@ mod tests { None, &test_registry, None, - None, &mut response, ); @@ -1073,7 +1200,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -1115,14 +1241,10 @@ mod tests { ); ec_context.set_recovery_eligible(true); ec_context.set_eid_sync_source(EidSyncSource::Navigation); - ec_context.set_client_eids(vec![Eid { - source: "id5-sync.com".to_owned(), - uids: vec![Uid { - id: "ID5_should_not_persist".to_owned(), - atype: Some(1), - ext: None, - }], - }]); + ec_context.stage_browser_eid_updates(vec![PartnerIdUpdate::new( + "id5-sync.com", + "ID5_should_not_persist", + )]); let partners = vec![make_partner("id5-sync.com")]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let graph = KvIdentityGraph::in_memory("test_store"); @@ -1134,7 +1256,6 @@ mod tests { Some(&graph), ®istry, None, - None, &mut response, ); @@ -1189,7 +1310,6 @@ mod tests { Some(&graph), ®istry, None, - None, &mut response, ); @@ -1232,7 +1352,6 @@ mod tests { &mut ec_context, Some(&graph), ®istry, - None, Some("shared-cookie-id"), &mut response, ); @@ -1276,7 +1395,6 @@ mod tests { &mut ec_context, Some(&graph), ®istry, - None, Some(cookie_id), &mut response, ); @@ -1319,7 +1437,6 @@ mod tests { &mut ec_context, Some(&graph), ®istry, - None, Some("generated-cookie-id"), &mut response, ); @@ -1464,7 +1581,6 @@ mod tests { &mut ec_context, Some(&graph), ®istry, - None, Some("shared-cookie-id"), &mut response, ); @@ -1482,12 +1598,9 @@ mod tests { } #[test] - fn finalize_persists_every_configured_partner_from_client_eids_over_a_trimmed_cookie() { - // Regression test for #1184: the `ts-eids` cookie only carries what - // the browser could fit under its size cap, but `/auction` also - // hands finalization the full EID set from the request body via - // `EcContext::set_client_eids`. That full set must land in KV even - // when the cookie alone would have dropped a configured partner. + fn finalize_persists_every_configured_partner_from_staged_body_eids() { + // Regression test for #1184: persistence must use the full auction body, + // without depending on the retired size-capped EID cookie. let settings = create_test_settings(); let ec_id = sample_ec_id("cleids1"); let graph = KvIdentityGraph::in_memory("test_store"); @@ -1508,31 +1621,9 @@ mod tests { false, ); ec_context.set_eid_sync_source(EidSyncSource::Auction); - // Only `id5-sync.com` "fit" in the (simulated) trimmed cookie; - // `liveramp.com` was dropped by the browser's size cap. - let eids_cookie = base64::engine::general_purpose::STANDARD.encode( - serde_json::to_vec(&serde_json::json!([ - {"source": "id5-sync.com", "uids": [{"id": "ID5_from_cookie", "atype": 1}]} - ])) - .expect("should serialize test cookie payload"), - ); - ec_context.set_client_eids(vec![ - Eid { - source: "id5-sync.com".to_owned(), - uids: vec![Uid { - id: "ID5_from_body".to_owned(), - atype: Some(1), - ext: None, - }], - }, - Eid { - source: "liveramp.com".to_owned(), - uids: vec![Uid { - id: "LR_from_body".to_owned(), - atype: Some(3), - ext: None, - }], - }, + ec_context.stage_browser_eid_updates(vec![ + PartnerIdUpdate::new("id5-sync.com", "ID5_from_body"), + PartnerIdUpdate::new("liveramp.com", "LR_from_body"), ]); let partners = vec![make_partner("id5-sync.com"), make_partner("liveramp.com")]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); @@ -1543,7 +1634,6 @@ mod tests { &mut ec_context, Some(&graph), ®istry, - Some(&eids_cookie), None, &mut response, ); @@ -1555,12 +1645,12 @@ mod tests { assert_eq!( stored.ids.get("id5-sync.com").map(|id| id.uid.as_str()), Some("ID5_from_body"), - "the request body's EID should win over the cookie's stale value" + "the request body's EID should persist without an EID cookie" ); assert_eq!( stored.ids.get("liveramp.com").map(|id| id.uid.as_str()), Some("LR_from_body"), - "a partner the cookie trimmed must still be ingested from the body" + "every configured partner must be ingested from the body" ); } @@ -1595,14 +1685,10 @@ mod tests { ec_id: ec_id.clone(), }); ec_context.set_eid_sync_source(EidSyncSource::Auction); - ec_context.set_client_eids(vec![Eid { - source: "id5-sync.com".to_owned(), - uids: vec![Uid { - id: "ID5_should_not_persist".to_owned(), - atype: Some(1), - ext: None, - }], - }]); + ec_context.stage_browser_eid_updates(vec![PartnerIdUpdate::new( + "id5-sync.com", + "ID5_should_not_persist", + )]); let partners = vec![make_partner("id5-sync.com")]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let mut response = empty_response(); @@ -1613,7 +1699,6 @@ mod tests { Some(&graph), ®istry, None, - None, &mut response, ); @@ -1652,7 +1737,6 @@ mod tests { &mut ec_context, Some(&graph), ®istry, - None, Some("shared-cookie-id"), &mut response, ); @@ -1697,7 +1781,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -1744,7 +1827,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -1801,7 +1883,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -1848,7 +1929,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -1878,7 +1958,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -1901,7 +1980,6 @@ mod tests { None, &test_registry, None, - None, &mut response, ); @@ -1937,7 +2015,6 @@ mod tests { None, &test_registry, None, - None, &mut response, ); @@ -2009,7 +2086,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -2036,7 +2112,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -2062,7 +2137,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -2090,7 +2164,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -2129,7 +2202,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -2182,7 +2254,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -2218,7 +2289,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut repeated_response, ); @@ -2266,7 +2336,6 @@ mod tests { Some(&graph), &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -2317,6 +2386,7 @@ mod tests { "ssp.example.com".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "partner-uid".to_owned(), + ..Default::default() }, ); ec_context.set_kv_snapshot(EcKvSnapshot::Present { @@ -2332,7 +2402,6 @@ mod tests { None, ®istry, None, - None, &mut response, ); @@ -2368,7 +2437,6 @@ mod tests { None, &PartnerRegistry::empty(), None, - None, &mut response, ); @@ -2399,7 +2467,6 @@ mod tests { None, &PartnerRegistry::empty(), None, - None, &mut response, ); diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 94464d1a5..3c1153eff 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -97,7 +97,7 @@ pub fn handle_identify( } else { // Extract only this partner's UID. if let Some(partner_uid) = entry.ids.get(&partner.source_domain) - && !partner_uid.uid.is_empty() + && partner_uid.is_usable(super::checked_current_timestamp()) { uid = Some(partner_uid.uid.clone()); } @@ -125,6 +125,9 @@ pub fn handle_identify( } let eid = uid.as_ref().map(|u| Eid { + inserter: None, + matcher: None, + mm: None, source: partner.source_domain.clone(), uids: vec![Uid { id: u.clone(), @@ -374,6 +377,7 @@ mod tests { pull_sync_allowed_domains: vec![], pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), + identity_owner: None, ts_pull_token: None, } } @@ -586,6 +590,70 @@ mod tests { assert_no_store(&response); } + #[test] + fn handle_identify_omits_expired_identity() { + let settings = create_test_settings(); + let graph = KvIdentityGraph::in_memory("identify-store"); + let registry = + PartnerRegistry::from_config(&[make_test_partner("ids.example.com", VALID_API_TOKEN)]) + .expect("should build registry"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let mut entry = + crate::ec::kv_types::KvEntry::minimal("ids.example.com", "expired-uid", 1_000); + entry + .ids + .get_mut("ids.example.com") + .expect("should contain record") + .expires_at = Some(1); + entry.network = Some(crate::ec::kv_types::KvNetwork { + cluster_size: Some(1), + }); + graph.create(&ec_id, &entry).expect("should seed identity"); + let (_, generation_before) = graph + .get(&ec_id) + .expect("should read identity") + .expect("should find identity"); + let req = Request::builder() + .method("GET") + .uri("https://publisher.example.com/_ts/api/v1/identify") + .header("authorization", format!("Bearer {VALID_API_TOKEN}")) + .body(EdgeBody::empty()) + .expect("should build identify request"); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + + let response = handle_identify(&settings, &graph, ®istry, &req, &ec_context) + .expect("should build identify response"); + assert_eq!( + response.status(), + StatusCode::OK, + "should retain the identify response contract" + ); + assert_no_store(&response); + let body: serde_json::Value = serde_json::from_slice( + &response + .into_body() + .into_bytes() + .expect("should read response"), + ) + .expect("should decode identify response"); + assert!( + body.get("uid").is_none() && body.get("eid").is_none(), + "should omit the expired UID and EID" + ); + let (retained, generation_after) = graph + .get(&ec_id) + .expect("should read retained identity") + .expect("should retain identity"); + assert_eq!( + retained, entry, + "should retain lifecycle state without cleanup" + ); + assert_eq!( + generation_after, generation_before, + "should not write merely to omit an expired ID" + ); + } + #[test] fn handle_identify_kv_failure_sets_degraded_true() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/identity.rs b/crates/trusted-server-core/src/ec/identity.rs new file mode 100644 index 000000000..c1711d927 --- /dev/null +++ b/crates/trusted-server-core/src/ec/identity.rs @@ -0,0 +1,1082 @@ +//! Shared policy for framework-associated identity observations. +//! +//! Integrations interpret provider data; this module validates authority and +//! stages effects. Only the adapter's post-send executor applies capture writes. + +use std::collections::BTreeMap; + +use crate::consent::{RawConsentSignals, identity_capture_consent_allowed}; +use crate::settings::Settings; + +use super::identity_handoff_codec::{HANDOFF_TTL_SECS, HandoffEntry}; +use super::kv::KvIdentityGraph; +use super::kv_types::MAX_UID_LENGTH; +use super::partner::normalize_partner_source_domain; +use super::registry::PartnerRegistry; +use super::{EcContext, EcKvSnapshot, checked_current_timestamp, is_valid_ec_id}; + +/// Maximum distinct sources contributed by one observation. +const MAX_CAPTURE_SOURCES: usize = 64; + +/// Provider interpretation. Browser submissions cannot construct trusted effects. +pub enum IdentityOutcome { + /// A provider issued a UID, optionally with expiry in Unix seconds. + Issued { + /// Registered identity namespace. + source: String, + /// Opaque provider UID. + uid: String, + /// Supported provider expiry, when supplied. + expires_at: Option, + }, + /// No new token, not invalidation or withdrawal. + NoChange, + /// Explicit approved publisher-wide withdrawal, independent of token gating. + ConsentWithdrawn, +} + +/// Body consent signals that may restrict, but never grant request permission. +#[derive(Clone, Default)] +pub struct IdentityConsentSignals { + /// Populated TCF string. + pub tcf: Option, + /// Populated GPP string. + pub gpp: Option, + /// Populated US Privacy string. + pub usp: Option, + /// Malformed body or signal shape must fail acquisition closed. + pub malformed: bool, +} + +/// Untrusted interpretation returned by a registered capture capability. +pub struct IdentityObservation { + /// Interpreted token or withdrawal outcomes. + pub outcomes: Vec, + /// Optional body restrictions, checked by the shared consent policy. + pub consent: IdentityConsentSignals, +} + +/// Validated capture update. Writer and revisions are assigned by EC, not JSON. +#[derive(Clone)] +pub(crate) struct ManagedPartnerIdUpdate { + pub(crate) source: String, + pub(crate) uid: String, + pub(crate) expires_at: Option, + /// Matching authenticated pending expiry, used only when filling an absent source. + pub(crate) pending_expires_at: Option, +} + +/// Request-scoped effects sealed by the framework's proxy dispatch boundary. +/// +/// This type intentionally has neither deserialization nor a public constructor. +/// It contains only bounded UID data and the existing full EC-ID-bound snapshot. +#[derive(Clone)] +pub struct IdentityEffects { + module: &'static str, + ec_id: String, + snapshot: EcKvSnapshot, + action: IdentityAction, + replay: Vec, +} + +#[derive(Clone)] +enum IdentityAction { + Enrich(Vec), + Withdraw, +} + +impl IdentityEffects { + pub(crate) fn handoff_entries(&self, context: &EcContext, now: u64) -> Vec { + if context.ec_value() != Some(self.ec_id.as_str()) { + return Vec::new(); + } + let IdentityAction::Enrich(updates) = &self.action else { + return Vec::new(); + }; + updates + .iter() + .map(|update| { + let expiry = update.expires_at.or(update.pending_expires_at); + let mut entry = HandoffEntry { + module: self.module.to_owned(), + source: update.source.clone(), + uid: update.uid.clone(), + expires_at: expiry, + deadline: expiry.map_or(now.saturating_add(HANDOFF_TTL_SECS), |expiry| { + expiry.min(now.saturating_add(HANDOFF_TTL_SECS)) + }), + }; + if let Some(pending) = context.identity_handoff.matching_capture( + context, + self.module, + &update.source, + &update.uid, + now, + ) && pending.expires_at == expiry + { + entry.deadline = entry.deadline.min(pending.deadline); + } + entry + }) + .collect() + } + + pub(crate) fn handoff_replay(context: &EcContext, replay: Vec) -> Self { + Self { + module: "handoff", + ec_id: context.ec_value().unwrap_or_default().to_owned(), + snapshot: context.kv_snapshot().clone(), + action: IdentityAction::Enrich(Vec::new()), + replay, + } + } + + pub(crate) fn add_handoff_replay(&mut self, replay: Vec) { + if matches!(self.action, IdentityAction::Enrich(_)) { + self.replay = replay; + } + } + + pub(crate) fn protects_source(&self, source: &str) -> bool { + matches!(&self.action, IdentityAction::Enrich(updates) if updates.iter().any(|update| update.source == source)) + } + + /// Whether this effect requests withdrawal for the current full EC identity. + #[must_use] + pub fn withdraws(&self, context: &EcContext) -> bool { + matches!(self.action, IdentityAction::Withdraw) + && context.ec_value() == Some(self.ec_id.as_str()) + } + + /// Applies once after send, then supplies persisted state to later pull work. + /// + /// Missing, failed, mismatched and withdrawn roots never authorize enrichment. + /// A withdrawal already completed by finalization does not write again. + pub fn apply( + self, + graph: &KvIdentityGraph, + partners: &PartnerRegistry, + context: &mut EcContext, + ) { + if context.ec_value() != Some(self.ec_id.as_str()) { + return; + } + let snapshot = if context.kv_snapshot().belongs_to(&self.ec_id) { + context.kv_snapshot().clone() + } else { + self.snapshot + }; + let result = match self.action { + IdentityAction::Withdraw => { + graph.tombstone_existing_from_snapshot(&self.ec_id, snapshot) + } + IdentityAction::Enrich(updates) if context.ec_allowed() => graph + .upsert_managed_ids_from_snapshot( + &self.ec_id, + self.module, + partners, + &updates, + snapshot, + ), + IdentityAction::Enrich(_) => return, + }; + let result = if !self.replay.is_empty() && context.ec_allowed() { + graph.upsert_handoff_ids_from_snapshot(&self.ec_id, partners, &self.replay, result) + } else { + result + }; + context.set_kv_snapshot(result); + } +} + +/// Sends exactly once before opening capture storage or applying effects. +/// +/// Returns false if capture storage is unavailable, suppressing later disclosure. +/// No effect means no capture lookup. Adapters must finalize withdrawal cookies +/// before handing their actual response sender to this function. +pub fn send_then_apply_identity( + effects: Option, + context: &mut EcContext, + partners: &PartnerRegistry, + send: F, + graph_factory: G, +) -> bool +where + F: FnOnce(), + G: FnOnce() -> Option, +{ + send(); + if let Some(effects) = effects { + let Some(graph) = graph_factory() else { + return false; + }; + effects.apply(&graph, partners, context); + } + true +} + +/// Seals observations from the actual registered proxy response, never headers. +pub(crate) fn stage_capture( + module: &'static str, + claims: &[&str], + observation: IdentityObservation, + context: &EcContext, + partners: &PartnerRegistry, + settings: &Settings, +) -> Option { + let ec_id = context.ec_value().filter(|id| is_valid_ec_id(id))?; + if !context.ec_was_present() || context.ec_generated() { + return None; + } + if observation + .outcomes + .iter() + .any(|outcome| matches!(outcome, IdentityOutcome::ConsentWithdrawn)) + { + return Some(IdentityEffects { + module, + ec_id: ec_id.to_owned(), + snapshot: context.kv_snapshot().clone(), + action: IdentityAction::Withdraw, + replay: Vec::new(), + }); + } + if !observation_consent_allowed(&observation.consent, context, settings) { + return None; + } + let now = checked_current_timestamp(); + let mut updates = BTreeMap::new(); + for outcome in observation.outcomes { + let IdentityOutcome::Issued { + source, + uid, + expires_at, + } = outcome + else { + continue; + }; + let Ok(source) = normalize_partner_source_domain(&source) else { + continue; + }; + let Some(partner) = partners.get(&source) else { + continue; + }; + if partner.identity_owner.as_deref() != Some(module) + || !claims.iter().any(|claim| { + normalize_partner_source_domain(claim).ok().as_deref() == Some(&source) + }) + || uid.trim().is_empty() + || uid.len() > MAX_UID_LENGTH + || expires_at.is_some_and(|expiry| now.is_none_or(|now| now >= expiry)) + { + continue; + } + if !updates.contains_key(&source) && updates.len() >= MAX_CAPTURE_SOURCES { + continue; + } + let pending_expires_at = now + .and_then(|now| { + context + .identity_handoff + .matching_capture(context, module, &source, &uid, now) + }) + .and_then(|entry| entry.expires_at); + updates.insert( + source.clone(), + ManagedPartnerIdUpdate { + source, + uid, + expires_at, + pending_expires_at, + }, + ); + } + if updates.is_empty() { + return None; + } + Some(IdentityEffects { + module, + ec_id: ec_id.to_owned(), + snapshot: context.kv_snapshot().clone(), + action: IdentityAction::Enrich(updates.into_values().collect()), + replay: Vec::new(), + }) +} + +pub(crate) fn observation_consent_allowed( + signals: &IdentityConsentSignals, + context: &EcContext, + settings: &Settings, +) -> bool { + let raw = RawConsentSignals { + raw_tc_string: signals.tcf.clone(), + raw_gpp_string: signals.gpp.clone(), + raw_us_privacy: signals.usp.clone(), + ..Default::default() + }; + !signals.malformed + && context.ec_allowed() + && identity_capture_consent_allowed(context.consent(), &raw, &settings.consent) +} + +#[cfg(test)] +mod tests { + use std::cell::{Cell, RefCell}; + + use super::*; + use crate::consent::jurisdiction::Jurisdiction; + use crate::consent::{ConsentContext, ConsentSource}; + use crate::consent_config::ConsentMode; + use crate::ec::kv_types::{KvEntry, KvPartnerId}; + use crate::settings::EcPartner; + use crate::test_support::tests::create_test_settings; + + const SOURCE: &str = "ids.example.com"; + const MODULE: &str = "example_identity"; + const CLAIMS: &[&str] = &[SOURCE]; + + fn ec_id(suffix: &str) -> String { + format!("{}.{}", "a".repeat(64), &suffix[..6]) + } + + fn context(id: &str) -> EcContext { + EcContext::new_for_test( + Some(id.to_owned()), + ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + source: ConsentSource::Cookie, + ..Default::default() + }, + ) + } + + fn registry(owner: Option<&str>) -> PartnerRegistry { + PartnerRegistry::from_config(&[EcPartner { + name: "Synthetic ID source".to_owned(), + source_domain: SOURCE.to_owned(), + identity_owner: owner.map(str::to_owned), + openrtb_atype: 1, + bidstream_enabled: true, + api_token: None, + batch_rate_limit: EcPartner::default_batch_rate_limit(), + pull_sync_enabled: false, + pull_sync_url: None, + pull_sync_allowed_domains: vec![], + pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), + pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), + ts_pull_token: None, + }]) + .expect("should build synthetic registry") + } + + fn issued(source: &str, uid: &str, expiry: Option) -> IdentityObservation { + IdentityObservation { + outcomes: vec![IdentityOutcome::Issued { + source: source.to_owned(), + uid: uid.to_owned(), + expires_at: expiry, + }], + consent: IdentityConsentSignals::default(), + } + } + + fn live_graph(id: &str) -> KvIdentityGraph { + let graph = KvIdentityGraph::in_memory("identity-capture-tests"); + let mut entry = KvEntry::minimal( + "other.example.com", + "untouched", + super::super::current_timestamp(), + ); + entry.consent.ok = true; + graph + .create(id, &entry) + .expect("should seed consenting EC row"); + graph + } + + fn capture_and_apply( + graph: &KvIdentityGraph, + context: &mut EcContext, + partners: &PartnerRegistry, + settings: &Settings, + observation: IdentityObservation, + claims: &[&str], + ) -> bool { + let Some(effect) = stage_capture(MODULE, claims, observation, context, partners, settings) + else { + return false; + }; + effect.apply(graph, partners, context); + true + } + + fn stored(graph: &KvIdentityGraph, id: &str) -> (KvEntry, u64) { + graph + .get(id) + .expect("should read KV") + .expect("should find existing EC row") + } + + #[test] + fn capture_rejects_unclaimed_unowned_and_unknown_sources_without_mutation() { + let id = ec_id("source01"); + let graph = live_graph(&id); + let settings = create_test_settings(); + let owned = registry(Some(MODULE)); + let unowned = registry(None); + let original = stored(&graph, &id); + for (partners, claims, source) in [ + (&owned, &[][..], SOURCE), + (&unowned, CLAIMS, SOURCE), + (&owned, CLAIMS, "unknown.example.com"), + ] { + let mut request = context(&id); + request.set_kv_snapshot(graph.load_snapshot(&id)); + assert!(!capture_and_apply( + &graph, + &mut request, + partners, + &settings, + issued(source, "synthetic-uid", None), + claims + )); + assert_eq!( + stored(&graph, &id), + original, + "rejected source cannot write" + ); + } + let mut request = context(&id); + for bad in [ + issued("https://ids.example.com/path", "synthetic-uid", None), + issued(SOURCE, &"x".repeat(MAX_UID_LENGTH + 1), None), + ] { + assert!(!capture_and_apply( + &graph, + &mut request, + &owned, + &settings, + bad, + CLAIMS + )); + assert_eq!(stored(&graph, &id), original); + } + } + + #[test] + fn trusted_writer_and_revision_are_assigned_by_ec_not_observation() { + let id = ec_id("writer01"); + let graph = KvIdentityGraph::in_memory("identity-writer-test"); + let mut entry = KvEntry::minimal( + "other.example.com", + "untouched", + super::super::current_timestamp(), + ); + entry.consent.ok = true; + entry.ids.insert( + SOURCE.to_owned(), + KvPartnerId { + uid: "synthetic-uid".to_owned(), + expires_at: None, + writer: Some("browser".to_owned()), + revision: Some("untrusted-label".to_owned()), + }, + ); + graph + .create(&id, &entry) + .expect("should seed non-authoritative source metadata"); + let partners = registry(Some(MODULE)); + let settings = create_test_settings(); + let mut request = context(&id); + request.set_kv_snapshot(graph.load_snapshot(&id)); + assert!(capture_and_apply( + &graph, + &mut request, + &partners, + &settings, + issued("IDS.EXAMPLE.COM", "synthetic-uid", None), + CLAIMS + )); + let (entry, _) = stored(&graph, &id); + let record = &entry.ids[SOURCE]; + assert_eq!(record.uid, "synthetic-uid"); + assert_eq!(record.writer.as_deref(), Some(MODULE)); + assert!( + record + .revision + .as_ref() + .is_some_and(|revision| !revision.is_empty() && revision != "untrusted-label") + ); + assert!(record.expires_at.is_none()); + assert_eq!(entry.ids["other.example.com"].uid, "untouched"); + } + + #[test] + fn usp_section_in_gpp_allows_capture_persistence_from_request_or_body() { + let id = ec_id("gppusp01"); + let partners = registry(Some(MODULE)); + for mode in [ConsentMode::Proxy, ConsentMode::Interpreter] { + let mut settings = create_test_settings(); + settings.consent.mode = mode; + for in_body in [false, true] { + let graph = live_graph(&id); + let mut request = context(&id); + let mut observation = issued(SOURCE, "synthetic-uid", None); + if in_body { + observation.consent.gpp = Some("DBABTA~1YNN".to_owned()); + } else { + request.consent = + crate::consent::build_context_from_signals(&RawConsentSignals { + raw_gpp_string: Some("DBABTA~1YNN".to_owned()), + ..Default::default() + }); + request.consent.jurisdiction = Jurisdiction::NonRegulated; + } + + assert!( + capture_and_apply( + &graph, + &mut request, + &partners, + &settings, + observation, + CLAIMS, + ), + "should accept a valid no-opt-out USP section in request or body GPP" + ); + let (entry, _) = stored(&graph, &id); + assert_eq!(entry.ids[SOURCE].uid, "synthetic-uid"); + assert_eq!(entry.ids[SOURCE].writer.as_deref(), Some(MODULE)); + } + } + } + + #[test] + fn restrictive_body_signals_fail_closed_even_when_jurisdiction_or_mode_is_permissive() { + let id = ec_id("consent1"); + let graph = live_graph(&id); + let partners = registry(Some(MODULE)); + for mode in [ConsentMode::Proxy, ConsentMode::Interpreter] { + let mut settings = create_test_settings(); + settings.consent.mode = mode; + for consent in [ + IdentityConsentSignals { + tcf: Some("not-tcf".to_owned()), + ..Default::default() + }, + IdentityConsentSignals { + gpp: Some("not-gpp".to_owned()), + ..Default::default() + }, + IdentityConsentSignals { + gpp: Some("DBABTA~1YYN".to_owned()), + ..Default::default() + }, + IdentityConsentSignals { + gpp: Some("DBABTA~1XNN".to_owned()), + ..Default::default() + }, + IdentityConsentSignals { + gpp: Some("DBACNY~not-a-valid-tcf~1YNN".to_owned()), + ..Default::default() + }, + IdentityConsentSignals { + usp: Some("not-usp".to_owned()), + ..Default::default() + }, + IdentityConsentSignals { + usp: Some("1YYN".to_owned()), + ..Default::default() + }, + IdentityConsentSignals { + malformed: true, + ..Default::default() + }, + ] { + let mut observation = issued(SOURCE, "synthetic-uid", None); + observation.consent = consent; + let mut request = context(&id); + assert!( + stage_capture(MODULE, CLAIMS, observation, &request, &partners, &settings) + .is_none() + ); + assert_eq!(stored(&graph, &id).0.ids.get(SOURCE), None); + request.set_kv_snapshot(graph.load_snapshot(&id)); + } + } + } + + #[test] + fn request_denial_wins_over_permissive_body_and_body_optout_over_cookie_permission() { + let id = ec_id("consent2"); + let partners = registry(Some(MODULE)); + let settings = create_test_settings(); + let graph = live_graph(&id); + for (raw_usp, raw_gpp, gpc) in [ + (Some("1YYN"), None, false), + (None, Some("DBABTA~1YYN"), false), + (None, Some("DBABTA~1XNN"), false), + (None, None, true), + ] { + let mut denied = context(&id); + denied.consent.raw_us_privacy = raw_usp.map(str::to_owned); + denied.consent.raw_gpp_string = raw_gpp.map(str::to_owned); + denied.consent.gpc = gpc; + assert!( + stage_capture( + MODULE, + CLAIMS, + issued(SOURCE, "synthetic-uid", None), + &denied, + &partners, + &settings + ) + .is_none() + ); + } + let mut allowed = context(&id); + let mut observation = issued(SOURCE, "synthetic-uid", None); + observation.consent.usp = Some("1YYN".to_owned()); + assert!(!capture_and_apply( + &graph, + &mut allowed, + &partners, + &settings, + observation, + CLAIMS + )); + assert!(!stored(&graph, &id).0.ids.contains_key(SOURCE)); + } + + #[test] + fn no_change_and_absent_identity_do_not_create_or_mutate_kv_rows() { + let id = ec_id("nochange"); + let graph = live_graph(&id); + let partners = registry(Some(MODULE)); + let settings = create_test_settings(); + let mut request = context(&id); + request.set_kv_snapshot(graph.load_snapshot(&id)); + let original = stored(&graph, &id); + assert!(!capture_and_apply( + &graph, + &mut request, + &partners, + &settings, + IdentityObservation { + outcomes: vec![IdentityOutcome::NoChange], + consent: Default::default() + }, + CLAIMS + )); + assert_eq!(stored(&graph, &id), original); + + let missing_id = ec_id("missing1"); + let mut missing = context(&missing_id); + missing.set_kv_snapshot(graph.load_snapshot(&missing_id)); + assert!(capture_and_apply( + &graph, + &mut missing, + &partners, + &settings, + issued(SOURCE, "synthetic-uid", None), + CLAIMS + )); + assert!(graph.get(&missing_id).expect("should read KV").is_none()); + assert!(matches!( + missing.kv_snapshot(), + EcKvSnapshot::Missing { .. } + )); + assert_eq!(stored(&graph, &id), original); + + let no_token = EcContext::new_for_test(None, context(&id).consent().clone()); + assert!( + stage_capture( + MODULE, + CLAIMS, + issued(SOURCE, "synthetic-uid", None), + &no_token, + &partners, + &settings + ) + .is_none() + ); + let generated = EcContext::new_for_test_with_cookie( + Some(ec_id("newborn")), + None, + false, + true, + context(&id).consent().clone(), + ); + assert!( + stage_capture( + MODULE, + CLAIMS, + issued(SOURCE, "synthetic-uid", None), + &generated, + &partners, + &settings + ) + .is_none() + ); + } + + #[test] + fn unreadable_or_tombstoned_roots_cannot_be_revived() { + let id = ec_id("unread1"); + let graph = live_graph(&id); + let partners = registry(Some(MODULE)); + let settings = create_test_settings(); + let original = stored(&graph, &id); + let mut failed = context(&id); + failed.set_kv_snapshot(EcKvSnapshot::Failed { ec_id: id.clone() }); + assert!(capture_and_apply( + &graph, + &mut failed, + &partners, + &settings, + issued(SOURCE, "synthetic-uid", None), + CLAIMS + )); + assert!(matches!(failed.kv_snapshot(), EcKvSnapshot::Failed { .. })); + assert_eq!(stored(&graph, &id), original); + + let mut stale = context(&id); + stale.set_kv_snapshot(graph.load_snapshot(&id)); + let late_effect = stage_capture( + MODULE, + CLAIMS, + issued(SOURCE, "stale-uid", None), + &stale, + &partners, + &settings, + ) + .expect("should stage while live"); + graph.tombstone_existing_from_snapshot(&id, graph.load_snapshot(&id)); + late_effect.apply(&graph, &partners, &mut stale); + let (_, before_generation) = stored(&graph, &id); + let mut withdrawn = context(&id); + withdrawn.set_kv_snapshot(graph.load_snapshot(&id)); + assert!(capture_and_apply( + &graph, + &mut withdrawn, + &partners, + &settings, + issued(SOURCE, "late-uid", None), + CLAIMS + )); + let (after, after_generation) = stored(&graph, &id); + assert!(!after.consent.ok); + assert!(after.ids.is_empty()); + assert_eq!(before_generation, after_generation); + } + + #[test] + fn managed_same_uid_retains_expiry_extends_it_and_never_restores_an_old_revision() { + let id = ec_id("revision"); + let graph = live_graph(&id); + let partners = registry(Some(MODULE)); + let settings = create_test_settings(); + let initial_expiry = checked_current_timestamp().expect("should read test clock") + 3600; + let extended_expiry = initial_expiry + 3600; + let mut request = context(&id); + let mut versions: Vec<(Option, u64)> = Vec::new(); + for (uid, expiry, expected_expiry, should_change) in [ + ( + "synthetic-uid", + Some(initial_expiry), + Some(initial_expiry), + true, + ), + ("synthetic-uid", None, Some(initial_expiry), false), + ( + "synthetic-uid", + Some(extended_expiry), + Some(extended_expiry), + true, + ), + ("other-uid", None, None, true), + ( + "synthetic-uid", + Some(initial_expiry), + Some(initial_expiry), + true, + ), + ] { + request.set_kv_snapshot(graph.load_snapshot(&id)); + assert!(capture_and_apply( + &graph, + &mut request, + &partners, + &settings, + issued(SOURCE, uid, expiry), + CLAIMS + )); + let (entry, generation) = stored(&graph, &id); + let record = &entry.ids[SOURCE]; + assert_eq!(record.uid, uid); + assert_eq!(record.expires_at, expected_expiry); + assert_eq!(record.writer.as_deref(), Some(MODULE)); + if let Some((earlier_revision, earlier_generation)) = versions.last() { + assert_eq!( + generation != *earlier_generation, + should_change, + "only material changes should write" + ); + assert_eq!( + record.revision.as_ref() != earlier_revision.as_ref(), + should_change, + "revision must change only for material updates" + ); + } + versions.push((record.revision.clone(), generation)); + } + let (before, generation) = stored(&graph, &id); + assert_ne!( + versions[0].0, versions[4].0, + "ABA must not restore an older revision" + ); + request.set_kv_snapshot(graph.load_snapshot(&id)); + assert!(capture_and_apply( + &graph, + &mut request, + &partners, + &settings, + issued(SOURCE, "synthetic-uid", None), + CLAIMS + )); + assert_eq!( + stored(&graph, &id), + (before, generation), + "same UID without expiry cannot clear or bump metadata" + ); + } + + #[test] + fn expiry_is_rechecked_when_the_deferred_effect_is_applied() { + let id = ec_id("expire01"); + let graph = live_graph(&id); + let partners = registry(Some(MODULE)); + let mut request = context(&id); + request.set_kv_snapshot(graph.load_snapshot(&id)); + let original = stored(&graph, &id); + // Construct a sealed effect inside the owning module to model an ID + // that was valid at capture but expired while the response was sent. + let effect = IdentityEffects { + module: MODULE, + ec_id: id.clone(), + snapshot: request.kv_snapshot().clone(), + action: IdentityAction::Enrich(vec![ManagedPartnerIdUpdate { + source: SOURCE.to_owned(), + uid: "expired-uid".to_owned(), + expires_at: Some(1), + pending_expires_at: None, + }]), + replay: Vec::new(), + }; + effect.apply(&graph, &partners, &mut request); + assert_eq!( + stored(&graph, &id), + original, + "expired token cannot be written" + ); + } + + #[test] + fn capture_effects_are_bound_to_the_full_ec_id() { + let id = ec_id("original"); + let rotated = ec_id("rotated1"); + let graph = live_graph(&id); + let partners = registry(Some(MODULE)); + let settings = create_test_settings(); + let first = context(&id); + let effect = stage_capture( + MODULE, + CLAIMS, + issued(SOURCE, "synthetic-uid", None), + &first, + &partners, + &settings, + ) + .expect("should stage eligible capture"); + let mut later = context(&rotated); + assert!(!effect.withdraws(&later)); + effect.apply(&graph, &partners, &mut later); + assert!(!stored(&graph, &id).0.ids.contains_key(SOURCE)); + assert!(graph.get(&rotated).expect("should read KV").is_none()); + } + + #[test] + fn finalized_withdrawal_skips_duplicate_tombstone_and_late_enrichment() { + let id = ec_id("withdraw"); + let graph = live_graph(&id); + let partners = registry(Some(MODULE)); + let settings = create_test_settings(); + let mut request = context(&id); + request.set_kv_snapshot(graph.load_snapshot(&id)); + let withdrawal = stage_capture( + MODULE, + CLAIMS, + IdentityObservation { + outcomes: vec![IdentityOutcome::ConsentWithdrawn], + consent: Default::default(), + }, + &request, + &partners, + &settings, + ) + .expect("should stage explicit withdrawal"); + assert!(withdrawal.withdraws(&request)); + let finalized = graph.tombstone_existing_from_snapshot(&id, request.kv_snapshot().clone()); + request.set_kv_snapshot(finalized); + let (first, generation) = stored(&graph, &id); + assert!(!first.consent.ok); + assert!(first.ids.is_empty()); + withdrawal.apply(&graph, &partners, &mut request); + let (second, after_generation) = stored(&graph, &id); + assert_eq!( + after_generation, generation, + "post-send must not duplicate finalization" + ); + assert_eq!(second, first); + assert!(capture_and_apply( + &graph, + &mut request, + &partners, + &settings, + issued(SOURCE, "late-uid", None), + CLAIMS + )); + assert_eq!( + stored(&graph, &id), + (first, generation), + "late result cannot revive tombstone" + ); + } + + #[test] + fn send_boundary_persists_staged_effect_only_after_the_response() { + let id = ec_id("send01"); + let graph = live_graph(&id); + let partners = registry(Some(MODULE)); + let settings = create_test_settings(); + let mut request = context(&id); + request.set_kv_snapshot(graph.load_snapshot(&id)); + let before = stored(&graph, &id); + let expires_at = checked_current_timestamp().expect("should read test clock") + 3600; + let effect = stage_capture( + MODULE, + CLAIMS, + issued(SOURCE, "synthetic-uid", Some(expires_at)), + &request, + &partners, + &settings, + ) + .expect("should stage a real validated effect"); + let order = RefCell::new(Vec::new()); + let handled = send_then_apply_identity( + Some(effect), + &mut request, + &partners, + || { + assert_eq!( + stored(&graph, &id), + before, + "send cannot observe capture mutation" + ); + order.borrow_mut().push("sent"); + }, + || { + assert_eq!( + order.borrow().as_slice(), + &["sent"], + "open graph only after send" + ); + assert_eq!(stored(&graph, &id), before); + order.borrow_mut().push("opened graph"); + Some(graph.clone()) + }, + ); + assert!(handled); + assert_eq!(order.borrow().as_slice(), &["sent", "opened graph"]); + let (persisted, generation) = stored(&graph, &id); + assert!( + generation > before.1, + "managed update must reach storage after send" + ); + let record = &persisted.ids[SOURCE]; + assert_eq!(record.uid, "synthetic-uid"); + assert_eq!(record.expires_at, Some(expires_at)); + assert_eq!(record.writer.as_deref(), Some(MODULE)); + assert!(record.revision.is_some()); + let handed_to_later_pull = request + .kv_snapshot() + .entry_for(&id) + .expect("should keep persisted identity state in post-send context"); + assert_eq!( + handed_to_later_pull, &persisted, + "later pull must receive persisted outcome, not the pre-send snapshot" + ); + } + + #[test] + fn send_boundary_skips_storage_without_effects_and_fails_closed_when_unavailable() { + let id = ec_id("send02"); + let graph = live_graph(&id); + let partners = registry(Some(MODULE)); + let settings = create_test_settings(); + let mut request = context(&id); + request.set_kv_snapshot(graph.load_snapshot(&id)); + let before = stored(&graph, &id); + let sends = Cell::new(0); + let factories = Cell::new(0); + assert!(send_then_apply_identity( + None, + &mut request, + &partners, + || sends.set(sends.get() + 1), + || { + factories.set(factories.get() + 1); + Some(graph.clone()) + } + )); + assert_eq!(sends.get(), 1); + assert_eq!(factories.get(), 0, "no token must not open capture storage"); + assert_eq!(stored(&graph, &id), before); + + let effect = stage_capture( + MODULE, + CLAIMS, + issued(SOURCE, "synthetic-uid", None), + &request, + &partners, + &settings, + ) + .expect("should stage real effect"); + let available = send_then_apply_identity( + Some(effect), + &mut request, + &partners, + || { + assert_eq!(stored(&graph, &id), before); + sends.set(sends.get() + 1); + }, + || { + assert_eq!(sends.get(), 2, "factory must follow response send"); + factories.set(factories.get() + 1); + None + }, + ); + assert!( + !available, + "unavailable graph suppresses post-send identity work" + ); + assert_eq!(sends.get(), 2, "each invocation sends exactly once"); + assert_eq!(factories.get(), 1); + assert_eq!(stored(&graph, &id), before); + assert_eq!( + request.kv_snapshot().entry_for(&id), + Some(&before.0), + "failed capture must not claim a persisted UID" + ); + } +} diff --git a/crates/trusted-server-core/src/ec/identity_handoff.rs b/crates/trusted-server-core/src/ec/identity_handoff.rs new file mode 100644 index 000000000..77659f55e --- /dev/null +++ b/crates/trusted-server-core/src/ec/identity_handoff.rs @@ -0,0 +1,367 @@ +//! Shared request policy and response reconciliation for authenticated capture handoffs. + +use core::fmt; +use std::collections::BTreeMap; + +use edgezero_core::body::Body as EdgeBody; +use http::{HeaderValue, Response, header}; + +use crate::consent::{RawConsentSignals, identity_capture_consent_allowed}; +use crate::consent_config::ConsentConfig; +use crate::constants::COOKIE_TS_EIDS_SERVER; +use crate::integrations::IntegrationRegistry; +use crate::openrtb::Eid; +use crate::response_privacy::enforce_set_cookie_cache_privacy; +use crate::settings::Settings; + +use super::eids::{ResolvedPartnerId, to_eids}; +use super::identity::IdentityEffects; +use super::identity_handoff_codec::{self as codec, HANDOFF_TTL_SECS, HandoffEntry}; +use super::partner::normalize_partner_source_domain; +use super::registry::PartnerRegistry; +use super::{EcContext, EcKvSnapshot, checked_current_timestamp}; + +/// Authenticated incoming observations, never raw cookie data in diagnostics. +#[derive(Clone, Default)] +pub(crate) struct HandoffState { + present: bool, + ec_id: Option, + entries: Vec, + consent_config: Option, + pub(crate) supported: bool, + pub(crate) proxy_replay_allowed: bool, + pub(crate) proxy_replay_denied: bool, +} + +impl fmt::Debug for HandoffState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("HandoffState") + .field("present", &self.present) + .field("entries", &self.entries.len()) + .field("supported", &self.supported) + .finish() + } +} + +impl HandoffState { + pub(crate) fn from_cookie( + settings: &Settings, + ec_id: Option<&str>, + cookie: Option<&str>, + ) -> Self { + let mut state = Self { + present: cookie.is_some(), + ..Self::default() + }; + let Some(value) = cookie else { + return state; + }; + let Some(ec_id) = ec_id else { + return state; + }; + let Some(now) = checked_current_timestamp() else { + return state; + }; + let Some(entries) = codec::decode( + &settings.ec.identity_handoff, + &settings.publisher.domain, + ec_id, + value, + now, + ) else { + return state; + }; + state.entries = entries + .into_iter() + .filter(|entry| source_allowed(settings, entry)) + .collect(); + state.ec_id = Some(ec_id.to_owned()); + state.consent_config = Some(settings.consent.clone()); + state + } + + pub(crate) fn matching_capture( + &self, + context: &EcContext, + module: &str, + source: &str, + uid: &str, + now: u64, + ) -> Option<&HandoffEntry> { + if !self.allowed(context) { + return None; + } + self.entries.iter().find(|entry| { + entry.module == module && entry.source == source && entry.uid == uid && live(entry, now) + }) + } + + fn allowed(&self, context: &EcContext) -> bool { + self.supported + && !self.proxy_replay_denied + && context.ec_was_present() + && !context.ec_generated() + && context.ec_value() == self.ec_id.as_deref() + && self.consent_config.as_ref().is_some_and(|config| { + identity_capture_consent_allowed( + context.consent(), + &RawConsentSignals::default(), + config, + ) + }) + } +} + +fn source_allowed(settings: &Settings, entry: &HandoffEntry) -> bool { + settings.ec.partners.iter().any(|partner| { + normalize_partner_source_domain(&partner.source_domain) + .ok() + .as_deref() + == Some(entry.source.as_str()) + && partner.identity_owner.as_deref() == Some(entry.module.as_str()) + }) && IntegrationRegistry::capture_claim_enabled(settings, &entry.module, &entry.source) +} + +fn live(entry: &HandoffEntry, now: u64) -> bool { + now < entry.deadline && entry.expires_at.is_none_or(|expiry| now < expiry) +} + +/// Resolves handoff IDs only for absent sources in the current live root. +pub(crate) fn resolve_eids( + context: &EcContext, + snapshot: &EcKvSnapshot, + registry: &PartnerRegistry, + now: Option, +) -> Vec { + if !context.identity_handoff.allowed(context) { + return Vec::new(); + } + let Some(now) = now else { + return Vec::new(); + }; + let Some(ec_id) = context.ec_value() else { + return Vec::new(); + }; + let Some(root) = snapshot.entry_for(ec_id).filter(|root| root.consent.ok) else { + return Vec::new(); + }; + let ids = context + .identity_handoff + .entries + .iter() + .filter_map(|entry| { + let partner = registry.get(&entry.source)?; + (live(entry, now) + && partner.bidstream_enabled + && partner.identity_owner.as_deref() == Some(entry.module.as_str()) + && !root.ids.contains_key(&entry.source)) + .then(|| ResolvedPartnerId { + source_domain: entry.source.clone(), + uid: entry.uid.clone(), + writer: Some(entry.module.clone()), + openrtb_atype: partner.openrtb_atype, + }) + }) + .collect::>(); + to_eids(&ids) +} + +/// Prevents ordinary browser ingestion from stripping a trusted pending record's metadata. +pub(crate) fn protects_source(context: &EcContext, source: &str) -> bool { + let Some(now) = checked_current_timestamp() else { + return false; + }; + context.identity_handoff.allowed(context) + && context + .identity_handoff + .entries + .iter() + .any(|entry| entry.source == source && live(entry, now)) +} + +/// Sets a new capture handoff or reconciles an existing one without opening KV. +/// +/// A `NotRead` snapshot may stage replay, but only the post-send mutation can +/// authorize persistence against an existing live row. Auction reads separately +/// require an already loaded live root. +pub(crate) fn finalize_response( + settings: &Settings, + partners: &PartnerRegistry, + context: &mut EcContext, + response: &mut Response, +) { + if !context.identity_handoff.supported { + return; + } + let withdrawal = response + .extensions() + .get::() + .is_some_and(|effects| effects.withdraws(context)) + || super::consent::ec_consent_withdrawn(context.consent()); + let Some(now) = checked_current_timestamp() else { + clear_if_present(settings, context, response); + return; + }; + if withdrawal + || !settings.ec.identity_handoff.enabled + || !context.ec_allowed() + || context.ec_generated() + { + clear_if_present(settings, context, response); + return; + } + let Some(ec_id) = context.ec_value().map(str::to_owned) else { + clear_if_present(settings, context, response); + return; + }; + let new_entries = response + .extensions() + .get::() + .map(|effects| effects.handoff_entries(context, now)) + .unwrap_or_default(); + let mut pending = BTreeMap::new(); + let mut replay = Vec::new(); + if context.identity_handoff.allowed(context) { + for entry in &context.identity_handoff.entries { + if !live(entry, now) || !source_allowed(settings, entry) { + continue; + } + let partner_owned = partners.get(&entry.source).is_some_and(|partner| { + partner.identity_owner.as_deref() == Some(entry.module.as_str()) + }); + if !partner_owned { + continue; + } + match context.kv_snapshot() { + EcKvSnapshot::Present { .. } => { + let Some(root) = context + .kv_snapshot() + .entry_for(&ec_id) + .filter(|root| root.consent.ok) + else { + continue; + }; + // Both acknowledgement and conflict retire the handoff. An + // expired record is still an existing record, never a hole. + if root.ids.contains_key(&entry.source) { + continue; + } + } + EcKvSnapshot::Missing { .. } => continue, + EcKvSnapshot::Failed { .. } => { + // A failed lookup cannot authorize replay, but need not + // destroy the browser's bounded chance on a later request. + pending.insert(entry.source.clone(), entry.clone()); + continue; + } + EcKvSnapshot::NotRead => {} + } + pending.insert(entry.source.clone(), entry.clone()); + if context.eid_sync_source().is_some() || context.identity_handoff.proxy_replay_allowed + { + replay.push(entry.clone()); + } + } + } + let capture_sources = new_entries + .iter() + .map(|entry| entry.source.clone()) + .collect::>(); + replay.retain(|entry| !capture_sources.contains(&entry.source)); + for entry in new_entries { + if !live(&entry, now) || !source_allowed(settings, &entry) { + continue; + } + match context.kv_snapshot() { + EcKvSnapshot::Present { .. } => { + let Some(root) = context + .kv_snapshot() + .entry_for(&ec_id) + .filter(|root| root.consent.ok) + else { + continue; + }; + if root.ids.contains_key(&entry.source) { + continue; + } + } + EcKvSnapshot::Missing { .. } | EcKvSnapshot::Failed { .. } => continue, + EcKvSnapshot::NotRead => {} + } + pending.insert(entry.source.clone(), entry); + } + if !replay.is_empty() { + if let Some(effects) = response.extensions_mut().get_mut::() { + effects.add_handoff_replay(replay); + } else { + response + .extensions_mut() + .insert(IdentityEffects::handoff_replay(context, replay)); + } + } + let entries = pending.into_values().collect::>(); + if entries.is_empty() { + clear_if_present(settings, context, response); + return; + } + if let Some(value) = codec::encode( + &settings.ec.identity_handoff, + &settings.publisher.domain, + &ec_id, + entries, + now, + ) && let Some(retained) = codec::decode( + &settings.ec.identity_handoff, + &settings.publisher.domain, + &ec_id, + &value, + now, + ) { + let max_age = retained + .iter() + .map(|entry| entry.deadline) + .max() + .unwrap_or(now) + .saturating_sub(now) + .min(HANDOFF_TTL_SECS); + append_cookie(settings, response, &value, max_age); + } else { + clear_if_present(settings, context, response); + } +} + +fn clear_if_present( + settings: &Settings, + context: &mut EcContext, + response: &mut Response, +) { + if context.identity_handoff.present { + append_cookie(settings, response, "", 0); + } + context.identity_handoff.entries.clear(); +} + +fn append_cookie( + settings: &Settings, + response: &mut Response, + value: &str, + max_age: u64, +) { + let cookie = format!( + "{COOKIE_TS_EIDS_SERVER}={value}; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={max_age}", + settings.publisher.ec_cookie_domain() + ); + if let Ok(value) = HeaderValue::from_str(&cookie) { + response.headers_mut().append(header::SET_COOKIE, value); + enforce_set_cookie_cache_privacy(response); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + } +} + +#[cfg(test)] +#[path = "identity_handoff_tests.rs"] +mod tests; diff --git a/crates/trusted-server-core/src/ec/identity_handoff_codec.rs b/crates/trusted-server-core/src/ec/identity_handoff_codec.rs new file mode 100644 index 000000000..c1990d3c3 --- /dev/null +++ b/crates/trusted-server-core/src/ec/identity_handoff_codec.rs @@ -0,0 +1,808 @@ +//! Bounded authenticated codec for the short-lived server identity handoff. + +use std::collections::{BTreeMap, BTreeSet}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, Mac as _}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +use crate::settings::{IdentityHandoffConfig, IdentityHandoffKey}; + +use super::is_valid_ec_id; +use super::kv_types::MAX_UID_LENGTH; +use super::partner::normalize_partner_source_domain; + +/// Maximum size of the complete encoded cookie value. +pub(crate) const MAX_HANDOFF_BYTES: usize = 2048; +/// Maximum remaining lifetime of an entry accepted by the codec. +pub(crate) const HANDOFF_TTL_SECS: u64 = 300; + +const TOKEN_VERSION: &str = "v1"; +const SIGNING_DOMAIN: &[u8] = b"trusted-server/ts-eids-s/hmac-sha256\0"; +const MAX_HANDOFF_SOURCES: usize = 64; +const MAX_KEY_ID_BYTES: usize = 32; +const MAX_MODULE_ID_BYTES: usize = 64; + +type HmacSha256 = Hmac; + +/// One authenticated server-capture observation. +/// +/// This type deliberately omits `Debug`: UIDs must not leak through routine +/// formatting of handoff state. +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct HandoffEntry { + #[serde(rename = "m")] + pub(crate) module: String, + #[serde(rename = "s")] + pub(crate) source: String, + #[serde(rename = "u")] + pub(crate) uid: String, + #[serde(rename = "x", skip_serializing_if = "Option::is_none")] + pub(crate) expires_at: Option, + #[serde(rename = "d")] + pub(crate) deadline: u64, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct HandoffEnvelope { + #[serde(rename = "p")] + publisher: String, + #[serde(rename = "e")] + ec_id: String, + #[serde(rename = "d")] + deadline: u64, + #[serde(rename = "i")] + entries: Vec, +} + +/// Encodes entries with the configured active key. +/// +/// Invalid entries fail closed. When multiple valid entries exceed either the +/// source-count or encoded-size bound, complete entries are removed by +/// `(deadline, source)` order, earliest first. +pub(crate) fn encode( + config: &IdentityHandoffConfig, + publisher: &str, + ec_id: &str, + entries: Vec, + now: u64, +) -> Option { + if !config.enabled || publisher.is_empty() || !is_valid_ec_id(ec_id) { + return None; + } + + let key_id = config.active_key_id.as_str(); + let key = configured_key(config, key_id)?; + let secret = decode_secret(key)?; + + let max_deadline = now.saturating_add(HANDOFF_TTL_SECS); + let mut by_source = BTreeMap::new(); + for mut entry in entries { + if !valid_module_id(&entry.module) + || entry.uid.trim().is_empty() + || entry.uid.len() > MAX_UID_LENGTH + || entry.deadline > max_deadline + || entry + .expires_at + .is_some_and(|expires_at| entry.deadline > expires_at) + { + return None; + } + + entry.source = normalize_partner_source_domain(&entry.source).ok()?; + if by_source.insert(entry.source.clone(), entry).is_some() { + return None; + } + } + + // Never issue already-expired observations. This also lets callers rewrite + // a merged handoff without extending or reissuing stale entries. + by_source.retain(|_, entry| entry_is_live(entry, now)); + if by_source.is_empty() { + return None; + } + + // Reject an individually unencodable entry without letting it suppress + // smaller entries in the same handoff. The exact signed value is measured, + // so JSON escaping, publisher binding, key ID, and signature all count. + let mut individually_oversized = Vec::new(); + for (source, entry) in &by_source { + let value = encode_envelope_value(publisher, ec_id, key_id, &secret, vec![entry.clone()])?; + if value.len() > MAX_HANDOFF_BYTES { + individually_oversized.push(source.clone()); + } + } + for source in individually_oversized { + by_source.remove(&source); + } + if by_source.is_empty() { + return None; + } + + while by_source.len() > MAX_HANDOFF_SOURCES { + remove_earliest(&mut by_source)?; + } + + loop { + let entries = by_source.values().cloned().collect(); + let value = encode_envelope_value(publisher, ec_id, key_id, &secret, entries)?; + if value.len() <= MAX_HANDOFF_BYTES { + return Some(value); + } + remove_earliest(&mut by_source)?; + } +} + +/// Authenticates and decodes a handoff for the exact publisher and full EC ID. +/// +/// An authenticated envelope whose entries have all expired returns +/// `Some(Vec::new())`, allowing the caller to clear it. Structural, signature, +/// key, and binding failures return `None`. +pub(crate) fn decode( + config: &IdentityHandoffConfig, + publisher: &str, + ec_id: &str, + value: &str, + now: u64, +) -> Option> { + if !config.enabled || publisher.is_empty() || value.len() > MAX_HANDOFF_BYTES { + return None; + } + + let mut segments = value.split('.'); + let version = segments.next()?; + let key_id = segments.next()?; + let payload_b64 = segments.next()?; + let signature_b64 = segments.next()?; + if segments.next().is_some() || version != TOKEN_VERSION || !valid_key_id(key_id) { + return None; + } + + let key = configured_key(config, key_id)?; + let secret = decode_secret(key)?; + let signature = URL_SAFE_NO_PAD.decode(signature_b64.as_bytes()).ok()?; + let mut mac = new_mac(&secret)?; + update_mac(&mut mac, version, key_id, payload_b64); + mac.verify_slice(&signature).ok()?; + + let payload = URL_SAFE_NO_PAD.decode(payload_b64.as_bytes()).ok()?; + let envelope: HandoffEnvelope = serde_json::from_slice(&payload).ok()?; + if envelope.publisher != publisher + || envelope.ec_id != ec_id + || !is_valid_ec_id(&envelope.ec_id) + || envelope.entries.is_empty() + || envelope.entries.len() > MAX_HANDOFF_SOURCES + { + return None; + } + + let max_future_deadline = now.saturating_add(HANDOFF_TTL_SECS); + let mut seen_sources = BTreeSet::new(); + for entry in &envelope.entries { + let normalized_source = normalize_partner_source_domain(&entry.source).ok()?; + if normalized_source != entry.source + || !valid_module_id(&entry.module) + || entry.uid.trim().is_empty() + || entry.uid.len() > MAX_UID_LENGTH + || entry.deadline > max_future_deadline + || entry + .expires_at + .is_some_and(|expires_at| entry.deadline > expires_at) + || !seen_sources.insert(entry.source.as_str()) + { + return None; + } + } + + let latest_deadline = envelope.entries.iter().map(|entry| entry.deadline).max()?; + if envelope.deadline != latest_deadline || envelope.deadline > max_future_deadline { + return None; + } + + Some( + envelope + .entries + .into_iter() + .filter(|entry| entry_is_live(entry, now)) + .collect(), + ) +} + +fn configured_key<'a>( + config: &'a IdentityHandoffConfig, + key_id: &str, +) -> Option<&'a IdentityHandoffKey> { + if !valid_key_id(key_id) { + return None; + } + + let mut matches = config.keys.iter().filter(|key| key.id == key_id); + let key = matches.next()?; + if matches.next().is_some() { + return None; + } + Some(key) +} + +fn decode_secret(key: &IdentityHandoffKey) -> Option<[u8; 32]> { + if !valid_key_id(&key.id) { + return None; + } + URL_SAFE_NO_PAD + .decode(key.secret.expose().as_bytes()) + .ok()? + .try_into() + .ok() +} + +fn valid_key_id(value: &str) -> bool { + valid_safe_id(value, MAX_KEY_ID_BYTES) +} + +fn valid_module_id(value: &str) -> bool { + valid_safe_id(value, MAX_MODULE_ID_BYTES) +} + +fn valid_safe_id(value: &str, max_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +fn entry_is_live(entry: &HandoffEntry, now: u64) -> bool { + entry.deadline > now && entry.expires_at.is_none_or(|expires_at| expires_at > now) +} + +fn remove_earliest(entries: &mut BTreeMap) -> Option<()> { + let source = entries + .values() + .min_by(|left, right| { + left.deadline + .cmp(&right.deadline) + .then_with(|| left.source.cmp(&right.source)) + })? + .source + .clone(); + entries.remove(&source).map(|_| ()) +} + +fn encode_envelope_value( + publisher: &str, + ec_id: &str, + key_id: &str, + secret: &[u8; 32], + entries: Vec, +) -> Option { + let deadline = entries.iter().map(|entry| entry.deadline).max()?; + let envelope = HandoffEnvelope { + publisher: publisher.to_owned(), + ec_id: ec_id.to_owned(), + deadline, + entries, + }; + let payload = serde_json::to_vec(&envelope).ok()?; + sign_value(key_id, secret, &payload) +} + +fn sign_value(key_id: &str, secret: &[u8; 32], payload: &[u8]) -> Option { + let payload_b64 = URL_SAFE_NO_PAD.encode(payload); + let mut mac = new_mac(secret)?; + update_mac(&mut mac, TOKEN_VERSION, key_id, &payload_b64); + let signature_b64 = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + Some(format!( + "{TOKEN_VERSION}.{key_id}.{payload_b64}.{signature_b64}" + )) +} + +fn new_mac(secret: &[u8; 32]) -> Option { + ::new_from_slice(secret).ok() +} + +fn update_mac(mac: &mut HmacSha256, version: &str, key_id: &str, payload_b64: &str) { + mac.update(SIGNING_DOMAIN); + mac.update(version.as_bytes()); + mac.update(b"\0"); + mac.update(key_id.as_bytes()); + mac.update(b"\0"); + mac.update(payload_b64.as_bytes()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::redacted::Redacted; + + const NOW: u64 = 1_800_000_000; + const PUBLISHER: &str = "publisher.example"; + const SOURCE: &str = "id.example"; + + fn ec_id(seed: char) -> String { + format!("{}.Ab123Z", seed.to_string().repeat(64)) + } + + fn key(id: &str, byte: u8) -> IdentityHandoffKey { + IdentityHandoffKey { + id: id.to_owned(), + secret: Redacted::new(URL_SAFE_NO_PAD.encode([byte; 32])), + } + } + + fn config() -> IdentityHandoffConfig { + IdentityHandoffConfig { + enabled: true, + active_key_id: "current".to_owned(), + keys: vec![key("current", 7), key("previous", 9)], + } + } + + fn entry(source: &str, uid: &str, deadline: u64) -> HandoffEntry { + HandoffEntry { + module: "lockr".to_owned(), + source: source.to_owned(), + uid: uid.to_owned(), + expires_at: None, + deadline, + } + } + + fn signed_envelope( + config: &IdentityHandoffConfig, + envelope: &HandoffEnvelope, + key_id: &str, + ) -> String { + let key = configured_key(config, key_id).expect("test key should be configured"); + let secret = decode_secret(key).expect("test key should decode"); + let payload = serde_json::to_vec(envelope).expect("test envelope should serialize"); + sign_value(key_id, &secret, &payload).expect("test envelope should sign") + } + + fn resign_payload(config: &IdentityHandoffConfig, payload: &serde_json::Value) -> String { + let key = configured_key(config, "current").expect("should configure test key"); + let secret = decode_secret(key).expect("should decode test key"); + let payload = serde_json::to_vec(payload).expect("should serialize test payload"); + sign_value("current", &secret, &payload).expect("should sign test payload") + } + + fn token_payload(value: &str) -> serde_json::Value { + let payload = value.split('.').nth(2).expect("token should have payload"); + let decoded = URL_SAFE_NO_PAD + .decode(payload.as_bytes()) + .expect("payload should decode"); + serde_json::from_slice(&decoded).expect("payload should be JSON") + } + + #[test] + fn round_trip_normalizes_sources_and_preserves_entry_deadlines() { + let config = config(); + let ec_id = ec_id('a'); + let entries = vec![HandoffEntry { + module: "lockr_capture".to_owned(), + source: "ID.Example.".to_owned(), + uid: "opaque-user-id".to_owned(), + expires_at: Some(NOW + 240), + deadline: NOW + 180, + }]; + + let value = + encode(&config, PUBLISHER, &ec_id, entries, NOW).expect("valid handoff should encode"); + let decoded = + decode(&config, PUBLISHER, &ec_id, &value, NOW).expect("valid handoff should decode"); + + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].source, SOURCE); + assert_eq!(decoded[0].module, "lockr_capture"); + assert_eq!(decoded[0].uid, "opaque-user-id"); + assert_eq!(decoded[0].expires_at, Some(NOW + 240)); + assert_eq!(decoded[0].deadline, NOW + 180); + assert!(value.len() <= MAX_HANDOFF_BYTES); + } + + #[test] + fn disabled_codec_neither_encodes_nor_decodes() { + let mut config = config(); + let ec_id = ec_id('a'); + let value = encode( + &config, + PUBLISHER, + &ec_id, + vec![entry(SOURCE, "uid", NOW + 100)], + NOW, + ) + .expect("enabled codec should encode"); + + config.enabled = false; + assert!( + encode( + &config, + PUBLISHER, + &ec_id, + vec![entry(SOURCE, "uid", NOW + 100)], + NOW, + ) + .is_none() + ); + assert!(decode(&config, PUBLISHER, &ec_id, &value, NOW).is_none()); + } + + #[test] + fn decode_accepts_rotated_non_active_key() { + let mut old_config = config(); + old_config.active_key_id = "previous".to_owned(); + let ec_id = ec_id('a'); + let value = encode( + &old_config, + PUBLISHER, + &ec_id, + vec![entry(SOURCE, "uid", NOW + 100)], + NOW, + ) + .expect("old active key should encode"); + + let new_config = config(); + assert!(decode(&new_config, PUBLISHER, &ec_id, &value, NOW).is_some()); + } + + #[test] + fn invalid_or_ambiguous_key_material_fails_closed() { + let ec_id = ec_id('a'); + for secret in [ + URL_SAFE_NO_PAD.encode([1_u8; 31]), + URL_SAFE_NO_PAD.encode([1_u8; 33]), + format!("{}=", URL_SAFE_NO_PAD.encode([1_u8; 32])), + "not base64!".to_owned(), + ] { + let config = IdentityHandoffConfig { + enabled: true, + active_key_id: "current".to_owned(), + keys: vec![IdentityHandoffKey { + id: "current".to_owned(), + secret: Redacted::new(secret), + }], + }; + assert!( + encode( + &config, + PUBLISHER, + &ec_id, + vec![entry(SOURCE, "uid", NOW + 100)], + NOW, + ) + .is_none() + ); + } + + let mut duplicate = config(); + duplicate.keys.push(key("current", 11)); + assert!( + encode( + &duplicate, + PUBLISHER, + &ec_id, + vec![entry(SOURCE, "uid", NOW + 100)], + NOW, + ) + .is_none() + ); + + let mut unsafe_id = config(); + unsafe_id.active_key_id = "bad.key".to_owned(); + unsafe_id.keys.push(key("bad.key", 4)); + assert!( + encode( + &unsafe_id, + PUBLISHER, + &ec_id, + vec![entry(SOURCE, "uid", NOW + 100)], + NOW, + ) + .is_none() + ); + } + + #[test] + fn signature_and_exact_bindings_are_required() { + let config = config(); + let bound_ec_id = ec_id('a'); + let value = encode( + &config, + PUBLISHER, + &bound_ec_id, + vec![entry(SOURCE, "uid", NOW + 100)], + NOW, + ) + .expect("valid handoff should encode"); + + assert!(decode(&config, "other.example", &bound_ec_id, &value, NOW).is_none()); + assert!( + decode(&config, PUBLISHER, &ec_id('b'), &value, NOW).is_none(), + "the full EC ID must match" + ); + + let mut tampered = value.into_bytes(); + let index = tampered + .iter() + .rposition(|byte| *byte == b'.') + .expect("token should have a signature separator") + + 1; + tampered[index] = if tampered[index] == b'A' { b'B' } else { b'A' }; + let tampered = String::from_utf8(tampered).expect("test token remains ASCII"); + assert!(decode(&config, PUBLISHER, &bound_ec_id, &tampered, NOW).is_none()); + } + + #[test] + fn rejects_oversized_value_before_decoding() { + let config = config(); + assert!( + decode( + &config, + PUBLISHER, + &ec_id('a'), + &"A".repeat(MAX_HANDOFF_BYTES + 1), + NOW, + ) + .is_none() + ); + } + + #[test] + fn rejects_unknown_envelope_and_entry_fields() { + let config = config(); + let ec_id = ec_id('a'); + let value = encode( + &config, + PUBLISHER, + &ec_id, + vec![entry(SOURCE, "uid", NOW + 100)], + NOW, + ) + .expect("valid handoff should encode"); + + let mut envelope_unknown = token_payload(&value); + envelope_unknown + .as_object_mut() + .expect("envelope should be an object") + .insert("unknown".to_owned(), serde_json::json!(true)); + let value = resign_payload(&config, &envelope_unknown); + assert!(decode(&config, PUBLISHER, &ec_id, &value, NOW).is_none()); + + let valid = encode( + &config, + PUBLISHER, + &ec_id, + vec![entry(SOURCE, "uid", NOW + 100)], + NOW, + ) + .expect("valid handoff should encode"); + let mut entry_unknown = token_payload(&valid); + entry_unknown["i"][0]["unknown"] = serde_json::json!(true); + let value = resign_payload(&config, &entry_unknown); + assert!(decode(&config, PUBLISHER, &ec_id, &value, NOW).is_none()); + } + + #[test] + fn rejects_invalid_entries_and_duplicate_normalized_sources() { + let config = config(); + let ec_id = ec_id('a'); + let cases = vec![ + entry(SOURCE, " ", NOW + 100), + entry(SOURCE, &"u".repeat(MAX_UID_LENGTH + 1), NOW + 100), + HandoffEntry { + module: "bad.module".to_owned(), + ..entry(SOURCE, "uid", NOW + 100) + }, + entry("not_a_domain", "uid", NOW + 100), + entry(SOURCE, "uid", NOW + HANDOFF_TTL_SECS + 1), + HandoffEntry { + expires_at: Some(NOW + 50), + ..entry(SOURCE, "uid", NOW + 100) + }, + ]; + for invalid in cases { + assert!( + encode(&config, PUBLISHER, &ec_id, vec![invalid], NOW).is_none(), + "invalid entry should fail closed" + ); + } + + assert!( + encode( + &config, + PUBLISHER, + &ec_id, + vec![ + entry("ID.Example", "one", NOW + 100), + entry("id.example.", "two", NOW + 200), + ], + NOW, + ) + .is_none(), + "duplicates are checked after normalization" + ); + } + + #[test] + fn decode_filters_each_expiry_and_returns_some_when_all_expired() { + let config = config(); + let ec_id = ec_id('a'); + let value = encode( + &config, + PUBLISHER, + &ec_id, + vec![ + entry("early.example", "early", NOW + 10), + HandoffEntry { + expires_at: Some(NOW + 20), + ..entry("provider.example", "provider", NOW + 20) + }, + entry("live.example", "live", NOW + 100), + ], + NOW, + ) + .expect("valid handoff should encode"); + + let decoded = decode(&config, PUBLISHER, &ec_id, &value, NOW + 20) + .expect("authenticated mixed-expiry handoff should decode"); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].source, "live.example"); + + let decoded = decode(&config, PUBLISHER, &ec_id, &value, NOW + 100) + .expect("authenticated expired handoff should support cleanup"); + assert!(decoded.is_empty()); + } + + #[test] + fn decode_rejects_wrong_envelope_deadline_and_excess_future_deadline() { + let config = config(); + let ec_id = ec_id('a'); + let envelope = HandoffEnvelope { + publisher: PUBLISHER.to_owned(), + ec_id: ec_id.clone(), + deadline: NOW + 99, + entries: vec![entry(SOURCE, "uid", NOW + 100)], + }; + let value = signed_envelope(&config, &envelope, "current"); + assert!(decode(&config, PUBLISHER, &ec_id, &value, NOW).is_none()); + + let envelope = HandoffEnvelope { + deadline: NOW + HANDOFF_TTL_SECS + 1, + entries: vec![entry(SOURCE, "uid", NOW + HANDOFF_TTL_SECS + 1)], + ..envelope + }; + let value = signed_envelope(&config, &envelope, "current"); + assert!(decode(&config, PUBLISHER, &ec_id, &value, NOW).is_none()); + } + + #[test] + fn decode_rejects_noncanonical_or_duplicate_sources_even_when_signed() { + let config = config(); + let ec_id = ec_id('a'); + let envelope = HandoffEnvelope { + publisher: PUBLISHER.to_owned(), + ec_id: ec_id.clone(), + deadline: NOW + 100, + entries: vec![entry("ID.Example", "uid", NOW + 100)], + }; + let value = signed_envelope(&config, &envelope, "current"); + assert!(decode(&config, PUBLISHER, &ec_id, &value, NOW).is_none()); + + let envelope = HandoffEnvelope { + entries: vec![ + entry(SOURCE, "one", NOW + 100), + entry(SOURCE, "two", NOW + 100), + ], + ..envelope + }; + let value = signed_envelope(&config, &envelope, "current"); + assert!(decode(&config, PUBLISHER, &ec_id, &value, NOW).is_none()); + } + + #[test] + fn individually_oversized_entry_does_not_suppress_smaller_entry() { + let config = config(); + let ec_id = ec_id('a'); + let value = encode( + &config, + PUBLISHER, + &ec_id, + vec![ + entry("small.example", "small", NOW + 100), + entry("oversized.example", &"\0".repeat(MAX_UID_LENGTH), NOW + 200), + ], + NOW, + ) + .expect("smaller entry should still encode"); + + let decoded = + decode(&config, PUBLISHER, &ec_id, &value, NOW).expect("bounded handoff should decode"); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].source, "small.example"); + } + + #[test] + fn deterministic_truncation_removes_earliest_deadlines_first() { + let config = config(); + let ec_id = ec_id('a'); + let entries: Vec<_> = (0..10) + .map(|index| { + entry( + &format!("id-{index}.example"), + &format!("{index}-{}", "u".repeat(500)), + NOW + 100 + index, + ) + }) + .collect(); + let mut reversed = entries.clone(); + reversed.reverse(); + + let value = + encode(&config, PUBLISHER, &ec_id, entries, NOW).expect("bounded subset should encode"); + let reversed_value = encode(&config, PUBLISHER, &ec_id, reversed, NOW) + .expect("reordered bounded subset should encode"); + assert_eq!(value, reversed_value, "encoding should be deterministic"); + assert!(value.len() <= MAX_HANDOFF_BYTES); + + let decoded = decode(&config, PUBLISHER, &ec_id, &value, NOW) + .expect("truncated handoff should decode"); + assert!(decoded.len() < 10); + assert!(decoded.iter().all(|entry| entry.deadline > NOW + 100)); + assert!( + decoded.iter().any(|entry| entry.deadline == NOW + 109), + "latest entry should survive" + ); + } + + #[test] + fn source_count_truncation_keeps_the_latest_entries() { + let config = config(); + let ec_id = ec_id('a'); + let entries: Vec<_> = (0..=MAX_HANDOFF_SOURCES) + .map(|index| { + entry( + &format!("id-{index}.example"), + "u", + NOW + 100 + index as u64, + ) + }) + .collect(); + + let value = encode(&config, PUBLISHER, &ec_id, entries, NOW) + .expect("bounded source subset should encode"); + let decoded = decode(&config, PUBLISHER, &ec_id, &value, NOW) + .expect("bounded source subset should decode"); + + assert!(decoded.len() <= MAX_HANDOFF_SOURCES); + assert!(decoded.iter().all(|entry| entry.deadline > NOW + 100)); + assert!( + decoded + .iter() + .any(|entry| entry.deadline == NOW + 100 + MAX_HANDOFF_SOURCES as u64), + "latest entry should survive" + ); + } + + #[test] + fn decode_rejects_more_than_sixty_four_sources_even_when_signed() { + let config = config(); + let ec_id = ec_id('a'); + let entries: Vec<_> = (0..=MAX_HANDOFF_SOURCES) + .map(|index| entry(&format!("id-{index}.example"), "u", NOW + 100)) + .collect(); + let envelope = HandoffEnvelope { + publisher: PUBLISHER.to_owned(), + ec_id: ec_id.clone(), + deadline: NOW + 100, + entries, + }; + let value = signed_envelope(&config, &envelope, "current"); + + // The normal encoded-size cap usually rejects this fixture first. Its + // source-count check is still exercised if compact schema changes ever + // make the same signed fixture fit. + assert!(decode(&config, PUBLISHER, &ec_id, &value, NOW).is_none()); + } +} diff --git a/crates/trusted-server-core/src/ec/identity_handoff_tests.rs b/crates/trusted-server-core/src/ec/identity_handoff_tests.rs new file mode 100644 index 000000000..da5d9d4f2 --- /dev/null +++ b/crates/trusted-server-core/src/ec/identity_handoff_tests.rs @@ -0,0 +1,786 @@ +use super::*; +use std::sync::Arc; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde_json::json; + +use crate::auction::endpoints::resolve_auction_eids; +use crate::consent::jurisdiction::Jurisdiction; +use crate::ec::EidSyncSource; +use crate::ec::finalize::ec_finalize_response; +use crate::ec::identity::{ + IdentityConsentSignals, IdentityObservation, IdentityOutcome, send_then_apply_identity, + stage_capture, +}; +use crate::ec::kv::{KvIdentityGraph, PartnerIdUpdate}; +use crate::ec::kv_types::{KvEntry, KvPartnerId}; +use crate::integrations::ProxyDispatchInput; +use crate::platform::test_support::{ + StubHttpClient, build_services_with_http_client, noop_services, +}; +use crate::redacted::Redacted; +use crate::settings::{IdentityHandoffConfig, IdentityHandoffKey}; +use crate::test_support::tests::create_test_settings; + +const SOURCE: &str = "id5-sync.com"; +const UID: &str = "ID5*synthetic-handoff"; +const PATH: &str = "/integrations/lockr/api/publisher/app/v2/identityLockr/page-view"; + +fn id() -> String { + format!("{}.ABC123", "a".repeat(64)) +} +fn now() -> u64 { + checked_current_timestamp().expect("should read test clock") +} +fn settings() -> Settings { + let mut settings = create_test_settings(); + settings.ec.identity_handoff = IdentityHandoffConfig { + enabled: true, + active_key_id: "current".to_owned(), + keys: vec![IdentityHandoffKey { + id: "current".to_owned(), + secret: Redacted::new(URL_SAFE_NO_PAD.encode([7u8; 32])), + }], + }; + settings.ec.partners = vec![serde_json::from_value(json!({ + "name":"Synthetic capture source", "source_domain":SOURCE, "identity_owner":"lockr", "openrtb_atype":1, "bidstream_enabled":true + })).expect("should parse source policy")]; + settings + .integrations + .insert_config( + "lockr", + &json!({"app_id":"synthetic", "capture_identity":true}), + ) + .expect("should configure capture"); + settings +} +fn partners(settings: &Settings) -> PartnerRegistry { + PartnerRegistry::from_config(&settings.ec.partners).expect("should build registry") +} +fn graph() -> KvIdentityGraph { + let graph = KvIdentityGraph::in_memory("handoff-tests"); + let mut root = KvEntry::minimal("other.example.com", "other-id", now()); + root.consent.ok = true; + graph + .create(&id(), &root) + .expect("should seed consenting root"); + graph +} +fn entry() -> HandoffEntry { + HandoffEntry { + module: "lockr".to_owned(), + source: SOURCE.to_owned(), + uid: UID.to_owned(), + expires_at: Some(now() + 600), + deadline: now() + 300, + } +} +fn cookie(settings: &Settings, entries: Vec) -> String { + codec::encode( + &settings.ec.identity_handoff, + &settings.publisher.domain, + &id(), + entries, + now(), + ) + .expect("should encode fixture") +} +fn context(settings: &Settings, value: Option<&str>) -> EcContext { + let mut cookies = format!("ts-ec={}", id()); + if let Some(value) = value { + cookies.push_str(&format!("; ts-eids-s={value}")); + } + let request = http::Request::builder() + .uri("https://publisher.example/auction") + .header(header::COOKIE, cookies) + .body(EdgeBody::empty()) + .expect("should build request"); + let mut context = EcContext::read_from_request(settings, &request, &noop_services()) + .expect("should read request"); + context.consent_mut().jurisdiction = Jurisdiction::NonRegulated; + context.set_identity_handoff_supported(true); + context +} +fn response() -> Response { + Response::builder() + .status(200) + .header(header::CACHE_CONTROL, "public, max-age=60") + .header(header::CONTENT_ENCODING, "gzip") + .body(EdgeBody::from("original bytes")) + .expect("should build response") +} +fn handoff_header(response: &Response) -> Option<&str> { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .find(|value| value.starts_with("ts-eids-s=")) +} +fn captured_effect( + settings: &Settings, + context: &EcContext, + partners: &PartnerRegistry, +) -> IdentityEffects { + stage_capture( + "lockr", + &[SOURCE], + IdentityObservation { + outcomes: vec![IdentityOutcome::Issued { + source: SOURCE.to_owned(), + uid: UID.to_owned(), + expires_at: Some(now() + 600), + }], + consent: IdentityConsentSignals::default(), + }, + context, + partners, + settings, + ) + .expect("should seal acquisition") +} +fn send( + response: &mut Response, + context: &mut EcContext, + graph: &KvIdentityGraph, + partners: &PartnerRegistry, +) { + let effects = response.extensions_mut().remove::(); + assert!(send_then_apply_identity( + effects, + context, + partners, + || {}, + || Some(graph.clone()) + )); +} + +#[test] +fn capture_sets_private_handoff_before_storage_and_preserves_bytes() { + let settings = settings(); + let partners = partners(&settings); + let graph = graph(); + let mut context = context(&settings, None); + let mut response = response(); + response + .extensions_mut() + .insert(captured_effect(&settings, &context, &partners)); + ec_finalize_response( + &settings, + &mut context, + Some(&graph), + &partners, + None, + &mut response, + ); + let header = handoff_header(&response).expect("should set capture handoff"); + for attribute in [ + "HttpOnly", + "Secure", + "SameSite=Lax", + "Path=/", + "Max-Age=300", + ] { + assert!( + header.contains(attribute), + "should use bounded secure cookie" + ); + } + let value = header + .strip_prefix("ts-eids-s=") + .expect("should get value") + .split(';') + .next() + .expect("should get token"); + let decoded = codec::decode( + &settings.ec.identity_handoff, + &settings.publisher.domain, + &id(), + value, + now(), + ) + .expect("should authenticate cookie"); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].uid, UID); + assert!( + !graph + .get(&id()) + .expect("should read graph") + .expect("should find root") + .0 + .ids + .contains_key(SOURCE) + ); + assert!( + response.headers()[header::CACHE_CONTROL] + .to_str() + .expect("should read cache policy") + .contains("no-store") + ); + assert_eq!(response.headers()[header::CONTENT_ENCODING], "gzip"); + assert!(matches!(response.body(),EdgeBody::Once(bytes) if bytes.as_ref()==b"original bytes")); + let effects = response.extensions_mut().remove::(); + assert!(send_then_apply_identity( + effects, + &mut context, + &partners, + || { + assert!( + !graph + .get(&id()) + .expect("should read graph at send") + .expect("should find root") + .0 + .ids + .contains_key(SOURCE) + ); + }, + || Some(graph.clone()) + )); + let stored = graph + .get(&id()) + .expect("should read persisted acquisition") + .expect("should find root") + .0; + assert_eq!(stored.ids[SOURCE].writer.as_deref(), Some("lockr")); + assert!(stored.ids[SOURCE].expires_at.is_some()); + assert_eq!(stored.ids["other.example.com"].uid, "other-id"); +} + +#[test] +fn pending_capture_delivers_before_visibility_then_replays_and_acknowledges() { + let settings = settings(); + let partners = partners(&settings); + let graph = graph(); + let original_entry = entry(); + let value = cookie(&settings, vec![original_entry.clone()]); + let mut context = context(&settings, Some(&value)); + context.set_kv_snapshot(graph.load_snapshot(&id())); + context.set_eid_sync_source(EidSyncSource::Auction); + let eids = resolve_auction_eids( + context.kv_snapshot(), + Some(&partners), + &context, + Some(now()), + ) + .expect("should resolve handoff auction"); + assert_eq!(eids.len(), 1); + assert_eq!(eids[0].uids[0].id, UID); + assert_eq!(eids[0].inserter.as_deref(), Some("Lockr-For-Publishers")); + assert_eq!(eids[0].matcher.as_deref(), Some("")); + assert_eq!(eids[0].mm, Some(2)); + // The matching browser value must not be persisted as unknown-expiry browser state. + context.stage_browser_eid_updates(vec![PartnerIdUpdate::new(SOURCE, UID)]); + let mut response = response(); + ec_finalize_response( + &settings, + &mut context, + Some(&graph), + &partners, + None, + &mut response, + ); + assert!( + !graph + .get(&id()) + .expect("should read before replay") + .expect("should find root") + .0 + .ids + .contains_key(SOURCE) + ); + send(&mut response, &mut context, &graph, &partners); + let stored = graph + .get(&id()) + .expect("should read replay") + .expect("should find root") + .0; + assert_eq!(stored.ids[SOURCE].uid, UID); + assert_eq!(stored.ids[SOURCE].writer.as_deref(), Some("lockr")); + assert_eq!(stored.ids[SOURCE].expires_at, original_entry.expires_at); + let mut acknowledged = self::context(&settings, Some(&value)); + acknowledged.set_kv_snapshot(graph.load_snapshot(&id())); + let mut response = self::response(); + ec_finalize_response( + &settings, + &mut acknowledged, + Some(&graph), + &partners, + None, + &mut response, + ); + assert!( + handoff_header(&response) + .expect("should clear acknowledged cookie") + .contains("Max-Age=0") + ); + assert!(response.extensions().get::().is_none()); +} + +#[test] +fn matching_capture_retains_pending_deadline_and_missing_expiry_without_overriding_kv() { + let settings = settings(); + let partners = partners(&settings); + let mut original_entry = entry(); + let expiry = now() + 500; + original_entry.expires_at = Some(expiry); + original_entry.deadline = now() + 200; + let value = cookie(&settings, vec![original_entry]); + for reported_expiry in [Some(expiry), None] { + let graph = graph(); + let mut context = context(&settings, Some(&value)); + let effects = stage_capture( + "lockr", + &[SOURCE], + IdentityObservation { + outcomes: vec![IdentityOutcome::Issued { + source: SOURCE.to_owned(), + uid: UID.to_owned(), + expires_at: reported_expiry, + }], + consent: IdentityConsentSignals::default(), + }, + &context, + &partners, + &settings, + ) + .expect("should seal matching capture"); + let mut response = response(); + response.extensions_mut().insert(effects); + ec_finalize_response( + &settings, + &mut context, + Some(&graph), + &partners, + None, + &mut response, + ); + let returned = handoff_header(&response) + .expect("should retain pending observation") + .strip_prefix("ts-eids-s=") + .expect("should get value") + .split(';') + .next() + .expect("should get token"); + assert_eq!( + returned, value, + "identical capture or missing expiry must not renew pending metadata" + ); + if reported_expiry.is_none() { + // A newer stored expiry appears after seal. The cookie fallback is + // fill-absent-only metadata, never authority over this record. + let mut fresh_context = self::context(&settings, None); + captured_effect(&settings, &fresh_context, &partners).apply( + &graph, + &partners, + &mut fresh_context, + ); + let before = graph.get(&id()).expect("should read newer expiry"); + send(&mut response, &mut context, &graph, &partners); + assert_eq!( + graph.get(&id()).expect("should retain newer expiry"), + before + ); + } else { + send(&mut response, &mut context, &graph, &partners); + assert_eq!( + graph + .get(&id()) + .expect("should read capture") + .expect("should find root") + .0 + .ids[SOURCE] + .expires_at, + Some(expiry) + ); + } + } + // Missing provider expiry also preserves known pending expiry when the + // post-send read still finds the source absent. + let graph = graph(); + let mut context = context(&settings, Some(&value)); + let effects = stage_capture( + "lockr", + &[SOURCE], + IdentityObservation { + outcomes: vec![IdentityOutcome::Issued { + source: SOURCE.to_owned(), + uid: UID.to_owned(), + expires_at: None, + }], + consent: IdentityConsentSignals::default(), + }, + &context, + &partners, + &settings, + ) + .expect("should seal capture without expiry"); + effects.apply(&graph, &partners, &mut context); + assert_eq!( + graph + .get(&id()) + .expect("should read filled source") + .expect("should find root") + .0 + .ids[SOURCE] + .expires_at, + Some(expiry) + ); +} + +#[test] +fn existing_records_including_expired_and_same_uid_refresh_block_replay() { + let settings = settings(); + let partners = partners(&settings); + for (uid, expiry) in [ + ("newer-uid", Some(now() + 900)), + (UID, Some(now() + 900)), + (UID, Some(1)), + ] { + let graph = KvIdentityGraph::in_memory("handoff-existing-records"); + let mut root = KvEntry::minimal(SOURCE, uid, now()); + root.consent.ok = true; + let record = root.ids.get_mut(SOURCE).expect("should get fixture record"); + record.expires_at = expiry; + record.writer = Some("lockr".to_owned()); + record.revision = Some("newer-revision".to_owned()); + graph + .create(&id(), &root) + .expect("should seed existing record"); + let before = graph.get(&id()).expect("should read before"); + let value = cookie(&settings, vec![entry()]); + let mut context = context(&settings, Some(&value)); + context.set_kv_snapshot(graph.load_snapshot(&id())); + context.set_eid_sync_source(EidSyncSource::Auction); + assert!(resolve_eids(&context, context.kv_snapshot(), &partners, Some(now())).is_empty()); + let mut response = response(); + ec_finalize_response( + &settings, + &mut context, + Some(&graph), + &partners, + None, + &mut response, + ); + assert!( + handoff_header(&response) + .expect("should retire conflicting handoff") + .contains("Max-Age=0") + ); + send(&mut response, &mut context, &graph, &partners); + assert_eq!(graph.get(&id()).expect("should read after"), before); + } +} + +#[test] +fn replay_cas_cannot_revive_withdrawal_or_replace_a_concurrent_capture() { + let settings = settings(); + let partners = partners(&settings); + for withdrawal in [true, false] { + let graph = graph(); + let value = cookie(&settings, vec![entry()]); + let mut context = context(&settings, Some(&value)); + context.set_kv_snapshot(graph.load_snapshot(&id())); + context.set_eid_sync_source(EidSyncSource::Auction); + let mut response = response(); + ec_finalize_response( + &settings, + &mut context, + Some(&graph), + &partners, + None, + &mut response, + ); + if withdrawal { + graph.tombstone_existing_from_snapshot(&id(), graph.load_snapshot(&id())); + } else { + graph + .upsert_partner_id(&id(), SOURCE, "concurrent-newer-uid") + .expect("should write concurrent fixture"); + } + let before = graph.get(&id()).expect("should read concurrent state"); + send(&mut response, &mut context, &graph, &partners); + assert_eq!(graph.get(&id()).expect("should read after replay"), before); + } +} + +#[test] +fn observed_stale_snapshot_can_deliver_but_replay_rechecks_authoritative_generation() { + let settings = settings(); + let partners = partners(&settings); + let graph = graph(); + let value = cookie(&settings, vec![entry()]); + let mut context = context(&settings, Some(&value)); + context.set_kv_snapshot(graph.load_snapshot(&id())); + context.set_eid_sync_source(EidSyncSource::Auction); + // Model an older live point read after another request has withdrawn. + graph.tombstone_existing_from_snapshot(&id(), graph.load_snapshot(&id())); + assert_eq!( + resolve_eids(&context, context.kv_snapshot(), &partners, Some(now())).len(), + 1, + "delivery retains the existing snapshot consistency limit" + ); + let before = graph + .get(&id()) + .expect("should read authoritative tombstone"); + let mut response = response(); + ec_finalize_response( + &settings, + &mut context, + Some(&graph), + &partners, + None, + &mut response, + ); + send(&mut response, &mut context, &graph, &partners); + assert_eq!( + graph.get(&id()).expect("should read after replay CAS"), + before, + "replay must not revive the authoritative tombstone" + ); +} + +#[test] +fn deadline_and_provider_expiry_are_rechecked_after_send() { + let settings = settings(); + let partners = partners(&settings); + let graph = graph(); + let before = graph.get(&id()).expect("should read before"); + for expired_deadline in [true, false] { + let mut update = entry(); + if expired_deadline { + update.deadline = 1; + } else { + update.expires_at = Some(1); + } + let mut context = context(&settings, None); + context.set_kv_snapshot(graph.load_snapshot(&id())); + IdentityEffects::handoff_replay(&context, vec![update]).apply( + &graph, + &partners, + &mut context, + ); + assert_eq!( + graph.get(&id()).expect("should read after expired replay"), + before + ); + } +} + +#[test] +fn missing_failed_or_withdrawn_roots_and_unknown_clock_do_not_deliver() { + let settings = settings(); + let partners = partners(&settings); + let value = cookie(&settings, vec![entry()]); + let mut context = context(&settings, Some(&value)); + let mut withdrawn = KvEntry::tombstone(now()); + withdrawn + .ids + .insert(SOURCE.to_owned(), KvPartnerId::default()); + for snapshot in [ + EcKvSnapshot::NotRead, + EcKvSnapshot::Missing { ec_id: id() }, + EcKvSnapshot::Failed { ec_id: id() }, + EcKvSnapshot::Present { + ec_id: id(), + entry: Box::new(withdrawn), + generation: Some(1), + }, + ] { + context.set_kv_snapshot(snapshot); + assert!(resolve_eids(&context, context.kv_snapshot(), &partners, Some(now())).is_empty()); + } + context.set_kv_snapshot(graph().load_snapshot(&id())); + assert!(resolve_eids(&context, context.kv_snapshot(), &partners, None).is_empty()); +} + +#[test] +fn tampering_rotation_disabled_capture_and_request_denial_never_authorize_handoff() { + let settings = settings(); + let partners = partners(&settings); + let mut value = cookie(&settings, vec![entry()]); + value.push('x'); + let mut context = context(&settings, Some(&value)); + context.set_kv_snapshot(graph().load_snapshot(&id())); + assert!(resolve_eids(&context, context.kv_snapshot(), &partners, Some(now())).is_empty()); + let value = cookie(&settings, vec![entry()]); + let mut disabled = settings.clone(); + disabled + .integrations + .insert_config( + "lockr", + &json!({"app_id":"synthetic","capture_identity":false}), + ) + .expect("should disable acquisition"); + let mut context = self::context(&disabled, Some(&value)); + context.set_kv_snapshot(graph().load_snapshot(&id())); + assert!(resolve_eids(&context, context.kv_snapshot(), &partners, Some(now())).is_empty()); + let mut context = self::context(&settings, Some(&value)); + context.set_kv_snapshot(graph().load_snapshot(&id())); + context.consent_mut().gpc = true; + assert!(resolve_eids(&context, context.kv_snapshot(), &partners, Some(now())).is_empty()); + let mut context = self::context(&settings, Some(&value)); + context.replace_with_generated(format!("{}.XYZ789", "a".repeat(64)), EcKvSnapshot::NotRead); + assert!(resolve_eids(&context, context.kv_snapshot(), &partners, Some(now())).is_empty()); + assert!(!format!("{context:?}").contains(UID)); +} + +#[test] +fn no_token_proxy_request_repairs_pending_capture_after_send_but_body_optout_clears_it() { + let settings = settings(); + let partners = partners(&settings); + let value = cookie(&settings, vec![entry()]); + for denied in [false, true] { + let graph = graph(); + let mut context = context(&settings, Some(&value)); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response( + 200, + serde_json::to_vec(&json!({"success":true,"aimTokens":[]})) + .expect("should encode no-token response"), + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let body = if denied { + json!({"ccpaString":"1YYN"}) + } else { + json!({}) + }; + let request = http::Request::builder() + .method(http::Method::POST) + .uri(PATH) + .body(EdgeBody::from( + serde_json::to_vec(&body).expect("should serialize body"), + )) + .expect("should build proxy request"); + let mut response = futures::executor::block_on(registry.handle_proxy(ProxyDispatchInput { + method: &http::Method::POST, + path: PATH, + settings: &settings, + kv: Some(&graph), + ec_context: &mut context, + services: &services, + req: request, + })) + .expect("should select proxy") + .expect("should proxy upstream"); + ec_finalize_response( + &settings, + &mut context, + Some(&graph), + &partners, + None, + &mut response, + ); + assert!( + matches!(context.kv_snapshot(), EcKvSnapshot::NotRead), + "no-token proxy must not add a pre-send KV lookup" + ); + if denied { + assert!( + handoff_header(&response) + .expect("should clear denied handoff") + .contains("Max-Age=0") + ); + } else { + let header = handoff_header(&response).expect("should retain pending handoff"); + let returned_value = header + .strip_prefix("ts-eids-s=") + .expect("should get cookie value") + .split(';') + .next() + .expect("should get token"); + assert_eq!( + returned_value, value, + "NoChange must retain the original authenticated deadline" + ); + } + send(&mut response, &mut context, &graph, &partners); + assert_eq!( + graph + .get(&id()) + .expect("should read repair") + .expect("should find root") + .0 + .ids + .contains_key(SOURCE), + !denied + ); + } +} + +#[test] +fn oversized_replacement_expires_existing_cookie_when_nothing_fits() { + let settings = settings(); + let partners = partners(&settings); + let graph = graph(); + let value = cookie(&settings, vec![entry()]); + let mut context = context(&settings, Some(&value)); + let effects = stage_capture( + "lockr", + &[SOURCE], + IdentityObservation { + outcomes: vec![IdentityOutcome::Issued { + source: SOURCE.to_owned(), + uid: "\u{1}".repeat(512), + expires_at: None, + }], + consent: IdentityConsentSignals::default(), + }, + &context, + &partners, + &settings, + ) + .expect("should seal bounded but JSON-expanding UID"); + let mut response = response(); + response.extensions_mut().insert(effects); + ec_finalize_response( + &settings, + &mut context, + Some(&graph), + &partners, + None, + &mut response, + ); + assert!( + handoff_header(&response) + .expect("should retire oversized replacement") + .contains("Max-Age=0") + ); +} + +#[test] +fn capture_and_handoff_stay_off_on_unsupported_adapters_and_unrelated_requests_do_not_clean_up() { + let settings = settings(); + let partners = partners(&settings); + let graph = graph(); + let mut context = context(&settings, None); + context.set_identity_handoff_supported(false); + let mut response = response(); + response + .extensions_mut() + .insert(captured_effect(&settings, &context, &partners)); + ec_finalize_response( + &settings, + &mut context, + Some(&graph), + &partners, + None, + &mut response, + ); + assert!(handoff_header(&response).is_none()); + let mut context = self::context(&settings, None); + let mut response = self::response(); + ec_finalize_response( + &settings, + &mut context, + Some(&graph), + &partners, + None, + &mut response, + ); + assert!(handoff_header(&response).is_none()); +} diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs index b0eb83d3f..5f4443f4c 100644 --- a/crates/trusted-server-core/src/ec/kv.rs +++ b/crates/trusted-server-core/src/ec/kv.rs @@ -159,6 +159,7 @@ fn apply_cookie_partner_id_updates( partner_id.to_owned(), super::kv_types::KvPartnerId { uid: uid.to_owned(), + ..Default::default() }, ); changed = true; @@ -187,6 +188,30 @@ fn partner_id_updates_match(entry: &KvEntry, updates: &[PartnerIdUpdate]) -> boo }) } +/// Applies a UID-only legacy update without retaining another UID's lifecycle metadata. +fn apply_legacy_partner_id_update(entry: &mut KvEntry, partner_id: &str, uid: &str) -> bool { + let previous = entry.ids.get(partner_id); + if previous.is_some_and(|record| record.uid == uid) { + return false; + } + let had_metadata = previous.is_some_and(|record| { + record.expires_at.is_some() || record.writer.is_some() || record.revision.is_some() + }); + let mut replacement = super::kv_types::KvPartnerId { + uid: uid.to_owned(), + ..Default::default() + }; + // Keep legacy-only rows UID-only during the reader/writer rollout. Once a + // record has lifecycle metadata, replacement must change its version and + // writer without applying the old token's expiry to the replacement UID. + if had_metadata { + replacement.writer = Some("legacy".to_owned()); + replacement.revision = Some(uuid::Uuid::new_v4().to_string()); + } + entry.ids.insert(partner_id.to_owned(), replacement); + true +} + pub(crate) fn apply_partner_id_updates(entry: &mut KvEntry, updates: &[PartnerIdUpdate]) -> bool { let mut latest_updates = BTreeMap::new(); for update in updates { @@ -195,21 +220,7 @@ pub(crate) fn apply_partner_id_updates(entry: &mut KvEntry, updates: &[PartnerId let mut changed = false; for (partner_id, uid) in latest_updates { - if entry - .ids - .get(partner_id) - .is_some_and(|existing| existing.uid == uid) - { - continue; - } - - entry.ids.insert( - partner_id.to_owned(), - super::kv_types::KvPartnerId { - uid: uid.to_owned(), - }, - ); - changed = true; + changed |= apply_legacy_partner_id_update(entry, partner_id, uid); } changed @@ -837,6 +848,230 @@ impl KvIdentityGraph { } } + /// Applies validated owned capture records using bounded existing-root CAS. + /// + /// Missing and failed snapshots are never promoted to existence proof. + /// Ownership, root consent and effective expiry are rechecked at mutation. + pub(crate) fn upsert_managed_ids_from_snapshot( + &self, + ec_id: &str, + module: &str, + partners: &super::registry::PartnerRegistry, + updates: &[super::identity::ManagedPartnerIdUpdate], + snapshot: EcKvSnapshot, + ) -> EcKvSnapshot { + if updates.is_empty() { + return snapshot; + } + let mut current = match snapshot { + EcKvSnapshot::NotRead => self.load_snapshot(ec_id), + EcKvSnapshot::Present { + ec_id: ref id, + generation: None, + .. + } if id == ec_id => self.load_snapshot(ec_id), + state if state.belongs_to(ec_id) => state, + _ => { + return EcKvSnapshot::Failed { + ec_id: ec_id.to_owned(), + }; + } + }; + for _ in 0..MAX_CAS_RETRIES { + let EcKvSnapshot::Present { + ec_id: ref id, + ref entry, + generation: Some(generation), + } = current + else { + return current; + }; + if id != ec_id || !entry.consent.ok { + return current; + } + let now = checked_current_timestamp(); + let mut candidate = entry.as_ref().clone(); + let mut changed = false; + for update in updates { + if partners + .get(&update.source) + .and_then(|partner| partner.identity_owner.as_deref()) + != Some(module) + { + continue; + } + let previous = candidate.ids.get(&update.source); + let same_uid = previous.is_some_and(|record| record.uid == update.uid); + let expiry = update.expires_at.or_else(|| { + if same_uid { + previous.and_then(|record| record.expires_at) + } else if previous.is_none() { + // Preserve known pending metadata only in an absent + // slot. A cookie never changes any stored expiry. + update.pending_expires_at + } else { + None + } + }); + if expiry.is_some_and(|expiry| now.is_none_or(|now| now >= expiry)) { + continue; + } + if previous.is_some_and(|record| { + same_uid + && record.expires_at == expiry + && record.writer.as_deref() == Some(module) + && record.revision.is_some() + }) { + continue; + } + candidate.ids.insert( + update.source.clone(), + super::kv_types::KvPartnerId { + uid: update.uid.clone(), + expires_at: expiry, + writer: Some(module.to_owned()), + revision: Some(uuid::Uuid::new_v4().to_string()), + }, + ); + changed = true; + } + if !changed { + return current; + } + let Ok((body, metadata)) = Self::serialize_entry(&candidate, self.store_name()) else { + return EcKvSnapshot::Failed { + ec_id: ec_id.to_owned(), + }; + }; + match self.write_entry( + ec_id, + &body, + &metadata, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + ) { + Ok(EcKvWriteOutcome::Written) => { + return EcKvSnapshot::Present { + ec_id: ec_id.to_owned(), + entry: Box::new(candidate), + generation: None, + }; + } + Ok(EcKvWriteOutcome::PreconditionFailed) => current = self.load_snapshot(ec_id), + Err(_) => { + return EcKvSnapshot::Failed { + ec_id: ec_id.to_owned(), + }; + } + } + } + EcKvSnapshot::Failed { + ec_id: ec_id.to_owned(), + } + } + + /// Replays authenticated capture only into absent sources on an existing live root. + /// + /// Rechecks deadline, expiry and ownership after every conflict. Existing + /// records, including expired records, never become replacement slots. + pub(crate) fn upsert_handoff_ids_from_snapshot( + &self, + ec_id: &str, + partners: &super::registry::PartnerRegistry, + updates: &[super::identity_handoff_codec::HandoffEntry], + snapshot: EcKvSnapshot, + ) -> EcKvSnapshot { + if updates.is_empty() { + return snapshot; + } + let mut current = match snapshot { + EcKvSnapshot::NotRead => self.load_snapshot(ec_id), + EcKvSnapshot::Present { + ec_id: ref id, + generation: None, + .. + } if id == ec_id => self.load_snapshot(ec_id), + state if state.belongs_to(ec_id) => state, + _ => { + return EcKvSnapshot::Failed { + ec_id: ec_id.to_owned(), + }; + } + }; + for _ in 0..MAX_CAS_RETRIES { + let EcKvSnapshot::Present { + ec_id: ref id, + ref entry, + generation: Some(generation), + } = current + else { + return current; + }; + if id != ec_id || !entry.consent.ok { + return current; + } + let Some(now) = checked_current_timestamp() else { + return current; + }; + let mut candidate = entry.as_ref().clone(); + let mut changed = false; + for update in updates { + if now >= update.deadline + || update.expires_at.is_some_and(|expiry| now >= expiry) + || partners + .get(&update.source) + .and_then(|partner| partner.identity_owner.as_deref()) + != Some(update.module.as_str()) + || candidate.ids.contains_key(&update.source) + { + continue; + } + candidate.ids.insert( + update.source.clone(), + super::kv_types::KvPartnerId { + uid: update.uid.clone(), + expires_at: update.expires_at, + writer: Some(update.module.clone()), + revision: Some(uuid::Uuid::new_v4().to_string()), + }, + ); + changed = true; + } + if !changed { + return current; + } + let Ok((body, metadata)) = Self::serialize_entry(&candidate, self.store_name()) else { + return EcKvSnapshot::Failed { + ec_id: ec_id.to_owned(), + }; + }; + match self.write_entry( + ec_id, + &body, + &metadata, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + ) { + Ok(EcKvWriteOutcome::Written) => { + return EcKvSnapshot::Present { + ec_id: ec_id.to_owned(), + entry: Box::new(candidate), + generation: None, + }; + } + Ok(EcKvWriteOutcome::PreconditionFailed) => current = self.load_snapshot(ec_id), + Err(_) => { + return EcKvSnapshot::Failed { + ec_id: ec_id.to_owned(), + }; + } + } + } + EcKvSnapshot::Failed { + ec_id: ec_id.to_owned(), + } + } + /// Returns `proven` instead of a read outcome that cannot disprove it. /// /// Partner-ID enrichment is best effort. When the caller already held proof @@ -910,21 +1145,10 @@ impl KvIdentityGraph { ))); } - if entry - .ids - .get(partner_id) - .is_some_and(|existing| existing.uid == uid) - { + if !apply_legacy_partner_id_update(&mut entry, partner_id, uid) { return Ok(()); } - entry.ids.insert( - partner_id.to_owned(), - super::kv_types::KvPartnerId { - uid: uid.to_owned(), - }, - ); - let (body, meta_str) = Self::serialize_entry(&entry, self.store_name())?; match self.write_entry( @@ -982,21 +1206,10 @@ impl KvIdentityGraph { return Ok(UpsertResult::ConsentWithdrawn); } - if entry - .ids - .get(partner_id) - .is_some_and(|existing| existing.uid == uid) - { + if !apply_legacy_partner_id_update(&mut entry, partner_id, uid) { return Ok(UpsertResult::Unchanged); } - entry.ids.insert( - partner_id.to_owned(), - super::kv_types::KvPartnerId { - uid: uid.to_owned(), - }, - ); - let (body, meta_str) = Self::serialize_entry(&entry, self.store_name())?; match self.write_entry( @@ -1717,6 +1930,28 @@ mod tests { assert!(ts > 0, "should return a nonzero timestamp"); } + #[test] + fn kv_span_accumulates_across_graph_operations() { + let timings = crate::request_timing::RequestTimings::new(); + let graph = KvIdentityGraph::new(crate::platform::TimedKvStore::new( + crate::ec::kv_backend::test_support::InMemoryEcKv::new("test-store"), + timings.clone(), + )); + + graph + .create("ec-1", &live_entry()) + .expect("should create entry through the timed store"); + graph + .get("ec-1") + .expect("should read the entry back through the timed store"); + + timings.mark_headers_ready(); + assert!( + timings.snapshot().kv_ms.is_some(), + "should accumulate Phase::EcKv across both graph operations, not just the last write" + ); + } + #[test] fn serialize_entry_produces_valid_json() { let entry = KvEntry::tombstone(1000); @@ -1739,6 +1974,7 @@ mod tests { "ssp_x".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "x".repeat(crate::ec::kv_types::MAX_UID_LENGTH + 1), + ..Default::default() }, ); let body = serde_json::to_vec(&entry).expect("should serialize invalid entry payload"); @@ -1774,6 +2010,7 @@ mod tests { "ssp_x".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "x".repeat(crate::ec::kv_types::MAX_UID_LENGTH + 1), + ..Default::default() }, ); @@ -1792,12 +2029,251 @@ mod tests { entry } + fn lifecycle_entry() -> KvEntry { + let mut entry = live_entry(); + for source in ["ids.example.com", "other.example.com"] { + entry.ids.insert( + source.to_owned(), + super::super::kv_types::KvPartnerId { + uid: format!("uid-{source}"), + expires_at: Some(2_000), + writer: Some("example_identity".to_owned()), + revision: Some(format!("revision-{source}")), + }, + ); + } + entry + } + + #[test] + fn lifecycle_metadata_survives_all_existing_enrichment_writers() { + for mode in ["browser", "bulk", "single", "batch", "cluster"] { + let graph = KvIdentityGraph::in_memory("lifecycle-store"); + let ec_id = snapshot_ec_id(); + let entry = lifecycle_entry(); + assert_eq!( + graph + .create_if_absent(&ec_id, &entry) + .expect("should create identity"), + CreateIfAbsentOutcome::Written + ); + match mode { + "browser" => { + let (snapshot, outcome) = graph.sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[ + PartnerIdUpdate::new("ids.example.com", "different-browser-uid"), + PartnerIdUpdate::new("new.example.com", "new-uid"), + ], + graph.load_snapshot(&ec_id), + ); + assert_eq!(outcome, EidCookieSyncOutcome::WrittenWithDeferredFreshness); + assert!( + snapshot.entry_for(&ec_id).is_some(), + "should return the persisted snapshot" + ); + } + "bulk" => { + let snapshot = graph.upsert_partner_ids_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("new.example.com", "new-uid")], + graph.load_snapshot(&ec_id), + ); + assert!( + snapshot.entry_for(&ec_id).is_some(), + "should return the persisted snapshot" + ); + } + "single" => graph + .upsert_partner_id(&ec_id, "new.example.com", "new-uid") + .expect("should update legacy source"), + "batch" => assert_eq!( + graph + .upsert_partner_id_if_exists(&ec_id, "new.example.com", "new-uid") + .expect("should update legacy source"), + UpsertResult::Written + ), + "cluster" => { + let (stored, generation) = graph + .get(&ec_id) + .expect("should read identity") + .expect("should find identity"); + graph + .evaluate_cluster(&ec_id, &stored, generation) + .expect("should update network metadata"); + } + _ => unreachable!("should use a listed writer"), + } + let (retained, _) = graph + .get(&ec_id) + .expect("should read stored state") + .expect("should retain identity"); + for source in entry.ids.keys() { + assert_eq!( + retained.ids.get(source), + entry.ids.get(source), + "should preserve unrelated lifecycle state through {mode}" + ); + } + if mode != "cluster" { + assert_eq!( + retained.ids["new.example.com"].uid, "new-uid", + "should actually persist the additional source" + ); + } + } + } + + #[test] + fn lifecycle_legacy_same_uid_is_a_noop_for_every_update_path() { + for mode in ["browser", "bulk", "single", "batch"] { + let graph = KvIdentityGraph::in_memory("lifecycle-store"); + let ec_id = snapshot_ec_id(); + let entry = lifecycle_entry(); + graph.create(&ec_id, &entry).expect("should seed identity"); + let (_, before) = graph + .get(&ec_id) + .expect("should read identity") + .expect("should find identity"); + let uid = &entry.ids["ids.example.com"].uid; + match mode { + "browser" => { + let (_, outcome) = graph.sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ids.example.com", uid)], + graph.load_snapshot(&ec_id), + ); + assert_eq!(outcome, EidCookieSyncOutcome::AlreadyMatched); + } + "bulk" => { + graph.upsert_partner_ids_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ids.example.com", uid)], + graph.load_snapshot(&ec_id), + ); + } + "single" => graph + .upsert_partner_id(&ec_id, "ids.example.com", uid) + .expect("should accept unchanged UID"), + "batch" => assert_eq!( + graph + .upsert_partner_id_if_exists(&ec_id, "ids.example.com", uid) + .expect("should accept unchanged UID"), + UpsertResult::Unchanged + ), + _ => unreachable!("should use a listed writer"), + } + let (retained, after) = graph + .get(&ec_id) + .expect("should read retained identity") + .expect("should retain identity"); + assert_eq!( + retained, entry, + "should retain metadata omitted by UID-only input" + ); + assert_eq!( + after, before, + "should not write merely to observe the same UID" + ); + } + } + + #[test] + fn lifecycle_legacy_replacement_resets_expiry_and_never_reuses_revision() { + for mode in ["bulk", "single", "batch"] { + let graph = KvIdentityGraph::in_memory("lifecycle-store"); + let ec_id = snapshot_ec_id(); + let entry = lifecycle_entry(); + graph.create(&ec_id, &entry).expect("should seed identity"); + let mut previous_revision = entry.ids["ids.example.com"].revision.clone(); + for uid in ["replacement-uid", entry.ids["ids.example.com"].uid.as_str()] { + match mode { + "bulk" => { + graph.upsert_partner_ids_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ids.example.com", uid)], + graph.load_snapshot(&ec_id), + ); + } + "single" => graph + .upsert_partner_id(&ec_id, "ids.example.com", uid) + .expect("should replace legacy UID"), + "batch" => assert_eq!( + graph + .upsert_partner_id_if_exists(&ec_id, "ids.example.com", uid) + .expect("should replace legacy UID"), + UpsertResult::Written + ), + _ => unreachable!("should use a listed writer"), + } + let (retained, _) = graph + .get(&ec_id) + .expect("should read retained identity") + .expect("should retain identity"); + let record = &retained.ids["ids.example.com"]; + assert_eq!(record.uid, uid, "should persist replacement UID"); + assert_eq!( + record.expires_at, None, + "should not apply another token's expiry to the replacement" + ); + assert_eq!( + record.writer.as_deref(), + Some("legacy"), + "should not retain another writer's provenance" + ); + assert!( + record.revision.is_some(), + "should version the lifecycle change" + ); + assert_ne!( + record.revision, previous_revision, + "should not reuse the previous revision" + ); + assert_ne!( + record.revision, entry.ids["ids.example.com"].revision, + "should not restore a historical revision when restoring a UID" + ); + assert_eq!( + retained.ids["other.example.com"], entry.ids["other.example.com"], + "should preserve unrelated sources" + ); + previous_revision.clone_from(&record.revision); + } + } + } + + #[test] + fn lifecycle_withdrawal_removes_all_identity_payload() { + let graph = KvIdentityGraph::in_memory("lifecycle-store"); + let ec_id = snapshot_ec_id(); + graph + .create(&ec_id, &lifecycle_entry()) + .expect("should seed identity"); + let snapshot = graph.tombstone_existing_from_snapshot(&ec_id, graph.load_snapshot(&ec_id)); + assert!( + snapshot + .entry_for(&ec_id) + .is_some_and(|entry| !entry.consent.ok), + "should return the persisted tombstone" + ); + let (retained, _) = graph + .get(&ec_id) + .expect("should read tombstone") + .expect("should retain tombstone"); + assert!(!retained.consent.ok, "should preserve withdrawal authority"); + assert!( + retained.ids.is_empty(), + "should remove UIDs and all lifecycle metadata" + ); + } + fn concurrent_live_entry() -> KvEntry { let mut entry = live_entry(); entry.ids.insert( "concurrent.example.com".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "concurrent-uid".to_owned(), + ..Default::default() }, ); entry @@ -2133,6 +2609,7 @@ mod tests { "ssp_x".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "uid-1".to_owned(), + ..Default::default() }, ); let updates = vec![PartnerIdUpdate::new("ssp_x", "uid-1")]; @@ -2161,6 +2638,7 @@ mod tests { "ssp_x".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "old-uid".to_owned(), + ..Default::default() }, ); let updates = vec![PartnerIdUpdate::new("ssp_x", "new-uid")]; @@ -2193,6 +2671,7 @@ mod tests { "ssp_x".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "original".to_owned(), + ..Default::default() }, ); let updates = vec![ @@ -2217,6 +2696,7 @@ mod tests { "ssp_x".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "desired-uid".to_owned(), + ..Default::default() }, ); let lookups = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -2335,6 +2815,7 @@ mod tests { "ssp_x".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "latest-uid".to_owned(), + ..Default::default() }, ); let updates = [ @@ -2356,6 +2837,7 @@ mod tests { "ssp_x".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "concurrent-uid".to_owned(), + ..Default::default() }, ); let lookups = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -2441,6 +2923,7 @@ mod tests { "ssp_x".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "protected-uid".to_owned(), + ..Default::default() }, ); graph.create(&ec_id, &entry).expect("should seed entry"); @@ -2468,6 +2951,7 @@ mod tests { "ssp_x".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "protected-uid".to_owned(), + ..Default::default() }, ); graph.create(&ec_id, &entry).expect("should seed entry"); diff --git a/crates/trusted-server-core/src/ec/kv_types.rs b/crates/trusted-server-core/src/ec/kv_types.rs index 589f280c1..1c8ba0abf 100644 --- a/crates/trusted-server-core/src/ec/kv_types.rs +++ b/crates/trusted-server-core/src/ec/kv_types.rs @@ -34,10 +34,13 @@ pub const MAX_STORED_DOMAIN_LENGTH: usize = 255; /// validation. pub const MAX_UID_LENGTH: usize = 512; +/// Maximum byte length for a stored writer label or opaque record revision. +pub const MAX_LIFECYCLE_LABEL_BYTES: usize = 128; + /// Full KV entry stored as the body of an EC identity graph record. /// /// **KV key:** Full EC ID (`{64hex}.{6alnum}`). -/// **KV value:** JSON-serialized `KvEntry` (max ~5KB). +/// **KV value:** JSON-serialized `KvEntry`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct KvEntry { /// Schema version — always [`SCHEMA_VERSION`]. @@ -97,11 +100,34 @@ pub struct KvGeo { pub dma: Option, } -/// A partner user ID within a KV entry. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// A partner user ID and its optional lifecycle metadata within a KV entry. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct KvPartnerId { /// The partner's user identifier. pub uid: String, + /// Provider expiry in Unix seconds, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + /// Framework-assigned writer identity, not `OpenRTB` provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub writer: Option, + /// Opaque version of a material record change. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, +} + +impl KvPartnerId { + /// Returns whether this record can be delivered at the supplied checked time. + /// + /// Unknown-expiry records retain legacy behavior. A known expiry requires + /// usable time and is exclusive: the record is unusable at its expiry. + #[must_use] + pub fn is_usable(&self, now: Option) -> bool { + !self.uid.is_empty() + && self + .expires_at + .is_none_or(|expiry| now.is_some_and(|now| now < expiry)) + } } /// Publisher property metadata captured when an EC entry is created. @@ -307,6 +333,7 @@ impl KvEntry { partner_id.to_owned(), KvPartnerId { uid: uid.to_owned(), + ..Default::default() }, ); Self { @@ -381,6 +408,18 @@ impl KvEntry { } for (partner_id, partner_uid) in &self.ids { + for (field, label) in [ + ("writer", partner_uid.writer.as_deref()), + ("revision", partner_uid.revision.as_deref()), + ] { + if let Some(label) = label + && (label.trim().is_empty() || label.len() > MAX_LIFECYCLE_LABEL_BYTES) + { + return Err(format!( + "partner ID '{partner_id}' has invalid {field} metadata" + )); + } + } if partner_uid.uid.len() > MAX_UID_LENGTH { return Err(format!( "partner ID '{partner_id}' exceeds MAX_UID_LENGTH ({})", @@ -485,6 +524,149 @@ mod tests { } } + #[test] + fn lifecycle_labels_are_bounded_without_logging_their_values() { + for label in [ + String::new(), + " ".to_owned(), + "x".repeat(MAX_LIFECYCLE_LABEL_BYTES + 1), + ] { + for field in ["writer", "revision"] { + let mut entry = KvEntry::minimal("ids.example.com", "example-uid", 1_000); + let record = entry + .ids + .get_mut("ids.example.com") + .expect("should contain record"); + if field == "writer" { + record.writer = Some(label.clone()); + } else { + record.revision = Some(label.clone()); + } + let error = entry + .validate() + .expect_err("should reject invalid lifecycle labels"); + assert_eq!( + error, + format!("partner ID 'ids.example.com' has invalid {field} metadata"), + "should report only the field, not its value" + ); + } + } + } + + #[test] + fn partner_usability_respects_expiry_boundaries_and_clock_failure() { + let mut record = KvPartnerId { + uid: "example-uid".to_owned(), + expires_at: Some(2_000), + ..Default::default() + }; + assert!( + record.is_usable(Some(1_999)), + "should allow a record before expiry" + ); + assert!( + !record.is_usable(Some(2_000)), + "should omit a record at expiry" + ); + assert!( + !record.is_usable(Some(2_001)), + "should omit a record after expiry" + ); + assert!( + !record.is_usable(None), + "should not prove known expiry usable without a clock" + ); + record.expires_at = None; + assert!( + record.is_usable(None), + "should retain unknown-expiry legacy behavior" + ); + record.uid.clear(); + assert!(!record.is_usable(Some(1_999)), "should omit an empty UID"); + } + + #[test] + fn legacy_partner_records_keep_their_uid_only_shape() { + let record = serde_json::json!({ "uid": "example-uid" }); + let partner: KvPartnerId = + serde_json::from_value(record.clone()).expect("should decode a legacy partner record"); + + assert_eq!(partner.expires_at, None, "should retain unknown expiry"); + assert_eq!( + partner.writer, None, + "should not invent a historical writer" + ); + assert_eq!( + partner.revision, None, + "should not invent a historical revision" + ); + assert_eq!( + serde_json::to_value(partner).expect("should serialize a legacy partner record"), + record, + "should omit absent lifecycle fields" + ); + } + + #[test] + fn partner_record_equality_includes_lifecycle_metadata() { + let original = KvPartnerId { + uid: "example-uid".to_owned(), + expires_at: Some(2_000), + writer: Some("example_identity".to_owned()), + revision: Some("example-revision".to_owned()), + }; + let mut changed = original.clone(); + changed.expires_at = Some(3_000); + assert_ne!( + original, changed, + "should distinguish same-UID expiry changes" + ); + changed = original.clone(); + changed.writer = Some("legacy".to_owned()); + assert_ne!(original, changed, "should distinguish writer changes"); + changed = original.clone(); + changed.revision = Some("next-revision".to_owned()); + assert_ne!(original, changed, "should distinguish revision changes"); + assert_eq!( + original, + original.clone(), + "should match unchanged metadata" + ); + } + + #[test] + fn malformed_partner_expiry_fails_deserialization() { + for expiry in [serde_json::json!(-1), serde_json::json!("invalid")] { + assert!( + serde_json::from_value::(serde_json::json!({ + "uid": "example-uid", + "expires_at": expiry + })) + .is_err(), + "should not downgrade malformed known expiry to unknown expiry" + ); + } + } + + #[test] + fn partner_lifecycle_metadata_round_trips() { + let record = serde_json::json!({ + "uid": "example-uid", + "expires_at": 2_000, + "writer": "example_identity", + "revision": "example-revision" + }); + let partner: KvPartnerId = serde_json::from_value(record.clone()) + .expect("should deserialize partner lifecycle metadata"); + + assert_eq!( + serde_json::to_value(partner).expect("should serialize partner lifecycle metadata"), + record, + "should preserve lifecycle metadata instead of silently dropping it" + ); + } + #[test] fn entry_serialization_roundtrip() { let geo = sample_geo_info(); @@ -494,6 +676,7 @@ mod tests { "liveramp".to_owned(), KvPartnerId { uid: "LR_xyz".to_owned(), + ..Default::default() }, ); @@ -739,6 +922,7 @@ mod tests { "ssp_x".to_owned(), KvPartnerId { uid: "x".repeat(MAX_UID_LENGTH + 1), + ..Default::default() }, ); diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 31a57fece..3b399733f 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -40,6 +40,9 @@ pub mod eids; pub mod finalize; pub mod generation; pub mod identify; +pub mod identity; +pub(crate) mod identity_handoff; +pub(crate) mod identity_handoff_codec; pub mod kv; pub mod kv_backend; pub mod kv_types; @@ -73,12 +76,11 @@ use error_stack::Report; use http::Request; use crate::consent::{self as consent_mod, ConsentContext, ConsentPipelineInput}; -use crate::constants::{COOKIE_TS_EC, COOKIE_TS_EC_PULL_COMPLETE}; +use crate::constants::{COOKIE_TS_EC, COOKIE_TS_EC_PULL_COMPLETE, COOKIE_TS_EIDS_SERVER}; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; use crate::geo::GeoInfo; -use crate::openrtb::Eid; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; @@ -87,7 +89,7 @@ use self::kv::{CreateIfAbsentOutcome, KvIdentityGraph}; use self::kv_types::KvEntry; use self::pull_sync_marker::{PullSyncMarkerState, validate_marker_state}; -/// Bounded request classifications that may persist browser EID cookies. +/// Bounded request classifications that may persist browser EIDs. /// /// Adapters classify publisher navigations and `POST /auction` only after /// pre-route filters allow dispatch. The shared page-bids handler classifies an @@ -272,16 +274,14 @@ pub struct EcContext { kv_snapshot: EcKvSnapshot, /// Whether this request may rotate an orphaned EC identity. recovery_eligible: bool, - /// EIDs the route resolved from the current request: the `/auction` JSON - /// body, or the `ts-eids` cookie when the body carried none. Carried to - /// response finalization so KV ingestion can use the full set the client - /// sent instead of being limited to whatever fits in the size-capped - /// `ts-eids` cookie. - client_eids: Option>, /// Browser-carried proof of recent pull-partner completeness. pull_sync_marker: PullSyncMarkerState, /// Allowed returning-user EID persistence source, assigned only after request filters pass. eid_sync_source: Option, + /// Normalized browser body updates retained even when auction dispatch fails. + browser_eid_updates: Vec, + /// Authenticated capture observations, bound to the incoming full EC ID. + pub(crate) identity_handoff: identity_handoff::HandoffState, } impl EcContext { @@ -350,7 +350,17 @@ impl EcContext { consent.jurisdiction, ); + let identity_handoff = identity_handoff::HandoffState::from_cookie( + settings, + ec_value.as_deref(), + parsed + .jar + .as_ref() + .and_then(|jar| jar.get(COOKIE_TS_EIDS_SERVER)) + .map(cookie::Cookie::value), + ); Ok(Self { + identity_handoff, ec_value, cookie_ec_value: parsed.cookie_ec, ec_was_present, @@ -361,9 +371,9 @@ impl EcContext { device_signals: None, kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, - client_eids: None, pull_sync_marker: PullSyncMarkerState::from_cookie(parsed.pull_sync_marker), eid_sync_source: None, + browser_eid_updates: Vec::new(), }) } @@ -531,6 +541,13 @@ impl EcContext { self.geo_info.as_ref() } + /// Allows authenticated handoff work on an adapter with supported KV/post-send execution. + /// + /// Adapters must leave this disabled for bot-gated or unsupported requests. + pub fn set_identity_handoff_supported(&mut self, supported: bool) { + self.identity_handoff.supported = supported; + } + /// Returns the request-scoped identity-graph snapshot. #[must_use] pub fn kv_snapshot(&self) -> &EcKvSnapshot { @@ -547,11 +564,21 @@ impl EcContext { self.recovery_eligible = eligible; } - /// Allows returning-user EID cookie persistence for this request source. + /// Allows returning-user browser EID persistence for this request source. pub fn set_eid_sync_source(&mut self, source: EidSyncSource) { self.eid_sync_source = Some(source); } + /// Stages registered browser body updates before dispatch. No root is created. + pub(crate) fn stage_browser_eid_updates(&mut self, updates: Vec) { + self.browser_eid_updates = updates; + } + + /// Takes the request's browser updates for one finalization attempt. + pub(crate) fn take_browser_eid_updates(&mut self) -> Vec { + std::mem::take(&mut self.browser_eid_updates) + } + /// Returns the allowed returning-user EID persistence source. #[must_use] pub fn eid_sync_source(&self) -> Option { @@ -564,18 +591,6 @@ impl EcContext { self.recovery_eligible } - /// Records the current request's own EIDs (e.g. an `/auction` body) for - /// use by response finalization's KV ingestion. - pub(crate) fn set_client_eids(&mut self, eids: Vec) { - self.client_eids = Some(eids); - } - - /// Returns the current request's own EIDs, if the route captured any. - #[must_use] - pub(crate) fn client_eids(&self) -> Option<&[Eid]> { - self.client_eids.as_deref() - } - /// Validates a browser completeness marker against the active EC and partner set. pub(crate) fn validate_pull_sync_marker( &mut self, @@ -652,6 +667,8 @@ impl EcContext { #[must_use] pub fn new_for_test(ec_value: Option, consent: ConsentContext) -> Self { Self { + identity_handoff: identity_handoff::HandoffState::default(), + browser_eid_updates: Vec::new(), ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, @@ -662,7 +679,6 @@ impl EcContext { device_signals: None, kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, - client_eids: None, pull_sync_marker: PullSyncMarkerState::Absent, eid_sync_source: None, } @@ -677,6 +693,8 @@ impl EcContext { client_ip: Option, ) -> Self { Self { + identity_handoff: identity_handoff::HandoffState::default(), + browser_eid_updates: Vec::new(), ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, @@ -687,7 +705,6 @@ impl EcContext { device_signals: None, kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, - client_eids: None, pull_sync_marker: PullSyncMarkerState::Absent, eid_sync_source: None, } @@ -705,6 +722,8 @@ impl EcContext { consent: ConsentContext, ) -> Self { Self { + identity_handoff: identity_handoff::HandoffState::default(), + browser_eid_updates: Vec::new(), ec_value, cookie_ec_value, ec_was_present, @@ -715,7 +734,6 @@ impl EcContext { device_signals: None, kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, - client_eids: None, pull_sync_marker: PullSyncMarkerState::Absent, eid_sync_source: None, } @@ -746,6 +764,21 @@ pub(crate) fn checked_current_timestamp() -> Option { .ok() } +/// Returns the current time as a [`std::time::SystemTime`] without calling +/// `std::time::SystemTime::now()`, which panics on `wasm32-unknown-unknown`. +/// +/// Use this for APIs that only accept the std type, such as `httpdate` or +/// chrono's `DateTime::from`. It reads the wasm-safe [`web_time::SystemTime`] +/// clock and adds the elapsed time to [`std::time::UNIX_EPOCH`], which is pure +/// arithmetic. On native and `wasm32-wasip1`, [`web_time`] delegates to the +/// std clock, so this equals `std::time::SystemTime::now()` there. +pub(crate) fn std_system_time_now() -> std::time::SystemTime { + std::time::UNIX_EPOCH + + web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .unwrap_or_default() +} + #[cfg(test)] mod tests { use super::*; @@ -1148,4 +1181,17 @@ mod tests { "should return cookie value for revocation even when header is present" ); } + + #[test] + fn std_system_time_now_tracks_std_clock() { + let before = std::time::SystemTime::now(); + + let now = std_system_time_now(); + + let after = std::time::SystemTime::now(); + assert!( + before <= now && now <= after, + "should fall between std clock reads taken before and after" + ); + } } diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index d823cb32f..8fe71c882 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -1,16 +1,7 @@ -//! Prebid EID cookie ingestion. +//! Browser EID normalization for existing-root enrichment. //! -//! Parses the `ts-eids` cookie written by the TSJS Prebid integration and -//! syncs matched partner UIDs to the KV identity graph. -//! -//! The current cookie format stores a base64-encoded JSON array of full -//! OpenRTB-style `Eid` objects (`{source, uids:[...]}`). For rollout -//! compatibility we also accept the earlier flattened payload shape -//! (`{source, id, atype}` per entry). - -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; -use serde::Deserialize; -use serde_json::Value as JsonValue; +//! Auction bodies supply current-request EIDs. Only registered sources produce +//! UID-only storage updates; provenance and lifecycle metadata are never stored. use crate::openrtb::{Eid, Uid}; @@ -18,140 +9,29 @@ use super::kv::PartnerIdUpdate; use super::kv_types::MAX_UID_LENGTH; use super::registry::PartnerRegistry; -/// Maximum raw `ts-eids` cookie size accepted before base64 decode. -const MAX_EIDS_COOKIE_BYTES: usize = 8 * 1024; - -/// Legacy flattened `ts-eids` cookie entry. -#[derive(Debug, Deserialize)] -struct LegacyCookieEid { - source: String, - id: String, - atype: i32, -} - -/// OpenRTB-style `ts-eids` cookie entry. -#[derive(Debug, Deserialize)] -struct StructuredCookieEid { - source: String, - #[serde(default)] - uids: Vec, -} - -#[derive(Debug, Deserialize)] -struct StructuredCookieUid { - id: String, - #[serde(default)] - atype: Option, - #[serde(default)] - ext: Option, -} - -enum DecodedCookieEids { - Legacy(Vec), - Structured(Vec), -} - -pub(crate) struct DiagnosticEidSource { - pub(crate) source: String, - pub(crate) uids: Vec, -} - -pub(crate) struct PrebidEidAnalysis { - pub(crate) eids: Vec, - pub(crate) diagnostic_sources: Vec, - pub(crate) updates: Vec, -} - -/// Parses a `ts-eids` cookie value into OpenRTB-style `Eid` entries. -/// -/// Accepts both the current structured cookie format and the earlier legacy -/// flattened format for backwards compatibility. -/// -/// # Errors -/// -/// Returns an error when the cookie exceeds the raw size limit, is not valid -/// base64, or does not contain either supported JSON payload shape. -pub fn parse_prebid_eids_cookie(cookie_value: &str) -> Result, String> { - decode_prebid_eids_cookie(cookie_value).map(DecodedCookieEids::into_openrtb) -} - -fn decode_prebid_eids_cookie(cookie_value: &str) -> Result { - if eids_cookie_exceeds_size_limit(cookie_value) { - return Err(format!( - "ts-eids cookie too large ({} bytes)", - cookie_value.len() - )); - } - - let bytes = BASE64 - .decode(cookie_value) - .map_err(|e| format!("base64 decode failed: {e}"))?; - - if let Ok(eids) = serde_json::from_slice::>(&bytes) { - return Ok(DecodedCookieEids::Legacy(eids)); - } - - let structured = serde_json::from_slice::>(&bytes) - .map_err(|e| format!("JSON parse failed: {e}"))?; - Ok(DecodedCookieEids::Structured(structured)) -} - -impl DecodedCookieEids { - fn diagnostic_sources(&self) -> Vec { - match self { - Self::Legacy(entries) => entries - .iter() - .filter(|entry| !entry.source.is_empty()) - .map(|entry| DiagnosticEidSource { - source: entry.source.clone(), - uids: vec![entry.id.clone()], - }) - .collect(), - Self::Structured(entries) => entries - .iter() - .filter(|entry| !entry.source.is_empty()) - .map(|entry| DiagnosticEidSource { - source: entry.source.clone(), - uids: entry.uids.iter().map(|uid| uid.id.clone()).collect(), - }) - .collect(), - } - } - - fn into_openrtb(self) -> Vec { - match self { - Self::Legacy(entries) => legacy_cookie_eids_to_openrtb(entries), - Self::Structured(entries) => structured_cookie_eids_to_openrtb(entries), - } - } +/// Collects one valid UID per registered source without KV I/O. +pub(crate) fn collect_prebid_eid_updates_from_eids( + eids: &[Eid], + registry: &PartnerRegistry, +) -> Vec { + let updates = eids + .iter() + .filter_map(|eid| { + let partner = registry.find_by_source_domain(&eid.source)?; + let uid = first_valid_uid(&eid.uids)?; + Some(PartnerIdUpdate::new(&partner.source_domain, &uid.id)) + }) + .collect(); + dedupe_partner_updates(updates) } -/// Collects validated request-local partner updates without performing KV I/O. -/// -/// `body_eids` is the current request's own EIDs (e.g. an `/auction` JSON -/// body), when the route captured any. It is applied after the `ts-eids` -/// cookie so it wins on conflicts: the cookie is size-capped by the browser -/// (see `MAX_EID_COOKIE_BYTES` in the TSJS Prebid integration) and may be -/// missing partners the request body still carries in full. The `sharedId` -/// cookie is applied last, unaffected by `body_eids`, preserving its existing -/// override behavior. -pub(crate) fn collect_eid_updates( - eids_cookie: Option<&str>, +/// Combines staged body updates with the separate sharedId cookie once. +/// The direct sharedId cookie retains its legacy precedence over body input. +pub(crate) fn collect_browser_eid_updates( + mut updates: Vec, sharedid_cookie: Option<&str>, - body_eids: Option<&[Eid]>, registry: &PartnerRegistry, ) -> Vec { - if registry.is_empty() { - return Vec::new(); - } - - let mut updates = Vec::new(); - if let Some(cookie) = eids_cookie { - updates.extend(collect_prebid_eid_updates(cookie, registry)); - } - if let Some(eids) = body_eids { - updates.extend(collect_prebid_eid_updates_from_eids(eids, registry)); - } if let Some(cookie) = sharedid_cookie && let Some(update) = collect_sharedid_update(cookie, registry) { @@ -160,69 +40,11 @@ pub(crate) fn collect_eid_updates( dedupe_partner_updates(updates) } -pub(crate) fn collect_prebid_eid_updates( - cookie_value: &str, - registry: &PartnerRegistry, -) -> Vec { - let Ok(analysis) = analyze_prebid_eids_cookie(cookie_value, registry) else { - log::trace!("Prebid EIDs: failed to decode ts-eids cookie; dropping"); - return Vec::new(); - }; - - analysis.updates -} - -pub(crate) fn analyze_prebid_eids_cookie( - cookie_value: &str, - registry: &PartnerRegistry, -) -> Result { - let decoded = decode_prebid_eids_cookie(cookie_value)?; - let diagnostic_sources = decoded.diagnostic_sources(); - let eids = decoded.into_openrtb(); - let updates = collect_prebid_eid_updates_from_eids(&eids, registry); - - Ok(PrebidEidAnalysis { - eids, - diagnostic_sources, - updates, - }) -} - -fn collect_prebid_eid_updates_from_eids( - eids: &[Eid], - registry: &PartnerRegistry, -) -> Vec { - let mut updates = Vec::new(); - for eid in eids { - let Some(partner) = registry.find_by_source_domain(&eid.source) else { - log::debug!("Prebid EIDs: no partner for source '{}'", eid.source); - continue; - }; - - // KV stores one UID per partner. Preserve the previous cookie-ingestion - // behavior by syncing the first valid UID under each source, while - // skipping malformed candidates instead of dropping the whole source. - let Some(uid) = first_valid_uid(&eid.uids) else { - log::debug!( - "Prebid EIDs: no valid uid for source_domain '{}' from source '{}'", - partner.source_domain, - eid.source, - ); - continue; - }; - - updates.push(PartnerIdUpdate::new(&partner.source_domain, &uid.id)); - } - - updates -} - pub(crate) fn dedupe_partner_updates(updates: Vec) -> Vec { let mut latest = std::collections::BTreeMap::new(); for update in updates { latest.insert(update.partner_id, update.uid); } - latest .into_iter() .map(|(partner_id, uid)| PartnerIdUpdate::new(partner_id, uid)) @@ -234,127 +56,35 @@ fn first_valid_uid(uids: &[Uid]) -> Option<&Uid> { } pub(crate) fn is_valid_eid_uid(uid: &str) -> bool { - !uid.trim().is_empty() && !eid_id_exceeds_size_limit(uid) + !uid.trim().is_empty() && uid.len() <= MAX_UID_LENGTH } -/// `SharedID` EID source domain used for partner registry lookup. -const SHAREDID_SOURCE_DOMAIN: &str = "sharedid.org"; - pub(crate) fn collect_sharedid_update( cookie_value: &str, registry: &PartnerRegistry, ) -> Option { let cookie_value = cookie_value.trim(); - if cookie_value.is_empty() { - return None; - } - - if sharedid_cookie_exceeds_size_limit(cookie_value) { - log::debug!( - "SharedID: cookie exceeds MAX_UID_LENGTH ({} bytes)", - cookie_value.len() - ); + if !is_valid_eid_uid(cookie_value) { return None; } - - let Some(partner) = registry.find_by_source_domain(SHAREDID_SOURCE_DOMAIN) else { - log::debug!("SharedID: no partner configured for source '{SHAREDID_SOURCE_DOMAIN}'"); - return None; - }; - + let partner = registry.find_by_source_domain("sharedid.org")?; Some(PartnerIdUpdate::new(&partner.source_domain, cookie_value)) } -fn eids_cookie_exceeds_size_limit(cookie_value: &str) -> bool { - cookie_value.len() > MAX_EIDS_COOKIE_BYTES -} - -fn eid_id_exceeds_size_limit(uid: &str) -> bool { - uid.len() > MAX_UID_LENGTH -} - -fn sharedid_cookie_exceeds_size_limit(cookie_value: &str) -> bool { - cookie_value.len() > MAX_UID_LENGTH -} - -fn structured_cookie_eids_to_openrtb(entries: Vec) -> Vec { - let mut eids = Vec::new(); - - for entry in entries { - if entry.source.is_empty() { - continue; - } - - let uids: Vec<_> = entry - .uids - .into_iter() - .filter_map(structured_cookie_uid_to_openrtb) - .collect(); - if uids.is_empty() { - continue; - } - - eids.push(Eid { - source: entry.source, - uids, - }); - } - - eids -} - -fn structured_cookie_uid_to_openrtb(uid: StructuredCookieUid) -> Option { - if uid.id.is_empty() { - return None; - } - - let atype = uid.atype.filter(|atype| *atype >= 0); - let ext = match uid.ext { - Some(JsonValue::Object(_)) => uid.ext, - _ => None, - }; - - Some(Uid { - id: uid.id, - atype, - ext, - }) -} - -fn legacy_cookie_eids_to_openrtb(entries: Vec) -> Vec { - entries - .into_iter() - .filter(|entry| !entry.source.is_empty() && !entry.id.is_empty()) - .map(|entry| Eid { - source: entry.source, - uids: vec![Uid { - id: entry.id, - atype: (entry.atype >= 0).then_some(entry.atype), - ext: None, - }], - }) - .collect() -} - #[cfg(test)] mod tests { use super::*; - use base64::engine::general_purpose::STANDARD as BASE64; - use serde_json::json; - - use crate::ec::registry::PartnerRegistry; - use crate::redacted::Redacted; use crate::settings::EcPartner; + use serde_json::json; - fn make_test_partner(_id: &str, source_domain: &str) -> EcPartner { - EcPartner { - name: format!("Partner {source_domain}"), - source_domain: source_domain.to_owned(), + fn registry() -> PartnerRegistry { + let partners = ["id5-sync.com", "sharedid.org"].map(|source| EcPartner { + name: source.to_owned(), + source_domain: source.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled: true, - api_token: Some(Redacted::new(format!( - "token-{source_domain}-32-bytes-minimum-value" - ))), + identity_owner: None, + api_token: None, batch_rate_limit: EcPartner::default_batch_rate_limit(), pull_sync_enabled: false, pull_sync_url: None, @@ -362,420 +92,45 @@ mod tests { pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), ts_pull_token: None, - } - } - - fn make_registry(partners: Vec<(&str, &str)>) -> PartnerRegistry { - let partners: Vec<_> = partners - .into_iter() - .map(|(id, source_domain)| make_test_partner(id, source_domain)) - .collect(); - PartnerRegistry::from_config(&partners).expect("should build partner registry") - } - - fn encode_json(value: &serde_json::Value) -> String { - BASE64.encode(serde_json::to_vec(value).expect("should serialize EID JSON")) - } - - #[test] - fn parse_prebid_eids_cookie_parses_legacy_flat_payload() { - let eids = vec![ - json!({"source": "id5-sync.com", "id": "ID5_abc", "atype": 1}), - json!({"source": "liveramp.com", "id": "LR_xyz", "atype": 3}), - json!({"source": "google.com", "id": "pair-id", "atype": 571187}), - ]; - let encoded = BASE64.encode(serde_json::to_vec(&eids).expect("should serialize")); - - let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode valid payload"); - assert_eq!(decoded.len(), 3, "should parse all EIDs"); - assert_eq!(decoded[0].source, "id5-sync.com"); - assert_eq!(decoded[0].uids[0].id, "ID5_abc"); - assert_eq!(decoded[1].source, "liveramp.com"); - assert_eq!(decoded[1].uids[0].id, "LR_xyz"); - assert_eq!( - decoded[2].source, "google.com", - "should preserve PAIR source" - ); - assert_eq!( - decoded[2].uids[0].atype, - Some(571187), - "should preserve PAIR vendor-specific atype" - ); - } - - #[test] - fn parse_prebid_eids_cookie_parses_structured_payload() { - let eids = vec![json!({ - "source": "sharedid.org", - "uids": [ - {"id": "shared_123", "atype": 3}, - {"id": "shared_456", "ext": {"provider": "example"}}, - {"id": "shared_invalid", "atype": -1} - ] - })]; - let encoded = BASE64.encode(serde_json::to_vec(&eids).expect("should serialize")); - - let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode valid payload"); - assert_eq!(decoded.len(), 1, "should parse one structured EID entry"); - assert_eq!(decoded[0].source, "sharedid.org"); - assert_eq!(decoded[0].uids.len(), 3, "should preserve multiple UIDs"); - assert_eq!(decoded[0].uids[0].id, "shared_123"); - assert_eq!(decoded[0].uids[0].atype, Some(3)); - assert_eq!( - decoded[0].uids[1].ext, - Some(json!({"provider": "example"})), - "should preserve UID ext objects" - ); - assert_eq!( - decoded[0].uids[2].atype, None, - "should drop negative atype values" - ); - } - - #[test] - fn parse_prebid_eids_cookie_preserves_pair_atype() { - let encoded = encode_json(&json!([ - { - "source": "google.com", - "uids": [{ "id": "pair-id", "atype": 571187 }] - } - ])); - - let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode PAIR EID"); - - assert_eq!( - decoded[0].uids[0].atype, - Some(571187), - "should preserve PAIR's vendor-specific atype" - ); - } - - #[test] - fn parse_prebid_eids_cookie_rejects_invalid_base64() { - let result = parse_prebid_eids_cookie("not-valid-base64!!!"); - assert!(result.is_err(), "should reject invalid base64"); - } - - #[test] - fn parse_prebid_eids_cookie_rejects_invalid_json() { - let encoded = BASE64.encode(b"not json"); - let result = parse_prebid_eids_cookie(&encoded); - assert!(result.is_err(), "should reject invalid JSON"); - } - - #[test] - fn ts_eids_cookie_rejects_oversized_payloads() { - let oversized = "x".repeat(MAX_EIDS_COOKIE_BYTES + 1); - let exact_limit = "x".repeat(MAX_EIDS_COOKIE_BYTES); - - assert!( - eids_cookie_exceeds_size_limit(&oversized), - "should reject cookies larger than the raw size cap" - ); - assert!( - !eids_cookie_exceeds_size_limit(&exact_limit), - "should allow cookies exactly at the raw size cap" - ); - } - - #[test] - fn sharedid_cookie_rejects_values_larger_than_uid_limit() { - let oversized = "x".repeat(MAX_UID_LENGTH + 1); - let exact_limit = "x".repeat(MAX_UID_LENGTH); - - assert!( - sharedid_cookie_exceeds_size_limit(&oversized), - "should reject sharedId values larger than MAX_UID_LENGTH" - ); - assert!( - !sharedid_cookie_exceeds_size_limit(&exact_limit), - "should allow sharedId values exactly at MAX_UID_LENGTH" - ); - } - - #[test] - fn prebid_eid_uid_rejects_values_larger_than_uid_limit() { - let oversized = "x".repeat(MAX_UID_LENGTH + 1); - let exact_limit = "x".repeat(MAX_UID_LENGTH); - - assert!( - eid_id_exceeds_size_limit(&oversized), - "should reject EID values larger than MAX_UID_LENGTH" - ); - assert!( - !eid_id_exceeds_size_limit(&exact_limit), - "should allow EID values exactly at MAX_UID_LENGTH" - ); - } - - #[test] - fn first_valid_uid_skips_oversized_and_uses_later_valid_uid() { - let oversized = "x".repeat(MAX_UID_LENGTH + 1); - let uids = vec![ - Uid { - id: oversized, - atype: Some(1), - ext: None, - }, - Uid { - id: "valid-uid".to_owned(), - atype: Some(1), - ext: None, - }, - ]; - - let uid = first_valid_uid(&uids).expect("should find later valid UID"); - assert_eq!(uid.id, "valid-uid", "should skip oversized first UID"); - } - - #[test] - fn first_valid_uid_rejects_whitespace_only_ids() { - let uids = vec![Uid { - id: " ".to_owned(), - atype: Some(1), - ext: None, - }]; - - assert!( - first_valid_uid(&uids).is_none(), - "should reject whitespace UID" - ); - } - - #[test] - fn collect_prebid_eid_updates_collects_multiple_partner_matches() { - let registry = make_registry(vec![("id5", "id5-sync.com"), ("liveramp", "liveramp.com")]); - let cookie = encode_json(&json!([ - {"source": "id5-sync.com", "uids": [{"id": "ID5_abc", "atype": 1}]}, - {"source": "liveramp.com", "uids": [{"id": "LR_xyz", "atype": 3}]} - ])); - - let updates = collect_prebid_eid_updates(&cookie, ®istry); - - assert_eq!(updates.len(), 2, "should collect both partner matches"); - assert_eq!(updates[0], PartnerIdUpdate::new("id5-sync.com", "ID5_abc")); - assert_eq!(updates[1], PartnerIdUpdate::new("liveramp.com", "LR_xyz")); - } - - #[test] - fn collect_prebid_eid_updates_skips_unknown_sources() { - let registry = make_registry(vec![("id5", "id5-sync.com")]); - let cookie = encode_json(&json!([ - {"source": "unknown.example", "uids": [{"id": "unknown", "atype": 1}]} - ])); - - let updates = collect_prebid_eid_updates(&cookie, ®istry); - - assert!( - updates.is_empty(), - "should skip EIDs without configured source-domain partners" - ); - } - - #[test] - fn collect_prebid_eid_updates_uses_later_valid_uid_candidate() { - let registry = make_registry(vec![("id5", "id5-sync.com")]); - let oversized = "x".repeat(MAX_UID_LENGTH + 1); - let cookie = encode_json(&json!([ - { - "source": "id5-sync.com", - "uids": [ - {"id": oversized, "atype": 1}, - {"id": "ID5_valid", "atype": 1} - ] - } - ])); - - let updates = collect_prebid_eid_updates(&cookie, ®istry); - - assert_eq!( - updates, - vec![PartnerIdUpdate::new("id5-sync.com", "ID5_valid")] - ); - } - - #[test] - fn collect_sharedid_update_maps_configured_sharedid_partner() { - let registry = make_registry(vec![("sharedid", "sharedid.org")]); - - let update = collect_sharedid_update(" shared-cookie-id ", ®istry) - .expect("should collect sharedId update"); - - assert_eq!( - update, - PartnerIdUpdate::new("sharedid.org", "shared-cookie-id") - ); - } - - #[test] - fn collect_eid_updates_merges_prebid_and_sharedid_without_kv() { - let registry = make_registry(vec![("id5", "id5-sync.com"), ("sharedid", "sharedid.org")]); - let eids_cookie = encode_json(&json!([ - {"source": "id5-sync.com", "uids": [{"id": "ID5_abc", "atype": 1}]} - ])); - - let updates = collect_eid_updates(Some(&eids_cookie), Some(" shared-1 "), None, ®istry); - - assert_eq!( - updates.len(), - 2, - "should collect prebid and sharedId matches" - ); - assert!(updates.contains(&PartnerIdUpdate::new("id5-sync.com", "ID5_abc"))); - assert!(updates.contains(&PartnerIdUpdate::new("sharedid.org", "shared-1"))); + }); + PartnerRegistry::from_config(&partners).expect("should build registry") } #[test] - fn collect_eid_updates_body_eids_ingest_partners_the_cookie_trimmed() { - // Simulates a `ts-eids` cookie that the browser trimmed to fit - // `MAX_EID_COOKIE_BYTES`, dropping the `liveramp.com` source, while - // the `/auction` request body still carried it (and the newer, - // updated `id5-sync.com` uid) in full. - let registry = make_registry(vec![("id5", "id5-sync.com"), ("liveramp", "liveramp.com")]); - let eids_cookie = encode_json(&json!([ - {"source": "id5-sync.com", "uids": [{"id": "ID5_stale", "atype": 1}]} - ])); - let body_eids = vec![ - Eid { - source: "id5-sync.com".to_owned(), - uids: vec![Uid { - id: "ID5_fresh".to_owned(), - atype: Some(1), - ext: None, - }], - }, - Eid { - source: "liveramp.com".to_owned(), - uids: vec![Uid { - id: "LR_xyz".to_owned(), - atype: Some(3), - ext: None, - }], - }, - ]; - - let updates = collect_eid_updates(Some(&eids_cookie), None, Some(&body_eids), ®istry); - + fn body_updates_are_registered_uid_only_and_skip_invalid_candidates() { + let eids: Vec = serde_json::from_value(json!([ + {"source":"ID5-SYNC.COM", "inserter":"forged-owner", "matcher":"", "mm":0, + "uids":[{"id":" ", "atype":1}, {"id":"opaque-envelope", "ext":{"writer":"forged"}}]}, + {"source":"unknown.example", "uids":[{"id":"current-request-only"}]} + ])) + .expect("should parse EIDs"); assert_eq!( - updates, - vec![ - PartnerIdUpdate::new("id5-sync.com", "ID5_fresh"), - PartnerIdUpdate::new("liveramp.com", "LR_xyz"), - ], - "body EIDs should ingest every configured partner present in the \ - request, including the one the cookie trimmed, and should win \ - over a stale cookie value for a partner both carry" - ); - } - - #[test] - fn collect_eid_updates_empty_registry_returns_no_updates() { - let registry = PartnerRegistry::empty(); - let eids_cookie = encode_json(&json!([ - {"source": "id5-sync.com", "uids": [{"id": "ID5_abc", "atype": 1}]} - ])); - - let updates = collect_eid_updates(Some(&eids_cookie), Some("shared-1"), None, ®istry); - - assert!( - updates.is_empty(), - "an empty registry matches no partners and touches no KV" + collect_prebid_eid_updates_from_eids(&eids, ®istry()), + vec![PartnerIdUpdate::new("id5-sync.com", "opaque-envelope")] ); } #[test] - fn dedupe_partner_updates_uses_last_partner_value() { + fn sharedid_combines_with_body_and_retains_direct_cookie_precedence() { let updates = vec![ - PartnerIdUpdate::new("sharedid.org", "prebid-shared"), - PartnerIdUpdate::new("id5-sync.com", "id5-uid"), - PartnerIdUpdate::new("sharedid.org", "cookie-shared"), + PartnerIdUpdate::new("sharedid.org", "body-shared"), + PartnerIdUpdate::new("id5-sync.com", "id5"), ]; - - let deduped = dedupe_partner_updates(updates); - - assert_eq!(deduped.len(), 2, "should keep one update per partner"); - assert_eq!( - deduped, - vec![ - PartnerIdUpdate::new("id5-sync.com", "id5-uid"), - PartnerIdUpdate::new("sharedid.org", "cookie-shared"), - ], - "should keep the last value for duplicate partners" - ); - } - - #[test] - fn collect_eid_updates_combines_cookie_sources() { - let registry = make_registry(vec![ - ("id5", "id5-sync.com"), - ("liveramp", "liveramp.com"), - ("sharedid", "sharedid.org"), - ]); - let cookie = encode_json(&json!([ - {"source": "id5-sync.com", "uids": [{"id": "ID5_abc", "atype": 1}]}, - {"source": "liveramp.com", "uids": [{"id": "LR_xyz", "atype": 3}]} - ])); - - let updates = collect_eid_updates(Some(&cookie), Some("shared-cookie-id"), None, ®istry); - assert_eq!( - updates, + collect_browser_eid_updates(updates, Some(" cookie-shared "), ®istry()), vec![ - PartnerIdUpdate::new("id5-sync.com", "ID5_abc"), - PartnerIdUpdate::new("liveramp.com", "LR_xyz"), - PartnerIdUpdate::new("sharedid.org", "shared-cookie-id"), - ], - "should collect every matched update in one batch" - ); - } - - #[test] - fn collect_liveramp_eid_cookie_updates_preserves_the_opaque_envelope() { - let registry = make_registry(vec![("liveramp", "liveramp.com")]); - let cookie = encode_json(&json!([ - { - "source": "liveramp.com", - "uids": [{"id": "opaque-test-envelope", "atype": 3}] - } - ])); - - let updates = collect_eid_updates(Some(&cookie), None, None, ®istry); - - assert_eq!( - updates, - vec![PartnerIdUpdate::new("liveramp.com", "opaque-test-envelope")], - "should preserve the opaque envelope without decoding it" - ); - } - - #[test] - fn collect_eid_updates_prefers_direct_sharedid_cookie() { - let registry = make_registry(vec![("sharedid", "sharedid.org")]); - let cookie = encode_json(&json!([ - {"source": "sharedid.org", "uids": [{"id": "prebid-shared", "atype": 3}]} - ])); - - let updates = collect_eid_updates(Some(&cookie), Some("cookie-shared"), None, ®istry); - - assert_eq!( - updates, - vec![PartnerIdUpdate::new("sharedid.org", "cookie-shared")], - "should apply the direct sharedId cookie after Prebid EIDs" + PartnerIdUpdate::new("id5-sync.com", "id5"), + PartnerIdUpdate::new("sharedid.org", "cookie-shared") + ] ); } #[test] - fn collect_eid_updates_ignores_unknown_sources() { - let registry = make_registry(vec![("id5", "id5-sync.com")]); - let cookie = encode_json(&json!([ - {"source": "unknown.example", "uids": [{"id": "unknown", "atype": 1}]} - ])); - - let updates = collect_eid_updates(Some(&cookie), None, None, ®istry); - - assert!( - updates.is_empty(), - "should ignore unmatched partner updates" - ); + fn uid_limits_are_byte_bounded() { + assert!(is_valid_eid_uid(&"x".repeat(MAX_UID_LENGTH))); + assert!(!is_valid_eid_uid(&"x".repeat(MAX_UID_LENGTH + 1))); + assert!(!is_valid_eid_uid(" \t")); + assert!(collect_sharedid_update(&"x".repeat(MAX_UID_LENGTH + 1), ®istry()).is_none()); + assert!(collect_sharedid_update("valid", &PartnerRegistry::empty()).is_none()); } } diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 191e6c6d2..1e700a99a 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -310,9 +310,10 @@ pub(crate) fn entry_is_pull_complete(entry: &KvEntry, registry: &PartnerRegistry } fn is_partner_pull_eligible(partner: &PartnerConfig, kv_entry: Option<&KvEntry>) -> bool { - kv_entry - .and_then(|entry| entry.ids.get(&partner.source_domain)) - .is_none() + partner.identity_owner.is_none() + && kv_entry + .and_then(|entry| entry.ids.get(&partner.source_domain)) + .is_none() } fn validated_pull_sync_url(partner: &PartnerConfig) -> Option { @@ -559,6 +560,7 @@ mod tests { pull_sync_allowed_domains: vec!["sync.partner.test".to_owned()], pull_sync_ttl_sec: ttl_sec, pull_sync_rate_limit: 20, + identity_owner: None, ts_pull_token: Some(Redacted::new("token".to_owned())), } } @@ -654,6 +656,7 @@ mod tests { "ssp.example.com".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "uid".to_owned(), + ..Default::default() }, ); } @@ -686,6 +689,26 @@ mod tests { ); } + #[test] + fn expired_identity_does_not_trigger_legacy_pull_refresh() { + let registry = PartnerRegistry::from_config(&[pull_enabled_ec_partner("ssp.example.com")]) + .expect("should build registry"); + let mut entry = KvEntry::minimal("ssp.example.com", "expired-uid", 1_000); + entry + .ids + .get_mut("ssp.example.com") + .expect("should contain record") + .expires_at = Some(1); + assert!( + entry_is_pull_complete(&entry, ®istry), + "legacy completeness tracks stored presence, not auction usability" + ); + assert!( + !is_partner_pull_eligible(&pull_partner(3_600), Some(&entry)), + "expiry must not introduce an unapproved generic refresh" + ); + } + #[test] fn completeness_requires_all_pull_partner_ids() { let registry = PartnerRegistry::from_config(&[ @@ -704,6 +727,7 @@ mod tests { "b.example.com".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "uid-b".to_owned(), + ..Default::default() }, ); @@ -928,6 +952,7 @@ mod tests { pull_sync_allowed_domains: vec![source_domain.to_owned()], pull_sync_ttl_sec: 3600, pull_sync_rate_limit: 100, + identity_owner: None, ts_pull_token: Some(Redacted::new("outbound-token".to_owned())), } } @@ -1215,6 +1240,7 @@ mod tests { "alpha.example.com".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "already-known".to_owned(), + ..Default::default() }, ); graph diff --git a/crates/trusted-server-core/src/ec/pull_sync_marker.rs b/crates/trusted-server-core/src/ec/pull_sync_marker.rs index f2a2fe601..17e763c34 100644 --- a/crates/trusted-server-core/src/ec/pull_sync_marker.rs +++ b/crates/trusted-server-core/src/ec/pull_sync_marker.rs @@ -350,6 +350,7 @@ mod tests { pull_sync_allowed_domains: vec![format!("sync.{source_domain}")], pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), + identity_owner: None, ts_pull_token: Some(Redacted::new("pull-token".to_owned())), } } @@ -377,6 +378,7 @@ mod tests { (*domain).to_owned(), crate::ec::kv_types::KvPartnerId { uid: format!("uid-{domain}"), + ..Default::default() }, ); } @@ -512,6 +514,7 @@ mod tests { "a.example.com".to_owned(), crate::ec::kv_types::KvPartnerId { uid: "stale".to_owned(), + ..Default::default() }, ); let snapshots = [ @@ -690,4 +693,64 @@ mod tests { ); assert!(!cookie.contains("Domain="), "marker should be host-only"); } + + #[test] + fn ownership_change_updates_effective_pull_fingerprint() { + let (settings, _) = settings_and_registry(&[]); + let unowned = PartnerRegistry::from_config(&[ + pull_partner("owned-source.example.com"), + pull_partner("legacy-source.example.com"), + ]) + .expect("should build unowned pull registry"); + let mut owned_source = pull_partner("owned-source.example.com"); + owned_source.identity_owner = Some("identity_module".to_owned()); + let owned = PartnerRegistry::from_config(&[ + owned_source, + pull_partner("legacy-source.example.com"), + ]) + .expect("should build registry with one owned source"); + + let before = partner_set_fingerprint(&unowned); + let after = partner_set_fingerprint(&owned); + assert_ne!( + before, after, + "moving a source to an owner should change the effective pull fingerprint" + ); + let marker = create_marker(&settings, &unowned, EC_ID, 4_600) + .expect("should create marker for the original effective pull set"); + assert!( + validate_marker(&marker, &settings, &owned, EC_ID, 1_000).is_none(), + "ownership change should invalidate a marker signed for the old pull set" + ); + } + + #[test] + fn owned_missing_source_is_skipped_and_expired_unowned_presence_is_complete() { + let mut owned_source = pull_partner("owned-source.example.com"); + owned_source.identity_owner = Some("identity_module".to_owned()); + let legacy_source = pull_partner("legacy-source.example.com"); + let registry = PartnerRegistry::from_config(&[owned_source, legacy_source]) + .expect("should build mixed owned and legacy pull registry"); + + let mut entry = KvEntry::tombstone(1_000); + entry.consent.ok = true; + entry.ids.insert( + "legacy-source.example.com".to_owned(), + crate::ec::kv_types::KvPartnerId { + uid: "expired-legacy-uid".to_owned(), + expires_at: Some(1), + ..Default::default() + }, + ); + assert!( + entry_is_pull_complete(&entry, ®istry), + "owned absence should not make legacy acquisition incomplete, and expired unowned presence remains fill-missing complete" + ); + + entry.ids.remove("legacy-source.example.com"); + assert!( + !entry_is_pull_complete(&entry, ®istry), + "a missing unowned source should still make legacy acquisition incomplete" + ); + } } diff --git a/crates/trusted-server-core/src/ec/registry.rs b/crates/trusted-server-core/src/ec/registry.rs index c4637b431..01780c623 100644 --- a/crates/trusted-server-core/src/ec/registry.rs +++ b/crates/trusted-server-core/src/ec/registry.rs @@ -28,6 +28,8 @@ pub struct PartnerConfig { pub openrtb_atype: i32, /// Whether this partner's UIDs appear in auction `user.eids`. pub bidstream_enabled: bool, + /// Selected authoritative integration, when managed acquisition owns this source. + pub identity_owner: Option, /// SHA-256 hex of the partner's API token, when inbound API access is enabled. pub api_key_hash: Option, /// Max batch sync API requests per partner per minute. @@ -239,7 +241,7 @@ impl PartnerRegistry { pub fn pull_enabled_partners(&self) -> Vec<&PartnerConfig> { self.by_source_domain .values() - .filter(|p| p.pull_sync_enabled) + .filter(|p| p.pull_sync_enabled && p.identity_owner.is_none()) .collect() } @@ -304,6 +306,7 @@ fn build_partner_config( pull_sync_allowed_domains: partner.pull_sync_allowed_domains.clone(), pull_sync_ttl_sec: partner.pull_sync_ttl_sec, pull_sync_rate_limit: partner.pull_sync_rate_limit, + identity_owner: partner.identity_owner.clone(), ts_pull_token: partner.ts_pull_token.clone(), } } @@ -431,6 +434,7 @@ mod tests { pull_sync_allowed_domains: vec![], pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), + identity_owner: None, ts_pull_token: None, } } diff --git a/crates/trusted-server-core/src/forwarder.rs b/crates/trusted-server-core/src/forwarder.rs new file mode 100644 index 000000000..4a3bd7ae8 --- /dev/null +++ b/crates/trusted-server-core/src/forwarder.rs @@ -0,0 +1,512 @@ +//! Authenticated browser-facing origin metadata, separate from transport evidence. + +use edgezero_core::body::Body; +use http::{HeaderMap, HeaderName, Request}; +use url::Url; + +use crate::settings::Settings; + +/// The default credential header emitted by the development proxy. +pub const FORWARDER_AUTH_HEADER: &str = "x-ts-forwarder-auth"; + +/// A frozen forwarding decision captured before transport header sanitation. +/// +/// A rejected decision carries no origin or authentication material. Adapters +/// may carry this decision across conversion without changing runtime ingress. +#[derive(Clone, Debug, Default)] +pub struct ForwarderPreparation { + origin: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PublicOrigin { + pub(crate) scheme: String, + pub(crate) authority: String, +} + +impl PublicOrigin { + pub(crate) fn serialized(&self) -> String { + format!("{}://{}", self.scheme, self.authority) + } +} + +impl ForwarderPreparation { + /// Capture only authenticated, publisher-bounded forwarding metadata. + /// + /// Invalid or missing inputs produce an empty decision. This does not + /// change the supplied fields or retain a credential. + /// + /// # Examples + /// + /// ```ignore + /// let prepared = ForwarderPreparation::capture(request.headers(), &settings); + /// request.extensions_mut().insert(prepared); + /// ``` + #[must_use] + pub fn capture(headers: &HeaderMap, settings: &Settings) -> Self { + Self { + origin: authenticated_public_origin(headers, settings), + } + } +} + +/// Freeze forwarding metadata and remove forwarding credentials before routing. +/// +/// Repeated calls retain the first decision, including a rejected decision. +/// Runtime ingress and browser Origin remain untouched. +/// +/// # Examples +/// +/// ```ignore +/// prepare_trusted_forwarder(&mut request, &settings); +/// ``` +pub fn prepare_trusted_forwarder(request: &mut Request, settings: &Settings) { + if request.extensions().get::().is_none() { + let prepared = ForwarderPreparation::capture(request.headers(), settings); + request.extensions_mut().insert(prepared); + } + request.headers_mut().remove(FORWARDER_AUTH_HEADER); + if let Some(config) = &settings.trusted_forwarder { + request.headers_mut().remove(config.auth_header.as_str()); + } + // The decision is now typed metadata. Raw forwarding fields must not + // influence later consumers or escape to publishers and vendors. + for name in ["forwarded", "x-forwarded-host", "x-forwarded-proto"] { + request.headers_mut().remove(name); + } +} + +fn authenticated_public_origin(headers: &HeaderMap, settings: &Settings) -> Option { + let config = settings.trusted_forwarder.as_ref()?; + let credential = single_field(headers, &config.auth_header)?; + if !config.authenticates(credential) { + return None; + } + let scheme = single_field(headers, "x-forwarded-proto")?; + let authority = single_field(headers, "x-forwarded-host")?; + let origin = parse_public_origin(scheme, authority)?; + let url = Url::parse(&origin.serialized()).ok()?; + let hostname = url.host_str()?; + let publisher = settings.publisher.domain.to_ascii_lowercase(); + if hostname != publisher + && !hostname + .strip_suffix(&publisher) + .is_some_and(|prefix| prefix.ends_with('.')) + { + return None; + } + Some(origin) +} + +fn single_field<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + let name = HeaderName::from_bytes(name.as_bytes()).ok()?; + let mut fields = headers.get_all(name).iter(); + let field = fields.next()?; + if fields.next().is_some() { + return None; + } + core::str::from_utf8(field.as_bytes()).ok() +} + +fn parse_public_origin(scheme: &str, authority: &str) -> Option { + if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") + || authority.is_empty() + || !authority + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b".-:[]".contains(&byte)) + { + return None; + } + let port = if authority.starts_with('[') { + let end = authority.find(']')?; + let suffix = &authority[end + 1..]; + if suffix.is_empty() { + None + } else { + Some(suffix.strip_prefix(':')?) + } + } else { + let (hostname, port) = authority + .split_once(':') + .map_or((authority, None), |(host, port)| (host, Some(port))); + if hostname.len() > 253 + || hostname.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return None; + } + port + }; + if let Some(port) = port + && (port.is_empty() + || !port.bytes().all(|byte| byte.is_ascii_digit()) + || port.parse::().ok().is_none_or(|port| port == 0)) + { + return None; + } + let url = Url::parse(&format!("{}://{authority}", scheme.to_ascii_lowercase())).ok()?; + let serialized = url.origin().ascii_serialization(); + let (scheme, authority) = serialized.split_once("://")?; + Some(PublicOrigin { + scheme: scheme.to_owned(), + authority: authority.to_owned(), + }) +} + +pub(crate) fn public_origin(request: &Request) -> Option<&PublicOrigin> { + request + .extensions() + .get::() + .and_then(|prepared| prepared.origin.as_ref()) +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, RequestIngress, + TargetUnavailable, + }; + use http::{HeaderValue, header}; + + use crate::http_util::RequestInfo; + use crate::platform::ClientInfo; + use crate::test_support::tests::create_test_settings; + use crate::trace::inspect_cookies; + + const SECRET: &str = "fictional-forwarder-secret-0123456789"; + + fn settings() -> Settings { + let mut value = serde_json::to_value(create_test_settings()) + .expect("should serialize example settings"); + value["publisher"]["domain"] = serde_json::json!("publisher.example.com"); + value["trusted_forwarder"] = serde_json::json!({ + "auth_header": FORWARDER_AUTH_HEADER, + "shared_secret": SECRET, + }); + Settings::from_json_value(value).expect("should parse forwarder configuration") + } + + fn request() -> Request { + Request::builder() + .uri("http://upstream.example.com/path") + .header(header::HOST, "upstream.example.com") + .header(header::ORIGIN, "https://publisher.example.com:8443") + .header(FORWARDER_AUTH_HEADER, SECRET) + .header("x-forwarded-host", "PUBLISHER.example.com:8443") + .header("x-forwarded-proto", "https") + .body(Body::empty()) + .expect("should construct forwarded request") + } + + #[test] + fn authenticated_origin_overrides_transport_without_mutating_it() { + let mut request = request(); + let original_uri = request.uri().clone(); + prepare_trusted_forwarder(&mut request, &settings()); + + let info = RequestInfo::from_request(&request, &ClientInfo::default()) + .expect("should extract request info"); + assert_eq!( + info.host, "publisher.example.com:8443", + "should retain the public port" + ); + assert_eq!(info.scheme, "https", "should use the public scheme"); + assert_eq!(request.uri(), &original_uri, "should retain transport URI"); + assert_eq!( + request.headers()[header::HOST], + "upstream.example.com", + "should retain transport Host" + ); + assert_eq!( + request.headers()[header::ORIGIN], + "https://publisher.example.com:8443", + "should retain browser Origin" + ); + for name in [ + FORWARDER_AUTH_HEADER, + "forwarded", + "x-forwarded-host", + "x-forwarded-proto", + ] { + assert!( + !request.headers().contains_key(name), + "should remove forwarding field {name}" + ); + } + } + + #[test] + fn capture_accepts_canonical_publisher_origins_and_subdomains() { + for (host, scheme, expected) in [ + ( + "publisher.example.com:443", + "HTTPS", + "https://publisher.example.com", + ), + ( + "a.publisher.example.com:8443", + "https", + "https://a.publisher.example.com:8443", + ), + ( + "publisher.example.com:80", + "http", + "http://publisher.example.com", + ), + ] { + let mut request = request(); + request + .headers_mut() + .insert("x-forwarded-host", HeaderValue::from_static(host)); + request + .headers_mut() + .insert("x-forwarded-proto", HeaderValue::from_static(scheme)); + let prepared = ForwarderPreparation::capture(request.headers(), &settings()); + assert_eq!( + prepared + .origin + .as_ref() + .map(PublicOrigin::serialized) + .as_deref(), + Some(expected), + "should canonicalize an allowed public origin" + ); + assert!( + !format!("{prepared:?}").contains(SECRET), + "should not retain authentication material" + ); + } + } + + #[test] + fn capture_rejects_unauthenticated_ambiguous_and_malformed_forwarding() { + for (name, values) in [ + (FORWARDER_AUTH_HEADER, vec![]), + ( + FORWARDER_AUTH_HEADER, + vec!["fictional-wrong-secret-0123456789"], + ), + (FORWARDER_AUTH_HEADER, vec![SECRET, SECRET]), + ("x-forwarded-host", vec![]), + ( + "x-forwarded-host", + vec!["publisher.example.com", "publisher.example.com"], + ), + ( + "x-forwarded-host", + vec!["publisher.example.com, publisher.example.com"], + ), + ("x-forwarded-host", vec!["other.example.com"]), + ("x-forwarded-host", vec!["evilpublisher.example.com"]), + ( + "x-forwarded-host", + vec!["publisher.example.com.evil.example.com"], + ), + ("x-forwarded-host", vec!["publisher.example.com/"]), + ("x-forwarded-host", vec!["publisher.example.com?"]), + ("x-forwarded-host", vec!["publisher.example.com#"]), + ("x-forwarded-host", vec!["@publisher.example.com"]), + ("x-forwarded-host", vec![" publisher.example.com"]), + ("x-forwarded-host", vec!["publisher.example.com "]), + ("x-forwarded-host", vec!["publisher.example.com:"]), + ("x-forwarded-host", vec!["publisher.example.com:0"]), + ("x-forwarded-host", vec!["publisher.example.com:65536"]), + ("x-forwarded-host", vec!["publisher.example.com:+443"]), + ("x-forwarded-proto", vec![]), + ("x-forwarded-proto", vec!["https", "https"]), + ("x-forwarded-proto", vec!["https, http"]), + ("x-forwarded-proto", vec![" https"]), + ("x-forwarded-proto", vec!["ftp"]), + ] { + let mut request = request(); + request.headers_mut().remove(name); + for value in values { + request + .headers_mut() + .append(name, HeaderValue::from_static(value)); + } + prepare_trusted_forwarder(&mut request, &settings()); + assert!( + public_origin(&request).is_none(), + "should reject invalid {name}" + ); + assert!( + !request.headers().contains_key(FORWARDER_AUTH_HEADER), + "should strip credentials on rejection" + ); + let info = RequestInfo::from_request(&request, &ClientInfo::default()) + .expect("should extract request info"); + assert_eq!( + info.host, "upstream.example.com", + "should fall back to received Host" + ); + assert_eq!(info.scheme, "http", "should fall back to transport scheme"); + } + } + + #[test] + fn preparation_is_frozen_even_when_the_first_decision_rejects() { + for valid in [true, false] { + let mut request = request(); + if !valid { + request.headers_mut().remove(FORWARDER_AUTH_HEADER); + } + prepare_trusted_forwarder(&mut request, &settings()); + let first = public_origin(&request).cloned(); + request + .headers_mut() + .insert(FORWARDER_AUTH_HEADER, HeaderValue::from_static(SECRET)); + request.headers_mut().insert( + "x-forwarded-host", + HeaderValue::from_static("a.publisher.example.com"), + ); + request + .headers_mut() + .insert("x-forwarded-proto", HeaderValue::from_static("http")); + prepare_trusted_forwarder(&mut request, &settings()); + assert_eq!( + public_origin(&request), + first.as_ref(), + "should retain the first forwarding decision" + ); + assert!( + !request.headers().contains_key(FORWARDER_AUTH_HEADER), + "should strip credentials on repeated preparation" + ); + } + } + + #[test] + fn disabled_forwarding_ignores_and_strips_spoofed_metadata() { + let mut request = request(); + prepare_trusted_forwarder(&mut request, &create_test_settings()); + assert!( + public_origin(&request).is_none(), + "should require explicit configuration" + ); + assert!( + !request.headers().contains_key(FORWARDER_AUTH_HEADER), + "should remove the known credential header even when disabled" + ); + let info = RequestInfo::from_request(&request, &ClientInfo::default()) + .expect("should extract request info"); + assert_eq!( + info.host, "upstream.example.com", + "should ignore spoofed forwarding" + ); + } + + #[test] + fn custom_credentials_are_consumed_without_leaving_default_copies() { + let mut settings = settings(); + settings + .trusted_forwarder + .as_mut() + .expect("should configure forwarding") + .auth_header = "x-example-forwarder-auth".to_owned(); + let mut request = request(); + request + .headers_mut() + .insert("x-example-forwarder-auth", HeaderValue::from_static(SECRET)); + + prepare_trusted_forwarder(&mut request, &settings); + + assert!( + public_origin(&request).is_some(), + "should authenticate the configured credential field" + ); + for name in ["x-example-forwarder-auth", FORWARDER_AUTH_HEADER] { + assert!( + !request.headers().contains_key(name), + "should strip both configured and known credential fields" + ); + } + } + + #[test] + fn forwarding_cannot_upgrade_ingress_or_cookie_fidelity() { + for accepted in [true, false] { + let mut request = request(); + let ingress = RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::NotExposed), + Some( + InboundOrigin::parse("http", "upstream.example.com", OriginSource::RuntimeUri) + .expect("should construct immutable transport origin"), + ), + HeaderFidelity::default(), + vec![], + ) + .expect("should retain unavailable target and fidelity"); + let original_fidelity = ingress.header_fidelity(&header::COOKIE); + request.extensions_mut().insert(ingress); + request.headers_mut().insert( + header::COOKIE, + HeaderValue::from_static("__Host-ts-console=1, unrelated=1"), + ); + let health = inspect_cookies( + request.headers(), + request.extensions().get::(), + ); + if !accepted { + request.headers_mut().remove(FORWARDER_AUTH_HEADER); + request + .headers_mut() + .insert(header::HOST, HeaderValue::from_static("other.example.com")); + } + + prepare_trusted_forwarder(&mut request, &settings()); + + let retained = request + .extensions() + .get::() + .expect("should retain runtime ingress"); + assert!( + matches!( + retained.target(), + CapturedTarget::Unavailable(TargetUnavailable::NotExposed) + ), + "should retain unavailable target evidence" + ); + let origin = retained.origin().expect("should retain transport origin"); + assert_eq!(origin.scheme(), "http", "should retain transport scheme"); + assert_eq!( + origin.authority(), + "upstream.example.com", + "should retain transport authority" + ); + assert_eq!( + retained.header_fidelity(&header::COOKIE), + original_fidelity, + "should retain cookie header fidelity" + ); + assert_eq!( + inspect_cookies(request.headers(), Some(retained)), + health, + "should preserve ambiguous cookie health" + ); + assert!( + !health.observed_active(), + "should never treat folded cookies as a verified session" + ); + let info = RequestInfo::from_request(&request, &ClientInfo::default()) + .expect("should extract request info"); + assert_eq!( + info.host, + if accepted { + "publisher.example.com:8443" + } else { + "upstream.example.com" + }, + "should use one trusted origin instead of mixed mutable facts" + ); + } + } +} diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index 63f7907f5..fe5785d26 100644 --- a/crates/trusted-server-core/src/geo.rs +++ b/crates/trusted-server-core/src/geo.rs @@ -48,6 +48,24 @@ impl GeoInfo { } } +/// Carries the outcome of a request-phase geo lookup across to +/// response-phase finalization, so a finalize consumer can reuse it instead +/// of performing a second lookup for the same request. +/// +/// Attached as a response extension on every exit path that attempted a +/// lookup, including the asset-route fallback (which does not carry an EC +/// finalize state). +#[derive(Debug, Clone)] +pub enum GeoLookupState { + /// No lookup has been attempted for this request. + NotAttempted, + /// A lookup ran and failed (or returned no result). This must not be + /// retried: finalize treats it the same as no geo info being available. + Attempted, + /// A lookup ran and resolved geo info. + Resolved(GeoInfo), +} + fn insert_geo_header(headers: &mut http::HeaderMap, name: http::header::HeaderName, value: &str) { match HeaderValue::from_str(value) { Ok(header_value) => { diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 3889d7d59..9928ef428 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -1,7 +1,8 @@ //! Simplified HTML processor that combines URL replacement and integration injection //! //! This module provides a `StreamProcessor` implementation for HTML content. -use std::cell::Cell; +use std::borrow::Cow; +use std::cell::{Cell, RefCell}; use std::io; use std::rc::Rc; use std::sync::Arc; @@ -91,6 +92,10 @@ pub struct HtmlProcessorConfig { pub max_buffered_body_bytes: usize, /// Request-scoped conditional diagnostics delivery decision. pub gpt_diagnostics: Option, + /// Request-only trace bootstrap, excluded from shared templates and ESI. + /// + /// The publisher supplies script-safe bytes only for an eligible document. + pub trace_bootstrap: Option, /// What the `` seam injects. Decided by the caller rather than inferred /// from [`Self::ad_slots_script`]. pub body_close: BodyCloseInjection, @@ -122,6 +127,7 @@ impl HtmlProcessorConfig { ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: settings.publisher.max_buffered_body_bytes, gpt_diagnostics: None, + trace_bootstrap: None, body_close: BodyCloseInjection::None, suppress_datadome_client_side_tag: false, csp_nonce_observed: None, @@ -164,6 +170,13 @@ impl HtmlProcessorConfig { self } + /// Attach the already gated, request-only trace bootstrap. + #[must_use] + pub(crate) fn with_trace_bootstrap(mut self, bootstrap: Option) -> Self { + self.trace_bootstrap = bootstrap; + self + } + /// Watch the document for a response-bound CSP nonce delivered in its own markup. /// /// Pass `Some` only when the completed transform may be stored as a shared template; @@ -271,6 +284,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let body_close = config.body_close.clone(); let ad_bids_state = config.ad_bids_state.clone(); let gpt_diagnostics = config.gpt_diagnostics.clone(); + let trace_bootstrap = config.trace_bootstrap.clone(); // No source-comment neutralization here: rewriting a publisher comment that happens // to match the reserved marker would change publisher content bytes. Collisions are @@ -301,6 +315,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let document_state = document_state.clone(); let ad_slots_script = ad_slots_script.clone(); let gpt_diagnostics = gpt_diagnostics.clone(); + let trace_bootstrap = trace_bootstrap.clone(); move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); @@ -325,6 +340,9 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso { snippet.push_str(&bootstrap); } + if let Some(bootstrap) = &trace_bootstrap { + snippet.push_str(bootstrap); + } // Main bundle: core + non-deferred integrations (synchronous). let immediate_ids = integrations.js_module_ids_immediate(); let script_attributes = integrations.tsjs_script_tag_attributes(); @@ -661,38 +679,62 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso })); } - for script_rewriter in script_rewriters { - let selector = script_rewriter.selector(); - let rewriter = script_rewriter.clone(); - let patterns = patterns.clone(); - let document_state = document_state.clone(); - element_content_handlers.push(text!(selector, { - let rewriter = rewriter.clone(); - let patterns = patterns.clone(); - let document_state = document_state.clone(); - move |text| { - let ctx = IntegrationScriptContext { - selector, - request_host: &patterns.request_host, - request_scheme: &patterns.request_scheme, - origin_host: &patterns.origin_host, - is_last_in_text_node: text.last_in_text_node(), - max_buffered_script_bytes: config.max_buffered_body_bytes, - document_state: &document_state, - }; - match rewriter.rewrite(text.as_str(), &ctx) { - ScriptRewriteAction::Keep => {} - ScriptRewriteAction::Replace(rewritten) => { - text.replace(&rewritten, ContentType::Text); - } - ScriptRewriteAction::RemoveNode => { - text.remove(); - } - } - Ok(()) - } + // Selector callbacks retain lol_html's CSS matching; only the composed text + // callback mutates a fragment. Start each script with fresh match state. + let script_matches = Rc::new(RefCell::new(vec![false; script_rewriters.len()])); + let reset_matches = script_matches.clone(); + element_content_handlers.push(element!("script", move |_el| { + reset_matches.borrow_mut().fill(false); + Ok(()) + })); + for (index, rewriter) in script_rewriters.iter().enumerate() { + let matches = script_matches.clone(); + element_content_handlers.push(element!(rewriter.selector(), move |_el| { + matches.borrow_mut()[index] = true; + Ok(()) })); } + let script_patterns = patterns.clone(); + let script_document_state = document_state.clone(); + element_content_handlers.push(text!("script", move |text| { + let mut working = Cow::Borrowed(text.as_str()); + let mut changed = false; + for (rewriter, matched) in script_rewriters.iter().zip(script_matches.borrow().iter()) { + if !matched { + continue; + } + let ctx = IntegrationScriptContext { + selector: rewriter.selector(), + request_host: &script_patterns.request_host, + request_scheme: &script_patterns.request_scheme, + origin_host: &script_patterns.origin_host, + is_last_in_text_node: text.last_in_text_node(), + max_buffered_script_bytes: config.max_buffered_body_bytes, + document_state: &script_document_state, + }; + match rewriter.rewrite(&working, &ctx) { + ScriptRewriteAction::Keep => {} + ScriptRewriteAction::Replace(rewritten) => { + working = Cow::Owned(rewritten); + changed = true; + } + ScriptRewriteAction::RemoveNode => { + working = Cow::Borrowed(""); + changed = true; + } + } + } + if changed { + if working.is_empty() { + text.remove(); + } else if let Cow::Owned(rewritten) = working { + // Script is raw text: HTML entities would change JS/JSON data. + // Emit composed source verbatim rather than escaping it again. + text.replace(&rewritten, ContentType::Html); + } + } + Ok(()) + })); let rewriter_settings = RewriterSettings { document_content_handlers, @@ -723,7 +765,7 @@ mod tests { use super::*; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, - IntegrationHeadInjector, IntegrationHtmlContext, + IntegrationHeadInjector, IntegrationHtmlContext, IntegrationScriptRewriter, }; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; use crate::test_support::tests::create_test_settings; @@ -748,10 +790,76 @@ mod tests { ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, } } + struct SuppressingScript; + impl IntegrationScriptRewriter for SuppressingScript { + fn integration_id(&self) -> &'static str { + "suppress" + } + fn selector(&self) -> &'static str { + "script[data-suppress='yes']" + } + fn rewrite(&self, _text: &str, ctx: &IntegrationScriptContext<'_>) -> ScriptRewriteAction { + assert_eq!(ctx.selector, self.selector()); + ScriptRewriteAction::RemoveNode + } + } + + struct FlushingScript; + impl IntegrationScriptRewriter for FlushingScript { + fn integration_id(&self) -> &'static str { + "flush" + } + fn selector(&self) -> &'static str { + "script#flush" + } + fn rewrite(&self, text: &str, ctx: &IntegrationScriptContext<'_>) -> ScriptRewriteAction { + assert_eq!(ctx.selector, self.selector()); + assert!(text.is_empty(), "should see upstream suppression"); + if ctx.is_last_in_text_node { + ScriptRewriteAction::Replace("flushed-once".into()) + } else { + ScriptRewriteAction::Keep + } + } + } + + #[test] + fn script_actions_compose_with_selector_reset_and_empty_final_flush() { + let mut config = create_test_config(); + config.integrations = IntegrationRegistry::from_rewriters( + Vec::new(), + vec![Arc::new(SuppressingScript), Arc::new(FlushingScript)], + ); + let mut processor = create_html_processor(config); + let mut output = processor + .process_chunk(b"", true) + .expect("should finalize all matching stages"), + ); + let output = String::from_utf8(output).expect("should emit UTF-8"); + assert!( + output.contains(""), + "should flush downstream even on suppressed final: {output}" + ); + assert!( + output.contains(""), + "should reset CSS match state between scripts" + ); + assert_eq!( + output.matches("flushed-once").count(), + 1, + "should emit the flushed fragment exactly once" + ); + } + #[test] fn integration_attribute_rewriter_can_remove_elements() { struct RemovingLinkRewriter; @@ -1553,6 +1661,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, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, }; let mut processor = create_html_processor(config); @@ -1630,6 +1739,7 @@ mod tests { ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, }; let mut processor = create_html_processor(config); @@ -1669,6 +1779,7 @@ mod tests { ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, }; let mut processor = create_html_processor(config); @@ -1707,6 +1818,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, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, }; let mut processor = create_html_processor(config); @@ -1763,6 +1875,7 @@ mod tests { ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, }; let mut processor = create_html_processor(config); @@ -1793,6 +1906,7 @@ mod tests { ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, }; let mut processor = create_html_processor(config); @@ -1818,6 +1932,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, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, } } @@ -2079,6 +2194,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, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, }; let source = diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 457e50356..4303c796f 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -1,15 +1,18 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chacha20poly1305::{XChaCha20Poly1305, XNonce, aead::Aead as _, aead::KeyInit as _}; use edgezero_core::body::Body as EdgeBody; +use edgezero_core::request::{InboundOrigin, RequestIngress}; use error_stack::Report; use http::{Request, Response, StatusCode, header}; use sha2::{Digest as _, Sha256}; +use std::net::Ipv6Addr; use std::time::Duration; use subtle::ConstantTimeEq as _; use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::INTERNAL_HEADERS; use crate::error::TrustedServerError; +use crate::forwarder::public_origin; use crate::platform::ClientInfo; use crate::settings::{Settings, TrustedClientIpConfig}; @@ -117,177 +120,174 @@ pub fn is_navigation_request(req: &Request) -> bool { }) } -/// Extracted request information for host rewriting. +/// Validated adapter runtime origin used when immutable ingress has no origin. /// -/// This struct captures the effective host and scheme from an incoming request. -/// The parser checks forwarded headers (`Forwarded`, `X-Forwarded-Host`, -/// `X-Forwarded-Proto`) as fallbacks, but on the Fastly edge -/// [`sanitize_forwarded_headers`] strips those headers before this method is -/// called, so the `Host` header and Fastly SDK TLS detection are the effective -/// sources in production. +/// Adapters construct this only from trusted runtime metadata. It supplies a +/// fallback for [`RequestInfo`] without changing [`RequestIngress`] or granting +/// origin trust to trace actions. +#[derive(Clone)] +pub struct RuntimeRequestOrigin(InboundOrigin); + +impl RuntimeRequestOrigin { + /// Wrap an origin already validated by [`InboundOrigin::parse`]. + /// + /// # Examples + /// + /// ``` + /// use edgezero_core::request::{InboundOrigin, OriginSource}; + /// use trusted_server_core::http_util::RuntimeRequestOrigin; + /// let origin = InboundOrigin::parse( + /// "https", "publisher.example.com:8443", OriginSource::RuntimeUri, + /// )?; + /// let runtime_origin = RuntimeRequestOrigin::new(origin); + /// # Ok::<(), edgezero_core::error::EdgeError>(()) + /// ``` + #[must_use] + pub fn new(origin: InboundOrigin) -> Self { + Self(origin) + } +} + +/// Effective public request information for URL rewriting. +/// +/// Authenticated forwarding takes precedence over transport origin. Without +/// authenticated metadata, immutable ingress, adapter runtime origin, Host and +/// platform TLS facts remain the sources; raw forwarding headers never grant +/// origin trust. #[derive(Debug, Clone)] pub struct RequestInfo { - /// The effective host for URL rewriting (typically the `Host` header after edge sanitization). + /// The effective public authority, including a non-default port. pub host: String, - /// The effective scheme (typically from Fastly SDK TLS detection after edge sanitization). + /// The effective public HTTP or HTTPS scheme. pub scheme: String, } impl RequestInfo { - /// Extract request info from a Fastly request. + /// Extract validated public origin information from a prepared request. /// - /// Host fallback order (first present wins): - /// 1. `Forwarded` header (`host=...`) - /// 2. `X-Forwarded-Host` - /// 3. `Host` header + /// The selected host must be an ASCII hostname (including underscores for + /// local service names), IPv4 address, or bracketed IPv6 address, optionally + /// followed by a numeric port. Requests without any host retain an empty host. /// - /// Scheme fallback order: - /// 1. Fastly SDK TLS detection - /// 2. `Forwarded` header (`proto=...`) - /// 3. `X-Forwarded-Proto` - /// 4. `Fastly-SSL` - /// 5. Default `http` + /// # Examples /// - /// In production the forwarded headers are stripped by - /// [`sanitize_forwarded_headers`] at the edge, so `Host` and - /// [`ClientInfo`] TLS detection are the only sources that fire. - pub fn from_request(req: &Request, client_info: &ClientInfo) -> Self { - let host = extract_request_host(req); - let scheme = detect_request_scheme( - req, - client_info.tls_protocol.as_deref(), - client_info.tls_cipher.as_deref(), - ); - - Self { host, scheme } - } -} - -fn extract_request_host(req: &Request) -> String { - req.headers() - .get("forwarded") - .and_then(|h| h.to_str().ok()) - .and_then(|value| parse_forwarded_param(value, "host")) - .or_else(|| { - req.headers() - .get("x-forwarded-host") - .and_then(|h| h.to_str().ok()) - .and_then(parse_list_header_value) - }) - .or_else(|| { - req.headers() - .get(header::HOST) - .and_then(|h| h.to_str().ok()) - }) - .unwrap_or_default() - .to_owned() -} - -fn parse_forwarded_param<'a>(forwarded: &'a str, param: &str) -> Option<&'a str> { - for entry in forwarded.split(',') { - for part in entry.split(';') { - let mut iter = part.splitn(2, '='); - let key = iter.next().unwrap_or("").trim(); - let value = iter.next().unwrap_or("").trim(); - if key.is_empty() || value.is_empty() { - continue; - } - if key.eq_ignore_ascii_case(param) { - let value = strip_quotes(value); - if !value.is_empty() { - return Some(value); - } + /// ```ignore + /// let info = RequestInfo::from_request(&request, &client_info)?; + /// let origin = format!("{}://{}", info.scheme, info.host); + /// ``` + /// + /// # Errors + /// + /// Returns [`TrustedServerError::BadRequest`] when the selected host is + /// malformed or the `Host` header is not text, rather than falling back to + /// a lower-priority source. + pub fn from_request( + req: &Request, + client_info: &ClientInfo, + ) -> Result> { + let invalid_host = || { + Report::new(TrustedServerError::BadRequest { + message: "Invalid request host".to_owned(), + }) + }; + if let Some(origin) = public_origin(req) { + if !is_valid_request_host(&origin.authority) { + return Err(invalid_host()); } + return Ok(Self { + host: origin.authority.clone(), + scheme: origin.scheme.clone(), + }); } + let runtime_origin = req + .extensions() + .get::() + .and_then(RequestIngress::origin) + .or_else(|| { + req.extensions() + .get::() + .map(|origin| &origin.0) + }); + let host = match runtime_origin { + Some(origin) => Some(origin.authority()), + None => req + .headers() + .get(header::HOST) + .map(|value| value.to_str().map_err(|_| invalid_host())) + .transpose()?, + }; + if host.is_some_and(|host| !is_valid_request_host(host)) { + return Err(invalid_host()); + } + let scheme = runtime_origin + .map(|origin| origin.scheme().to_owned()) + .unwrap_or_else(|| detect_request_scheme(req, client_info)); + Ok(Self { + host: host.unwrap_or_default().to_owned(), + scheme, + }) } - None -} - -fn parse_list_header_value(value: &str) -> Option<&str> { - value - .split(',') - .map(str::trim) - .find(|part| !part.is_empty()) - .map(strip_quotes) - .filter(|part| !part.is_empty()) } -fn strip_quotes(value: &str) -> &str { - let trimmed = value.trim(); - if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { - &trimmed[1..trimmed.len() - 1] +/// Accept only literal host syntax that is safe to insert into script source. +fn is_valid_request_host(value: &str) -> bool { + let port = if let Some(ipv6) = value.strip_prefix('[') { + let Some((address, suffix)) = ipv6.split_once(']') else { + return false; + }; + if address.parse::().is_err() { + return false; + } + if suffix.is_empty() { + None + } else if let Some(port) = suffix.strip_prefix(':') { + Some(port) + } else { + return false; + } } else { - trimmed - } -} - -fn normalize_scheme(value: &str) -> Option { - let scheme = value.trim().to_ascii_lowercase(); - (scheme == "https" || scheme == "http").then_some(scheme) + let (host, port) = value + .split_once(':') + .map_or((value, None), |(host, port)| (host, Some(port))); + let host = host.strip_suffix('.').unwrap_or(host); + if host.is_empty() + || host.len() > 253 + || host.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || !label.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_') + || !label.ends_with(|c: char| c.is_ascii_alphanumeric() || c == '_') + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + }) + { + return false; + } + port + }; + port.is_none_or(|port| { + !port.is_empty() + && port.bytes().all(|byte| byte.is_ascii_digit()) + && port.parse::().is_ok() + }) } -/// Detects the request scheme (HTTP or HTTPS) using Fastly SDK methods and headers. -/// -/// Tries multiple methods in order of reliability: -/// 1. Fastly SDK TLS detection methods (most reliable) -/// 2. Forwarded header (RFC 7239) -/// 3. X-Forwarded-Proto header -/// 4. Fastly-SSL header (trusted on `EdgeZero` path; can be spoofed on legacy path) -/// 5. Default to HTTP -fn detect_request_scheme( - req: &Request, - tls_protocol: Option<&str>, - tls_cipher: Option<&str>, -) -> String { - // 1. First try ClientInfo TLS fields populated at the adapter entry point. - if let Some(tls_protocol) = tls_protocol { - log::debug!("TLS protocol detected: {tls_protocol}"); - return "https".to_owned(); - } - - // Also check TLS cipher - if present, connection is HTTPS. - if tls_cipher.is_some() { - log::debug!("TLS cipher detected, using HTTPS"); +fn detect_request_scheme(req: &Request, client_info: &ClientInfo) -> String { + if client_info.tls_protocol.is_some() || client_info.tls_cipher.is_some() { return "https".to_owned(); } - - // 2. Try the Forwarded header (RFC 7239) - if let Some(forwarded) = req.headers().get("forwarded") - && let Ok(forwarded_str) = forwarded.to_str() - && let Some(proto) = parse_forwarded_param(forwarded_str, "proto") - && let Some(scheme) = normalize_scheme(proto) - { - return scheme; - } - - // 3. Try X-Forwarded-Proto header - if let Some(proto) = req.headers().get("x-forwarded-proto") - && let Ok(proto_str) = proto.to_str() - && let Some(value) = parse_list_header_value(proto_str) - && let Some(scheme) = normalize_scheme(value) - { - return scheme; - } - - // 4. Check Fastly-SSL header. On the `EdgeZero` path this is injected from - // authoritative Fastly TLS metadata after spoofable headers are stripped, - // so it is reliable. On direct or legacy paths it can be spoofed by clients. - // - // Layering wart: this is a vendor-specific header name living in - // platform-neutral core. It is only a fallback — signal #1 above - // (`ClientInfo::tls_protocol`) is the neutral path adapters populate. The - // `fastly-ssl` fallback (plus its entry in `SPOOFABLE_FORWARDED_HEADERS` - // and the origin-forwarding strip in `publisher::rewrite_origin_request`) - // should be replaced by a platform-neutral scheme signal in a separate - // change, after confirming the legacy path is covered by `ClientInfo`. - if let Some(ssl) = req.headers().get("fastly-ssl") - && let Ok(ssl_str) = ssl.to_str() - && (ssl_str == "1" || ssl_str.to_lowercase() == "true") + // Fastly injects this signal from runtime TLS metadata after removing the + // client-supplied copy. Preserve the compatibility fallback for requests + // without a runtime ingress extension. + if req + .headers() + .get("fastly-ssl") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) { return "https".to_owned(); } - - // Default to HTTP "http".to_owned() } @@ -573,84 +573,251 @@ mod tests { // RequestInfo tests #[test] - fn test_request_info_from_host_header() { - let mut req = build_request(Method::GET, "https://test.example.com/page"); - set_header(&mut req, "host", "test.example.com"); + fn request_info_prioritizes_forwarder_then_ingress_then_runtime_origin() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.publisher.domain = "publisher.example.com".to_owned(); + settings.trusted_forwarder = Some(crate::settings::TrustedForwarderConfig { + auth_header: crate::forwarder::FORWARDER_AUTH_HEADER.to_owned(), + shared_secret: crate::redacted::Redacted::new( + "fictional-forwarder-secret-0123456789".to_owned(), + ), + }); + for (accepted, ingress_available, expected_host, expected_scheme) in [ + (true, true, "publisher.example.com:8443", "https"), + (false, true, "internal.example.com:8080", "http"), + (false, false, "runtime.example.com:9443", "https"), + ] { + let mut request = build_request(Method::GET, "/article"); + set_header(&mut request, "host", "client-supplied.example.com"); + set_header( + &mut request, + "x-forwarded-host", + "publisher.example.com:8443", + ); + set_header(&mut request, "x-forwarded-proto", "https"); + if accepted { + set_header( + &mut request, + crate::forwarder::FORWARDER_AUTH_HEADER, + "fictional-forwarder-secret-0123456789", + ); + } + request.extensions_mut().insert(RuntimeRequestOrigin::new( + InboundOrigin::parse( + "https", + "runtime.example.com:9443", + edgezero_core::request::OriginSource::RuntimeUri, + ) + .expect("should validate runtime origin"), + )); + request.extensions_mut().insert( + RequestIngress::new( + edgezero_core::request::CapturedTarget::Unavailable( + edgezero_core::request::TargetUnavailable::NotExposed, + ), + ingress_available.then(|| { + InboundOrigin::parse( + "http", + "internal.example.com:8080", + edgezero_core::request::OriginSource::RuntimeUri, + ) + .expect("should validate ingress origin") + }), + edgezero_core::request::HeaderFidelity::default(), + Vec::new(), + ) + .expect("should freeze ingress"), + ); + crate::forwarder::prepare_trusted_forwarder(&mut request, &settings); + + let info = RequestInfo::from_request(&request, &default_client_info()) + .expect("should accept trusted origin precedence"); + + assert_eq!( + info.host, expected_host, + "should honor public, immutable ingress and runtime authority precedence" + ); + assert_eq!( + info.scheme, expected_scheme, + "should honor public, immutable ingress and runtime scheme precedence" + ); + } + } + + #[test] + fn request_info_ignores_unauthenticated_public_origin_hints() { + let mut request = build_request(Method::GET, "http://upstream.example.com/path"); + set_header(&mut request, "host", "upstream.example.com"); + set_header( + &mut request, + "forwarded", + "proto=https;host=other.example.com", + ); + set_header(&mut request, "x-forwarded-host", "other.example.com"); + set_header(&mut request, "x-forwarded-proto", "https"); + + let info = RequestInfo::from_request(&request, &default_client_info()) + .expect("should extract request info"); - let info = RequestInfo::from_request(&req, &default_client_info()); assert_eq!( - info.host, "test.example.com", - "Host should use Host header when forwarded headers are missing" + info.host, "upstream.example.com", + "should require authenticated public host metadata" ); - // No TLS or forwarded headers, defaults to http. assert_eq!( info.scheme, "http", - "Scheme should default to http without TLS or forwarded headers" + "should require authenticated public scheme metadata" ); } #[test] - fn test_request_info_x_forwarded_host_precedence() { - let mut req = build_request(Method::GET, "https://test.example.com/page"); - set_header(&mut req, "host", "internal-proxy.local"); - set_header( - &mut req, - "x-forwarded-host", - "public.example.com, proxy.local", - ); - - let info = RequestInfo::from_request(&req, &default_client_info()); - assert_eq!( - info.host, "public.example.com", - "Host should prefer X-Forwarded-Host over Host" - ); + fn request_info_accepts_valid_hosts_without_normalizing_them() { + for host in [ + "example.com", + "Sub-Domain.EXAMPLE.com.", + "xn--bcher-kva.example.com", + "example.com:443", + "localhost:3000", + "internal-proxy:8080", + "my_service:8080", + "service_.example.com:8443", + "_service.example.com", + "_.example.com", + "bad_label.example.com", + "192.0.2.1:8080", + "[2001:db8::1]", + "[2001:db8::1]:443", + "[::1]:3000", + "[::ffff:192.0.2.1]:65535", + ] { + let mut req = build_request(Method::GET, "https://example.com/page"); + set_header(&mut req, "host", host); + let info = RequestInfo::from_request(&req, &default_client_info()) + .expect("should accept valid host syntax"); + assert_eq!(info.host, host, "should preserve Host header: {host}"); + } } #[test] - fn test_request_info_scheme_from_x_forwarded_proto() { - let mut req = build_request(Method::GET, "https://test.example.com/page"); - set_header(&mut req, "host", "test.example.com"); - set_header(&mut req, "x-forwarded-proto", "https, http"); + fn request_info_rejects_malformed_selected_hosts_without_fallback() { + for host in [ + "", + "example.com", + "example.com'", + "example.com\"", + "example.com`", + "example.com\\path", + "example.com/path", + "example.com?query", + "example.com#fragment", + "user@example.com", + "https://example.com", + "example.com%22", + "example.com:invalid", + "example.com:", + "example.com:65536", + "example.com:+443", + "example.com:443:80", + "bad host.example.com", + "-bad.example.com", + "bad-.example.com", + "example..com", + ".example.com", + ".", + "::1", + "[not-ipv6]", + "[2001:db8::1", + "[2001:db8::1]suffix", + "[2001:db8::1]:", + ] { + let mut req = build_request(Method::GET, "https://example.com/page"); + set_header(&mut req, "host", host); + let error = RequestInfo::from_request(&req, &default_client_info()) + .expect_err("should reject malformed selected host"); + assert!( + matches!( + error.current_context(), + TrustedServerError::BadRequest { .. } + ), + "should reject Host header with a bad-request error: {host}" + ); + assert!( + !format!("{error}").contains(""), + "should not reflect hostile host bytes in the error" + ); + } + } - let info = RequestInfo::from_request(&req, &default_client_info()); - assert_eq!( - info.scheme, "https", - "Scheme should prefer the first X-Forwarded-Proto value" + #[test] + fn request_info_rejects_non_text_host_header() { + let mut req = build_request(Method::GET, "https://example.com/page"); + req.headers_mut().insert( + header::HOST, + HeaderValue::from_bytes(b"example.com\xff").expect("should build non-text header"), ); + assert!( + RequestInfo::from_request(&req, &default_client_info()).is_err(), + "should reject a non-text Host header rather than treating it as absent" + ); + } - // Test HTTP - let mut req = build_request(Method::GET, "http://test.example.com/page"); - set_header(&mut req, "host", "test.example.com"); - set_header(&mut req, "x-forwarded-proto", "http"); + #[test] + fn request_info_rejects_oversized_dns_names() { + for host in [ + format!("{}.example.com", "a".repeat(64)), + format!( + "{}.{}.{}.{}", + "a".repeat(63), + "b".repeat(63), + "c".repeat(63), + "d".repeat(63) + ), + ] { + let mut req = build_request(Method::GET, "https://example.com/page"); + set_header(&mut req, "host", &host); + assert!( + RequestInfo::from_request(&req, &default_client_info()).is_err(), + "should reject oversized DNS name: {host}" + ); + } + } - let info = RequestInfo::from_request(&req, &default_client_info()); + #[test] + fn request_info_ignores_hostile_unauthenticated_forwarding_headers() { + let mut req = build_request(Method::GET, "https://example.com/page"); + set_header(&mut req, "forwarded", "host=\"example.com\""); + set_header(&mut req, "x-forwarded-host", "example.com"); + set_header(&mut req, "host", "public.example.com"); + let info = RequestInfo::from_request(&req, &default_client_info()) + .expect("should validate only the selected Host header"); assert_eq!( - info.scheme, "http", - "Scheme should use the X-Forwarded-Proto value when present" + info.host, "public.example.com", + "should neither select nor validate unauthenticated forwarding hosts" ); } #[test] - fn request_info_forwarded_header_precedence() { - // Forwarded header takes precedence over X-Forwarded-Proto + fn request_info_preserves_absent_host_behavior() { + let req = build_request(Method::GET, "/page"); + let info = RequestInfo::from_request(&req, &default_client_info()) + .expect("should preserve request metadata without host headers"); + assert!(info.host.is_empty(), "should keep absent host empty"); + } + + #[test] + fn test_request_info_from_host_header() { let mut req = build_request(Method::GET, "https://test.example.com/page"); - set_header( - &mut req, - "forwarded", - "for=192.0.2.60;proto=\"HTTPS\";host=\"public.example.com:443\"", - ); - set_header(&mut req, "host", "internal-proxy.local"); - set_header(&mut req, "x-forwarded-host", "proxy.local"); - set_header(&mut req, "x-forwarded-proto", "http"); + set_header(&mut req, "host", "test.example.com"); - let info = RequestInfo::from_request(&req, &default_client_info()); + let info = RequestInfo::from_request(&req, &default_client_info()) + .expect("should extract request info"); assert_eq!( - info.host, "public.example.com:443", - "Host should prefer Forwarded host over X-Forwarded-Host" + info.host, "test.example.com", + "Host should use Host header when forwarded headers are missing" ); + // No TLS or forwarded headers, defaults to http. assert_eq!( - info.scheme, "https", - "Scheme should prefer Forwarded proto over X-Forwarded-Proto" + info.scheme, "http", + "Scheme should default to http without TLS or forwarded headers" ); } @@ -659,33 +826,14 @@ mod tests { let mut req = build_request(Method::GET, "https://test.example.com/page"); set_header(&mut req, "fastly-ssl", "1"); - let info = RequestInfo::from_request(&req, &default_client_info()); + let info = RequestInfo::from_request(&req, &default_client_info()) + .expect("should extract request info"); assert_eq!( info.scheme, "https", "Scheme should fall back to Fastly-SSL when other signals are missing" ); } - #[test] - fn test_request_info_chained_proxy_scenario() { - // Simulate: Client (HTTPS) -> Proxy A -> Trusted Server (HTTP internally) - // Proxy A sets X-Forwarded-Host and X-Forwarded-Proto - let mut req = build_request(Method::GET, "http://trusted-server.internal/page"); - set_header(&mut req, "host", "trusted-server.internal"); - set_header(&mut req, "x-forwarded-host", "public.example.com"); - set_header(&mut req, "x-forwarded-proto", "https"); - - let info = RequestInfo::from_request(&req, &default_client_info()); - assert_eq!( - info.host, "public.example.com", - "Host should use X-Forwarded-Host in chained proxy scenarios" - ); - assert_eq!( - info.scheme, "https", - "Scheme should use X-Forwarded-Proto in chained proxy scenarios" - ); - } - // Sanitization tests #[test] @@ -769,7 +917,8 @@ mod tests { set_header(&mut req, "x-forwarded-proto", "http"); sanitize_forwarded_headers(&mut req); - let info = RequestInfo::from_request(&req, &default_client_info()); + let info = RequestInfo::from_request(&req, &default_client_info()) + .expect("should extract request info"); assert_eq!( info.host, "legit.example.com", @@ -892,7 +1041,8 @@ mod tests { ..ClientInfo::default() }; - let info = RequestInfo::from_request(&req, &client_info); + let info = + RequestInfo::from_request(&req, &client_info).expect("should extract request info"); assert_eq!( info.scheme, "https", @@ -908,7 +1058,8 @@ mod tests { ..ClientInfo::default() }; - let info = RequestInfo::from_request(&req, &client_info); + let info = + RequestInfo::from_request(&req, &client_info).expect("should extract request info"); assert_eq!( info.scheme, "https", diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4f45fbeb9..45dcf85cd 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -78,42 +78,54 @@ const APS_RENDERER_DOCUMENT: &str = r#" var match=/^#tsaps=([A-Za-z0-9_-]{22,128})$/.exec(location.hash); var expected=match&&match[1]; try{history.replaceState(null,'',location.pathname+location.search);}catch(_error){} -if(!expected)return; +var reported=false; +function report(reason,nonce){ + if(reported)return; + reported=true; + try{parent.postMessage({message:'trusted-server/aps/renderer-failed',nonce:nonce,reason:reason},'*');}catch(_error){} +} +if(!expected){report('bad_hash');return;} function keys(value,expectedKeys){ if(!value||typeof value!=='object'||Array.isArray(value))return false; var actual=Object.keys(value).sort(); return actual.length===expectedKeys.length&&actual.every(function(key,index){return key===expectedKeys[index];}); } -function validRenderer(renderer){ +function rendererProblem(renderer){ if(!keys(renderer,['aaxResponse','accountId','bidId','creativeId','creativeUrl','height','tagType','type','version','width'])&& - !keys(renderer,['aaxResponse','accountId','bidId','creativeUrl','height','tagType','type','version','width']))return false; - if(renderer.type!=='aps'||renderer.version!==1||typeof renderer.accountId!=='string'||!renderer.accountId||new TextEncoder().encode(renderer.accountId).length>1024)return false; - if(typeof renderer.bidId!=='string'||!renderer.bidId||!Number.isInteger(renderer.width)||renderer.width<=0||!Number.isInteger(renderer.height)||renderer.height<=0)return false; - if(Object.prototype.hasOwnProperty.call(renderer,'creativeId')&&(typeof renderer.creativeId!=='string'||!renderer.creativeId||new TextEncoder().encode(renderer.creativeId).length>1024))return false; - if(renderer.tagType!=='iframe'&&renderer.tagType!=='script')return false; - if(typeof renderer.creativeUrl!=='string'||new TextEncoder().encode(renderer.creativeUrl).length>4096)return false; - if(typeof renderer.aaxResponse!=='string'||!renderer.aaxResponse||renderer.aaxResponse.length>349528)return false; + !keys(renderer,['aaxResponse','accountId','bidId','creativeUrl','height','tagType','type','version','width']))return 'descriptor_keys'; + if(renderer.type!=='aps'||renderer.version!==1||typeof renderer.accountId!=='string'||!renderer.accountId||new TextEncoder().encode(renderer.accountId).length>1024)return 'descriptor_fields'; + if(typeof renderer.bidId!=='string'||!renderer.bidId||!Number.isInteger(renderer.width)||renderer.width<=0||!Number.isInteger(renderer.height)||renderer.height<=0)return 'descriptor_fields'; + if(Object.prototype.hasOwnProperty.call(renderer,'creativeId')&&(typeof renderer.creativeId!=='string'||!renderer.creativeId||new TextEncoder().encode(renderer.creativeId).length>1024))return 'descriptor_fields'; + if(renderer.tagType!=='iframe'&&renderer.tagType!=='script')return 'descriptor_fields'; + if(typeof renderer.creativeUrl!=='string'||new TextEncoder().encode(renderer.creativeUrl).length>4096)return 'descriptor_fields'; + if(typeof renderer.aaxResponse!=='string'||!renderer.aaxResponse||renderer.aaxResponse.length>349528)return 'descriptor_fields'; try{ var url=new URL(renderer.creativeUrl); - if(url.protocol!=='https:'||url.username||url.password)return false; + if(url.protocol!=='https:'||url.username||url.password)return 'descriptor_envelope'; var binary=atob(renderer.aaxResponse); - if(binary.length>262144||btoa(binary)!==renderer.aaxResponse)return false; + if(binary.length>262144||btoa(binary)!==renderer.aaxResponse)return 'descriptor_envelope'; var bytes=Uint8Array.from(binary,function(character){return character.charCodeAt(0);}); var decoded=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes)); - if(!keys(decoded,['seatbid'])||!Array.isArray(decoded.seatbid)||decoded.seatbid.length!==1)return false; + if(!keys(decoded,['seatbid'])||!Array.isArray(decoded.seatbid)||decoded.seatbid.length!==1)return 'descriptor_envelope'; var seat=decoded.seatbid[0]; - if(!keys(seat,['bid'])||!Array.isArray(seat.bid)||seat.bid.length!==1)return false; + if(!keys(seat,['bid'])||!Array.isArray(seat.bid)||seat.bid.length!==1)return 'descriptor_envelope'; var bid=seat.bid[0]; - if(!keys(bid,['ext','h','id','price','w'])||!keys(bid.ext,['creativeurl','tagtype']))return false; - return bid.id===renderer.bidId&&bid.w===renderer.width&&bid.h===renderer.height&& + if(!keys(bid,['ext','h','id','price','w'])||!keys(bid.ext,['creativeurl','tagtype']))return 'descriptor_envelope'; + 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'&&Number.isFinite(bid.price)&&bid.price>=0; - }catch(_error){return false;} + typeof bid.price==='number'&&Number.isFinite(bid.price)&&bid.price>=0)return undefined; + return 'descriptor_envelope'; + }catch(_error){return 'descriptor_envelope';} } function receive(event){ + // The source check stays first: a frame that cannot be reached by an + // unrelated sender cannot have its single report consumed by one either. if(event.source!==parent)return; var message=event.data; - if(!keys(message,['nonce','renderer'])||message.nonce!==expected||!validRenderer(message.renderer))return; + if(!keys(message,['nonce','renderer']))return; + if(message.nonce!==expected){report('nonce_mismatch',message.nonce);return;} + var problem=rendererProblem(message.renderer); + if(problem){report(problem,message.nonce);return;} removeEventListener('message',receive); var acceptedNonce=expected; expected=''; @@ -128,7 +140,7 @@ function receive(event){ var script=document.createElement('script'); script.src='https://client.aps.amazon-adsystem.com/prebid-creative.js'; script.onload=function(){parent.postMessage({message:'trusted-server/aps/renderer-ready',nonce:acceptedNonce},'*');}; - script.onerror=function(){parent.postMessage({message:'trusted-server/aps/renderer-failed',nonce:acceptedNonce},'*');}; + script.onerror=function(){report('amazon_script_error',acceptedNonce);}; document.head.appendChild(script); } addEventListener('message',receive); @@ -3308,4 +3320,50 @@ mod tests { assert!(APS_RENDERER_CSP.contains("sandbox allow-forms")); assert!(!APS_RENDERER_CSP.contains("allow-same-origin")); } + + /// Guards the properties of the failure report that a substring check can + /// actually establish. + /// + /// Reachability of each reason is covered on the JavaScript side, where the + /// document is evaluated rather than read as text. The assertions kept here + /// are the ones that stay meaningful against a string: that a reason is a + /// fixed category rather than attacker-influenced content, that reporting is + /// one-shot, and that the frame never answers the sender. + #[test] + fn renderer_document_reports_a_fixed_reason_category() { + // A reason is a literal category, never a copy of the rejected input. + assert!( + !APS_RENDERER_DOCUMENT.contains("JSON.stringify(renderer)"), + "should never echo the rejected descriptor" + ); + assert!( + !APS_RENDERER_DOCUMENT.contains("reason:message"), + "should never echo the received message as a reason" + ); + + // Reporting is one-shot, so a sender cannot flood the parent. + assert!( + APS_RENDERER_DOCUMENT.contains("if(reported)return"), + "should report at most one reason per frame" + ); + + // The report goes to the parent, never back to the sender. + assert!( + !APS_RENDERER_DOCUMENT.contains("event.source.postMessage"), + "should never answer the sender" + ); + + // A non-parent sender is still dropped before any reporting happens, so + // an unrelated window cannot consume the frame's single report. + let source_check = APS_RENDERER_DOCUMENT + .find("event.source!==parent") + .expect("should check the sender before handling a message"); + let nonce_report = APS_RENDERER_DOCUMENT + .find("report('nonce_mismatch'") + .expect("should report a nonce mismatch"); + assert!( + source_check < nonce_report, + "should check the sender before reporting anything" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/datadome/protection.rs b/crates/trusted-server-core/src/integrations/datadome/protection.rs index a7c55bf86..1d153d087 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -9,7 +9,7 @@ use subtle::ConstantTimeEq as _; use url::Url; use crate::error::TrustedServerError; -use crate::http_util::is_navigation_request; +use crate::http_util::{RequestInfo, is_navigation_request}; use crate::integrations::{ HeaderMutation, RequestFilterDecision, RequestFilterEffects, RequestFilterInput, }; @@ -263,6 +263,15 @@ impl DataDomeIntegration { ) -> ProtectionPayload { let req = &*input.request; let client_info = input.services.client_info(); + let public_info = crate::forwarder::public_origin(req) + .and_then(|_| RequestInfo::from_request(req, client_info).ok()); + let request_host = public_info + .as_ref() + .map_or_else(|| request_host(req), |info| info.host.clone()); + let request_protocol = public_info.as_ref().map_or_else( + || req.uri().scheme_str().unwrap_or_default(), + |info| info.scheme.as_str(), + ); let mut fields = Vec::new(); let header_client_id = header_value(req, HEADER_DATADOME_CLIENT_ID); let cookie_header = header_value(req, header::COOKIE.as_str()); @@ -320,7 +329,7 @@ impl DataDomeIntegration { push_field(&mut fields, "CookiesLen", cookie_header.len().to_string()); push_header_field(&mut fields, req, "From", "from"); push_field(&mut fields, "HeadersList", headers_list(req)); - push_field(&mut fields, "Host", request_host(req)); + push_field(&mut fields, "Host", &request_host); push_field(&mut fields, "Method", req.method().as_str()); push_field(&mut fields, "ModuleVersion", MODULE_VERSION); push_header_field(&mut fields, req, "Origin", header::ORIGIN.as_str()); @@ -332,11 +341,7 @@ impl DataDomeIntegration { header::CONTENT_LENGTH.as_str(), ); push_header_field(&mut fields, req, "Pragma", header::PRAGMA.as_str()); - push_field( - &mut fields, - "Protocol", - req.uri().scheme_str().unwrap_or_default(), - ); + push_field(&mut fields, "Protocol", request_protocol); push_header_field(&mut fields, req, "Referer", header::REFERER.as_str()); push_field(&mut fields, "Request", request_path_and_query(req)); push_field(&mut fields, "RequestModuleName", REQUEST_MODULE_NAME); @@ -367,7 +372,7 @@ impl DataDomeIntegration { "sec-fetch-storage-access", ); push_header_field(&mut fields, req, "SecFetchUser", "sec-fetch-user"); - push_field(&mut fields, "ServerHostname", request_host(req)); + push_field(&mut fields, "ServerHostname", &request_host); push_field( &mut fields, "ServerName", @@ -923,6 +928,87 @@ mod tests { .is_some() } + #[test] + fn public_origin_datadome_payload_preserves_transport_tls_metadata() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.publisher.domain = "publisher.example.com".to_owned(); + settings.trusted_forwarder = Some(crate::settings::TrustedForwarderConfig { + auth_header: crate::forwarder::FORWARDER_AUTH_HEADER.to_owned(), + shared_secret: crate::redacted::Redacted::new( + "fictional-forwarder-secret-0123456789".to_owned(), + ), + }); + let integration = protection_integration(); + let mut services = crate::platform::test_support::noop_services(); + services.client_info.server_hostname = Some("transport.example.com".to_owned()); + services.client_info.tls_protocol = Some("TLSv1.3".to_owned()); + services.client_info.tls_cipher = Some("transport-cipher".to_owned()); + for authenticated in [true, false] { + let mut request = Request::builder() + .uri("http://internal.example.com/page?x=1") + .header(header::HOST, "internal.example.com") + .header( + crate::forwarder::FORWARDER_AUTH_HEADER, + "fictional-forwarder-secret-0123456789", + ) + .header("x-forwarded-host", "publisher.example.com:8443") + .header("x-forwarded-proto", "https") + .body(EdgeBody::empty()) + .expect("should construct filter request"); + if !authenticated { + request + .headers_mut() + .remove(crate::forwarder::FORWARDER_AUTH_HEADER); + } + crate::forwarder::prepare_trusted_forwarder(&mut request, &settings); + let payload = integration.build_protection_payload( + &RequestFilterInput { + settings: &settings, + services: &services, + request: &mut request, + geo_info: None, + is_integration_route: false, + }, + &Redacted::new("fictional-server-side-key".to_owned()), + ); + let fields: HashMap<_, _> = payload.fields.into_iter().collect(); + let host = if authenticated { + "publisher.example.com:8443" + } else { + "internal.example.com" + }; + assert_eq!( + fields["Host"], host, + "should send effective browser authority" + ); + assert_eq!( + fields["ServerHostname"], host, + "should send effective browser server hostname" + ); + assert_eq!( + fields["Protocol"], + if authenticated { "https" } else { "http" }, + "should retain legacy URI protocol without authentication" + ); + assert_eq!( + fields["ServerName"], "transport.example.com", + "should retain actual runtime server name" + ); + assert_eq!( + fields["TlsProtocol"], "TLSv1.3", + "should retain actual TLS protocol" + ); + assert_eq!( + fields["TlsCipher"], "transport-cipher", + "should retain actual TLS cipher" + ); + assert_eq!( + fields["Request"], "/page?x=1", + "should retain request path and query" + ); + } + } + #[test] fn protection_test_bypass_skips_api_suppresses_tag_and_strips_header() { let config = DataDomeConfig { diff --git a/crates/trusted-server-core/src/integrations/datadome/protection_scope.rs b/crates/trusted-server-core/src/integrations/datadome/protection_scope.rs index 73bd77070..6ddc51fe5 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection_scope.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection_scope.rs @@ -2,11 +2,12 @@ use std::collections::{HashMap, HashSet}; use std::net::IpAddr; use std::str::FromStr; use std::sync::{LazyLock, Mutex}; -use std::time::{Duration, Instant}; +use std::time::Duration; use error_stack::Report; use regex::Regex; use serde::Deserialize; +use web_time::Instant; use crate::error::TrustedServerError; use crate::platform::{RuntimeServices, StoreName}; 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 3b1c051a3..1c40fe420 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -12,7 +12,7 @@ //! | `GET/POST` | `.../collect` | Proxies GA analytics beacons | //! | `GET/POST` | `.../g/collect` | Proxies GA4 analytics beacons | -use std::sync::{Arc, LazyLock}; +use std::sync::{Arc, LazyLock, Mutex}; use async_trait::async_trait; use edgezero_core::body::Body as EdgeBody; @@ -24,7 +24,6 @@ use serde::{Deserialize, Serialize}; use validator::{Validate, ValidationError}; use crate::error::TrustedServerError; -use crate::integrations::ScriptTextAccumulator; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, IntegrationScriptContext, @@ -85,9 +84,9 @@ static GTM_TAG_ID_PATTERN: LazyLock = LazyLock::new(|| { .expect("GTM tag ID regex should compile") }); -/// Host alternation shared by the URL patterns below. +/// Host alternation shared by ordinary and Flight URL patterns. const GTM_URL_HOSTS: &str = - r"(?:https?:)?//(?:www\.(?:googletagmanager|google-analytics)\.com|analytics\.google\.com)"; + r"(?:www\.(?:googletagmanager|google-analytics)\.com|analytics\.google\.com)"; /// The paths this integration routes, plus the empty path. /// /// A script may hold an origin and build the path later @@ -103,7 +102,7 @@ const GTM_URL_PATHS: &str = r"(?P/gtm\.js|/gtag/js|/gtag\.js|/g/collect|/c /// must be the URL. static GTM_WHOLE_URL_PATTERN: LazyLock = LazyLock::new(|| { Regex::new(&format!( - r"^{GTM_URL_HOSTS}{GTM_URL_PATHS}(?P[?#].*)?$" + r"^(?:https?:)?//{GTM_URL_HOSTS}{GTM_URL_PATHS}(?P[?#].*)?$" )) .expect("GTM whole-URL regex should compile") }); @@ -137,13 +136,103 @@ static GTM_QUOTED_URL_PATTERNS: LazyLock> = LazyLock::new(|| { .iter() .map(|(open, suffix, close)| { Regex::new(&format!( - "{open}{GTM_URL_HOSTS}{GTM_URL_PATHS}{suffix}{close}" + "{open}(?:https?:)?//{GTM_URL_HOSTS}{GTM_URL_PATHS}{suffix}{close}" )) .expect("GTM quoted-URL regex should compile") }) .collect() }); +// Flight spans are JS string source, not decoded JSON. Match only whole or +// quoted routed URL tokens and preserve their delimiters, suffix and slash layer. +static GTM_RSC_WHOLE_URL_PATTERN: LazyLock = LazyLock::new(|| { + // Preserve the routed paths while accepting Flight's slash escape layers. + let paths = GTM_URL_PATHS.replace('/', r"(?:\\{0,8}/)"); + Regex::new(&format!( + r"^(?:https?:)?(?P\\{{0,8}}/)(?:\\{{0,8}}/){GTM_URL_HOSTS}{paths}(?P[?#].*)?$" + )) + .expect("should compile whole Flight URL pattern") +}); +static GTM_RSC_QUOTED_URL_START: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r#"(?P\\{{0,8}}["'`])(?:https?:)?\\{{0,8}}/\\{{0,8}}/{GTM_URL_HOSTS}"# + )) + .expect("should compile quoted Flight URL start") +}); + +// Match the opening quote's escape layer. Higher-layer instances of that +// delimiter belong to the URL (e.g. escaped quotes inside a JSON query). +fn flight_url_token_end( + content: &str, + start: usize, + delimiter: u8, + layer: usize, +) -> Option<(usize, usize)> { + let bytes = content.as_bytes(); + let mut slash_run = 0; + for index in start..bytes.len() { + let byte = bytes[index]; + if byte == delimiter { + if slash_run == layer { + return Some((index - slash_run, index + 1)); + } + if slash_run < layer { + return None; + } + } else if delimiter == b'`' && byte == b'$' { + return (bytes.get(index + 1) == Some(&b'{')).then_some((index, index + 2)); + } + slash_run = if byte == b'\\' { slash_run + 1 } else { 0 }; + } + None +} + +/// Rewrite supported GTM/GA tokens in a captured Flight source span. +/// +/// Call only after original T-record boundaries are known, before lengths are +/// recomputed. Originals used for fallback must never pass through this function. +pub(crate) fn rewrite_gtm_rsc_span(content: &str) -> String { + fn route(token: &str) -> Option { + let captures = GTM_RSC_WHOLE_URL_PATTERN.captures(token)?; + let slash = &captures["slash"]; + let path = &captures["path"]; + let suffix = captures.name("suffix").map_or("", |value| value.as_str()); + Some(format!( + "{slash}integrations{slash}google_tag_manager{path}{suffix}" + )) + } + if let Some(rewritten) = route(content) { + return rewritten; + } + let mut rewritten = String::with_capacity(content.len()); + let mut cursor = 0; + let mut search = 0; + while let Some(captures) = GTM_RSC_QUOTED_URL_START.captures(&content[search..]) { + let open = captures.name("open").expect("should capture quote layer"); + let open_start = search + open.start(); + let url_start = search + open.end(); + let delimiter = content.as_bytes()[url_start - 1]; + let layer = open.len() - 1; + search = url_start; + // Never accept a suffix of an unsupported, deeper opening layer. + if open_start > 0 && content.as_bytes()[open_start - 1] == b'\\' { + continue; + } + let Some((url_end, token_end)) = flight_url_token_end(content, url_start, delimiter, layer) + else { + continue; + }; + search = token_end; + if let Some(url) = route(&content[url_start..url_end]) { + rewritten.push_str(&content[cursor..url_start]); + rewritten.push_str(&url); + cursor = url_end; + } + } + rewritten.push_str(&content[cursor..]); + rewritten +} + #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct GoogleTagManagerConfig { #[serde(default = "default_enabled")] @@ -359,6 +448,12 @@ pub struct GoogleTagManagerIntegration { proxy_allowed_domains: Vec, } +#[derive(Default)] +struct GtmScriptState { + held: String, + bypass_until_last: bool, +} + impl GoogleTagManagerIntegration { fn new(config: GoogleTagManagerConfig) -> Arc { let proxy_allowed_domains = Self::proxy_allowed_domains(&config); @@ -1021,41 +1116,57 @@ impl IntegrationScriptRewriter for GoogleTagManagerIntegration { } fn rewrite(&self, content: &str, ctx: &IntegrationScriptContext<'_>) -> ScriptRewriteAction { - // Per document, never per registry: a registry-lifetime buffer would - // carry one document's partial script into the next. - let accumulator = ctx + let state = ctx .document_state - .get_or_insert_with(GTM_INTEGRATION_ID, ScriptTextAccumulator::default); - let mut buf = accumulator.buffer(); - - // Cheap gate: only engage the accumulation path for scripts whose - // running text could plausibly contain a GTM/GA domain. Unrelated - // scripts (the vast majority) return Keep on every fragment so they - // stay visible in `lol_html`'s output unchanged — and, critically, - // so a Replace from this rewriter does not clobber a Replace from - // another rewriter on the same element (e.g., `NextJsNextDataRewriter` - // on `script#__NEXT_DATA__`). See `might_contain_gtm_prefix` for the - // boundary-safe substring check. - let prior_and_current_might_match = - might_contain_gtm_prefix(&buf) || might_contain_gtm_prefix(content); - - if !ctx.is_last_in_text_node { - // Intermediate fragment. Only accumulate + suppress when the script - // might still resolve to a GTM match. Otherwise return Keep so the - // fragment is emitted as-is by `lol_html` and untouched by us. - if prior_and_current_might_match { - buf.push_str(content); - return ScriptRewriteAction::RemoveNode; + .get_or_insert_with(GTM_INTEGRATION_ID, || Mutex::new(GtmScriptState::default())); + let mut state = state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if crate::integrations::nextjs::protects_current_flight_fragment(ctx.document_state) { + if ctx.is_last_in_text_node { + state.bypass_until_last = false; + } + if state.held.is_empty() { + return ScriptRewriteAction::Keep; } - return ScriptRewriteAction::keep(); + let mut restored = std::mem::take(&mut state.held); + restored.push_str(content); + return ScriptRewriteAction::Replace(restored); } - - // Last fragment. If we accumulated prior fragments, combine them. - let full_content: Option = if buf.is_empty() { + if state.bypass_until_last { + if ctx.is_last_in_text_node { + state.bypass_until_last = false; + } + return ScriptRewriteAction::Keep; + } + // Once holding begins, retain every following fragment in source order, + // even if the tentative hostname prefix turns out not to match. + if state.held.is_empty() && !might_contain_gtm_prefix(content) { + return ScriptRewriteAction::Keep; + } + if state + .held + .len() + .checked_add(content.len()) + .is_none_or(|length| length > ctx.max_buffered_script_bytes) + { + state.bypass_until_last = !ctx.is_last_in_text_node; + if state.held.is_empty() { + return ScriptRewriteAction::Keep; + } + let mut restored = std::mem::take(&mut state.held); + restored.push_str(content); + return ScriptRewriteAction::Replace(restored); + } + if !ctx.is_last_in_text_node { + state.held.push_str(content); + return ScriptRewriteAction::RemoveNode; + } + let full_content = if state.held.is_empty() { None } else { - buf.push_str(content); - Some(std::mem::take(&mut *buf)) + state.held.push_str(content); + Some(std::mem::take(&mut state.held)) }; let text = full_content.as_deref().unwrap_or(content); @@ -1092,6 +1203,7 @@ mod tests { }; use crate::platform::test_support::{StubHttpClient, build_services_with_http_client}; use crate::settings::Settings; + use crate::streaming_processor::StreamProcessor as _; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; #[test] @@ -3408,6 +3520,271 @@ container_id = "GTM-DEFAULT" } } + #[test] + fn ordinary_and_flight_urls_share_routing_policy() { + for (host, supported_host) in [ + ("www.googletagmanager.com", true), + ("www.google-analytics.com", true), + ("analytics.google.com", true), + ("www.googletagmanager.com.evil", false), + ("example.com", false), + ] { + for (path, supported_path) in [ + ("", true), + ("/gtm.js", true), + ("/gtag/js", true), + ("/gtag.js", true), + ("/g/collect", true), + ("/collect", true), + ("/ns.html", false), + ("/gtm.js/path", false), + ("/collect;matrix", false), + ] { + for scheme in ["https:", "http:", ""] { + for suffix in ["", "?id=GTM-MIX1&v=2", "#fragment"] { + let url = format!("{scheme}//{host}{path}{suffix}"); + let expected = if supported_host && supported_path { + format!("/integrations/google_tag_manager{path}{suffix}") + } else { + url.clone() + }; + for quote in ["", "'", "\"", "`"] { + let source = format!("{quote}{url}{quote}"); + let expected = format!("{quote}{expected}{quote}"); + assert_eq!( + GoogleTagManagerIntegration::rewrite_gtm_urls(&source), + expected, + "should apply ordinary routing policy: {source}" + ); + for slash in ["/", r"\/", r"\\/"] { + let flight_source = source.replace('/', slash); + assert_eq!( + rewrite_gtm_rsc_span(&flight_source), + expected.replace('/', slash), + "should preserve Flight routing policy and escapes: {flight_source}" + ); + } + } + } + } + } + } + } + + #[test] + fn captured_flight_preserves_quoted_query_delimiters_and_template_interpolation() { + for (source, expected) in [ + ( + r#"\"http://www.googletagmanager.com/gtm.js?q='text'\""#, + r#"\"/integrations/google_tag_manager/gtm.js?q='text'\""#, + ), + ( + r#"\"http://www.googletagmanager.com/gtm.js?q=`text`\""#, + r#"\"/integrations/google_tag_manager/gtm.js?q=`text`\""#, + ), + ( + r#"'http://www.googletagmanager.com/gtm.js?q=\"text\"'"#, + r#"'/integrations/google_tag_manager/gtm.js?q=\"text\"'"#, + ), + ( + r#"\"http://www.googletagmanager.com/gtm.js?q=\\\"text\\\"\""#, + r#"\"/integrations/google_tag_manager/gtm.js?q=\\\"text\\\"\""#, + ), + ( + r"'http://www.googletagmanager.com/gtm.js?q=\'text\''", + r"'/integrations/google_tag_manager/gtm.js?q=\'text\''", + ), + ( + "`http://www.googletagmanager.com/gtm.js?q=$literal`", + "`http://www.googletagmanager.com/gtm.js?q=$literal`", + ), + ( + "`http://www.googletagmanager.com/gtm.js${id}`", + "`/integrations/google_tag_manager/gtm.js${id}`", + ), + ] { + assert_eq!( + rewrite_gtm_rsc_span(source), + expected, + "should preserve query delimiters and template interpolation: {source}" + ); + } + } + + #[test] + fn captured_flight_token_rewrite_preserves_escape_layers_and_allowed_paths() { + for slash in ["/", r"\/", r"\\/"] { + for quote in ["", "'", "\"", r#"\""#, "`"] { + let source = format!( + "{quote}http:{slash}{slash}www.googletagmanager.com{slash}gtm.js{quote}" + ); + let expected = format!( + "{quote}{slash}integrations{slash}google_tag_manager{slash}gtm.js{quote}" + ); + assert_eq!( + rewrite_gtm_rsc_span(&source), + expected, + "should preserve source escape layer: {source}" + ); + } + } + for source in [ + r#""https://www.googletagmanager.com/ns.html?id=GTM-MIX1""#, + "text http://www.googletagmanager.com/gtm.js text", + "'http://www.googletagmanager.com/gtm.js/path'", + "'http://www.googletagmanager.com/collect;matrix'", + "'http://www.googletagmanager.com.evil/gtm.js'", + "'http://www.googletagmanager.com/gtm.js\"", + ] { + assert_eq!( + rewrite_gtm_rsc_span(source), + source, + "should not route unsupported or unbounded token" + ); + } + } + + #[test] + fn inline_gtm_split_after_underscore_emits_once() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "google_tag_manager", + &serde_json::json!({"enabled": true, "container_id": "GTM-MIX1"}), + ) + .expect("should enable GTM"); + settings + .integrations + .insert_config("nextjs", &serde_json::json!({"enabled": true})) + .expect("should enable Next.js"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new(crate::auction::compile_auction_plan(&settings).expect("should compile plan")), + ) + .expect("should build registry"); + let mut processor = create_html_processor(config_from_settings(&settings, ®istry)); + let mut bytes = processor + .process_chunk( + b"", true) + .expect("should consume final"), + ); + let html = String::from_utf8(bytes).expect("should emit UTF-8"); + assert!(html.contains("var url='/integrations/google_tag_manager/gtm.js';var tail_=1;")); + assert_eq!( + html.matches("var url=").count(), + 1, + "should emit script text once" + ); + } + + #[test] + fn sticky_gtm_four_fragments_preserve_order_after_false_google_prefix() { + let integration = GoogleTagManagerIntegration::new(tag_config("GTM-MIX1", &[])); + let state = IntegrationDocumentState::default(); + let fragments = [ + "var falsePrefix='google", + "X';first=1;", + "second=2;var tag='http://www.googletagmanager.com/gtm.js", + "';last=3;", + ]; + let mut emitted = String::new(); + for (index, fragment) in fragments.iter().enumerate() { + let context = IntegrationScriptContext { + selector: "script", + request_host: "test.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + is_last_in_text_node: index == 3, + max_buffered_script_bytes: 1000, + document_state: &state, + }; + match IntegrationScriptRewriter::rewrite(&*integration, fragment, &context) { + ScriptRewriteAction::Keep => emitted.push_str(fragment), + ScriptRewriteAction::Replace(value) => emitted.push_str(&value), + ScriptRewriteAction::RemoveNode => {} + } + } + assert_eq!( + emitted, + "var falsePrefix='googleX';first=1;second=2;var tag='/integrations/google_tag_manager/gtm.js';last=3;" + ); + } + + #[test] + fn sticky_gtm_holding_is_bounded_and_document_local() { + let integration = GoogleTagManagerIntegration::new(tag_config("GTM-MIX1", &[])); + let a = IntegrationDocumentState::default(); + let b = IntegrationDocumentState::default(); + let context = |last, budget, state| IntegrationScriptContext { + selector: "script", + request_host: "test.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + is_last_in_text_node: last, + max_buffered_script_bytes: budget, + document_state: state, + }; + let fragments = ["prefix google", "X-middle", "later", "-tail"]; + let mut emitted = String::new(); + for (i, fragment) in fragments.iter().enumerate() { + let action = IntegrationScriptRewriter::rewrite( + &*integration, + fragment, + &context(i == 3, 100, &a), + ); + match action { + ScriptRewriteAction::Keep => emitted.push_str(fragment), + ScriptRewriteAction::Replace(value) => emitted.push_str(&value), + ScriptRewriteAction::RemoveNode => {} + } + if i == 0 { + assert_eq!( + IntegrationScriptRewriter::rewrite( + &*integration, + "other", + &context(true, 100, &b) + ), + ScriptRewriteAction::Keep, + "should isolate documents" + ); + } + } + assert_eq!( + emitted, + fragments.concat(), + "should preserve source order after false prefix" + ); + assert_eq!( + IntegrationScriptRewriter::rewrite(&*integration, "google", &context(false, 6, &a)), + ScriptRewriteAction::RemoveNode + ); + assert_eq!( + IntegrationScriptRewriter::rewrite(&*integration, "X", &context(false, 6, &a)), + ScriptRewriteAction::Replace("googleX".into()), + "should restore on overflow" + ); + assert_eq!( + IntegrationScriptRewriter::rewrite(&*integration, "tail", &context(true, 6, &a)), + ScriptRewriteAction::Keep + ); + assert_eq!( + IntegrationScriptRewriter::rewrite(&*integration, "google", &context(false, 6, &a)), + ScriptRewriteAction::RemoveNode + ); + assert_eq!( + IntegrationScriptRewriter::rewrite(&*integration, "", &context(true, 6, &a)), + ScriptRewriteAction::Replace("google".into()), + "should flush empty final at exact limit" + ); + } + #[test] fn fragmented_gtm_snippet_is_accumulated_and_rewritten() { let config = tag_config("GTM-FRAG1", &[]); diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index afae77535..5c958130c 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -326,7 +326,12 @@ // 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) { + ts.scheduleInitialAdInit = function ( + initialBids, + initialSlots, + initialAuctionDiagnostics, + traceAuctionTransport, + ) { // 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; @@ -336,8 +341,22 @@ // would overwrite a committed SPA navigation's slots. if (initialSlots !== undefined) ts.adSlots = initialSlots; if (initialBids !== undefined) ts.bids = initialBids; + if (initialAuctionDiagnostics !== undefined) { + ts.auctionDiagnostics = initialAuctionDiagnostics; + } var fire = function () { if ((ts.navGeneration || 0) !== 0) return; + try { + if (window.__tsjs_trace_active === true && ts.traceGpt) { + ts.traceGpt.observeTransport( + initialSlots === undefined ? ts.adSlots : initialSlots, + traceAuctionTransport, + "initial_navigation_ssat", + ); + } + } catch (_) { + // Trace failure cannot interrupt the initial advertising pass. + } if (typeof ts.adInit === "function") ts.adInit(); }; var afterFrames = function () { @@ -349,6 +368,50 @@ else window.addEventListener("load", afterFrames, { once: true }); }; + function recordTraceOpportunity(gptSlot, slot, bid, fallback) { + if (window.__tsjs_trace_active !== true) return; + try { + var bridge = ts.traceGpt; + if (!bridge || !bridge.identity(slot) || !ts.gptDiagnosticsRecorder) return; + var trace = bridge.opportunity(slot, bid.hb_auction_id); + var handoff = + ts.gptSlotHandoffs && ts.gptSlotHandoffs[gptSlot.getSlotElementId()]; + var requestedSizes = fallback ? slot.formats : handoff && handoff.formats; + var nonempty = function (value) { + return typeof value === "string" && value.length > 0; + }; + // Mirror the bundle's opportunity classification at the same concrete + // GPT binding. Token validation remains in the single core bridge. + var hasTargeting = [ + "hb_pb", + "hb_bidder", + "hb_adid", + "hb_cache_host", + "hb_cache_path", + ].some(function (key) { + return nonempty(bid[key]); + }); + var opportunity = !hasTargeting + ? "no_candidate" + : nonempty(bid.hb_adid) && + (nonempty(bid.adm) || + (nonempty(bid.hb_cache_host) && nonempty(bid.hb_cache_path))) + ? "renderable_candidate" + : "unrenderable_candidate"; + ts.gptDiagnosticsRecorder.recordTrustedServerOpportunity( + gptSlot, + slot.id, + opportunity, + trace.auctionId, + requestedSizes, + undefined, + trace.identity, + ); + } catch (_) { + // Diagnostics never change targeting, display, or refresh. + } + } + function findSlotByElementId(pubads, elementId) { var slots = pubads.getSlots ? pubads.getSlots() : []; return ( @@ -721,6 +784,7 @@ ts.prevGptSlots = ts.prevGptSlots || []; ts.prevGptSlots.push(gptSlot); } + recordTraceOpportunity(gptSlot, slot, bid, true); if (!ts.servicesEnabled) { pubads.enableSingleRequest(); window.googletag.enableServices(); @@ -866,6 +930,7 @@ // by the bundle's render bridge (index.ts) once it loads. divToSlotId[actualDivId] = slot.id; var slotElementId = s.getSlotElementId(); + recordTraceOpportunity(s, slot, b, false); var targetingKeys = Object.keys(slot.targeting || {}); nextSlotTargetingKeys[actualDivId] = targetingKeys; if (slotElementId && slotElementId !== actualDivId) { diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs index 1447a8358..d50dc51a5 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -1,4 +1,4 @@ -//! Query-activated, browser-session GPT runtime diagnostics integration. +//! Query-activated GPT runtime diagnostics with a bounded session lifetime. //! //! Deployment configuration makes the standalone browser module available. //! Exact `ts_console` directives establish or clear a host-only session cookie; @@ -8,7 +8,8 @@ use error_stack::{Report, ResultExt}; use http::{HeaderValue, Method, Request, Response, Uri, header, uri::PathAndQuery}; use serde::Deserialize; -use validator::Validate; +use std::borrow::Cow; +use validator::{Validate, ValidationError}; use edgezero_core::body::Body as EdgeBody; @@ -24,20 +25,36 @@ use super::IntegrationRegistration; pub const GPT_DIAGNOSTICS_INTEGRATION_ID: &str = "gpt_diagnostics"; /// Reserved activation query parameter. pub const GPT_DIAGNOSTICS_QUERY: &str = "ts_console"; -/// Host-only browser-session activation cookie. +/// Host-only activation cookie with a 30-minute explicit-activation lifetime. pub const GPT_DIAGNOSTICS_COOKIE: &str = "__Host-ts-console"; -const SET_CONSOLE_COOKIE: &str = "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax"; +const SET_CONSOLE_COOKIE: &str = + "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800"; const CLEAR_CONSOLE_COOKIE: &str = "__Host-ts-console=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0"; /// Configuration for the GPT runtime diagnostics integration. #[derive(Debug, Clone, Deserialize, Validate)] #[serde(deny_unknown_fields)] +#[validate(schema(function = "validate_trace_dependency"))] pub struct GptDiagnosticsConfig { /// Whether the GPT diagnostics browser module is available. #[serde(default)] pub enabled: bool, + /// Whether the mobile trace journey is available under operator auth rules. + /// + /// Requires [`Self::enabled`]. Defaults to false. + #[serde(default)] + pub trace_page_enabled: bool, +} + +fn validate_trace_dependency(config: &GptDiagnosticsConfig) -> Result<(), ValidationError> { + if config.trace_page_enabled && !config.enabled { + let mut error = ValidationError::new("trace_requires_enabled"); + error.message = Some(Cow::Borrowed("trace_page_enabled requires enabled = true")); + return Err(error); + } + Ok(()) } impl IntegrationConfig for GptDiagnosticsConfig { @@ -52,16 +69,28 @@ pub enum GptDiagnosticsCookieAction { /// Do not mutate the activation cookie. #[default] None, - /// Establish a host-only browser-session activation cookie. + /// Establish or renew the host-only 30-minute activation cookie. SetSession, /// Clear the activation cookie. ClearSession, } +impl GptDiagnosticsCookieAction { + /// Return the shared host-only cookie policy for an explicit action. + pub(crate) fn set_cookie_header(self) -> Option { + match self { + Self::None => None, + Self::SetSession => Some(HeaderValue::from_static(SET_CONSOLE_COOKIE)), + Self::ClearSession => Some(HeaderValue::from_static(CLEAR_CONSOLE_COOKIE)), + } + } +} + /// Immutable request-scoped diagnostics decision. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct GptDiagnosticsRequestDecision { active: bool, + browser_session_active: bool, clean_browser_path_and_query: Option, cookie_action: GptDiagnosticsCookieAction, } @@ -73,6 +102,16 @@ impl GptDiagnosticsRequestDecision { self.active } + /// Whether this request came from an activated diagnostics browser session. + /// + /// Unlike [`Self::active`], this remains true for non-document requests such + /// as the SPA page-bids fetch. It is captured before the private activation + /// cookie is stripped from the request. + #[must_use] + pub(crate) fn browser_session_active(&self) -> bool { + self.browser_session_active + } + /// Whether the response must be private and non-storeable. #[must_use] pub fn requires_private_no_store(&self) -> bool { @@ -121,6 +160,7 @@ impl GptDiagnosticsRequestDecision { pub(crate) fn active_for_tests() -> Self { Self { active: true, + browser_session_active: true, clean_browser_path_and_query: None, cookie_action: GptDiagnosticsCookieAction::None, } @@ -143,6 +183,7 @@ mod head_seam_invariant_tests { ] { out.push(GptDiagnosticsRequestDecision { active, + browser_session_active: active, clean_browser_path_and_query: clean.clone(), cookie_action, }); @@ -279,12 +320,19 @@ pub fn prepare_request( replace_path_and_query(request, &clean_path)?; } - let mut decision = GptDiagnosticsRequestDecision::default(); + let mut decision = GptDiagnosticsRequestDecision { + browser_session_active: integration_enabled + && directive == QueryDirective::Absent + && cookie_state.occurrences == 1 + && cookie_state.canonical, + ..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; + decision.browser_session_active = true; decision.cookie_action = GptDiagnosticsCookieAction::SetSession; } QueryDirective::Disable => { @@ -320,16 +368,7 @@ pub fn finalize_response( decision: &GptDiagnosticsRequestDecision, response: &mut Response, ) { - let cookie = match decision.cookie_action { - GptDiagnosticsCookieAction::None => None, - GptDiagnosticsCookieAction::SetSession => { - Some(HeaderValue::from_static(SET_CONSOLE_COOKIE)) - } - GptDiagnosticsCookieAction::ClearSession => { - Some(HeaderValue::from_static(CLEAR_CONSOLE_COOKIE)) - } - }; - if let Some(cookie) = cookie { + if let Some(cookie) = decision.cookie_action.set_cookie_header() { response.headers_mut().append(header::SET_COOKIE, cookie); } @@ -547,6 +586,22 @@ mod tests { assert_eq!(duplicate.headers()[header::COOKIE], "other=value"); } + #[test] + fn active_cookie_marks_non_document_requests_without_activating_document_behavior() { + let mut request = Request::builder() + .method(Method::GET) + .uri("https://publisher.example/_ts/page-bids?path=/article") + .header(header::COOKIE, "__Host-ts-console=1; other=value") + .body(EdgeBody::empty()) + .expect("should build page-bids request"); + + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + + assert!(!decision.active()); + assert!(decision.browser_session_active()); + assert_eq!(request.headers()[header::COOKIE], "other=value"); + } + #[test] fn invalid_duplicate_and_disable_directives_fail_closed() { for query in [ @@ -675,6 +730,67 @@ mod tests { ); } + #[test] + fn explicit_activation_has_bounded_lifetime_even_when_trace_is_disabled() { + for trace_enabled in [false, true] { + let mut settings = settings(true); + if trace_enabled { + settings + .integrations + .insert_config( + GPT_DIAGNOSTICS_INTEGRATION_ID, + &json!({"enabled": true, "trace_page_enabled": true}), + ) + .expect("should insert diagnostics trace flag"); + } + + for query in ["1", "true"] { + let mut request = navigation( + &format!("https://publisher.example/?ts_console={query}"), + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings, &mut request) + .expect("should prepare deliberate activation"); + let mut response = Response::new(EdgeBody::empty()); + finalize_response(&decision, &mut response); + assert_eq!( + response.headers()[header::SET_COOKIE], + "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800", + "should renew bounded lifetime only on explicit activation" + ); + } + + let mut request = navigation( + "https://publisher.example/article", + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings, &mut request) + .expect("should prepare an established session"); + let mut response = Response::new(EdgeBody::empty()); + finalize_response(&decision, &mut response); + assert!( + !response.headers().contains_key(header::SET_COOKIE), + "should not extend lifetime on ordinary requests" + ); + + for query in ["0", "false"] { + let mut request = navigation( + &format!("https://publisher.example/?ts_console={query}"), + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings, &mut request) + .expect("should prepare deliberate deactivation"); + let mut response = Response::new(EdgeBody::empty()); + finalize_response(&decision, &mut response); + assert_eq!( + response.headers()[header::SET_COOKIE], + "__Host-ts-console=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0", + "should clear using the same host-only cookie scope" + ); + } + } + } + #[test] fn config_rejects_unknown_fields() { let mut settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/integrations/identity.rs b/crates/trusted-server-core/src/integrations/identity.rs new file mode 100644 index 000000000..3b6e7ee30 --- /dev/null +++ b/crates/trusted-server-core/src/integrations/identity.rs @@ -0,0 +1,447 @@ +//! Shared, policy-free response inspection for registered identity capture modules. +//! Original response parts and bytes are never decoded or rewritten for the client. + +use std::io::Read as _; + +use bytes::Bytes; +use edgezero_core::body::Body as EdgeBody; +use flate2::read::MultiGzDecoder; +use futures::{StreamExt as _, stream}; +use http::{Method, Response, StatusCode, header}; + +const CAPTURE_WIRE_LIMIT: usize = 64 * 1024; +const CAPTURE_DECODED_LIMIT: usize = 64 * 1024; + +/// An integration's response capture capability. +/// +/// The registry selects matching upstream routes and supplies only bounded, +/// successfully decoded response bytes to [`Self::observe`]. +pub trait IdentityCapture: Send + Sync { + /// Return the finite EID sources claimed by this integration. + fn sources(&self) -> &'static [&'static str]; + /// Return whether response observation is active, without removing claims. + fn enabled(&self) -> bool; + /// Select an exact method and proxy path for inspection. + fn matches(&self, method: &Method, path: &str) -> bool; + /// Interpret a selected upstream response without changing forwarding. + fn observe( + &self, + input: IdentityCaptureInput<'_>, + ) -> Option; +} + +/// Selected upstream response and already-buffered forwarding request bytes. +pub struct IdentityCaptureInput<'a> { + /// Actual proxy request path used to distinguish token and revoke routes. + pub path: &'a str, + /// Borrowed POST forwarding buffer; inspection never reads it twice. + pub request_body: &'a [u8], + /// Upstream HTTP response status. + pub status: StatusCode, + /// Decoded response body, bounded separately from original wire bytes. + pub decoded_body: &'a [u8], +} + +/// Shared forwarding bytes, attached only to selected capture requests. +#[derive(Clone)] +pub(crate) struct IdentityRequestBody(pub Bytes); + +/// Inspect a selected upstream response without changing what the client sees. +/// +/// A stream is read only while its wire prefix fits the limit. On overflow or +/// upstream read error, the copied prefix precedes the untouched oversized +/// chunk or error and unread tail. An inspected stream can be returned buffered; +/// the response bytes remain identical. +/// `Content-Length` is deliberately not trusted for either bound. +pub(crate) async fn inspect_response( + response: Response, +) -> (Response, Option>) { + let (parts, body) = response.into_parts(); + let mut encodings = parts.headers.get_all(header::CONTENT_ENCODING).iter(); + let encoding = match (encodings.next(), encodings.next()) { + (None, _) => Some(Encoding::Identity), + (Some(value), None) if value.as_bytes().eq_ignore_ascii_case(b"identity") => { + Some(Encoding::Identity) + } + (Some(value), None) if value.as_bytes().eq_ignore_ascii_case(b"gzip") => { + Some(Encoding::Gzip) + } + _ => None, + }; + let Some(encoding) = encoding else { + return (Response::from_parts(parts, body), None); + }; + + match body { + EdgeBody::Once(bytes) => { + let decoded = (bytes.len() <= CAPTURE_WIRE_LIMIT) + .then(|| decode(&bytes, encoding)) + .flatten(); + (Response::from_parts(parts, EdgeBody::Once(bytes)), decoded) + } + EdgeBody::Stream(mut upstream) => { + // Only copy chunks that fit. A single oversized chunk stays in + // its original Bytes allocation and is replayed untouched. + let mut prefix = Vec::new(); + while let Some(item) = upstream.next().await { + match item { + Ok(chunk) => { + if chunk.len() > CAPTURE_WIRE_LIMIT - prefix.len() { + let replay = stream::once(async { Ok(Bytes::from(prefix)) }) + .chain(stream::once(async { Ok(chunk) })) + .chain(upstream); + return ( + Response::from_parts(parts, EdgeBody::from_stream(replay)), + None, + ); + } + prefix.extend_from_slice(&chunk); + } + Err(error) => { + let replay = stream::once(async { Ok(Bytes::from(prefix)) }) + .chain(stream::once(async { Err(error) })) + .chain(upstream); + return ( + Response::from_parts(parts, EdgeBody::from_stream(replay)), + None, + ); + } + } + } + + let decoded = decode(&prefix, encoding); + (Response::from_parts(parts, EdgeBody::from(prefix)), decoded) + } + } +} + +#[derive(Clone, Copy)] +enum Encoding { + Identity, + Gzip, +} + +fn decode(wire: &[u8], encoding: Encoding) -> Option> { + match encoding { + Encoding::Identity => (wire.len() <= CAPTURE_DECODED_LIMIT).then(|| wire.to_vec()), + Encoding::Gzip => { + // MultiGzDecoder also checks concatenated members instead of + // accepting only the first gzip member and ignoring trailing data. + let mut decoder = MultiGzDecoder::new(wire); + let mut decoded = Vec::new(); + let mut scratch = [0_u8; 4096]; + loop { + // One byte past the cap distinguishes an exact-size response + // from one with more decoded data without allocating it. + let remaining = CAPTURE_DECODED_LIMIT + 1 - decoded.len(); + let window = scratch.len().min(remaining); + let count = decoder.read(&mut scratch[..window]).ok()?; + if count == 0 { + return Some(decoded); + } + if count > CAPTURE_DECODED_LIMIT - decoded.len() { + return None; + } + decoded.extend_from_slice(&scratch[..count]); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::io::Write as _; + + use bytes::Bytes; + use flate2::{Compression, write::GzEncoder}; + use futures::{StreamExt as _, stream}; + use http::{Response, StatusCode, header}; + + use super::{ + CAPTURE_DECODED_LIMIT, CAPTURE_WIRE_LIMIT, EdgeBody, IdentityCaptureInput, inspect_response, + }; + + #[test] + fn capture_input_exposes_the_actual_route() { + let input = IdentityCaptureInput { + path: "/publisher/app/v2/identityLockr/revoke-consent", + request_body: b"{}", + status: StatusCode::OK, + decoded_body: b"{}", + }; + assert_eq!(input.path, "/publisher/app/v2/identityLockr/revoke-consent"); + } + + fn response( + body: EdgeBody, + encoding: Option<&str>, + content_length: &str, + ) -> Response { + let mut builder = Response::builder() + .status(StatusCode::CREATED) + .header(header::CONTENT_LENGTH, content_length) + .header("x-provider-header", "original"); + if let Some(encoding) = encoding { + builder = builder.header(header::CONTENT_ENCODING, encoding); + } + builder.body(body).expect("should build test response") + } + + async fn collect(body: EdgeBody) -> Vec { + match body { + EdgeBody::Once(bytes) => bytes.to_vec(), + EdgeBody::Stream(mut chunks) => { + let mut result = Vec::new(); + while let Some(chunk) = chunks.next().await { + result.extend_from_slice(&chunk.expect("should read replayed body chunk")); + } + result + } + } + } + + fn gzip(input: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(input).expect("should encode gzip input"); + encoder.finish().expect("should finish gzip encoding") + } + + #[tokio::test] + async fn wire_exact_limit_and_one_more_preserve_original_parts() { + for size in [CAPTURE_WIRE_LIMIT, CAPTURE_WIRE_LIMIT + 1] { + let bytes = vec![b'a'; size]; + let original = response(EdgeBody::from(bytes.clone()), None, "1"); + let (returned, decoded) = inspect_response(original).await; + assert_eq!(returned.status(), StatusCode::CREATED); + assert_eq!(returned.headers()[header::CONTENT_LENGTH], "1"); + assert_eq!(returned.headers()["x-provider-header"], "original"); + assert_eq!( + decoded, + (size == CAPTURE_WIRE_LIMIT).then_some(bytes.clone()) + ); + assert_eq!(collect(returned.into_body()).await, bytes); + } + } + + #[tokio::test] + async fn absent_length_and_duplicate_encoding_are_safe() { + let plain = b"synthetic"; + let response = Response::builder() + .status(StatusCode::ACCEPTED) + .body(EdgeBody::from(plain.to_vec())) + .expect("should build lengthless response"); + let (returned, decoded) = inspect_response(response).await; + assert_eq!(returned.status(), StatusCode::ACCEPTED); + assert!(!returned.headers().contains_key(header::CONTENT_LENGTH)); + assert_eq!(decoded.as_deref(), Some(plain.as_slice())); + assert_eq!(collect(returned.into_body()).await, plain); + + let response = Response::builder() + .header(header::CONTENT_ENCODING, "gzip") + .header(header::CONTENT_ENCODING, "identity") + .body(EdgeBody::from(gzip(plain))) + .expect("should build duplicate-encoding response"); + let (returned, decoded) = inspect_response(response).await; + assert!(decoded.is_none()); + assert_eq!( + returned + .headers() + .get_all(header::CONTENT_ENCODING) + .iter() + .count(), + 2 + ); + assert_eq!(collect(returned.into_body()).await, gzip(plain)); + } + + #[tokio::test] + async fn decoded_exact_limit_and_one_more_with_compressed_expansion() { + for size in [CAPTURE_DECODED_LIMIT, CAPTURE_DECODED_LIMIT + 1] { + let plain = vec![b'a'; size]; + let wire = gzip(&plain); + assert!(wire.len() < CAPTURE_WIRE_LIMIT); + let (returned, decoded) = inspect_response(response( + EdgeBody::from(wire.clone()), + Some("gzip"), + "999999", + )) + .await; + assert_eq!(decoded, (size == CAPTURE_DECODED_LIMIT).then_some(plain)); + assert_eq!(returned.headers()[header::CONTENT_ENCODING], "gzip"); + assert_eq!(returned.headers()[header::CONTENT_LENGTH], "999999"); + assert_eq!(collect(returned.into_body()).await, wire); + } + } + + #[tokio::test] + async fn streaming_gzip_crosses_chunk_boundary_and_retains_headers() { + let plain = b"a valid synthetic token"; + let wire = gzip(plain); + let chunks = vec![ + Bytes::copy_from_slice(&wire[..7]), + Bytes::copy_from_slice(&wire[7..]), + ]; + let body = EdgeBody::stream(stream::iter(chunks)); + let (returned, decoded) = inspect_response(response(body, Some("gzip"), "1")).await; + assert_eq!(decoded.as_deref(), Some(plain.as_slice())); + assert_eq!(returned.headers()[header::CONTENT_LENGTH], "1"); + assert_eq!(collect(returned.into_body()).await, wire); + } + + #[tokio::test] + async fn unsupported_and_broken_encodings_do_not_change_bytes() { + for (encoding, bytes) in [ + ("br", b"not gzip".as_slice()), + ("gzip", b"broken gzip".as_slice()), + ("gzip, br", b"not gzip".as_slice()), + ] { + let (returned, decoded) = inspect_response(response( + EdgeBody::from(bytes.to_vec()), + Some(encoding), + "0", + )) + .await; + assert!(decoded.is_none()); + assert_eq!(returned.headers()[header::CONTENT_ENCODING], encoding); + assert_eq!(returned.headers()[header::CONTENT_LENGTH], "0"); + assert_eq!(collect(returned.into_body()).await, bytes); + } + } + + #[tokio::test] + async fn streaming_oversized_chunk_replays_prefix_and_tail_without_copy() { + let prefix = Bytes::from_static(b"prefix"); + let oversized = Bytes::from(vec![b'X'; CAPTURE_WIRE_LIMIT + 1]); + let pointer = oversized.as_ptr(); + let tail = Bytes::from_static(b"tail"); + let body = EdgeBody::stream(stream::iter(vec![ + prefix.clone(), + oversized.clone(), + tail.clone(), + ])); + let (returned, decoded) = inspect_response(response(body, None, "2")).await; + assert!(decoded.is_none()); + assert_eq!(returned.headers()[header::CONTENT_LENGTH], "2"); + let EdgeBody::Stream(mut replay) = returned.into_body() else { + panic!("expected stream"); + }; + assert_eq!( + replay + .next() + .await + .expect("should replay prefix") + .expect("should read prefix"), + prefix + ); + let replayed_large = replay + .next() + .await + .expect("should replay oversized chunk") + .expect("should read oversized chunk"); + assert_eq!(replayed_large.as_ptr(), pointer); + assert_eq!(replayed_large, oversized); + assert_eq!( + replay + .next() + .await + .expect("should replay tail") + .expect("should read tail"), + tail + ); + assert!(replay.next().await.is_none()); + } + + #[tokio::test] + async fn many_single_byte_chunks_use_one_prefix_and_return_original_bytes() { + let chunks = (0..CAPTURE_WIRE_LIMIT).map(|_| Bytes::from_static(b"x")); + let body = EdgeBody::stream(stream::iter(chunks)); + let (returned, decoded) = inspect_response(response(body, None, "0")).await; + let expected = vec![b'x'; CAPTURE_WIRE_LIMIT]; + assert_eq!(decoded.as_deref(), Some(expected.as_slice())); + assert!(matches!(returned.body(), EdgeBody::Once(_))); + assert_eq!(collect(returned.into_body()).await, expected); + } + + #[tokio::test] + async fn streaming_total_overflow_at_plus_one_replays_every_byte() { + let first = Bytes::from(vec![b'X'; CAPTURE_WIRE_LIMIT]); + let tail = Bytes::from_static(b"Y"); + let body = EdgeBody::stream(stream::iter(vec![first.clone(), tail.clone()])); + let (returned, decoded) = inspect_response(response(body, None, "0")).await; + assert!(decoded.is_none()); + let EdgeBody::Stream(mut replay) = returned.into_body() else { + panic!("expected stream"); + }; + assert_eq!( + replay + .next() + .await + .expect("should replay prefix") + .expect("should read prefix"), + first + ); + assert_eq!( + replay + .next() + .await + .expect("should replay overflowing byte") + .expect("should read overflowing byte"), + tail + ); + assert!(replay.next().await.is_none()); + } + + #[tokio::test] + async fn streaming_read_error_is_replayed_in_order_with_remaining_tail() { + let body = EdgeBody::from_stream(stream::iter(vec![ + Ok(Bytes::from_static(b"before")), + Err(std::io::Error::other("upstream read failed")), + Ok(Bytes::from_static(b"after")), + ])); + let (returned, decoded) = inspect_response(response(body, None, "0")).await; + assert!(decoded.is_none()); + let EdgeBody::Stream(mut replay) = returned.into_body() else { + panic!("expected stream"); + }; + assert_eq!( + replay + .next() + .await + .expect("should replay prefix") + .expect("should read prefix"), + "before" + ); + assert!( + replay + .next() + .await + .expect("should replay upstream error") + .expect_err("should retain upstream error") + .to_string() + .contains("upstream read failed") + ); + assert_eq!( + replay + .next() + .await + .expect("should replay tail") + .expect("should read tail"), + "after" + ); + assert!(replay.next().await.is_none()); + } + + #[tokio::test] + async fn truncated_gzip_and_trailing_garbage_skip_capture() { + let mut truncated = gzip(b"test"); + truncated.pop(); + let mut trailing = gzip(b"test"); + trailing.extend_from_slice(b"garbage"); + for wire in [truncated, trailing] { + let (returned, decoded) = + inspect_response(response(EdgeBody::from(wire.clone()), Some("gzip"), "0")).await; + assert!(decoded.is_none()); + assert_eq!(collect(returned.into_body()).await, wire); + } + } +} diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index 6b301bf93..bb1a875e8 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -639,6 +639,7 @@ mod tests { ad_bids_state: Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, }); let pipeline_config = PipelineConfig { diff --git a/crates/trusted-server-core/src/integrations/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs index 002182bd1..46f1a704f 100644 --- a/crates/trusted-server-core/src/integrations/lockr.rs +++ b/crates/trusted-server-core/src/integrations/lockr.rs @@ -10,13 +10,18 @@ use std::sync::Arc; use async_trait::async_trait; +use bytes::Bytes; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::header::{self, HeaderMap, HeaderValue}; use http::{Method, StatusCode}; use serde::Deserialize; +use serde_json::Value; use validator::Validate; +use crate::ec::identity::{IdentityConsentSignals, IdentityObservation, IdentityOutcome}; +use crate::integrations::identity::{IdentityCapture, IdentityCaptureInput, IdentityRequestBody}; + use crate::constants::INTERNAL_HEADERS; use crate::error::TrustedServerError; use crate::integrations::{ @@ -25,10 +30,32 @@ use crate::integrations::{ UPSTREAM_SDK_MAX_RESPONSE_BYTES, collect_body_bounded, collect_response_bounded, ensure_integration_backend, }; +use crate::openrtb::Eid; use crate::platform::{PlatformHttpRequest, RuntimeServices}; use crate::settings::{IntegrationConfig, Settings}; const LOCKR_INTEGRATION_ID: &str = "lockr"; +const ID5_SOURCE: &str = "id5-sync.com"; +const LOCKR_API_PREFIX: &str = "/integrations/lockr/api"; +const LOCKR_TOKEN_PATHS: [&str; 4] = [ + "/publisher/app/v2/identityLockr/page-view", + "/publisher/app/v2/identityLockr/generate-tokens", + "/publisher/app/v2/identityLockr/refresh-tokens", + "/publisher/app/v2/identityLockr/sync-no-hem-ids", +]; +const LOCKR_REVOKE_PATH: &str = "/publisher/app/v2/identityLockr/revoke-consent"; + +/// Reconstructs Lockr's fixed ID5 provenance from the persisted acquisition writer. +/// +/// Source or configured ownership alone cannot establish Lockr origin. This +/// mapping remains valid for stored captures even when new capture is disabled. +pub(crate) fn apply_eid_provenance(eid: &mut Eid, writer: Option<&str>) { + if eid.source == ID5_SOURCE && writer == Some(LOCKR_INTEGRATION_ID) { + eid.inserter = Some("Lockr-For-Publishers".to_owned()); + eid.matcher = Some(String::new()); + eid.mm = Some(2); + } +} /// Configuration for Lockr integration. #[derive(Debug, Deserialize, Validate)] @@ -37,6 +64,14 @@ pub struct LockrConfig { #[serde(default = "default_enabled")] pub enabled: bool, + /// Kill switch for token observation; does not remove the source claim. + #[serde(default = "default_enabled")] + pub capture_identity: bool, + + /// Separately approved global EC withdrawal from successful revoke responses. + #[serde(default)] + pub capture_global_withdrawal: bool, + /// Lockr app ID (from meta tag lockr-signin-app_id) #[validate(length(min = 1))] pub app_id: String, @@ -111,7 +146,7 @@ impl LockrIntegration { services: &RuntimeServices, ) -> Result, Report> { let sdk_url = &self.config.sdk_url; - log::info!("Fetching Lockr SDK from {}", sdk_url); + log::info!("Fetching Lockr SDK"); // TODO: Check KV store cache first (future enhancement) @@ -121,19 +156,16 @@ impl LockrIntegration { .header(header::USER_AGENT, "TrustedServer/1.0") .header(header::ACCEPT, "application/javascript, */*") .body(EdgeBody::empty()) - .change_context(Self::error("Failed to build Lockr SDK request"))?; + .map_err(|_| Report::new(Self::error("Failed to build Lockr SDK request")))?; let backend_name = Self::backend_name_for_url(services, sdk_url) - .change_context(Self::error("Failed to determine backend for SDK fetch"))?; + .map_err(|_| Report::new(Self::error("Failed to determine backend for SDK fetch")))?; let lockr_response = services .http_client() .send(PlatformHttpRequest::new(lockr_req, backend_name)) .await - .change_context(Self::error(format!( - "Failed to fetch Lockr SDK from {}", - sdk_url - )))? + .map_err(|_| Report::new(Self::error("Failed to fetch Lockr SDK")))? .response; if !lockr_response.status().is_success() { @@ -170,7 +202,6 @@ impl LockrIntegration { ) .header("X-Lockr-SDK-Proxy", "true") .header("X-Lockr-SDK-Mode", "trust-server") - .header("X-SDK-Source", sdk_url) .body(EdgeBody::from(sdk_body)) .change_context(Self::error("Failed to build Lockr SDK response")) } @@ -186,13 +217,13 @@ impl LockrIntegration { let original_path = parts.uri.path().to_string(); let method = parts.method.clone(); - log::info!("Proxying Lockr API request: {} {}", method, original_path); + log::info!("Proxying Lockr API request: {}", method); // Extract path after /integrations/lockr/api and pass through directly. // This allows the Lockr SDK to use any API endpoint without hardcoded mappings. let target_path = original_path .strip_prefix("/integrations/lockr/api") - .ok_or_else(|| Self::error(format!("Invalid Lockr API path: {}", original_path)))?; + .ok_or_else(|| Self::error("Invalid Lockr API path"))?; let query = parts .uri @@ -201,39 +232,43 @@ impl LockrIntegration { .unwrap_or_default(); let target_url = format!("{}{}{}", self.config.api_endpoint, target_path, query); - log::info!("Forwarding to Lockr API: {}", target_url); - - let request_body = if method == Method::POST { - let bytes = + let capture_selected = self.matches(&method, &original_path); + // The same allocation backs the forwarded POST and request-local capture signals. + // Never read the browser request a second time for capture. + let (request_body, capture_body) = if method == Method::POST { + let bytes = Bytes::from( collect_body_bounded(body, INTEGRATION_MAX_BODY_BYTES, LOCKR_INTEGRATION_ID) - .await?; - EdgeBody::from(bytes) + .await?, + ); + let capture_body = capture_selected.then(|| IdentityRequestBody(bytes.clone())); + (EdgeBody::from_bytes(bytes), capture_body) } else { - EdgeBody::empty() + (EdgeBody::empty(), None) }; let mut target_req = http::Request::builder() .method(method.clone()) .uri(&target_url) .body(request_body) - .change_context(Self::error("Failed to build Lockr API proxy request"))?; + .map_err(|_| Report::new(Self::error("Failed to build Lockr API proxy request")))?; self.copy_request_headers(&parts.headers, target_req.headers_mut())?; let backend_name = Self::backend_name_for_url(services, &self.config.api_endpoint) - .change_context(Self::error("Failed to determine backend for API proxy"))?; + .map_err(|_| Report::new(Self::error("Failed to determine backend for API proxy")))?; let response = services .http_client() .send(PlatformHttpRequest::new(target_req, backend_name)) .await - .change_context(Self::error(format!( - "Failed to forward request to {}", - target_url - )))? + .map_err(|_| Report::new(Self::error("Failed to forward Lockr API request")))? .response; log::info!("Lockr API responded with status {}", response.status()); + let mut response = response; + if let Some(body) = capture_body { + response.extensions_mut().insert(body); + } Ok(response) } @@ -280,8 +315,8 @@ impl LockrIntegration { Ok(value) => { to.insert(header::ORIGIN, value); } - Err(error) => { - log::warn!("Skipping invalid Lockr origin header value '{origin}': {error}"); + Err(_) => { + log::warn!("Skipping invalid Lockr origin header"); } } } @@ -304,6 +339,95 @@ impl LockrIntegration { } } +// Only the confirmed ID5 token schema is recognized. Unknown keys do not become +// source claims, even when an EID in the same response names a registered source. +fn id5_token(token: &Value, ids: Option<&Value>, now: Option) -> Option { + if token.get("key_name")?.as_str()? != "id5id" { + return None; + } + let encoded = token.get("advertising_token")?.as_str()?; + // A malformed percent escape must not silently become a different UID. + let bytes = encoded.as_bytes(); + for (i, byte) in bytes.iter().enumerate() { + if *byte == b'%' + && (!bytes.get(i + 1).is_some_and(u8::is_ascii_hexdigit) + || !bytes.get(i + 2).is_some_and(u8::is_ascii_hexdigit)) + { + return None; + } + } + let decoded = urlencoding::decode(encoded).ok()?; + let uid = serde_json::from_str::(&decoded) + .ok()? + .get("universal_uid")? + .as_str()? + .to_string(); + if uid.is_empty() || uid.len() > 512 { + return None; + } + + // An associated EID is corroboration, not a source of new authority. + if let Some(ids) = ids + && let Some(provider) = ids.as_object()?.get("id5id") + { + let eid = provider.get("eid")?; + if eid.get("source")?.as_str()? != ID5_SOURCE { + return None; + } + let uids = eid.get("uids")?.as_array()?; + if uids.len() != 1 + || uids[0].get("id")?.as_str()? != uid + || uids[0].get("atype")?.as_u64()? != 1 + { + return None; + } + } + + let expires_at = match token.get("identity_expires") { + None => None, + Some(value) => { + let millis = value.as_u64()?; + let seconds = millis / 1000; + // Without a working clock we cannot prove that a dated token is live. + if seconds <= now? { + return None; + } + Some(seconds) + } + }; + Some(IdentityOutcome::Issued { + source: ID5_SOURCE.to_string(), + uid, + expires_at, + }) +} + +fn request_consent(body: &[u8]) -> IdentityConsentSignals { + let mut signals = IdentityConsentSignals::default(); + let Ok(value) = serde_json::from_slice::(body) else { + signals.malformed = true; + return signals; + }; + let Some(object) = value.as_object() else { + signals.malformed = true; + return signals; + }; + for (field, target) in [ + ("consentString", &mut signals.tcf), + ("gppString", &mut signals.gpp), + ("ccpaString", &mut signals.usp), + ] { + if let Some(value) = object.get(field) { + match value.as_str() { + Some("") => {} + Some(text) => *target = Some(text.to_string()), + None => signals.malformed = true, + } + } + } + signals +} + fn build(settings: &Settings) -> Result>, Report> { let Some(config) = settings.integration_config::(LOCKR_INTEGRATION_ID)? else { return Ok(None); @@ -339,6 +463,7 @@ pub fn register( Ok(Some( IntegrationRegistration::builder(LOCKR_INTEGRATION_ID) .with_proxy(integration.clone()) + .with_identity_capture(integration.clone()) .with_attribute_rewriter(integration) .build(), )) @@ -367,14 +492,89 @@ impl IntegrationProxy for LockrIntegration { } else if path.starts_with("/integrations/lockr/api/") { self.handle_api_proxy(settings, services, req).await } else { - Err(Report::new(Self::error(format!( - "Unknown Lockr route: {}", - path - )))) + Err(Report::new(Self::error("Unknown Lockr route"))) } } } +impl IdentityCapture for LockrIntegration { + fn sources(&self) -> &'static [&'static str] { + &[ID5_SOURCE] + } + + // The trait's enabled switch controls capture, not the entire integration proxy. + #[allow(clippy::misnamed_getters)] + fn enabled(&self) -> bool { + self.config.capture_identity + } + + fn matches(&self, method: &Method, path: &str) -> bool { + if *method != Method::POST || !self.enabled() { + return false; + } + let Some(path) = path.strip_prefix(LOCKR_API_PREFIX) else { + return false; + }; + LOCKR_TOKEN_PATHS.contains(&path) + || (self.config.capture_global_withdrawal && path == LOCKR_REVOKE_PATH) + } + + fn observe(&self, input: IdentityCaptureInput<'_>) -> Option { + if !self.config.capture_identity || !input.status.is_success() { + return None; + } + let response: Value = serde_json::from_slice(input.decoded_body).ok()?; + if !response.get("success")?.as_bool()? { + return None; + } + let path = input.path.strip_prefix(LOCKR_API_PREFIX)?; + if path == LOCKR_REVOKE_PATH { + return self + .config + .capture_global_withdrawal + .then(|| IdentityObservation { + outcomes: vec![IdentityOutcome::ConsentWithdrawn], + consent: IdentityConsentSignals::default(), + }); + } + if !LOCKR_TOKEN_PATHS.contains(&path) { + return None; + } + let tokens = match response.get("aimTokens") { + None => &[][..], + Some(tokens) => tokens.as_array()?.as_slice(), + }; + let ids = response.get("ids"); + if ids.is_some_and(|value| !value.is_object()) { + return None; + } + let now = crate::ec::checked_current_timestamp(); + let outcomes = tokens + .iter() + .filter_map(|token| id5_token(token, ids, now)) + .collect::>(); + // The provider has not defined ordering between overlapping tokens. + // Never choose an arbitrary winner for this one-source mapping. + if outcomes.len() > 1 { + return None; + } + let consent = request_consent(input.request_body); + // Bad request JSON or signal shapes may not acquire an ID, even if the + // upstream returned one. Revocation above is deliberately independent. + if consent.malformed { + return None; + } + Some(IdentityObservation { + outcomes: if outcomes.is_empty() { + vec![IdentityOutcome::NoChange] + } else { + outcomes + }, + consent, + }) + } +} + impl IntegrationAttributeRewriter for LockrIntegration { fn integration_id(&self) -> &'static str { LOCKR_INTEGRATION_ID @@ -443,6 +643,8 @@ mod tests { fn test_config() -> LockrConfig { LockrConfig { enabled: true, + capture_identity: true, + capture_global_withdrawal: false, app_id: "test-app-id".to_string(), api_endpoint: default_api_endpoint(), sdk_url: default_sdk_url(), @@ -609,6 +811,7 @@ mod tests { let response = futures::executor::block_on(integration.handle(&settings, &services, req)) .expect("should proxy request"); assert_eq!(response.status(), http::StatusCode::OK, "should return OK"); + assert!(response.extensions().get::().is_none()); let bodies = stub.recorded_request_bodies(); assert_eq!( @@ -699,6 +902,319 @@ mod tests { ); } + const TOKEN_PATH: &str = "/integrations/lockr/api/publisher/app/v2/identityLockr/page-view"; + const REVOKE_PATH: &str = + "/integrations/lockr/api/publisher/app/v2/identityLockr/revoke-consent"; + + fn sample_token() -> Value { + json!({ + "success": true, + "aimTokens": [{ + "key_name": "id5id", + "advertising_token": "%7B%22universal_uid%22%3A%22ID5*synthetic%22%7D", + "identity_expires": 4102444800000_u64 + }] + }) + } + + fn observed( + integration: &LockrIntegration, + path: &str, + response: &Value, + request: &[u8], + ) -> Option { + let decoded = serde_json::to_vec(response).expect("should serialize fixture"); + integration.observe(IdentityCaptureInput { + path, + request_body: request, + status: StatusCode::OK, + decoded_body: &decoded, + }) + } + + #[test] + fn capture_route_and_kill_switch() { + let integration = LockrIntegration::new(test_config()); + for route in LOCKR_TOKEN_PATHS { + let path = format!("{LOCKR_API_PREFIX}{route}"); + assert!(integration.matches(&Method::POST, &path)); + assert!(!integration.matches(&Method::GET, &path)); + assert!(!integration.matches(&Method::POST, &format!("{path}/extra"))); + } + assert!(!integration.matches(&Method::POST, REVOKE_PATH)); + assert!(!integration.matches(&Method::POST, "/integrations/lockr/sdk")); + assert!(!integration.matches( + &Method::POST, + "/integrations/lockr/api/publisher/app/v2/identityLockr/settings" + )); + let killed = LockrIntegration::new(LockrConfig { + capture_identity: false, + ..test_config() + }); + assert_eq!(killed.sources(), &[ID5_SOURCE]); + assert!(!killed.enabled()); + assert!(!killed.matches(&Method::POST, TOKEN_PATH)); + } + + #[test] + fn id5_capture_validates_associated_eid_expiry_and_uid() { + let integration = LockrIntegration::new(test_config()); + let request = br#"{"consentString":"","gppString":"","ccpaString":""}"#; + let result = observed(&integration, TOKEN_PATH, &sample_token(), request) + .expect("should capture synthetic ID5 token"); + assert!( + matches!(&result.outcomes[..], [IdentityOutcome::Issued { source, uid, expires_at: Some(4102444800) }] if source == ID5_SOURCE && uid == "ID5*synthetic") + ); + assert!(result.consent.tcf.is_none()); + let mut with_eid = sample_token(); + with_eid["ids"] = json!({"id5id":{"eid":{"source":"id5-sync.com","uids":[{"id":"ID5*synthetic","atype":1}]}}}); + assert!( + matches!( + &observed(&integration, TOKEN_PATH, &with_eid, request) + .expect("should capture matching EID") + .outcomes[..], + [IdentityOutcome::Issued { source, uid, expires_at: Some(4102444800) }] + if source == ID5_SOURCE && uid == "ID5*synthetic" + ), + "should issue the corroborated token, not a NoChange outcome" + ); + with_eid["ids"]["id5id"]["eid"]["uids"][0]["id"] = json!("different"); + assert!(matches!( + &observed(&integration, TOKEN_PATH, &with_eid, request) + .expect("should treat mismatched EID as no change") + .outcomes[..], + [IdentityOutcome::NoChange] + )); + let mut changed_expiry = sample_token(); + changed_expiry["aimTokens"][0]["identity_expires"] = json!(4102444801000_u64); + assert!(matches!( + &observed(&integration, TOKEN_PATH, &changed_expiry, request) + .expect("should observe extended expiry") + .outcomes[..], + [IdentityOutcome::Issued { uid, expires_at: Some(4102444801), .. }] if uid == "ID5*synthetic" + )); + let mut mismatch = sample_token(); + mismatch["ids"] = json!({"id5id":{"eid":{"source":"other.example","uids":[{"id":"ID5*synthetic","atype":1}]}}}); + assert!(matches!( + &observed(&integration, TOKEN_PATH, &mismatch, request) + .expect("should reject mismatched source") + .outcomes[..], + [IdentityOutcome::NoChange] + )); + let mut expired = sample_token(); + expired["aimTokens"][0]["identity_expires"] = json!(1000); + assert!(matches!( + &observed(&integration, TOKEN_PATH, &expired, request) + .expect("should reject expired token") + .outcomes[..], + [IdentityOutcome::NoChange] + )); + let mut missing = sample_token(); + missing["aimTokens"][0] + .as_object_mut() + .expect("should access fixture token") + .remove("identity_expires"); + assert!(matches!( + &observed(&integration, TOKEN_PATH, &missing, request) + .expect("should capture token without expiry") + .outcomes[..], + [IdentityOutcome::Issued { + expires_at: None, + .. + }] + )); + let mut unknown = sample_token(); + unknown["aimTokens"][0]["key_name"] = json!("liveramp"); + assert!(matches!( + &observed(&integration, TOKEN_PATH, &unknown, request) + .expect("should ignore unknown provider key") + .outcomes[..], + [IdentityOutcome::NoChange] + )); + let mut huge = sample_token(); + huge["aimTokens"][0]["advertising_token"] = json!( + urlencoding::encode(&json!({"universal_uid":"x".repeat(513)}).to_string()).to_string() + ); + assert!(matches!( + &observed(&integration, TOKEN_PATH, &huge, request) + .expect("should reject oversized UID") + .outcomes[..], + [IdentityOutcome::NoChange] + )); + } + + #[test] + fn associated_id5_eid_requires_a_valid_nested_eid() { + let integration = LockrIntegration::new(test_config()); + for associated in [ + json!({"source": ID5_SOURCE, "uids": [{"id": "ID5*synthetic", "atype": 1}]}), + json!({}), + json!({"eid": null}), + json!({"eid": {"source": ID5_SOURCE, "uids": []}}), + json!({"eid": {"source": ID5_SOURCE, "uids": [{"id": "ID5*synthetic", "atype": 3}]}}), + json!({"eid": {"source": ID5_SOURCE, "uids": [ + {"id": "ID5*synthetic", "atype": 1}, {"id": "another-uid", "atype": 1} + ]}}), + ] { + let mut response = sample_token(); + response["ids"] = json!({"id5id": associated}); + + assert!( + matches!( + &observed(&integration, TOKEN_PATH, &response, b"{}") + .expect("should preserve the response without acquiring an invalid EID") + .outcomes[..], + [IdentityOutcome::NoChange] + ), + "should reject malformed, unwrapped or inconsistent associated EIDs" + ); + } + } + + #[test] + fn acquisition_requires_valid_request_json_and_signal_shapes() { + let integration = LockrIntegration::new(test_config()); + assert!(observed(&integration, TOKEN_PATH, &sample_token(), b"{").is_none()); + assert!( + observed( + &integration, + TOKEN_PATH, + &sample_token(), + br#"{"gppString":4}"# + ) + .is_none() + ); + let signals = observed( + &integration, + TOKEN_PATH, + &sample_token(), + br#"{"consentString":"opt-out","gppString":"gpp","ccpaString":"usp"}"#, + ) + .expect("should parse populated consent signals") + .consent; + assert_eq!(signals.tcf.as_deref(), Some("opt-out")); + assert_eq!(signals.gpp.as_deref(), Some("gpp")); + assert_eq!(signals.usp.as_deref(), Some("usp")); + assert!( + observed( + &integration, + TOKEN_PATH, + &json!({"success":true,"aimTokens":[]}), + b"{}" + ) + .is_some() + ); + assert!( + observed( + &integration, + TOKEN_PATH, + &json!({"success":false,"aimTokens":[]}), + b"{}" + ) + .is_none() + ); + assert!( + observed( + &integration, + TOKEN_PATH, + &json!({"success":true,"aimTokens":{}}), + b"{}" + ) + .is_none() + ); + } + + #[test] + fn revoke_needs_explicit_global_approval_and_application_success() { + let integration = LockrIntegration::new(LockrConfig { + capture_global_withdrawal: true, + ..test_config() + }); + assert!(integration.matches(&Method::POST, REVOKE_PATH)); + assert!(matches!( + &observed(&integration, REVOKE_PATH, &json!({"success":true}), b"{") + .expect("should observe approved explicit withdrawal") + .outcomes[..], + [IdentityOutcome::ConsentWithdrawn] + )); + assert!(observed(&integration, REVOKE_PATH, &json!({"success":false}), b"{}").is_none()); + let default = LockrIntegration::new(test_config()); + assert!(observed(&default, REVOKE_PATH, &json!({"success":true}), b"{}").is_none()); + } + + #[test] + fn selected_proxy_reuses_forwarded_body_and_preserves_response() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"synthetic upstream".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let integration = LockrIntegration::new(test_config()); + let payload = br#"{"consentString":"synthetic"}"#; + let req = http::Request::builder() + .method(Method::POST) + .uri(format!("https://publisher.example{TOKEN_PATH}?id=private")) + .header(header::COOKIE, "session=private") + .header(header::AUTHORIZATION, "Bearer private") + .body(EdgeBody::from(payload.to_vec())) + .expect("should build selected proxy request"); + let response = futures::executor::block_on(integration.handle( + &create_test_settings(), + &services, + req, + )) + .expect("should forward selected proxy request"); + assert_eq!(stub.recorded_request_bodies(), vec![payload.to_vec()]); + let headers = stub.recorded_request_headers(); + assert!( + !headers[0] + .iter() + .any(|(key, _)| key == "cookie" || key == "authorization") + ); + assert_eq!( + response + .extensions() + .get::() + .expect("should attach shared forwarding bytes") + .0 + .as_ref(), + payload + ); + assert!( + matches!(response.into_body(), EdgeBody::Once(bytes) if bytes.as_ref() == b"synthetic upstream") + ); + } + + #[test] + fn malformed_or_failed_upstream_does_not_acquire_or_revoke() { + let integration = LockrIntegration::new(LockrConfig { + capture_global_withdrawal: true, + ..test_config() + }); + for path in [TOKEN_PATH, REVOKE_PATH] { + assert!( + integration + .observe(IdentityCaptureInput { + path, + request_body: b"{}", + status: StatusCode::BAD_REQUEST, + decoded_body: br#"{"success":true}"#, + }) + .is_none() + ); + assert!( + integration + .observe(IdentityCaptureInput { + path, + request_body: b"{}", + status: StatusCode::OK, + decoded_body: b"{", + }) + .is_none() + ); + } + } + #[test] fn disabled_invalid_config_does_not_error() { let mut settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 820e2c24b..75699808e 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -18,6 +18,7 @@ pub mod didomi; pub mod google_tag_manager; pub mod gpt; pub mod gpt_diagnostics; +pub mod identity; pub mod js_asset_proxy; pub mod lockr; pub mod nextjs; @@ -300,6 +301,8 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] { id: "testlight", build: testlight::register, }, + // Next.js must precede GTM: GTM reads the Flight-protection snapshot + // that the Next.js script stage records for the same fragment. IntegrationBuilder { id: "nextjs", build: nextjs::register, diff --git a/crates/trusted-server-core/src/integrations/nextjs/mod.rs b/crates/trusted-server-core/src/integrations/nextjs/mod.rs index f6f36f67c..4b6292fb1 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/mod.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/mod.rs @@ -13,6 +13,7 @@ const NEXTJS_INTEGRATION_ID: &str = "nextjs"; mod rsc; mod rsc_placeholders; mod rsc_stream; +mod script_lexer; mod script_rewriter; mod shared; @@ -63,6 +64,21 @@ pub(super) fn configuration_error(message: impl Into) -> Report bool { + state + .get::>(NEXTJS_INTEGRATION_ID) + .is_some_and(|state| { + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .current_fragment_protected + }) +} + /// Register the Next.js integration when enabled. /// /// # Errors @@ -91,8 +107,20 @@ pub fn register( // then substitute them through the bounded output stream processor. let placeholders = Arc::new(NextJsRscPlaceholderRewriter::new(config.clone())); - let stream_processor = Arc::new(NextJsRscStreamProcessorFactory::new(config.clone())); - + let gtm_transform = settings + .integration_config::( + "google_tag_manager", + )? + .map(|_| { + crate::integrations::google_tag_manager::rewrite_gtm_rsc_span as fn(&str) -> String + }); + let stream_processor = Arc::new(NextJsRscStreamProcessorFactory::new( + config.clone(), + gtm_transform, + )); + + // Pages data must precede Flight capture: it sets the one-shot discriminator + // that keeps JSON strings from being treated as executable Flight source. let builder = IntegrationRegistration::builder(NEXTJS_INTEGRATION_ID) .with_script_rewriter(structured) .with_script_rewriter(placeholders) @@ -115,10 +143,13 @@ mod tests { use super::*; use crate::html_processor::{HtmlProcessorConfig, create_html_processor}; use crate::integrations::IntegrationRegistry; - use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; + use crate::streaming_processor::{ + Compression, PipelineConfig, StreamProcessor as _, StreamingPipeline, + }; use crate::test_support::tests::create_test_settings; + use flate2::{read::GzDecoder, write::GzEncoder}; use serde_json::json; - use std::io::Cursor; + use std::io::{Cursor, Read as _, Write as _}; fn config_from_settings( settings: &Settings, @@ -133,6 +164,1253 @@ mod tests { ) } + fn mixed_output(html: &str, chunk_size: usize) -> String { + mixed_output_with_budget(html, chunk_size, 10 * 1024 * 1024, 16 * 1024 * 1024) + } + + fn mixed_config(group_limit: usize, script_limit: usize) -> HtmlProcessorConfig { + let mut settings = create_test_settings(); + for (id, config) in [ + ( + "nextjs", + json!({"enabled": true, "max_combined_payload_bytes": group_limit}), + ), + ( + "google_tag_manager", + json!({"enabled": true, "container_id": "GTM-MIX1"}), + ), + ] { + settings + .integrations + .insert_config(id, &config) + .expect("should configure integration"); + } + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new(crate::auction::compile_auction_plan(&settings).expect("should compile plan")), + ) + .expect("should create registry"); + let mut config = config_from_settings(&settings, ®istry); + config.max_buffered_body_bytes = script_limit; + config + } + + fn mixed_output_with_budget( + html: &str, + chunk_size: usize, + group_limit: usize, + script_limit: usize, + ) -> String { + let config = mixed_config(group_limit, script_limit); + let mut pipeline = StreamingPipeline::new( + PipelineConfig { + input_compression: Compression::None, + output_compression: Compression::None, + chunk_size, + }, + create_html_processor(config), + ); + let mut output = Vec::new(); + pipeline + .process(Cursor::new(html.as_bytes()), &mut output) + .expect("should consume complete mixed response"); + String::from_utf8(output).expect("should emit UTF-8") + } + + fn mixed_output_with_compression( + html: &str, + compression: Compression, + chunk_size: usize, + ) -> String { + let input = if compression == Compression::Gzip { + let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(html.as_bytes()) + .expect("should encode gzip input"); + encoder.finish().expect("should finish gzip input") + } else { + html.as_bytes().to_vec() + }; + let mut pipeline = StreamingPipeline::new( + PipelineConfig { + input_compression: compression, + output_compression: compression, + chunk_size, + }, + create_html_processor(mixed_config(100000, 100000)), + ); + let mut output = Vec::new(); + pipeline + .process(Cursor::new(&input), &mut output) + .expect("should consume complete mixed response"); + let mut decoded = Vec::new(); + if compression == Compression::Gzip { + GzDecoder::new(output.as_slice()) + .read_to_end(&mut decoded) + .expect("should decode gzip output"); + } else { + decoded = output; + } + String::from_utf8(decoded).expect("should emit UTF-8") + } + + #[test] + fn non_code_flight_spellings_preserve_source_and_later_flight_rewrites() { + let payload = "0:{\"url\":\"https://origin.example.com/page\"}\n"; + let flight = flight_html(&[payload]); + for prefix in [ + r#"const demo="self.__next_f.push(";"#, + r#"const demo='self.__next_f.push([1,"1:T7,example"])';"#, + r#"const demo="escaped\" self.__next_f.push(";"#, + "// self.__next_f.push(\n", + "/* self.__next_f.push( */", + "const demo=/self.__next_f.push()/;", + "if (ready) { work() } /self.__next_f.push()/;", + "function demo() { return 1 } /self.__next_f.push()/;", + "if (ready) { if (nested) { work() } } /self.__next_f.push()/;", + r#"if (ready) {} else /self.__next_f.push([2,"opaque"])/;"#, + r#"do /self.__next_f.push([2,"opaque"])/; while (ready);"#, + "const demo=`self.__next_f.push(`;", + "const demo=`outer ${`inner self.__next_f.push(`}`;", + r"const demo=`escaped\` self.__next_f.push( \${self.__next_f.push(}`;", + ] { + for split in 0..=prefix.len() { + let mut processor = create_html_processor(mixed_config(10000, 10000)); + let first = format!("{flight}", + &prefix[split..] + ); + let mut bytes = processor + .process_chunk(first.as_bytes(), false) + .expect("should accept non-code prefix"); + bytes.extend( + processor + .process_chunk(last.as_bytes(), true) + .expect("should complete ordinary and Flight scripts"), + ); + let output = String::from_utf8(bytes).expect("should emit UTF-8"); + assert!( + output.contains(&format!("")), + "should conservatively retain GTM beside inert Flight at split {split}: {output}" + ); + assert!( + output.contains(r#"\"url\":\"https://test.example.com/page\""#), + "should still rewrite later real Flight at split {split}: {output}" + ); + assert!( + !output.contains("__ts_rsc_"), + "should resolve every placeholder" + ); + } + } + } + + #[test] + fn lexical_filter_captures_only_executable_pushes_in_the_same_script() { + let fake = r#"const demo='self.__next_f.push([1,"0:{\"url\":\"https://origin.example.com/fake\"}\n"])';"#; + let push = r#"self.__next_f.push([1,"0:{\"url\":\"https://origin.example.com/page\"}\n"])"#; + let rewritten_push = push.replace("origin.example.com", "test.example.com"); + for (source, expected) in [ + ( + format!("{fake}{push};/* self.__next_f.push( */"), + format!("{fake}{rewritten_push};/* self.__next_f.push( */"), + ), + ( + format!("if (ready) {{ work() }} /self.__next_f.push()/;{push};"), + format!("if (ready) {{ work() }} /self.__next_f.push()/;{rewritten_push};"), + ), + ( + format!( + "const out=`text self.__next_f.push( ${{ {{a: {push}, b: `nested ${{{push}}}`}} }} tail`;" + ), + format!( + "const out=`text self.__next_f.push( ${{ {{a: {rewritten_push}, b: `nested ${{{rewritten_push}}}`}} }} tail`;" + ), + ), + ( + format!( + r#"const re=/["'`/]/;if (ready) /["'`/]/.test(input);const ratio=i++ / 2;const value=obj.return / 2;{push};"# + ), + format!( + r#"const re=/["'`/]/;if (ready) /["'`/]/.test(input);const ratio=i++ / 2;const value=obj.return / 2;{rewritten_push};"# + ), + ), + ] { + for split in 0..=source.len() { + let mut processor = create_html_processor(mixed_config(10000, 10000)); + let first = format!("", &source[split..]); + let mut bytes = processor + .process_chunk(first.as_bytes(), false) + .expect("should accept script prefix"); + bytes.extend( + processor + .process_chunk(last.as_bytes(), true) + .expect("should complete executable Flight"), + ); + assert_eq!( + String::from_utf8(bytes).expect("should emit UTF-8"), + format!(""), + "should rewrite only executable calls at split {split}" + ); + } + } + } + + #[test] + fn brace_slash_division_keeps_flight_lengths_and_both_rewrites() { + let data = json!({"url": "https://origin.example.com/page", "tag": "http://www.googletagmanager.com/gtm.js?id=GTM-MIX1", "text": "é😀"}); + let body = data.to_string(); + let payload = format!("1:T{:x},{body}", body.len()); + let push = format!( + "self.__next_f.push([1,{}])", + serde_json::to_string(&payload).expect("should encode Flight payload") + ); + let mut expected = data; + expected["url"] = json!("https://test.example.com/page"); + expected["tag"] = json!("/integrations/google_tag_manager/gtm.js?id=GTM-MIX1"); + for prefix in [ + "const n = {} / 2;", + "const n = {nested: {}} / 2;", + "const n = function() {} / 2;", + "const n = function named() { return 1 } / 2;", + "const n = class { method() { return 1 } } / 2;", + "const n = class extends function() {} {} / 2;", + "const n = class extends function Base() {} {} / 2;", + "const n = class extends (function() {}) {} / 2;", + ] { + let source = format!("{prefix}{push}"); + for split in (0..=source.len()).filter(|split| source.is_char_boundary(*split)) { + let mut processor = create_html_processor(mixed_config(10000, 10000)); + let first = format!("", &source[split..]); + let mut bytes = processor + .process_chunk(first.as_bytes(), false) + .expect("should accept division prefix"); + bytes.extend( + processor + .process_chunk(last.as_bytes(), true) + .expect("should complete Flight after division"), + ); + let output = String::from_utf8(bytes).expect("should emit UTF-8"); + assert!(output.starts_with(&format!(""); + for compression in [Compression::None, Compression::Gzip] { + for chunk_size in [32, 1000, 8192] { + let output = mixed_output_with_compression(&html, compression, chunk_size); + let payloads = flight_payloads(&output); + assert_eq!( + payloads.len(), + 2, + "should preserve compressed Flight pushes" + ); + for payload in payloads { + assert_mixed_t_model(&payload, &expected); + } + assert!(!output.contains("__ts_rsc_")); + } + } + } + } + + #[test] + fn lexer_false_negatives_preserve_raw_flight_and_later_rewrites() { + let data = json!({"url": "https://origin.example.com/page", "tag": "http://www.googletagmanager.com/gtm.js?id=GTM-MIX1", "text": "é😀"}); + let body = data.to_string(); + let payload = format!("1:T{:x},{body}", body.len()); + let push = format!( + "self.__next_f.push([1,{}]);", + serde_json::to_string(&payload).expect("should encode Flight payload") + ); + let mut rewritten = data; + rewritten["url"] = json!("https://test.example.com/page"); + rewritten["tag"] = json!("/integrations/google_tag_manager/gtm.js?id=GTM-MIX1"); + for prefix in [ + "foo: { bar() } /'/.test(s);", + "switch (k) { case 1: {} /'/.test(s) }", + "const f = () => {}\n/'/.test(s);", + "const o = {while: (a) / 2, b: '/'};", + "for (const m of /'/g.exec(s) || []) {}", + ] { + // Exercise both an entirely missed script and a missed call after + // a captured one. Capturing a safe payload must not unprotect raw + // Flight elsewhere in the same parser fragment. + for leading in ["", push.as_str()] { + let source = format!("{leading}{prefix}{push}"); + let assert_output = |output: &str| { + let payloads = flight_payloads(output); + let missed = usize::from(!leading.is_empty()); + assert_eq!(payloads.len(), missed + 2, "should emit each push once"); + assert_eq!( + payloads[missed], payload, + "should retain missed Flight bytes and lengths: {prefix}" + ); + if missed != 0 { + assert_mixed_t_model(&payloads[0], &rewritten); + } + assert_mixed_t_model(&payloads[missed + 1], &rewritten); + assert!(!output.contains("__ts_rsc_"), "should resolve all captures"); + }; + for split in (0..=source.len()).filter(|split| source.is_char_boundary(*split)) { + let mut processor = create_html_processor(mixed_config(10000, 10000)); + let first = format!("", &source[split..]); + let mut bytes = processor + .process_chunk(first.as_bytes(), false) + .expect("should accept ambiguous JavaScript prefix"); + bytes.extend( + processor + .process_chunk(last.as_bytes(), true) + .expect("should preserve raw and finish later Flight"), + ); + assert_output(&String::from_utf8(bytes).expect("should emit UTF-8")); + } + let html = format!(""); + for compression in [Compression::None, Compression::Gzip] { + for chunk_size in [32, 1000, 8192] { + assert_output(&mixed_output_with_compression( + &html, + compression, + chunk_size, + )); + } + } + } + } + } + + #[test] + fn raw_flight_protection_survives_lexer_resynchronization_and_capture() { + let missed_body = "`http://www.googletagmanager.com/gtm.js`"; + let missed_payload = format!("1:T{:x},{missed_body}", missed_body.len()); + let missed_push = format!( + "self.__next_f.push([1,{}]);", + serde_json::to_string(&missed_payload).expect("should encode missed Flight") + ); + let data = json!({"url": "https://origin.example.com/page", "tag": "http://www.googletagmanager.com/gtm.js?id=GTM-MIX1"}); + let body = data.to_string(); + let push = format!( + "self.__next_f.push([1,{}]);", + serde_json::to_string(&format!("1:T{:x},{body}", body.len())) + .expect("should encode capturable Flight") + ); + let mut expected = data; + expected["url"] = json!("https://test.example.com/page"); + expected["tag"] = json!("/integrations/google_tag_manager/gtm.js?id=GTM-MIX1"); + let source = format!("foo: {{ bar() }} /'/.test(s);{missed_push} /'/.test(s);{push}"); + let assert_output = |output: &str, boundary: usize| { + let payloads = flight_payloads(output); + assert_eq!( + payloads.len(), + 3, + "should retain missed and captured pushes" + ); + assert_eq!( + payloads[0], missed_payload, + "should preserve raw continuation at boundary {boundary}" + ); + assert_mixed_t_model(&payloads[1], &expected); + assert_mixed_t_model(&payloads[2], &expected); + assert!( + !output.contains("__ts_rsc_"), + "should resolve safe captures" + ); + }; + for split in 0..=source.len() { + let mut processor = create_html_processor(mixed_config(10000, 10000)); + let first = format!("", &source[split..]); + let mut bytes = processor + .process_chunk(first.as_bytes(), false) + .expect("should accept raw Flight prefix"); + bytes.extend( + processor + .process_chunk(last.as_bytes(), true) + .expect("should finish raw continuation and safe captures"), + ); + assert_output(&String::from_utf8(bytes).expect("should emit UTF-8"), split); + } + let html = format!(""); + for compression in [Compression::None, Compression::Gzip] { + for chunk_size in [32, 1000, 8192] { + assert_output( + &mixed_output_with_compression(&html, compression, chunk_size), + chunk_size, + ); + } + } + // A qualified head can be observed but not buffered when an earlier + // unrelated property makes the tentative receiver claim fail. + let body_start = missed_push.find('`').expect("should locate raw T body"); + let first = format!( + "", &continuation[split..]); + bytes.extend( + processor + .process_chunk(last.as_bytes(), true) + .expect("should finish later safe captures"), + ); + assert_output(&String::from_utf8(bytes).expect("should emit UTF-8"), split); + } + } + + #[test] + fn mixed_next_data_flight_mentions_do_not_claim_json_or_hide_gtm() { + for text in [ + "self.__next_f", + "window.__next_f", + "self.__next_f.push([1,content])", + ] { + for chunk in [32, 64, 1000, 8192] { + let data = json!({"props": {"pageProps": { + "text": text, "href": "https://origin.example.com/page", + "tag": "http://www.googletagmanager.com/gtm.js", "padding": "_".repeat(10000), + }}}); + let output = mixed_output( + &format!(r#""#), + chunk, + ); + let script = output + .split(r#"") + .next() + .expect("should close Pages script"); + let mut expected = data; + expected["props"]["pageProps"]["href"] = json!("https://test.example.com/page"); + expected["props"]["pageProps"]["tag"] = + json!("/integrations/google_tag_manager/gtm.js"); + assert_eq!( + serde_json::from_str::(script) + .expect("should preserve Pages JSON"), + expected, + "should not claim quoted Flight mention: {text}, chunk={chunk}" + ); + } + } + } + + #[test] + fn protected_flight_then_next_data_then_ordinary_gtm_resets_ownership() { + let payload = "1:Tffff,'http://www.googletagmanager.com/gtm.js'"; + let pages = json!({"text": "self.__next_f.push([1,content])", "href": "https://origin.example.com/page", "tag": "http://www.googletagmanager.com/gtm.js"}); + // Keep the ordinary URL's opening quote and >=6 marker bytes together; + // standalone GTM deliberately does not hold shorter tentative prefixes. + let alignment = " ".repeat(23); + let html = format!( + r#"{alignment}"#, + serde_json::to_string(payload).expect("should encode raw Flight") + ); + for chunk in [32, 8192] { + let output = mixed_output_with_budget(&html, chunk, 24, 10000); + assert_eq!( + flight_payloads(&output), + [payload], + "should preserve raw Flight" + ); + let source = output + .split(r#"") + .next() + .expect("should close Pages script"); + let mut expected = pages.clone(); + expected["href"] = json!("https://test.example.com/page"); + expected["tag"] = json!("/integrations/google_tag_manager/gtm.js"); + assert_eq!( + serde_json::from_str::(source).expect("should retain JSON"), + expected + ); + assert!( + output.ends_with( + "" + ), + "should rewrite later ordinary GTM at chunk={chunk}: {output}" + ); + } + } + + #[test] + fn push_head_after_tentative_reference_overflow_protects_raw_continuation() { + let payload = "1:Tffff,'http://www.googletagmanager.com/gtm.js'"; + let html = format!( + r#""#, + " ".repeat(512), + serde_json::to_string(payload).expect("should encode raw Flight") + ); + for chunk in [32, 8192] { + assert_eq!( + mixed_output_with_budget(&html, chunk, 100, 10000), + html, + "should protect a call confirmed after tentative probe overflow and preserve its later raw continuation" + ); + } + } + + #[test] + fn ordinary_next_f_reference_does_not_bypass_later_flight() { + let body = "http://www.googletagmanager.com/gtm.js"; + let payload = format!("1:T{:x},{body}", body.len()); + for padding in [0, 400] { + for chunk in [32, 8192] { + let html = format!( + "{}", + "_".repeat(padding), + flight_html(&[&payload]) + ); + let output = mixed_output_with_budget(&html, chunk, 100, 10000); + assert_eq!( + flight_payloads(&output), + [format!( + "1:T{:x},/integrations/google_tag_manager/gtm.js", + "/integrations/google_tag_manager/gtm.js".len() + )], + "should not bypass captured Flight because of a prior ordinary reference: padding={padding}, chunk={chunk}" + ); + } + } + } + + #[test] + fn trailing_next_f_references_preserve_current_and_later_flight() { + let data = json!({"url": "https://origin.example.com/page", "tag": "http://www.googletagmanager.com/gtm.js?id=GTM-MIX1"}); + let body = data.to_string(); + let payload = format!("1:T{:x},{body}", body.len()); + let push = format!( + "self.__next_f.push([1,{}])", + serde_json::to_string(&payload).expect("should encode Flight payload") + ); + let mut expected = data; + expected["url"] = json!("https://test.example.com/page"); + expected["tag"] = json!("/integrations/google_tag_manager/gtm.js?id=GTM-MIX1"); + for reference in ["self.__next_f", "window.__next_f", "foreign.__next_f"] { + let source = format!("{push};console.log({reference});"); + for split in 0..=source.len() { + let mut processor = create_html_processor(mixed_config(10000, 10000)); + let first = format!("", &source[split..]); + let mut bytes = processor + .process_chunk(first.as_bytes(), false) + .expect("should accept script prefix"); + bytes.extend( + processor + .process_chunk(last.as_bytes(), true) + .expect("should complete Flight with trailing reference"), + ); + let output = String::from_utf8(bytes).expect("should emit UTF-8"); + let payloads = flight_payloads(&output); + assert_eq!(payloads.len(), 2, "should emit both Flight pushes once"); + for payload in payloads { + assert_mixed_t_model(&payload, &expected); + } + assert!( + output.contains(&format!(";console.log({reference});")), + "should retain trailing reference at split {split}" + ); + assert!(!output.contains("__ts_rsc_"), "should resolve placeholders"); + } + let html = format!(""); + for compression in [Compression::None, Compression::Gzip] { + for chunk in [32, 1000, 8192] { + let output = mixed_output_with_compression(&html, compression, chunk); + let payloads = flight_payloads(&output); + assert_eq!(payloads.len(), 2, "should emit both compressed pushes once"); + for payload in payloads { + assert_mixed_t_model(&payload, &expected); + } + } + } + } + } + + #[test] + fn ordinary_next_f_reference_does_not_hide_gtm() { + let script = "var ref=self.__next_f;var tag='http://www.googletagmanager.com/gtm.js';"; + for chunk in [32, 64, 1000, 8192] { + let output = mixed_output(&format!(""), chunk); + assert_eq!( + output, + "", + "should not protect a non-push reference, chunk={chunk}" + ); + } + } + + #[test] + fn mixed_next_data_preserves_json_and_both_rewrites() { + for chunk in [32, 64, 1000, 8192] { + for marker in ["_", "__", "__n", "__ne", "__nex", "__next", "__next_"] { + for (url, routed) in [ + ( + "https://www.googletagmanager.com/gtm.js?id=GTM-MIX1", + "/integrations/google_tag_manager/gtm.js?id=GTM-MIX1", + ), + ( + "http://www.google-analytics.com/g/collect?v=2", + "/integrations/google_tag_manager/g/collect?v=2", + ), + ] { + for late in [false, true] { + let padding = json!(format!("{}{marker}", "_".repeat(10000))); + let source = if late { + format!( + r#"{{"padding":{padding},"href":"https://origin.example.com/page","tag":"{url}"}}"# + ) + } else { + format!( + r#"{{"tag":"{url}","padding":{padding},"href":"https://origin.example.com/page"}}"# + ) + }; + let html = format!( + r#"

suffix

"# + ); + let output = mixed_output(&html, chunk); + let script = output + .split(r#"") + .next() + .expect("should close script"); + let expected = json!({"padding": format!("{}{marker}", "_".repeat(10000)), "href": "https://test.example.com/page", "tag": routed}); + assert_eq!( + serde_json::from_str::(script) + .expect("should preserve valid JSON"), + expected, + "chunk={chunk}, marker={marker}, late={late}" + ); + assert!(output.ends_with(""), "should finish response"); + } + } + } + } + } + + fn flight_payloads(output: &str) -> Vec { + let mut payloads = Vec::new(); + for part in output.split("").next().expect("should close script"); + while let Some((start, end)) = shared::find_rsc_push_payload_range(script) { + payloads.push( + serde_json::from_str::(&script[start - 1..=end]) + .expect("should retain valid push string JSON"), + ); + script = &script[end + 1..]; + } + } + payloads + } + + fn assert_mixed_t_model(payload: &str, expected: &serde_json::Value) { + let (header, body) = payload.split_once(',').expect("should retain T record"); + assert_eq!( + usize::from_str_radix(&header[3..], 16).expect("should retain hex length"), + body.len(), + "should count decoded transformed T bytes" + ); + assert_eq!( + &serde_json::from_str::(body).expect("should retain model JSON"), + expected + ); + } + + #[test] + fn normal_flight_bootstrap_controls_allow_later_payload_rewriting() { + let controls = "(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])"; + let data = json!({"href": "https://origin.example.com/page", "tag": "http://www.googletagmanager.com/gtm.js", "ga": "//www.google-analytics.com/g/collect?v=2", "text": "é😀"}); + let body = data.to_string(); + let push = format!( + "self.__next_f.push([1,{}])", + serde_json::to_string(&format!("1:T{:x},{body}", body.len())) + .expect("should encode Flight") + ); + let mut expected = data; + expected["href"] = json!("https://test.example.com/page"); + expected["tag"] = json!("/integrations/google_tag_manager/gtm.js"); + expected["ga"] = json!("/integrations/google_tag_manager/g/collect?v=2"); + for together in [false, true] { + let scripts = if together { + format!("") + } else { + format!("") + }; + let html = format!("{scripts}

suffix

"); + for compression in [Compression::None, Compression::Gzip] { + for chunk_size in [32, 64, 1000, 8192] { + let output = mixed_output_with_compression(&html, compression, chunk_size); + assert!( + output.contains(controls), + "should preserve inert bootstrap controls" + ); + let payloads = flight_payloads(&output); + assert_eq!(payloads.len(), 1, "should emit payload exactly once"); + assert_mixed_t_model(&payloads[0], &expected); + assert!(!output.contains("__ts_rsc_")); + assert!(output.ends_with("

suffix

")); + } + } + } + } + + #[test] + fn same_delimiter_escaped_flight_queries_preserve_data_and_t_lengths() { + let data = json!({"tag": "http://www.googletagmanager.com/gtm.js?q=\"x\"&v=é", "href": "https://origin.example.com/page", "url": "http://origin.example.com/other"}); + let body = data.to_string(); + let payload = format!("1:T{:x},{body}", body.len()); + let mut expected = data; + expected["tag"] = json!("/integrations/google_tag_manager/gtm.js?q=\"x\"&v=é"); + expected["href"] = json!("https://test.example.com/page"); + expected["url"] = json!("https://test.example.com/other"); + for chunk in [32, 64, 1000, 8192] { + let output = mixed_output(&flight_html(&[&payload]), chunk); + let payloads = flight_payloads(&output); + assert_mixed_t_model(&payloads[0], &expected); + let body = r"'http://www.googletagmanager.com/gtm.js?q=\'x\''"; + let payload = format!("1:T{:x},{body}", body.len()); + let output = mixed_output(&flight_html(&[&payload]), chunk); + let payloads = flight_payloads(&output); + let (header, transformed) = payloads[0].split_once(',').expect("should retain T"); + assert_eq!( + usize::from_str_radix(&header[3..], 16).expect("should parse hex"), + transformed.len() + ); + assert_eq!( + transformed, + r"'/integrations/google_tag_manager/gtm.js?q=\'x\''" + ); + } + } + + #[test] + fn sticky_false_google_prefix_preserves_raw_script_metacharacters() { + let fragments = [ + "", + ]; + let mut processor = create_html_processor(mixed_config(10000, 10000)); + let mut bytes = Vec::new(); + for (index, fragment) in fragments.iter().enumerate() { + bytes.extend( + processor + .process_chunk(fragment.as_bytes(), index == fragments.len() - 1) + .expect("should consume raw script fragment"), + ); + } + assert_eq!( + String::from_utf8(bytes).expect("should emit UTF-8"), + fragments.concat(), + "holding/restoration must preserve raw JS source, not HTML-escape it" + ); + } + + #[test] + fn ordinary_gtm_before_captured_flight_is_chunk_invariant() { + let ordinary = "var tag='http://www.googletagmanager.com/gtm.js';"; + let expected_ordinary = "var tag='/integrations/google_tag_manager/gtm.js';"; + let data = json!({"url": "https://origin.example.com/page", "tag": "http://www.googletagmanager.com/gtm.js?id=GTM-MIX1", "text": "é😀"}); + let body = data.to_string(); + let mut expected = data; + expected["url"] = json!("https://test.example.com/page"); + expected["tag"] = json!("/integrations/google_tag_manager/gtm.js?id=GTM-MIX1"); + for payload in [format!("0:{body}\n"), format!("1:T{:x},{body}", body.len())] { + let push = format!( + "self.__next_f.push([1,{}])", + serde_json::to_string(&payload).expect("should encode Flight payload") + ); + let html = format!(""); + let whole = mixed_output(&html, 8192); + assert!( + whole.starts_with(&format!("", &push[split..]), + ]; + let mut bytes = Vec::new(); + for (index, fragment) in fragments.iter().enumerate() { + bytes.extend( + processor + .process_chunk(fragment.as_bytes(), index == fragments.len() - 1) + .expect("should complete ordinary and captured Flight source"), + ); + } + assert_eq!( + String::from_utf8(bytes).expect("should emit UTF-8"), + whole, + "should preserve both rewrites at Flight split {split}" + ); + } + for compression in [Compression::None, Compression::Gzip] { + for chunk in [128, 1000, 8192] { + assert_eq!( + mixed_output_with_compression(&html, compression, chunk), + whole, + "should preserve both rewrites for {compression:?} at chunk {chunk}" + ); + } + } + } + } + + #[test] + fn ordinary_gtm_before_flight_fallback_preserves_raw_payloads() { + let ordinary = "var tag='http://www.googletagmanager.com/gtm.js';"; + let payload = "1:Tffff,'http://www.googletagmanager.com/gtm.js?id=GTM-MIX1'"; + let encoded = serde_json::to_string(payload).expect("should encode raw Flight payload"); + for (channel, limit, captured) in [(1, 10000, true), (1, 24, false), (2, 10000, false)] { + let push = format!("self.__next_f.push([{channel},{encoded}])"); + let html = format!(""); + let whole = mixed_output_with_budget(&html, 8192, limit, 10000); + let expected_ordinary = if captured { + ordinary.replace( + "http://www.googletagmanager.com", + "/integrations/google_tag_manager", + ) + } else { + ordinary.to_owned() + }; + assert_eq!( + whole, + format!(""), + "should retain original fallback payload bytes" + ); + for split in 0..=push.len() { + let mut processor = create_html_processor(mixed_config(limit, 10000)); + let fragments = [ + format!("".to_owned(), + ]; + let mut bytes = Vec::new(); + for (index, fragment) in fragments.iter().enumerate() { + bytes.extend( + processor + .process_chunk(fragment.as_bytes(), index == fragments.len() - 1) + .expect("should restore raw Flight without changing payload lengths"), + ); + } + assert_eq!( + String::from_utf8(bytes).expect("should emit UTF-8"), + whole, + "should preserve fallback at channel {channel}, limit {limit}, split {split}" + ); + } + } + } + + #[test] + fn mixed_flight_first_party_urls_preserve_t_lengths() { + for scheme in ["https:", "http:", ""] { + for host_path in [ + "www.googletagmanager.com/gtm.js", + "www.googletagmanager.com/gtag/js?id=G-MIX1", + "www.google-analytics.com/g/collect", + "analytics.google.com/collect?v=2", + ] { + for quoted in [false, true] { + let url = format!("{scheme}//{host_path}"); + let body = if quoted { format!("\"{url}\"") } else { url }; + let payload = format!("1:T{:x},{body}", body.len()); + let encoded = serde_json::to_string(&payload).expect("should encode payload"); + let html = format!( + "

suffix

" + ); + for chunk in [32, 64, 1000, 8192] { + let output = mixed_output(&html, chunk); + let payloads = flight_payloads(&output); + assert_eq!(payloads.len(), 1); + let (header, body) = + payloads[0].split_once(',').expect("should retain T record"); + assert!( + body.contains("/integrations/google_tag_manager/"), + "should rewrite captured GTM-only flight: {body}" + ); + assert_eq!( + usize::from_str_radix(&header[3..], 16) + .expect("should keep hex length"), + body.len(), + "should count transformed decoded bytes" + ); + } + } + } + } + let payload = "0:{\"url\":\"https://www.googletagmanager.com/gtm.js\",\"href\":\"https://origin.example.com/page\"}\n"; + let encoded = serde_json::to_string(&payload).expect("should encode payload"); + let output = mixed_output( + &format!(""), + 32, + ); + let payloads = flight_payloads(&output); + assert!(payloads[0].contains("test.example.com/page")); + assert!( + payloads[0].contains("/integrations/google_tag_manager/gtm.js"), + "should rewrite queryless escaped quote URL" + ); + } + + #[test] + fn mixed_flight_batch_rewrites_every_payload() { + let bodies = [ + "http://www.googletagmanager.com/gtm.js", + "//www.google-analytics.com/collect?v=2", + ]; + let pushes: Vec = bodies + .iter() + .enumerate() + .map(|(index, body)| { + let payload = format!("{index}:T{:x},{body}", body.len()); + format!( + "self.__next_f.push([1,{}])", + serde_json::to_string(&payload).expect("should encode payload") + ) + }) + .collect(); + let output = mixed_output(&format!("", pushes.join(";")), 8192); + let script = output + .split("") + .next() + .expect("should close script"); + for push in script.split(';') { + let json = push + .strip_prefix("self.__next_f.push(") + .expect("should retain push") + .strip_suffix(')') + .expect("should close push"); + let value: serde_json::Value = + serde_json::from_str(json).expect("should preserve push JSON"); + let (header, body) = value[1] + .as_str() + .expect("should retain payload") + .split_once(',') + .expect("should retain T header"); + assert!( + body.contains("/integrations/google_tag_manager/"), + "should rewrite every captured push" + ); + assert_eq!( + usize::from_str_radix(&header[3..], 16).expect("should parse hex"), + body.len(), + "should recount every payload" + ); + } + } + + #[test] + fn mixed_next_data_prefix_tails_and_false_google_preserve_payload() { + for chunk in [32, 64, 1000, 8192] { + for prefix in [ + "_", "__", "__n", "__ne", "__nex", "__next", "__next_", "google", + ] { + for alignment in [0, 1, 13, 1023, 1024] { + let data = json!({"padding": format!("{}{prefix}", "_".repeat(12000)), "href": "https://origin.example.com/page"}); + let start = format!( + "{}"), chunk); + let script = output + .split("") + .next() + .expect("should close script"); + data["href"] = json!("https://test.example.com/page"); + assert_eq!( + serde_json::from_str::(script) + .expect("should keep valid JSON"), + data, + "prefix={prefix}, chunk={chunk}, alignment={alignment}" + ); + } + } + } + } + + #[test] + fn mixed_next_data_identity_and_gzip_chunk_sweep() { + let data = json!({"href": "https://origin.example.com/page", "tag": "https://www.google-analytics.com/collect?v=2", "padding": "_".repeat(12000)}); + let html = format!( + "

suffix

" + ); + for compression in [Compression::None, Compression::Gzip] { + for chunk_size in [32, 64, 1000, 8192] { + let output = mixed_output_with_compression(&html, compression, chunk_size); + let script = output + .split("") + .next() + .expect("should close script"); + let mut expected = data.clone(); + expected["href"] = json!("https://test.example.com/page"); + expected["tag"] = json!("/integrations/google_tag_manager/collect?v=2"); + assert_eq!( + serde_json::from_str::(script) + .expect("should retain valid JSON"), + expected + ); + assert!(output.ends_with("")); + } + } + } + + #[test] + fn mixed_registry_isolates_interleaved_html_documents() { + let config = mixed_config(10000, 10000); + let mut a = create_html_processor(config.clone()); + let mut b = create_html_processor(config); + let mut output_a = a + .process_chunk(b"", true) + .expect("should finish document A"), + ); + output_b.extend( + b.process_chunk(b"-analytics.com/collect?v=2';", true) + .expect("should finish document B"), + ); + assert_eq!( + String::from_utf8(output_a).expect("should emit UTF-8"), + "" + ); + assert_eq!( + String::from_utf8(output_b).expect("should emit UTF-8"), + "" + ); + } + + #[test] + fn structured_and_probe_composition_at_every_identifier_prefix_tail() { + for prefix in ["_", "__", "__n", "__ne", "__nex", "__next", "__next_"] { + let mut processor = create_html_processor(mixed_config(100000, 100000)); + let first = format!(""; + let mut bytes = processor + .process_chunk(first.as_bytes(), false) + .expect("should accept explicit prefix tail"); + bytes.extend( + processor + .process_chunk(second.as_bytes(), true) + .expect("should finish suppressed text"), + ); + let html = String::from_utf8(bytes).expect("should emit UTF-8"); + let script = html + .split("") + .next() + .expect("should close script"); + assert_eq!( + serde_json::from_str::(script).expect("should keep valid JSON"), + json!({"padding": prefix, "href": "https://test.example.com/page"}) + ); + } + } + + #[test] + fn mixed_existing_app_fixture_completes_with_both_enabled() { + let fixture = include_str!("../../html_processor.test.html"); + let data = json!({"href": "https://origin.example.com/fixture-proof", "tag": "http://www.googletagmanager.com/gtm.js", "ga": "//www.google-analytics.com/g/collect?v=2"}); + let body = data.to_string(); + let push = format!( + "", + serde_json::to_string(&format!("f:T{:x},{body}", body.len())) + .expect("should encode fixture proof") + ); + let (before, after) = fixture + .rsplit_once("") + .expect("should retain structural body close"); + let fixture_copy = format!("{before}{push}{after}"); + assert!( + fixture_copy.contains( + "(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])" + ), + "should retain real bootstrap sequence" + ); + let mut expected = data; + expected["href"] = json!("https://test.example.com/fixture-proof"); + expected["tag"] = json!("/integrations/google_tag_manager/gtm.js"); + expected["ga"] = json!("/integrations/google_tag_manager/g/collect?v=2"); + for chunk in [32, 8192] { + let output = mixed_output(&fixture_copy, chunk); + let payloads = flight_payloads(&output); + let proof = payloads + .iter() + .find(|payload| payload.starts_with("f:T")) + .expect("should retain designated Flight proof"); + assert_mixed_t_model(proof, &expected); + assert!(!output.contains("__ts_rsc_")); + assert!( + output.contains("googletagmanager.com/ns.html"), + "should retain unsupported iframe routing" + ); + assert!(output.trim_end().ends_with("")); + } + } + + fn flight_html(payloads: &[&str]) -> String { + let scripts: String = payloads + .iter() + .map(|payload| { + format!( + "", + serde_json::to_string(payload).expect("should encode Flight") + ) + }) + .collect(); + format!("{scripts}

suffix

") + } + + #[test] + fn mixed_flight_cross_script_lengths_and_split_hostname_safety() { + let body = r#"{"tag":"http://www.googletagmanager.com/gtm.js","url":"https://origin.example.com/page","text":"é😀"}"#; + let payload = format!("1:T{:x},{body}", body.len()); + for split in [ + (payload.find("http:").expect("should locate URL") - 1), + payload + .find("tagmanager") + .expect("should locate hostname split"), + ] { + for chunk in [32, 64, 1000, 8192] { + let output = + mixed_output(&flight_html(&[&payload[..split], &payload[split..]]), chunk); + let combined = flight_payloads(&output).concat(); + let (header, rewritten) = combined.split_once(',').expect("should retain T header"); + assert_eq!( + usize::from_str_radix(&header[3..], 16).expect("should parse length"), + rewritten.len() + ); + let data: serde_json::Value = + serde_json::from_str(rewritten).expect("should preserve model JSON"); + if split == (payload.find("http:").expect("should locate URL") - 1) { + assert_eq!(data["tag"], "/integrations/google_tag_manager/gtm.js"); + } else { + assert_eq!( + data["tag"], "http://www.googletagmanager.com/gtm.js", + "should safely preserve URL split across actual payload scripts" + ); + } + assert_eq!(data["url"], "https://test.example.com/page"); + assert_eq!(data["text"], "é😀"); + } + } + let escaped = body.replace('/', r"\/"); + let payload = format!("1:T{:x},{escaped}", escaped.len()); + let output = mixed_output(&flight_html(&[&payload]), 32); + let payloads = flight_payloads(&output); + let (header, body) = payloads[0].split_once(',').expect("should retain T"); + assert_eq!( + usize::from_str_radix(&header[3..], 16).expect("should parse length"), + body.len() + ); + let data: serde_json::Value = + serde_json::from_str(body).expect("should preserve escaped model JSON"); + assert_eq!(data["tag"], "/integrations/google_tag_manager/gtm.js"); + } + + #[test] + fn mixed_flight_fallback_preserves_original_payloads_and_later_scripts() { + let url = "'http://www.googletagmanager.com/gtm.js?id=GTM-MIX1'"; + let incomplete = format!("1:Tffff,{url}"); + let oversized = format!("1:T{:x},{url}", url.len()); + for (payloads, group_limit, script_limit) in [ + (vec![incomplete.as_str()], 10000, 10000), + (vec!["1:T", url], 10000, 10000), + (vec![oversized.as_str()], 24, 10000), + (vec![oversized.as_str()], 10000, 10), + ] { + for chunk in [32, 64, 1000, 8192] { + let html = flight_html(&payloads).replace("

suffix

", "

suffix

"); + let output = mixed_output_with_budget(&html, chunk, group_limit, script_limit); + assert_eq!( + flight_payloads(&output), + payloads, + "should preserve fallback payload bytes: group={group_limit}, script={script_limit}, chunk={chunk}" + ); + if script_limit > 10 { + assert!( + output.contains("var ordinary='/integrations/google_tag_manager/gtm.js';"), + "should rewrite ordinary script after Flight fallback" + ); + } + assert!(output.ends_with("")); + } + } + } + + #[test] + fn mixed_flight_response_completes() { + let payload = r#"0:{\"href\":\"https://origin.example.com/page\",\"url\":\"https://www.googletagmanager.com/gtm.js?id=GTM-MIX1\"}\n"#; + let html = format!( + r#"

suffix

"# + ); + let output = mixed_output(&html, 8192); + assert!( + output.contains("test.example.com/page"), + "should rewrite origin" + ); + assert!(!output.contains("__ts_rsc_"), "should resolve placeholders"); + assert!(output.ends_with(""), "should finish response"); + } + #[test] fn html_processor_rewrites_nextjs_script_when_enabled() { let html = r#" diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc.rs index ab144083c..9a53d5e8d 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc.rs @@ -398,12 +398,46 @@ fn find_tchunks_with_markers(content: &str) -> Option> { // Single-script T-chunk processing // ============================================================================= -pub(crate) fn rewrite_rsc_tchunks_with_rewriter( +fn rewrite_span( content: &str, rewriter: &RscUrlRewriter, origin_host: &str, request_host: &str, request_scheme: &str, + transform: Option String>, +) -> String { + let rewritten = rewriter.rewrite_to_string(content, origin_host, request_host, request_scheme); + match transform { + Some(transform) => transform(&rewritten), + None => rewritten, + } +} + +#[cfg(test)] +fn rewrite_rsc_tchunks_with_rewriter( + content: &str, + rewriter: &RscUrlRewriter, + origin_host: &str, + request_host: &str, + request_scheme: &str, +) -> String { + rewrite_rsc_tchunks_with_transform( + content, + rewriter, + origin_host, + request_host, + request_scheme, + None, + ) +} + +fn rewrite_rsc_tchunks_with_transform( + content: &str, + rewriter: &RscUrlRewriter, + origin_host: &str, + request_host: &str, + request_scheme: &str, + transform: Option String>, ) -> String { let Some(chunks) = find_tchunks(content) else { log::warn!( @@ -413,7 +447,14 @@ pub(crate) fn rewrite_rsc_tchunks_with_rewriter( }; if chunks.is_empty() { - return rewriter.rewrite_to_string(content, origin_host, request_host, request_scheme); + return rewrite_span( + content, + rewriter, + origin_host, + request_host, + request_scheme, + transform, + ); } let mut result = String::with_capacity(content.len()); @@ -421,15 +462,24 @@ pub(crate) fn rewrite_rsc_tchunks_with_rewriter( for chunk in &chunks { let before = &content[last_end..chunk.match_start]; - result.push_str( - rewriter - .rewrite(before, origin_host, request_host, request_scheme) - .as_ref(), - ); + result.push_str(&rewrite_span( + before, + rewriter, + origin_host, + request_host, + request_scheme, + transform, + )); let chunk_content = &content[chunk.header_end..chunk.content_end]; - let rewritten_content = - rewriter.rewrite_to_string(chunk_content, origin_host, request_host, request_scheme); + let rewritten_content = rewrite_span( + chunk_content, + rewriter, + origin_host, + request_host, + request_scheme, + transform, + ); let new_length = calculate_unescaped_byte_length(&rewritten_content); let new_length_hex = format!("{new_length:x}"); @@ -444,11 +494,14 @@ pub(crate) fn rewrite_rsc_tchunks_with_rewriter( } let remaining = &content[last_end..]; - result.push_str( - rewriter - .rewrite(remaining, origin_host, request_host, request_scheme) - .as_ref(), - ); + result.push_str(&rewrite_span( + remaining, + rewriter, + origin_host, + request_host, + request_scheme, + transform, + )); result } @@ -519,23 +572,44 @@ pub(crate) fn rewrite_rsc_scripts_combined_with_limit( request_host: &str, request_scheme: &str, max_combined_payload_bytes: usize, +) -> Vec { + rewrite_rsc_scripts_combined_with_transform( + payloads, + rewriter, + origin_host, + request_host, + request_scheme, + max_combined_payload_bytes, + None, + ) +} + +pub(super) fn rewrite_rsc_scripts_combined_with_transform( + payloads: &[&str], + rewriter: &RscUrlRewriter, + origin_host: &str, + request_host: &str, + request_scheme: &str, + max_combined_payload_bytes: usize, + transform: Option String>, ) -> Vec { let Some((_, preceding_payloads)) = payloads.split_last() else { return Vec::new(); }; // Early exit if no payload contains the origin host - avoids regex compilation - if !payloads.iter().any(|p| p.contains(origin_host)) { + if transform.is_none() && !payloads.iter().any(|p| p.contains(origin_host)) { return payloads.iter().map(|p| (*p).to_owned()).collect(); } if payloads.len() == 1 { - return vec![rewrite_rsc_tchunks_with_rewriter( + return vec![rewrite_rsc_tchunks_with_transform( payloads[0], rewriter, origin_host, request_host, request_scheme, + transform, )]; } @@ -570,12 +644,13 @@ pub(crate) fn rewrite_rsc_scripts_combined_with_limit( return payloads .iter() .map(|p| { - rewrite_rsc_tchunks_with_rewriter( + rewrite_rsc_tchunks_with_transform( p, rewriter, origin_host, request_host, request_scheme, + transform, ) }) .collect(); @@ -615,7 +690,16 @@ pub(crate) fn rewrite_rsc_scripts_combined_with_limit( if chunks.is_empty() { return payloads .iter() - .map(|p| rewriter.rewrite_to_string(p, origin_host, request_host, request_scheme)) + .map(|p| { + rewrite_span( + p, + rewriter, + origin_host, + request_host, + request_scheme, + transform, + ) + }) .collect(); } @@ -624,15 +708,24 @@ pub(crate) fn rewrite_rsc_scripts_combined_with_limit( for chunk in &chunks { let before = &combined[last_end..chunk.match_start]; - result.push_str( - rewriter - .rewrite(before, origin_host, request_host, request_scheme) - .as_ref(), - ); + result.push_str(&rewrite_span( + before, + rewriter, + origin_host, + request_host, + request_scheme, + transform, + )); let chunk_content = &combined[chunk.header_end..chunk.content_end]; - let rewritten_content = - rewriter.rewrite_to_string(chunk_content, origin_host, request_host, request_scheme); + let rewritten_content = rewrite_span( + chunk_content, + rewriter, + origin_host, + request_host, + request_scheme, + transform, + ); let new_length = calculate_unescaped_byte_length_skip_markers(&rewritten_content); let new_length_hex = format!("{new_length:x}"); @@ -647,11 +740,14 @@ pub(crate) fn rewrite_rsc_scripts_combined_with_limit( } let remaining = &combined[last_end..]; - result.push_str( - rewriter - .rewrite(remaining, origin_host, request_host, request_scheme) - .as_ref(), - ); + result.push_str(&rewrite_span( + remaining, + rewriter, + origin_host, + request_host, + request_scheme, + transform, + )); result.split(RSC_MARKER).map(String::from).collect() } diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs index 7fd8d70a8..c6d2cc0be 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs @@ -1,4 +1,6 @@ -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; + +use regex::Regex; use crate::integrations::{ IntegrationScriptContext, IntegrationScriptRewriter, ScriptRewriteAction, @@ -11,12 +13,39 @@ use super::rsc_stream::{ CapturedPayload, FragmentCapture, MAX_UNRESOLVED_RSC_PAYLOADS, RscGroupStatus, capture_fragment, classify_rsc_group, document_state, rsc_payload_placeholder, }; +use super::script_lexer::ScriptLexer; use super::shared::{ - RSC_RECEIVER_CONTEXT_BYTES, find_rsc_push_payload_range, find_trimmed_rsc_push_payload_range, + RSC_PUSH_CALL_PATTERN, RSC_PUSH_CALL_PATTERN_TRIMMED, RSC_RECEIVER_CONTEXT_BYTES, + find_rsc_push_payload_range, find_trimmed_rsc_push_payload_range, receiver_context_is_flight_push, }; use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; +// Observe actual qualified push calls, not bare property references. Unlike +// capture, ownership also covers unsupported channels and whitespace spellings +// because raw fallback must retain their original T lengths. +static QUALIFIED_FLIGHT_PUSH_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"(?:self|window)\.__next_f\s*(?:\.\s*push\s*\(|=\s*(?:self|window)\.__next_f\s*\|\|\s*\[\]\s*\)\s*\.\s*push\s*\()") + .expect("should compile qualified Flight push observation") +}); + +// The receiver may already have streamed, leaving a verified trimmed head. +static TRIMMED_FLIGHT_PUSH_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"^__next_f\s*(?:\.\s*push\s*\(|=\s*(?:self|window)\.__next_f\s*\|\|\s*\[\]\s*\)\s*\.\s*push\s*\()") + .expect("should compile trimmed Flight push observation") +}); + +// Only completed inert bootstrap controls may be skipped. Opaque channel-2 +// strings and incomplete calls must retain the conservative raw fallback. +static INERT_FLIGHT_CONTROL_ARGUMENTS: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*\[\s*(?:0|2\s*,\s*null)\s*\]\s*\)") + .expect("should compile inert Flight control arguments") +}); + +// Collapsing whitespace runs bounds either supported call head well below this +// size, while keeping the receiver's preceding boundary for qualification. +const FLIGHT_QUALIFIER_TAIL_BYTES: usize = 96; + pub(super) struct NextJsRscPlaceholderRewriter { config: Arc, } @@ -34,7 +63,8 @@ impl NextJsRscPlaceholderRewriter { limit: usize, max_queued_payload_bytes: usize, ) -> ScriptRewriteAction { - if !content.contains("__next_f") { + let code = state.rsc_lexical_start.clone().mask(content); + if !code.contains("__next_f") { return if was_buffered { ScriptRewriteAction::replace(content.to_owned()) } else { @@ -42,67 +72,114 @@ impl NextJsRscPlaceholderRewriter { }; } - let range = if state.rsc_receiver_trimmed { - find_trimmed_rsc_push_payload_range(content) - } else { - find_rsc_push_payload_range(content) - }; - state.rsc_receiver_trimmed = false; - let Some((payload_start, payload_end)) = range else { - return if was_buffered { - ScriptRewriteAction::replace(content.to_owned()) + // Discover all ranges and validate the queue budget before committing any + // capture. A malformed or unrecognized remainder stays original/protected. + let mut ranges = Vec::new(); + let mut cursor = 0; + let mut unclaimed_raw_flight = false; + let mut trimmed = std::mem::take(&mut state.rsc_receiver_trimmed); + let mut queued_bytes = state.captured_payload_bytes; + loop { + let remaining = &content[cursor..]; + let remaining_code = &code[cursor..]; + let head = if trimmed { + TRIMMED_FLIGHT_PUSH_PATTERN.find(remaining_code) } else { - ScriptRewriteAction::Keep + first_qualified_flight_push(remaining_code) }; - }; - - if payload_start > payload_end - || payload_end > content.len() - || !content.is_char_boundary(payload_start) - || !content.is_char_boundary(payload_end) - { - state.bypass_rsc = true; - return if was_buffered { - ScriptRewriteAction::replace(content.to_owned()) + // Property references are ordinary source, including after a push. + // Only another qualified call can require capture or raw fallback. + let Some(head) = head else { + break; + }; + if let Some(arguments) = INERT_FLIGHT_CONTROL_ARGUMENTS.find(&remaining[head.end()..]) { + unclaimed_raw_flight |= + contains_qualified_flight(&content[cursor..cursor + head.start()]); + cursor += head.end() + arguments.end(); + trimmed = false; + continue; + } + let range = executable_payload_range(remaining, remaining_code, trimmed); + let call = if trimmed { + RSC_PUSH_CALL_PATTERN_TRIMMED.find(remaining_code) } else { - ScriptRewriteAction::Keep + RSC_PUSH_CALL_PATTERN.find(remaining_code) }; + trimmed = false; + let Some((start, end)) = range.filter(|(start, _)| { + call.is_some_and(|call| { + call.end() == *start + && !contains_qualified_flight(&remaining_code[..call.start()]) + }) + }) else { + // A raw qualified push cannot safely continue a pending T group + // or be skipped by later captures. A bare property reference is + // ordinary script text and must not disable later Flight capture. + state.bypass_rsc |= state.flight_node_owned; + return if was_buffered { + ScriptRewriteAction::replace(content.to_owned()) + } else { + ScriptRewriteAction::Keep + }; + }; + let start = cursor + start; + let end = cursor + end; + let length = end.saturating_sub(start); + // Group bytes and queued parser-held bytes have distinct limits: + // independent groups parsed in one call must not consume each other's + // group allowance before the output processor can release them. + let total = queued_bytes.checked_add(length); + if start > end + || end >= content.len() + || !content.is_char_boundary(start) + || !content.is_char_boundary(end) + || length > limit + || state.captured_payloads.len() + ranges.len() >= MAX_UNRESOLVED_RSC_PAYLOADS + || total.is_none_or(|bytes| bytes > max_queued_payload_bytes) + { + state.bypass_rsc = true; + return if was_buffered { + ScriptRewriteAction::replace(content.to_owned()) + } else { + ScriptRewriteAction::Keep + }; + } + queued_bytes = total.expect("should have validated queue size"); + unclaimed_raw_flight |= + contains_qualified_flight(&content[cursor..cursor + head.start()]); + ranges.push((start, end)); + cursor = end + 1; } - - let payload = &content[payload_start..payload_end]; - // `limit` bounds one script here and the downstream unresolved group in - // `classify_rsc_group`. It deliberately does not bound this queue in - // aggregate: the processor cannot decrement a resolved group until the - // whole parser call returns, so a shared `limit` budget would make - // independent payloads bypass each other purely because they shared a - // source chunk. The queue is parser-held script text, so it is bounded - // by the parser's own script-buffer budget instead — the same budget - // `NextJsNextDataRewriter` buffers against — plus a payload count. - let exceeds_limit = payload.len() > limit - || state.captured_payloads.len() >= MAX_UNRESOLVED_RSC_PAYLOADS - || state - .captured_payload_bytes - .checked_add(payload.len()) - .is_none_or(|queued| queued > max_queued_payload_bytes); - if exceeds_limit { - state.bypass_rsc = true; + if ranges.is_empty() { return if was_buffered { ScriptRewriteAction::replace(content.to_owned()) } else { ScriptRewriteAction::Keep }; } - - let placeholder = rsc_payload_placeholder(&state.namespace, state.next_placeholder_index); - state.next_placeholder_index = state.next_placeholder_index.saturating_add(1); - state.captured_payload_bytes += payload.len(); - state.captured_payloads.push_back(CapturedPayload { - placeholder: placeholder.clone(), - original: payload.to_owned(), - }); - - let mut rewritten = content.to_owned(); - rewritten.replace_range(payload_start..payload_end, &placeholder); + let raw_flight_remaining = state.raw_flight_unclaimed + || unclaimed_raw_flight + || contains_qualified_flight(&content[cursor..]); + let mut rewritten = String::with_capacity(content.len()); + cursor = 0; + for (start, end) in ranges { + let placeholder = + rsc_payload_placeholder(&state.namespace, state.next_placeholder_index); + state.next_placeholder_index = state.next_placeholder_index.saturating_add(1); + state.captured_payloads.push_back(CapturedPayload { + placeholder: placeholder.clone(), + original: content[start..end].to_owned(), + }); + rewritten.push_str(&content[cursor..start]); + rewritten.push_str(&placeholder); + cursor = end; + } + state.captured_payload_bytes = queued_bytes; + rewritten.push_str(&content[cursor..]); + // Captured payloads and recognized bootstrap controls are safe, but + // raw push spellings outside them may be executable lexer misses. + // Preserve those bytes even when another call was captured successfully. + state.current_fragment_protected = raw_flight_remaining; ScriptRewriteAction::replace(rewritten) } @@ -121,24 +198,33 @@ impl NextJsRscPlaceholderRewriter { FragmentCapture::CompleteOwned(complete) => { self.rewrite_complete(&complete, true, state, limit, max_queued_payload_bytes) } - FragmentCapture::Suppress => ScriptRewriteAction::RemoveNode, + FragmentCapture::Suppress => { + // Captured bytes are withheld, so ordinary preceding source may + // still receive GTM rewriting. An earlier unclaimed raw head can + // leave its continuation in a released prefix, however, and that + // source must stay protected even during this later capture. + state.current_fragment_protected = state.raw_flight_unclaimed; + ScriptRewriteAction::RemoveNode + } FragmentCapture::Restore(restored) => { state.rsc_receiver_trimmed = false; - state.bypass_rsc = true; + state.bypass_rsc |= state.flight_node_owned; ScriptRewriteAction::replace(restored) } FragmentCapture::PassThrough => { let trimmed = state.rsc_receiver_trimmed; state.rsc_receiver_trimmed = false; + if !state.flight_node_owned { + return ScriptRewriteAction::Keep; + } if is_last && content.len() > limit && content.contains("__next_f") { + let code = state.rsc_lexical_start.clone().mask(content); let mut remaining = content; + let mut remaining_code = code.as_str(); let mut receiver_trimmed = trimmed; let unsafe_continuation = loop { - let range = if receiver_trimmed { - find_trimmed_rsc_push_payload_range(remaining) - } else { - find_rsc_push_payload_range(remaining) - }; + let range = + executable_payload_range(remaining, remaining_code, receiver_trimmed); let Some((start, end)) = range else { break true; }; @@ -152,8 +238,9 @@ impl NextJsRscPlaceholderRewriter { // payload is not classified as another call. Only the // first receiver can have streamed in an earlier fragment. remaining = &remaining[end + 1..]; + remaining_code = &remaining_code[end + 1..]; receiver_trimmed = false; - if !remaining.contains("__next_f") { + if !contains_qualified_flight(remaining_code) { break false; } }; @@ -167,26 +254,14 @@ impl NextJsRscPlaceholderRewriter { } } } -} - -impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { - fn integration_id(&self) -> &'static str { - NEXTJS_INTEGRATION_ID - } - - fn selector(&self) -> &'static str { - "script" - } - - fn rewrite(&self, content: &str, ctx: &IntegrationScriptContext<'_>) -> ScriptRewriteAction { - if !self.config.enabled || self.config.rewrite_attributes.is_empty() { - return ScriptRewriteAction::keep(); - } - - let state = document_state(ctx.document_state); - let mut state = state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + fn rewrite_fragment( + &self, + content: &str, + code: &str, + lexical_start: ScriptLexer, + ctx: &IntegrationScriptContext<'_>, + state: &mut super::rsc_stream::NextJsDocumentState, + ) -> ScriptRewriteAction { if state.bypass_rsc { // The downstream processor can enter bypass after the parser has // suppressed part of this script. Restore it before passing through @@ -213,21 +288,21 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { return self.rewrite_claimed_fragment( content, ctx.is_last_in_text_node, - &mut state, + state, limit, ctx.max_buffered_script_bytes, ); } - if state.rsc_probe.is_empty() && !content.contains("__next_f") { + if state.rsc_probe.is_empty() && !code.contains("__next_f") { if ctx.is_last_in_text_node { state.rsc_receiver_context.clear(); return ScriptRewriteAction::Keep; } - let probe_length = longest_identifier_prefix(content.as_bytes()); + let probe_length = longest_identifier_prefix(code.as_bytes()); let ready_length = content.len() - probe_length; state.rsc_probe.push_str(&content[ready_length..]); - remember_released(&mut state.rsc_receiver_context, &content[..ready_length]); + remember_released(&mut state.rsc_receiver_context, &code[..ready_length]); return if probe_length == 0 { ScriptRewriteAction::Keep } else if ready_length == 0 { @@ -240,7 +315,8 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { let prior_probe = std::mem::take(&mut state.rsc_probe); let mut combined = prior_probe.clone(); combined.push_str(content); - if !combined.contains("__next_f") { + let combined_code = format!("{prior_probe}{code}"); + if !combined_code.contains("__next_f") { if ctx.is_last_in_text_node { state.rsc_receiver_context.clear(); return if prior_probe.is_empty() { @@ -249,10 +325,13 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { ScriptRewriteAction::replace(combined) }; } - let probe_length = longest_identifier_prefix(combined.as_bytes()); + let probe_length = longest_identifier_prefix(combined_code.as_bytes()); let ready_length = combined.len() - probe_length; state.rsc_probe.push_str(&combined[ready_length..]); - remember_released(&mut state.rsc_receiver_context, &combined[..ready_length]); + remember_released( + &mut state.rsc_receiver_context, + &combined_code[..ready_length], + ); if prior_probe.is_empty() && probe_length == 0 { return ScriptRewriteAction::Keep; } @@ -263,18 +342,18 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { }; } - let identifier_start = combined + let identifier_start = combined_code .find("__next_f") .expect("should find the identifier that selected this branch"); let mut context = state.rsc_receiver_context.clone(); - context.push_str(&combined[..identifier_start]); + context.push_str(&combined_code[..identifier_start]); if !receiver_context_is_flight_push(&context) { // Some other object owns a `__next_f` property. Release the text // unchanged rather than claiming an unrelated publisher script. if ctx.is_last_in_text_node { state.rsc_receiver_context.clear(); } else { - remember_released(&mut state.rsc_receiver_context, &combined); + remember_released(&mut state.rsc_receiver_context, &combined_code); } return if prior_probe.is_empty() { ScriptRewriteAction::Keep @@ -287,7 +366,7 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { // one that already streamed leaves a trimmed claim whose receiver this // verified context stands in for. state.rsc_receiver_trimmed = - !receiver_context_is_flight_push(&combined[..identifier_start]); + !receiver_context_is_flight_push(&combined_code[..identifier_start]); state.rsc_receiver_context.clear(); let claimed_start = if state.rsc_receiver_trimmed { identifier_start @@ -296,10 +375,12 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { }; let prefix = &combined[..claimed_start]; let claimed = &combined[claimed_start..]; + state.rsc_lexical_start = lexical_start; + state.rsc_lexical_start.mask(&combined[..claimed_start]); let action = self.rewrite_claimed_fragment( claimed, ctx.is_last_in_text_node, - &mut state, + state, limit, ctx.max_buffered_script_bytes, ); @@ -315,6 +396,177 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { } } +impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { + fn integration_id(&self) -> &'static str { + NEXTJS_INTEGRATION_ID + } + + fn selector(&self) -> &'static str { + "script" + } + + fn rewrite(&self, content: &str, ctx: &IntegrationScriptContext<'_>) -> ScriptRewriteAction { + let shared = document_state(ctx.document_state); + let mut guard = shared + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let state = &mut *guard; + if std::mem::take(&mut state.next_data_fragment) { + // Pages data is not executable Flight, even when a JSON string + // contains a complete push spelling. Clear the downstream snapshot + // as well so a prior protected script cannot suppress Pages GTM. + state.current_fragment_protected = false; + state.flight_node_owned = false; + state.flight_qualifier_tail.clear(); + state.raw_flight_seen = false; + state.raw_flight_unclaimed = false; + state.raw_qualifier_tail.clear(); + state.flight_lexical = ScriptLexer::default(); + return ScriptRewriteAction::Keep; + } + if !self.config.enabled || self.config.rewrite_attributes.is_empty() { + state.current_fragment_protected = false; + return ScriptRewriteAction::Keep; + } + // A bounded raw observer is a safety guard, not a capture parser. Inert + // spellings may conservatively miss GTM rewriting in this script, but a + // lexical false negative must never let GTM corrupt raw Flight lengths. + let raw_head_end = observe_flight_qualifier( + content, + &mut state.raw_qualifier_tail, + &mut state.raw_flight_seen, + ); + let previously_owned = state.flight_node_owned; + let lexical_start = state.flight_lexical.clone(); + let code = state.flight_lexical.mask(content); + let code_head_end = if state.flight_lexical.is_opaque() { + // At the lexical nesting limit, retain the existing conservative raw + // qualification. Ordinary scripts without a push do not bypass Flight. + let head_end = observe_flight_qualifier( + content, + &mut state.flight_qualifier_tail, + &mut state.flight_node_owned, + ); + state.bypass_rsc |= state.flight_node_owned; + head_end + } else { + observe_flight_qualifier( + &code, + &mut state.flight_qualifier_tail, + &mut state.flight_node_owned, + ) + }; + // An unclaimed raw head may precede this fragment's capture buffer. + // Compare first-head offsets: a later lexical claim in the same fragment + // does not prove an earlier raw head safe, nor does a later fragment. + state.raw_flight_unclaimed |= !previously_owned + && raw_head_end + .is_some_and(|raw_end| code_head_end.is_none_or(|code_end| raw_end < code_end)); + // A call head can complete after a tentative property probe overflowed. + // Once it proves Flight, preserve its entire raw continuation/group. + if state.flight_node_owned + && matches!( + state.rsc_script, + super::rsc_stream::FragmentState::BypassUntilLast + ) + { + state.bypass_rsc = true; + } + state.current_fragment_protected = state.flight_node_owned || state.raw_flight_seen; + let action = self.rewrite_fragment(content, &code, lexical_start, ctx, state); + // Ownership alone does not prove buffering: an earlier unrelated + // property can cause the fragment to stream without a capture claim. + // Never let a later capture unprotect that raw source's continuation. + if !ctx.is_last_in_text_node + && state.raw_flight_seen + && !matches!( + state.rsc_script, + super::rsc_stream::FragmentState::Buffering(_) + ) + { + state.raw_flight_unclaimed = true; + } + if ctx.is_last_in_text_node { + state.flight_node_owned = false; + state.flight_qualifier_tail.clear(); + state.raw_flight_seen = false; + state.raw_flight_unclaimed = false; + state.raw_qualifier_tail.clear(); + state.rsc_probe.clear(); + state.rsc_receiver_context.clear(); + state.rsc_receiver_trimmed = false; + state.flight_lexical = ScriptLexer::default(); + state.rsc_lexical_start = ScriptLexer::default(); + } + action + } +} + +/// Match a call head only in executable source, then read its original string. +fn executable_payload_range(content: &str, code: &str, trimmed: bool) -> Option<(usize, usize)> { + if trimmed { + RSC_PUSH_CALL_PATTERN_TRIMMED.find(code)?; + return find_trimmed_rsc_push_payload_range(content); + } + let call = RSC_PUSH_CALL_PATTERN.find(code)?; + let identifier = call.start() + call.as_str().find("__next_f")?; + if !receiver_context_is_flight_push(&code[..identifier]) { + return None; + } + let (start, end) = find_rsc_push_payload_range(&content[call.start()..])?; + Some((call.start() + start, call.start() + end)) +} + +fn contains_qualified_flight(source: &str) -> bool { + first_qualified_flight_push(source).is_some() +} + +fn first_qualified_flight_push(source: &str) -> Option> { + QUALIFIED_FLIGHT_PUSH_PATTERN + .find_iter(source) + .find(|call| { + let identifier = call.start() + + call + .as_str() + .find("__next_f") + .expect("should find identifier in qualified push pattern"); + receiver_context_is_flight_push(&source[..identifier]) + }) +} + +/// Observe a bounded whitespace-normalized call head without buffering script +/// source, including initializer heads split after document bypass. Return its +/// original fragment byte-end so raw and masked observations can be compared. +fn observe_flight_qualifier(content: &str, tail: &mut String, seen: &mut bool) -> Option { + if *seen { + return None; + } + for (offset, character) in content.char_indices() { + let character = if character.is_whitespace() { + ' ' + } else { + character + }; + if character == ' ' && tail.ends_with(' ') { + continue; + } + tail.push(character); + if tail.len() > FLIGHT_QUALIFIER_TAIL_BYTES { + let start = tail.len() - FLIGHT_QUALIFIER_TAIL_BYTES; + let start = (start..tail.len()) + .find(|index| tail.is_char_boundary(*index)) + .expect("should find bounded qualifier character boundary"); + tail.drain(..start); + } + if character == '(' && contains_qualified_flight(tail) { + *seen = true; + tail.clear(); + return Some(offset + 1); + } + } + None +} + /// Bytes to withhold so a `__next_f` identifier split across text fragments can /// still be recognized once the next fragment arrives. fn longest_identifier_prefix(bytes: &[u8]) -> usize { @@ -370,6 +622,349 @@ mod tests { }) } + #[test] + fn quoted_flight_spellings_do_not_claim_ownership() { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); + rewriter.rewrite(r#"const demo="self.__next_f.push(";"#, &ctx(false, &state)); + let shared = document_state(&state); + let state = shared.lock().expect("should lock state"); + assert!(!state.flight_node_owned, "quoted text must not own Flight"); + assert!( + state.raw_flight_seen, + "raw spelling should trigger only protection" + ); + assert!( + state.current_fragment_protected, + "should protect possible raw Flight" + ); + assert!( + !state.bypass_rsc, + "inert source must not bypass later scripts" + ); + } + + #[test] + fn raw_qualifier_observation_is_bounded_and_survives_every_split() { + for head in [ + "self.__next_f . push (", + "window.__next_f . push (", + "(self.__next_f = self.__next_f || []).push (", + "(window.__next_f = window.__next_f || []).push (", + ] { + let head = head.replace(' ', &" \n".repeat(64)); + for split in 0..=head.len() { + let mut tail = String::new(); + let mut seen = false; + let _ = observe_flight_qualifier(&"é😀".repeat(1000), &mut tail, &mut seen); + assert!( + tail.len() <= FLIGHT_QUALIFIER_TAIL_BYTES, + "should bound raw tail" + ); + assert!(!seen, "ordinary source must not protect Flight"); + let _ = observe_flight_qualifier(&head[..split], &mut tail, &mut seen); + let _ = observe_flight_qualifier(&head[split..], &mut tail, &mut seen); + assert!( + seen, + "should recognize normalized raw head at split {split}" + ); + assert!( + tail.is_empty(), + "should release tail once qualification succeeds" + ); + } + } + } + + #[test] + fn unrecognized_qualified_prefix_makes_the_whole_batch_raw_and_protected() { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); + let script = r#"self.__next_f.push([2,"1:T40,'http://www.googletagmanager.com/gtm.js'"]);self.__next_f.push([1,"later"])"#; + assert_eq!( + rewriter.rewrite(script, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should not partially hide a batch with uncaptured qualified payloads" + ); + let shared = document_state(&state); + let state = shared.lock().expect("should lock state"); + assert!(state.captured_payloads.is_empty()); + assert!(state.current_fragment_protected); + } + + #[test] + fn inert_controls_do_not_make_unsafe_batches_partially_capturable() { + for unsafe_call in [ + r#"self.__next_f.push([2,"1:T40,'http://www.googletagmanager.com/gtm.js'"])"#, + "self.__next_f.push([0,1])", + "self.__next_f.push([2,null", + "self.__next_f.push([0]", + ] { + for unsafe_first in [false, true] { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); + let control = "self.__next_f.push([0]);self.__next_f.push([2,null])"; + let supported = r#"self.__next_f.push([1,"later"])"#; + let script = if unsafe_first { + format!("{control};{unsafe_call};{supported}") + } else { + format!("{control};{supported};{unsafe_call}") + }; + assert_eq!( + rewriter.rewrite(&script, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should preserve unsafe batch: {script}" + ); + let shared = document_state(&state); + let state = shared.lock().expect("should lock state"); + assert!(state.captured_payloads.is_empty()); + assert!(state.current_fragment_protected); + assert!(state.bypass_rsc); + } + } + } + + #[test] + fn inert_controls_are_preserved_without_document_bypass() { + for script in [ + "(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])", + "window.__next_f . push ( [ 0 ] );window.__next_f.push( [ 2 , null ] )", + ] { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); + assert_eq!( + rewriter.rewrite(script, &ctx(true, &state)), + ScriptRewriteAction::Keep + ); + let shared = document_state(&state); + let state = shared.lock().expect("should lock state"); + assert!(state.captured_payloads.is_empty()); + assert!(state.current_fragment_protected); + assert!(!state.bypass_rsc); + } + } + + #[test] + fn batched_capture_is_atomic_at_byte_and_count_limits() { + let payload = "x".repeat(40); + let push = format!(r#"self.__next_f.push([1,"{payload}"])"#); + for (count, budget, captured) in + [(2, 80, 2), (2, 79, 0), (256, 20000, 256), (257, 20000, 0)] + { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); + let script = vec![push.as_str(); count].join(";"); + let action = rewriter.rewrite( + &script, + &IntegrationScriptContext { + max_buffered_script_bytes: budget, + ..ctx(true, &state) + }, + ); + let shared = document_state(&state); + let state = shared.lock().expect("should lock state"); + assert_eq!( + state.captured_payloads.len(), + captured, + "should commit every push or none" + ); + assert_eq!(state.captured_payload_bytes, captured * payload.len()); + assert_eq!( + matches!(action, ScriptRewriteAction::Replace(_)), + captured > 0 + ); + assert_eq!( + state.current_fragment_protected, + captured == 0, + "should protect rejected raw batch" + ); + } + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); + let script = format!("{push};self.__next_f.push([1,\"unfinished"); + assert_eq!( + rewriter.rewrite(&script, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should leave unsafe remainder original" + ); + assert!( + document_state(&state) + .lock() + .expect("should lock state") + .captured_payloads + .is_empty() + ); + } + + #[test] + fn bypass_ownership_recognizes_every_split_and_resets_on_empty_final() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings + .integrations + .insert_config( + "google_tag_manager", + &serde_json::json!({"enabled": true, "container_id": "GTM-MIX1"}), + ) + .expect("should enable GTM"); + let registry = crate::integrations::IntegrationRegistry::with_plan( + &settings, + Arc::new(crate::auction::compile_auction_plan(&settings).expect("should compile plan")), + ) + .expect("should create registry"); + let gtm = registry + .script_rewriters() + .pop() + .expect("should register GTM"); + let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); + let spaced_initializer = format!( + r#"(self.__next_f{}={}window.__next_f{}||{}[]{}).{}push{}([1,"1:T40,'http://www.googletagmanager.com/gtm.js'"])"#, + " ".repeat(256), + " ".repeat(256), + " ".repeat(256), + " ".repeat(256), + " ".repeat(256), + " ".repeat(256), + " ".repeat(256) + ); + for script in [ + r#"self.__next_f.push([1,"1:T40,'http://www.googletagmanager.com/gtm.js'"])"#, + r#"window.__next_f.push([1,"1:T40,'http://www.googletagmanager.com/gtm.js'"])"#, + r#"(self.__next_f=self.__next_f||[]).push([1,"1:T40,'http://www.googletagmanager.com/gtm.js'"])"#, + r#"(window.__next_f=window.__next_f||[]).push([1,"1:T40,'http://www.googletagmanager.com/gtm.js'"])"#, + &spaced_initializer, + ] { + for split in 1..script.len() { + let state = IntegrationDocumentState::default(); + document_state(&state) + .lock() + .expect("should lock state") + .bypass_rsc = true; + for fragment in [&script[..split], &script[split..], ""] { + let final_fragment = fragment.is_empty(); + assert_eq!( + rewriter.rewrite(fragment, &ctx(final_fragment, &state)), + ScriptRewriteAction::Keep + ); + assert_eq!( + IntegrationScriptRewriter::rewrite( + &*gtm, + fragment, + &ctx(final_fragment, &state) + ), + ScriptRewriteAction::Keep, + "should preserve raw Flight at split {split}" + ); + assert!( + document_state(&state) + .lock() + .expect("should lock state") + .flight_qualifier_tail + .len() + <= FLIGHT_QUALIFIER_TAIL_BYTES, + "should bound fallback qualification observation" + ); + } + let ordinary = "var ordinary='http://www.googletagmanager.com/gtm.js';"; + assert_eq!( + rewriter.rewrite(ordinary, &ctx(true, &state)), + ScriptRewriteAction::Keep + ); + assert!( + matches!(IntegrationScriptRewriter::rewrite(&*gtm, ordinary, &ctx(true, &state)), ScriptRewriteAction::Replace(ref text) if text.contains("/integrations/google_tag_manager/gtm.js")) + ); + } + } + } + + #[test] + fn raw_flight_fallback_is_protected_but_later_ordinary_gtm_is_not() { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(Arc::new(NextJsIntegrationConfig { + max_combined_payload_bytes: 24, + ..(*test_config()).clone() + })); + let mut settings = crate::test_support::tests::create_test_settings(); + settings + .integrations + .insert_config( + "google_tag_manager", + &serde_json::json!({"enabled": true, "container_id": "GTM-MIX1"}), + ) + .expect("should configure GTM"); + let registry = crate::integrations::IntegrationRegistry::with_plan( + &settings, + Arc::new(crate::auction::compile_auction_plan(&settings).expect("should compile plan")), + ) + .expect("should create registry"); + let gtm = registry + .script_rewriters() + .pop() + .expect("should register script hook"); + let raw = r#"self.__next_f.push([1,"1:T2,'http://www.googletagmanager.com/gtm.js?id=GTM-MIX1'"] )"#; + assert_eq!( + rewriter.rewrite(raw, &ctx(true, &state)), + ScriptRewriteAction::Keep + ); + assert_eq!( + IntegrationScriptRewriter::rewrite(&*gtm, raw, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should not change original T bytes on overflow" + ); + for part in [ + "self.__n", + "ext_f.push([1,\"", + "1:T2,'http://www.googletagmanager.com/gtm.js?id=GTM-MIX1'\"])", + ] { + let last = part.ends_with(")"); + assert_eq!( + rewriter.rewrite(part, &ctx(last, &state)), + ScriptRewriteAction::Keep + ); + assert_eq!( + IntegrationScriptRewriter::rewrite(&*gtm, part, &ctx(last, &state)), + ScriptRewriteAction::Keep, + "should recognize and protect split qualified flight after bypass" + ); + } + let prefix = "var before='google"; + assert_eq!( + rewriter.rewrite(prefix, &ctx(false, &state)), + ScriptRewriteAction::Keep + ); + assert_eq!( + IntegrationScriptRewriter::rewrite(&*gtm, prefix, &ctx(false, &state)), + ScriptRewriteAction::RemoveNode + ); + let flight = r#"';self.__next_f.push([1,"1:T2,'http://www.googletagmanager.com/gtm.js'"])"#; + assert_eq!( + rewriter.rewrite(flight, &ctx(false, &state)), + ScriptRewriteAction::Keep + ); + assert_eq!( + IntegrationScriptRewriter::rewrite(&*gtm, flight, &ctx(false, &state)), + ScriptRewriteAction::Replace(format!("{prefix}{flight}")), + "should drain GTM-held prefix unchanged before protected raw text" + ); + assert_eq!( + rewriter.rewrite("", &ctx(true, &state)), + ScriptRewriteAction::Keep + ); + assert_eq!( + IntegrationScriptRewriter::rewrite(&*gtm, "", &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should preserve empty final protection" + ); + let ordinary = "var url='http://www.googletagmanager.com/gtm.js';"; + assert_eq!( + rewriter.rewrite(ordinary, &ctx(true, &state)), + ScriptRewriteAction::Keep + ); + assert!( + matches!(IntegrationScriptRewriter::rewrite(&*gtm, ordinary, &ctx(true, &state)), ScriptRewriteAction::Replace(ref value) if value.contains("/integrations/google_tag_manager/gtm.js")), + "should not leak flight ownership to ordinary scripts" + ); + } + #[test] fn inserts_placeholder_and_records_payload() { let state = IntegrationDocumentState::default(); @@ -459,13 +1054,13 @@ mod tests { })); assert_eq!( - rewriter.rewrite("self.__next_f", &ctx(false, &state)), + rewriter.rewrite("self.__next_f.push(", &ctx(false, &state)), ScriptRewriteAction::RemoveNode, "should initially suppress the script prefix", ); assert_eq!( rewriter.rewrite("-payload-overflow", &ctx(false, &state)), - ScriptRewriteAction::Replace("self.__next_f-payload-overflow".to_owned()), + ScriptRewriteAction::Replace("self.__next_f.push(-payload-overflow".to_owned()), "should restore suppressed text before overflow", ); assert_eq!( @@ -588,6 +1183,54 @@ mod tests { ); } + #[test] + fn oversized_payload_with_trailing_reference_keeps_later_capture() { + for trimmed in [false, true] { + for reference in ["self.__next_f", "window.__next_f", "foreign.__next_f"] { + let state = IntegrationDocumentState::default(); + let rewriter = + NextJsRscPlaceholderRewriter::new(Arc::new(NextJsIntegrationConfig { + max_combined_payload_bytes: 100, + ..(*test_config()).clone() + })); + let receiver = if trimmed { + assert_eq!( + rewriter.rewrite("self.", &ctx(false, &state)), + ScriptRewriteAction::Keep, + "should release the qualified receiver" + ); + "" + } else { + "self." + }; + let script = format!( + r#"{receiver}__next_f.push([1,"1:T50,{}"]);console.log({reference});"#, + "x".repeat(80) + ); + assert_eq!( + rewriter.rewrite(&script, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should retain oversized script" + ); + assert!( + !document_state(&state) + .lock() + .expect("should lock document state") + .bypass_rsc, + "should not bypass a complete group with a trailing reference" + ); + assert!( + matches!( + rewriter + .rewrite(r#"self.__next_f.push([1,"1:T3,abc"])"#, &ctx(true, &state)), + ScriptRewriteAction::Replace(_) + ), + "should still capture the next script" + ); + } + } + } + #[test] fn oversized_partial_header_bypasses_later_continuation() { for suffix in ["1", "1:", "1:T", "1:T2"] { diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs index ab12e13f1..28a1cc45d 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs @@ -9,8 +9,9 @@ use crate::streaming_processor::StreamProcessor; use super::rsc::{ DEFAULT_MAX_COMBINED_PAYLOAD_BYTES, PendingTChunk, TChunkStep, next_tchunk, - rewrite_rsc_scripts_combined_with_limit, + rewrite_rsc_scripts_combined_with_transform, }; +use super::script_lexer::ScriptLexer; use super::shared::RscUrlRewriter; use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; @@ -36,6 +37,9 @@ pub(super) struct CapturedPayload { pub(super) struct NextJsDocumentState { pub(super) namespace: String, pub(super) next_data: FragmentState, + /// One-shot discriminator set by the matched Pages stage and consumed by + /// the immediately following Flight stage, including empty/pass-through text. + pub(super) next_data_fragment: bool, pub(super) rsc_script: FragmentState, pub(super) rsc_probe: String, /// Tail of script text already released for the current text node, kept so a @@ -49,6 +53,23 @@ pub(super) struct NextJsDocumentState { pub(super) captured_payload_bytes: usize, pub(super) next_placeholder_index: usize, pub(super) bypass_rsc: bool, + /// Qualified Flight ownership lasts only for the current script text node. + pub(super) flight_node_owned: bool, + /// Snapshot consumed by downstream GTM, including the final raw fragment. + pub(super) current_fragment_protected: bool, + /// Bounded qualifier observation remains active even after document bypass. + pub(super) flight_qualifier_tail: String, + /// Raw qualification protects possible Flight even when the lexer misses it. + /// It never authorizes capture and lasts only for the current script node. + pub(super) raw_flight_seen: bool, + /// A raw head observed without a lexical claim may have already streamed. + /// Later captures cannot make its remaining source safe for ordinary GTM. + pub(super) raw_flight_unclaimed: bool, + pub(super) raw_qualifier_tail: String, + /// Lexical context advances with original parser fragments, never replacements. + pub(super) flight_lexical: ScriptLexer, + /// Snapshot at the beginning of a claimed buffer for source-offset matching. + pub(super) rsc_lexical_start: ScriptLexer, } impl Default for NextJsDocumentState { @@ -56,6 +77,7 @@ impl Default for NextJsDocumentState { Self { namespace: uuid::Uuid::new_v4().simple().to_string(), next_data: FragmentState::Idle, + next_data_fragment: false, rsc_script: FragmentState::Idle, rsc_probe: String::new(), rsc_receiver_context: String::new(), @@ -64,6 +86,14 @@ impl Default for NextJsDocumentState { captured_payload_bytes: 0, next_placeholder_index: 0, bypass_rsc: false, + flight_node_owned: false, + current_fragment_protected: false, + flight_qualifier_tail: String::new(), + raw_flight_seen: false, + raw_flight_unclaimed: false, + raw_qualifier_tail: String::new(), + flight_lexical: ScriptLexer::default(), + rsc_lexical_start: ScriptLexer::default(), } } } @@ -145,11 +175,15 @@ pub(super) fn capture_fragment<'a>( pub(super) struct NextJsRscStreamProcessorFactory { config: Arc, + transform: Option String>, } impl NextJsRscStreamProcessorFactory { - pub(super) fn new(config: Arc) -> Self { - Self { config } + pub(super) fn new( + config: Arc, + transform: Option String>, + ) -> Self { + Self { config, transform } } } @@ -170,6 +204,7 @@ impl IntegrationHtmlStreamProcessorFactory for NextJsRscStreamProcessorFactory { context.request_host, context.request_scheme, limit, + self.transform, )) } } @@ -185,6 +220,7 @@ pub(super) struct NextJsRscStreamProcessor { group: Vec, classifier: RscGroupClassifier, rewriter: RscUrlRewriter, + transform: Option String>, } impl NextJsRscStreamProcessor { @@ -194,6 +230,7 @@ impl NextJsRscStreamProcessor { request_host: String, request_scheme: String, limit: usize, + transform: Option String>, ) -> Self { Self { state, @@ -206,6 +243,7 @@ impl NextJsRscStreamProcessor { group: Vec::new(), classifier: RscGroupClassifier::new(limit), rewriter: RscUrlRewriter::new(), + transform, } } @@ -325,13 +363,14 @@ impl NextJsRscStreamProcessor { .iter() .map(|payload| payload.original.as_str()) .collect(); - let rewritten = rewrite_rsc_scripts_combined_with_limit( + let rewritten = rewrite_rsc_scripts_combined_with_transform( &payloads, &self.rewriter, &self.origin_host, &self.request_host, &self.request_scheme, self.limit, + self.transform, ); if rewritten.len() != self.group.len() { log::warn!( @@ -1126,11 +1165,60 @@ mod tests { "proxy.example.com".to_owned(), "https".to_owned(), limit, + None, ), placeholders, ) } + #[test] + fn enabled_gtm_transform_never_mutates_fallback_originals() { + let body = "'http://www.googletagmanager.com/gtm.js'"; + let invalid = format!("1:Tzz,{body}"); + let incomplete = format!("1:Tffff,{body}"); + let header_tail = format!("T{:x},{body}", body.len()); + let escape_tail = format!("41{body}"); + let marker_tail = format!("c\n\0SPLIT\0{body}"); + for payloads in [ + vec![invalid.as_str()], + vec![incomplete.as_str()], + vec!["1:", header_tail.as_str()], + vec![r"1:T2,\u00", escape_tail.as_str()], + vec!["1:T3,ab", marker_tail.as_str()], + ] { + let (mut processor, placeholders) = processor_with_payloads(&payloads, 10000); + processor.transform = + Some(crate::integrations::google_tag_manager::rewrite_gtm_rsc_span); + let output = processor + .process_chunk(placeholders.concat().as_bytes(), true) + .expect("should restore fallback group"); + assert_eq!( + output, + payloads.concat().as_bytes(), + "should never transform original fallback bytes" + ); + } + let (mut processor, placeholders) = processor_with_payloads(&[&incomplete, body], 120); + processor.transform = Some(crate::integrations::google_tag_manager::rewrite_gtm_rsc_span); + let interstitial = "x".repeat(100); + let input = format!("{}{interstitial}{}", placeholders[0], placeholders[1]); + assert_eq!( + processor + .process_chunk(input.as_bytes(), true) + .expect("should restore held-output overflow"), + format!("{incomplete}{interstitial}{body}").as_bytes() + ); + let (mut processor, _) = processor_with_payloads(&[body], 1000); + processor.transform = Some(crate::integrations::google_tag_manager::rewrite_gtm_rsc_span); + assert!( + processor + .process_chunk(&[], true) + .expect_err("should reject missing captured placeholder") + .to_string() + .contains("not present") + ); + } + #[test] fn stream_processor_emits_ordinary_html_before_eof() { let (mut processor, _) = processor_with_payloads(&[], 1024); diff --git a/crates/trusted-server-core/src/integrations/nextjs/script_lexer.rs b/crates/trusted-server-core/src/integrations/nextjs/script_lexer.rs new file mode 100644 index 000000000..a4d3822cd --- /dev/null +++ b/crates/trusted-server-core/src/integrations/nextjs/script_lexer.rs @@ -0,0 +1,513 @@ +//! Bounded lexical context for recognizing executable Flight call heads. +//! +//! This is not a JavaScript parser. It masks strings, comments and template text +//! without changing byte offsets, and exposes code inside template expressions. + +const MAX_TEMPLATE_DEPTH: usize = 32; + +#[derive(Clone, Debug, Default)] +enum Mode { + #[default] + Code, + Slash { + regex: bool, + }, + Regex { + escaped: bool, + class: bool, + }, + String { + quote: char, + escaped: bool, + }, + LineComment, + BlockComment { + star: bool, + }, + Template { + escaped: bool, + dollar: bool, + }, + Opaque, +} + +#[derive(Clone, Copy, Debug)] +enum BraceKind { + StatementBlock, + ExpressionBlock, + Object, +} + +#[derive(Clone, Copy, Debug)] +enum ParenthesisKind { + Control, + Expression, + Function(BraceKind), +} + +#[derive(Clone, Copy, Debug)] +struct PendingClass { + body: BraceKind, + parenthesis_depth: usize, + brace_depth: usize, +} + +#[derive(Clone, Debug)] +pub(super) struct ScriptLexer { + mode: Mode, + template_braces: Vec, + expression_start: bool, + statement_start: bool, + braces: Vec, + word: String, + word_statement_start: bool, + word_overflow: bool, + word_property: bool, + after_dot: bool, + control_pending: bool, + parentheses: Vec, + function_pending: Option, + body_pending: Option, + classes: Vec, + operator: Option<(char, bool)>, +} + +impl Default for ScriptLexer { + fn default() -> Self { + Self { + mode: Mode::Code, + template_braces: Vec::new(), + expression_start: true, + statement_start: true, + braces: Vec::new(), + word: String::new(), + word_statement_start: true, + word_overflow: false, + word_property: false, + after_dot: false, + control_pending: false, + parentheses: Vec::new(), + function_pending: None, + body_pending: None, + classes: Vec::new(), + operator: None, + } + } +} + +impl ScriptLexer { + /// Mask non-code bytes while preserving source offsets and opening quotes. + /// Opening quotes remain visible so the Flight payload regex can match them. + pub(super) fn mask(&mut self, source: &str) -> String { + let mut code = String::with_capacity(source.len()); + for character in source.chars() { + if self.is_code(character) { + code.push(character); + } else { + // A non-whitespace sentinel prevents matching across inert text. + code.extend(std::iter::repeat_n('#', character.len_utf8())); + } + } + code + } + + /// Excessive lexical nesting is unsupported rather than unbounded. + pub(super) fn is_opaque(&self) -> bool { + matches!(self.mode, Mode::Opaque) + } + + fn is_code(&mut self, character: char) -> bool { + match &mut self.mode { + Mode::Code => { + if character.is_alphanumeric() || matches!(character, '_' | '$') { + if self.word.is_empty() { + self.word_property = self.after_dot; + self.word_statement_start = self.statement_start; + self.after_dot = false; + self.body_pending = None; + } + self.operator = None; + if self.word.len() < 10 && !self.word_overflow { + self.word.push(character); + } else { + self.word_overflow = true; + } + self.expression_start = false; + self.statement_start = false; + return true; + } + if !self.word.is_empty() { + self.expression_start = !self.word_overflow + && !self.word_property + && matches!( + self.word.as_str(), + "return" + | "throw" + | "case" + | "delete" + | "void" + | "typeof" + | "new" + | "yield" + | "await" + | "else" + | "do" + ); + self.control_pending = !self.word_overflow + && !self.word_property + && matches!( + self.word.as_str(), + "if" | "while" | "for" | "with" | "switch" | "catch" + ); + if !self.word_overflow && !self.word_property { + if matches!(self.word.as_str(), "function" | "class") { + let body = if self.word_statement_start { + BraceKind::StatementBlock + } else { + BraceKind::ExpressionBlock + }; + if self.word == "function" { + self.function_pending = Some(body); + } else if self.classes.len() == MAX_TEMPLATE_DEPTH { + self.mode = Mode::Opaque; + return false; + } else { + self.classes.push(PendingClass { + body, + parenthesis_depth: self.parentheses.len(), + brace_depth: self.braces.len(), + }); + } + } + self.statement_start = matches!(self.word.as_str(), "else" | "do") + || (self.word_statement_start + && matches!(self.word.as_str(), "async" | "export" | "default")); + } + self.word.clear(); + self.word_overflow = false; + } + let prior_operator = self.operator.take(); + let mut pending_body = None; + if !character.is_whitespace() && character != '/' { + self.after_dot = character == '.'; + pending_body = self.body_pending.take(); + if !matches!(character, '(' | '*') { + self.function_pending = None; + } + } + match character { + '(' => { + if self.parentheses.len() == MAX_TEMPLATE_DEPTH { + self.mode = Mode::Opaque; + } else { + let kind = if self.control_pending { + ParenthesisKind::Control + } else if let Some(kind) = self.function_pending.take() { + ParenthesisKind::Function(kind) + } else { + ParenthesisKind::Expression + }; + self.parentheses.push(kind); + } + self.control_pending = false; + self.expression_start = true; + self.statement_start = false; + } + ')' => { + let kind = self.parentheses.pop(); + self.expression_start = matches!(kind, Some(ParenthesisKind::Control)); + self.statement_start = self.expression_start; + if let Some(ParenthesisKind::Function(kind)) = kind { + self.body_pending = Some(kind); + } + } + '=' => { + self.operator = Some((character, self.expression_start)); + self.expression_start = true; + self.statement_start = false; + } + '>' => { + if prior_operator.is_some_and(|(operator, _)| operator == '=') { + self.body_pending = Some(BraceKind::ExpressionBlock); + } + self.expression_start = true; + self.statement_start = false; + } + '+' | '-' => { + if let Some((operator, prior_expression)) = prior_operator + && operator == character + { + self.expression_start = prior_expression; + } else { + self.operator = Some((character, self.expression_start)); + self.expression_start = true; + } + self.statement_start = false; + } + '\'' | '"' => { + self.statement_start = false; + self.mode = Mode::String { + quote: character, + escaped: false, + }; + } + '/' => { + self.mode = Mode::Slash { + regex: self.expression_start, + } + } + '`' => { + self.statement_start = false; + if self.template_braces.len() == MAX_TEMPLATE_DEPTH { + self.mode = Mode::Opaque; + } else { + self.template_braces.push(0); + self.mode = Mode::Template { + escaped: false, + dollar: false, + }; + } + } + '{' => { + // Blocks admit a new statement after closing; objects and + // function expressions finish a value and admit division. + let class_body = self.classes.last().is_some_and(|class| { + class.parenthesis_depth == self.parentheses.len() + && class.brace_depth == self.braces.len() + }); + // A superclass function can open at the class head's + // depth. Its body must not consume the pending class. + let kind = if let Some(body) = pending_body { + body + } else if class_body { + self.classes.pop().expect("should have pending class").body + } else if self.statement_start || !self.expression_start { + BraceKind::StatementBlock + } else { + BraceKind::Object + }; + if self.braces.len() == MAX_TEMPLATE_DEPTH { + self.mode = Mode::Opaque; + } else { + self.braces.push(kind); + } + self.expression_start = true; + self.statement_start = !matches!(kind, BraceKind::Object); + if let Some(braces) = self.template_braces.last_mut() { + *braces = braces.saturating_add(1); + } + } + '}' => { + if self.template_braces.last() == Some(&0) { + self.mode = Mode::Template { + escaped: false, + dollar: false, + }; + } else { + let kind = self.braces.pop(); + self.expression_start = matches!(kind, Some(BraceKind::StatementBlock)); + self.statement_start = self.expression_start; + if let Some(braces) = self.template_braces.last_mut() { + *braces -= 1; + } + } + } + ':' => { + // A reserved word used as an object key is not a class head. + if self.classes.last().is_some_and(|class| { + class.parenthesis_depth == self.parentheses.len() + && class.brace_depth == self.braces.len() + }) { + self.classes.pop(); + } + self.expression_start = true; + self.statement_start = false; + } + _ => { + if !character.is_whitespace() { + self.expression_start = !matches!(character, ')' | ']' | '.'); + self.statement_start = character == ';'; + } + } + } + true + } + Mode::Slash { regex } => { + match character { + '/' => self.mode = Mode::LineComment, + '*' => self.mode = Mode::BlockComment { star: false }, + _ if *regex => { + self.mode = Mode::Regex { + escaped: false, + class: false, + }; + return self.is_code(character); + } + _ => { + self.mode = Mode::Code; + self.expression_start = true; + self.statement_start = false; + self.after_dot = false; + return self.is_code(character); + } + } + false + } + Mode::Regex { escaped, class } => { + if *escaped { + *escaped = false; + } else if character == '\\' { + *escaped = true; + } else if character == '[' { + *class = true; + } else if character == ']' { + *class = false; + } else if character == '/' && !*class { + self.mode = Mode::Code; + self.expression_start = false; + } + false + } + Mode::String { quote, escaped } => { + if *escaped { + *escaped = false; + } else if character == '\\' { + *escaped = true; + } else if character == *quote { + self.mode = Mode::Code; + self.expression_start = false; + } + false + } + Mode::LineComment => { + if matches!(character, '\n' | '\r' | '\u{2028}' | '\u{2029}') { + self.mode = Mode::Code; + } + false + } + Mode::BlockComment { star } => { + if *star && character == '/' { + self.mode = Mode::Code; + } else { + *star = character == '*'; + } + false + } + Mode::Template { escaped, dollar } => { + if *escaped { + *escaped = false; + *dollar = false; + } else if character == '\\' { + *escaped = true; + *dollar = false; + } else if character == '`' { + self.template_braces.pop(); + self.mode = Mode::Code; + self.expression_start = false; + } else if *dollar && character == '{' { + self.mode = Mode::Code; + self.expression_start = true; + self.statement_start = false; + } else { + *dollar = character == '$'; + } + false + } + Mode::Opaque => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn masks_inert_text_at_every_fragment_boundary() { + let source = r#"'é self.__next_f.push('/* self.__next_f.push( */`😀 ${ {nested: `self.__next_f.push(`} } ${self.__next_f.push([1,"data"])} `"#; + let expected = ScriptLexer::default().mask(source); + assert_eq!(expected.len(), source.len(), "should preserve byte offsets"); + assert_eq!(expected.matches("self.__next_f.push(").count(), 1); + for split in (0..=source.len()).filter(|split| source.is_char_boundary(*split)) { + let mut lexer = ScriptLexer::default(); + let actual = format!( + "{}{}", + lexer.mask(&source[..split]), + lexer.mask(&source[split..]) + ); + assert_eq!( + actual, expected, + "should preserve lexical state at split {split}" + ); + } + } + + #[test] + fn brace_context_survives_every_fragment_boundary() { + for prefix in [ + "{} /self.__next_f.push()/;", + "if (ready) { work() } /self.__next_f.push()/;", + "if (ready) { if (nested) { work() } } /self.__next_f.push()/;", + "function demo() { return 1 } /self.__next_f.push()/;", + "async function demo() {} /self.__next_f.push()/;", + "const n = {} / 2;", + "const n = {nested: {}} / 2;", + "const n = function() {} / 2;", + "const n = function named() { return {} } / 2;", + "const n = (() => {}) / 2;", + "class Demo { method() { return 1 } } /self.__next_f.push()/;", + "const n = class { method() { return 1 } } / 2;", + "const n = class extends mixin({}) {} / 2;", + "const n = class extends function() {} {} / 2;", + "const n = class extends function Base() {} {} / 2;", + "const n = class extends (function() {}) {} / 2;", + "const n = class extends (class {}) {} / 2;", + "const n = {class: {}, method() { return 1 }} / 2;", + "const n = {method() { if (ready) { work() } /self.__next_f.push()/ }} / 2;", + "const n = `value ${ {} / 2 }`;", + ] { + let source = format!("{prefix}self.__next_f.push([1,\"data\"])"); + for split in 0..=source.len() { + let mut lexer = ScriptLexer::default(); + let code = format!( + "{}{}", + lexer.mask(&source[..split]), + lexer.mask(&source[split..]) + ); + assert_eq!(code.len(), source.len(), "should preserve source offsets"); + assert_eq!( + code.matches("self.__next_f.push(").count(), + 1, + "should expose only the executable push: prefix={prefix}, split={split}" + ); + } + } + } + + #[test] + fn bounds_nested_braces() { + let mut lexer = ScriptLexer::default(); + lexer.mask(&"{".repeat(MAX_TEMPLATE_DEPTH + 1)); + assert!(lexer.is_opaque(), "should refuse excessive brace nesting"); + assert_eq!(lexer.braces.len(), MAX_TEMPLATE_DEPTH); + assert!(!lexer.mask("self.__next_f.push(").contains("__next_f")); + } + + #[test] + fn bounds_pending_class_heads() { + let mut lexer = ScriptLexer::default(); + lexer.mask(&"class extends (".repeat(MAX_TEMPLATE_DEPTH + 1)); + assert!(lexer.is_opaque(), "should refuse excessive class nesting"); + assert_eq!(lexer.classes.len(), MAX_TEMPLATE_DEPTH); + assert!(!lexer.mask("self.__next_f.push(").contains("__next_f")); + } + + #[test] + fn bounds_nested_templates() { + let mut lexer = ScriptLexer::default(); + lexer.mask(&"`${".repeat(MAX_TEMPLATE_DEPTH + 1)); + assert!(lexer.is_opaque(), "should refuse excessive nesting"); + assert_eq!(lexer.template_braces.len(), MAX_TEMPLATE_DEPTH); + assert!(!lexer.mask("self.__next_f.push(").contains("__next_f")); + } +} diff --git a/crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs b/crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs index 4e76ff7b1..4e473b6e4 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs @@ -62,14 +62,14 @@ impl IntegrationScriptRewriter for NextJsNextDataRewriter { } fn rewrite(&self, content: &str, ctx: &IntegrationScriptContext<'_>) -> ScriptRewriteAction { - if self.config.rewrite_attributes.is_empty() { - return ScriptRewriteAction::keep(); - } - let state = document_state(ctx.document_state); let mut state = state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + state.next_data_fragment = true; + if self.config.rewrite_attributes.is_empty() { + return ScriptRewriteAction::keep(); + } match capture_fragment( &mut state.next_data, diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index f1d1d0b50..ca6897aa4 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -36,7 +36,7 @@ use crate::auction::types::{AuctionContext, AuctionRequest, MediaType}; use crate::auction::types::{AuctionResponse, Bid as AuctionBid}; use crate::cache_policy::{CacheControlPolicy, EdgeCacheHeader}; use crate::consent_config::ConsentForwardingMode; -use crate::cookies::{CONSENT_COOKIE_NAMES, strip_cookies}; +use crate::cookies::auction_cookie_header; use crate::error::TrustedServerError; #[cfg(test)] use crate::http_util::RequestInfo; @@ -2088,24 +2088,10 @@ fn apply_prebid_header_values( let Some(cookie_value) = cookie else { return; }; - if !consent_forwarding.strips_consent_cookies() { - to.headers_mut() - .insert(header::COOKIE, cookie_value.clone()); - return; - } - match cookie_value.to_str() { - Ok(value) => { - let stripped = strip_cookies(value, CONSENT_COOKIE_NAMES); - if !stripped.is_empty() - && let Ok(cookie_header) = HeaderValue::from_str(&stripped) - { - to.headers_mut().insert(header::COOKIE, cookie_header); - } - } - Err(_) => { - to.headers_mut() - .insert(header::COOKIE, cookie_value.clone()); - } + if let Some(value) = + auction_cookie_header(cookie_value, consent_forwarding.strips_consent_cookies()) + { + to.headers_mut().insert(header::COOKIE, value); } } @@ -3964,6 +3950,7 @@ mod tests { fn make_request_info(context: &AuctionContext<'_>) -> RequestInfo { RequestInfo::from_request(context.request, context.services.client_info()) + .expect("should extract valid test request info") } fn config_from_settings( @@ -6864,6 +6851,9 @@ external_bundle_sri = "sha384-AAAA" let mut auction_request = create_test_auction_request(); auction_request.user.eids = Some(vec![ crate::openrtb::Eid { + inserter: None, + matcher: None, + mm: None, source: "liveramp.com".to_owned(), uids: vec![crate::openrtb::Uid { id: "LR_xyz".to_owned(), @@ -6872,6 +6862,9 @@ external_bundle_sri = "sha384-AAAA" }], }, crate::openrtb::Eid { + inserter: None, + matcher: None, + mm: None, source: "id5-sync.com".to_owned(), uids: vec![crate::openrtb::Uid { id: "ID5_abc".to_owned(), @@ -6880,6 +6873,9 @@ external_bundle_sri = "sha384-AAAA" }], }, crate::openrtb::Eid { + inserter: None, + matcher: None, + mm: None, source: "google.com".to_owned(), uids: vec![crate::openrtb::Uid { id: "pair-id".to_owned(), @@ -9731,6 +9727,68 @@ set = { networkId = 42 } ); } + #[test] + fn planned_transport_never_forwards_authenticated_handoff_proof() { + let plan = + crate::auction::plan::AuctionPlan::compile(crate::auction::plan::AuctionPlanConfig { + timeout_ms: 321, + providers: std::collections::BTreeMap::new(), + bidders: std::collections::BTreeMap::new(), + mediator: None, + request_signing: None, + }) + .expect("should compile empty plan"); + for mode in [ + ConsentForwardingMode::Both, + ConsentForwardingMode::CookiesOnly, + ConsentForwardingMode::OpenrtbOnly, + ] { + for cookie in [ + HeaderValue::from_static("session=fictional; ts-eids-s=signed-proof; __gpp=signal"), + HeaderValue::from_bytes(b"session=\xff; ts-eids-s \t =signed-proof") + .expect("should build opaque header"), + ] { + let inbound = http::Request::builder() + .uri("https://publisher.example/auction") + .header(header::COOKIE, cookie.clone()) + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let routed = crate::auction::routing::route_auction( + canonical_parity_auction_request(), + &inbound, + &plan, + None, + ); + let mut outbound = http::Request::builder() + .uri("https://pbs.example.test/openrtb2/auction") + .body(EdgeBody::empty()) + .expect("should build outbound request"); + apply_prebid_transport_headers( + routed.prebid_transport_headers(), + &mut outbound, + mode, + None, + ); + if cookie.to_str().is_err() { + assert!( + !outbound.headers().contains_key(header::COOKIE), + "should drop opaque headers carrying replay proof" + ); + } else { + let forwarded = outbound.headers()[header::COOKIE] + .to_str() + .expect("should read forwarded cookies"); + assert!( + !forwarded.contains("ts-eids-s"), + "should never disclose the shared handoff to PBS" + ); + assert!(forwarded.contains("session=fictional")); + assert_eq!(forwarded.contains("__gpp"), !mode.strips_consent_cookies()); + } + } + } + } + #[test] fn planned_transport_preserves_malformed_cookie_bytes_and_attested_xff() { let inbound = http::Request::builder() diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index d3c09cd7d..9ce05fbb6 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -8,10 +8,16 @@ use error_stack::Report; use http::{Method, Request, Response}; use matchit::Router; +use super::identity::{ + IdentityCapture, IdentityCaptureInput, IdentityRequestBody, inspect_response, +}; use crate::auction::AuctionPlan; use crate::constants::HEADER_X_TS_EC; use crate::ec::EcContext; +use crate::ec::identity::stage_capture; use crate::ec::kv::KvIdentityGraph; +use crate::ec::partner::normalize_partner_source_domain; +use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::http_util::is_navigation_request; @@ -55,11 +61,18 @@ pub enum AttributeRewriteOutcome { RemoveElement, } -/// Action returned by inline script rewriters to describe how to mutate the node. +/// Action on the current working script text fragment, composed in registration order. +/// +/// Every matching stage receives the previous stage's output, including empty +/// suppressed text and the original final-fragment flag. Only the composed result +/// is applied to the parser fragment. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ScriptRewriteAction { + /// Preserve the current working text, not the original parser text. Keep, + /// Pass replacement text to subsequent matching stages. Replace(String), + /// Suppress this fragment; downstream stages still run and may flush held text. RemoveNode, } @@ -93,6 +106,7 @@ pub struct IntegrationAttributeContext<'a> { /// Context passed to script/text rewriters for inline HTML handling. #[derive(Debug)] pub struct IntegrationScriptContext<'a> { + /// The individual rewriter's CSS selector, matched by `lol_html`. pub selector: &'a str, pub request_host: &'a str, pub request_scheme: &'a str, @@ -564,13 +578,15 @@ pub trait IntegrationAttributeRewriter: Send + Sync { ) -> AttributeRewriteAction; } -/// Trait for integration-provided inline script/text rewrite hooks. +/// Trait for integration-provided inline script rewrite hooks. pub trait IntegrationScriptRewriter: Send + Sync { /// Identifier for logging/diagnostics. fn integration_id(&self) -> &'static str; - /// CSS selector (e.g. `script#__NEXT_DATA__`) that should trigger this rewriter. + /// CSS selector matching `"#.to_vec(), + br#""#, + ) + } else { + ( + format!(r#"var api="https://{SOURCEPOINT_CDN_HOST}/consent/tcfv2";"#) + .into_bytes(), + br#"var api="/integrations/sourcepoint/cdn/consent/tcfv2";"#, + ) + }; + client.stub.push_response_with_headers( + 200, + input, + vec![ + ("content-type", content_type), + ("content-encoding", encoding), + ], + ); + let services = build_services_with_http_client(client.clone()); + let response = integration + .handle( + &settings, + &services, + make_req( + Method::GET, + "https://publisher.example.com/integrations/sourcepoint/cdn/asset", + ), + ) + .await + .expect("should rewrite explicitly identity-encoded responses"); + + assert!( + response.headers().get(header::CONTENT_ENCODING).is_none(), + "should drop {encoding:?} Content-Encoding after rewriting" + ); + assert!( + client.reads.load(Ordering::Relaxed) > 0, + "should collect {encoding:?} bodies for rewriting" + ); + assert_eq!( + take_body_bytes(response), + expected, + "should rewrite identity content regardless of casing and whitespace" + ); + } + } + }); + } + + #[test] + fn handle_rewrites_streamed_html_without_content_length() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let client = Arc::new(StreamingHttpClient::new()); + client.stub.push_response_with_headers( + 200, + br#""#.to_vec(), + vec![("content-type", "text/html"), ("cache-control", "no-store")], + ); + let services = build_services_with_http_client(client.clone()); + + let response = integration + .handle( + &settings, + &services, + make_req(Method::GET, "https://publisher.example.com/integrations/sourcepoint/cdn/us_pm/index.html"), + ) + .await + .expect("should proxy HTML without Content-Length"); + + assert_eq!( + get_header_str(&response, header::CACHE_CONTROL), + Some("no-store"), + "should preserve upstream HTML cache policy" + ); + assert_eq!( + response.into_body().into_bytes_bounded(1024).await.expect("should collect HTML response").as_ref(), + br#""#, + "should rewrite streamed privacy-manager assets without Content-Length" + ); + }); + } + + #[test] + fn handle_accepts_exact_rewrite_limit_and_stops_reading_on_overflow() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let limit = MAX_REWRITE_BODY_SIZE as usize; + for content_type in ["application/javascript", "text/html"] { + for declared_length in [None, Some("1")] { + for extra_bytes in [0, 1, TEST_CHUNK_SIZE * 2] { + let client = Arc::new(StreamingHttpClient::new()); + let mut headers = vec![("content-type", content_type)]; + if let Some(length) = declared_length { + headers.push(("content-length", length)); + } + client.stub.push_response_with_headers( + 200, + vec![b' '; limit + extra_bytes], + headers, + ); + let services = build_services_with_http_client(client.clone()); + + let result = integration.handle( + &settings, + &services, + make_req(Method::GET, "https://publisher.example.com/integrations/sourcepoint/cdn/asset"), + ).await; + + if extra_bytes == 0 { + let response = result.expect("should accept exactly 5 MiB"); + assert!( + response.headers().get(header::CONTENT_LENGTH).is_none(), + "should remove the advisory length after rewriting" + ); + assert_eq!( + take_body_bytes(response).len(), + limit, + "should retain the entire body at the limit" + ); + } else { + let error = + result.expect_err("should reject an oversized streamed body"); + assert_eq!( + error.current_context().status_code(), + StatusCode::BAD_GATEWAY, + "should report upstream overflow as 502" + ); + assert!( + matches!(error.current_context(), TrustedServerError::Integration { integration, message } if integration == SOURCEPOINT_INTEGRATION_ID && message.contains("exceeds")), + "should identify Sourcepoint response overflow" + ); + } + assert_eq!( + client.reads.load(Ordering::Relaxed), + limit / TEST_CHUNK_SIZE + 1, + "should stop at EOF or the first overflowing chunk without draining the stream" + ); + } + } + } + }); + } + + #[test] + fn handle_passes_through_declared_oversize_without_reading() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let integration = SourcepointIntegration::new(Arc::new(config(true))); + for content_type in ["application/javascript", "text/html"] { + let client = Arc::new(StreamingHttpClient::new()); + let length = (MAX_REWRITE_BODY_SIZE + 1).to_string(); + client.stub.push_response_with_headers( + 200, + vec![b' '; MAX_REWRITE_BODY_SIZE as usize + 1], + vec![ + ("content-type", content_type), + ("content-length", &length), + ("cache-control", "no-store"), + ], + ); + let services = build_services_with_http_client(client.clone()); + + let response = integration + .handle( + &settings, + &services, + make_req( + Method::GET, + "https://publisher.example.com/integrations/sourcepoint/cdn/asset", + ), + ) + .await + .expect("should pass through a declared oversized response"); + + assert!( + matches!(response.body(), EdgeBody::Stream(_)), + "should retain the original stream" + ); + assert_eq!( + client.reads.load(Ordering::Relaxed), + 0, + "should not poll a declared oversized body" + ); + assert_eq!( + get_header_str(&response, header::CONTENT_LENGTH), + Some(length.as_str()), + "should preserve the pass-through length" + ); + assert_eq!( + get_header_str(&response, header::CACHE_CONTROL), + Some("no-store"), + "should preserve upstream cache policy" + ); + } + }); + } + + #[test] + fn handle_keeps_ineligible_responses_streaming() { + futures::executor::block_on(async { + let settings = create_test_settings(); + for (content_type, method, status, rewrite_sdk) in [ + ("application/json", Method::GET, 200, true), + ("text/css", Method::GET, 200, true), + ("image/png", Method::GET, 200, true), + ("application/javascript", Method::GET, 200, false), + ("text/html", Method::GET, 200, false), + ("application/javascript", Method::HEAD, 200, true), + ("text/html", Method::POST, 200, true), + ("application/javascript", Method::GET, 206, true), + ("text/html", Method::GET, 404, true), + ] { + let mut cfg = config(true); + cfg.rewrite_sdk = rewrite_sdk; + let integration = SourcepointIntegration::new(Arc::new(cfg)); + let client = Arc::new(StreamingHttpClient::new()); + client.stub.push_response_with_headers( + status, + b"unchanged".to_vec(), + vec![ + ("content-type", content_type), + ("content-encoding", "identity"), + ("cache-control", "no-store"), + ], + ); + let services = build_services_with_http_client(client.clone()); + let expected_body: &[u8] = if method == Method::HEAD { + b"" + } else { + b"unchanged" + }; + let mut request = make_req( + method, + "https://publisher.example.com/integrations/sourcepoint/cdn/asset", + ); + set_req_header(&mut request, header::ACCEPT_ENCODING, "gzip, br"); + + let response = integration + .handle(&settings, &services, request) + .await + .expect("should pass through an ineligible response"); + + assert!( + matches!(response.body(), EdgeBody::Stream(_)), + "should leave ineligible bodies streaming" + ); + assert_eq!( + client.reads.load(Ordering::Relaxed), + 0, + "should not poll an ineligible body" + ); + assert_eq!( + get_header_str(&response, header::CONTENT_ENCODING), + Some("identity"), + "should preserve pass-through encoding" + ); + assert_eq!( + get_header_str(&response, header::CACHE_CONTROL), + Some("no-store"), + "should preserve pass-through cache policy" + ); + assert_eq!( + response + .into_body() + .into_bytes_bounded(1024) + .await + .expect("should collect pass-through body") + .as_ref(), + expected_body, + "should preserve pass-through bytes or omit them for HEAD" + ); + assert!( + client.stub.recorded_request_headers()[0] + .iter() + .any(|(name, value)| name == "accept-encoding" && value == "gzip, br"), + "should forward the client's encoding for non-script paths" + ); + } + }); + } + + #[test] + fn handle_rewrites_on_buffered_adapters_with_or_without_content_length() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let integration = SourcepointIntegration::new(Arc::new(config(true))); + for has_length in [false, true] { + let client = Arc::new(StubHttpClient::new()); + let input = format!(r#"var api="https://{SOURCEPOINT_CDN_HOST}/consent/tcfv2";"#); + let length = input.len().to_string(); + let mut headers = vec![ + ("content-type", "application/javascript"), + ("content-encoding", "identity"), + ("vary", "Accept-Encoding, Origin"), + ]; + if has_length { + headers.push(("content-length", &length)); + } + client.push_response_with_headers(200, input.into_bytes(), headers); + let services = build_services_with_http_client(client.clone()); + + let response = integration + .handle( + &settings, + &services, + make_req( + Method::GET, + "https://publisher.example.com/integrations/sourcepoint/cdn/wrapper.js", + ), + ) + .await + .expect("should rewrite a buffered response"); + + assert_eq!( + client.recorded_stream_response_flags(), + vec![false], + "should respect adapters without streaming support" + ); + assert!( + response.headers().get(header::CONTENT_LENGTH).is_none(), + "should not forward a stale upstream length" + ); + assert!( + response.headers().get(header::CONTENT_ENCODING).is_none(), + "should remove upstream encoding after rewriting" + ); + assert_eq!( + get_header_str(&response, header::VARY), + Some("Origin"), + "should remove only Accept-Encoding from Vary" + ); + assert_eq!( + get_header_str(&response, header::CACHE_CONTROL), + Some("public, max-age=3600"), + "should retain the static JavaScript cache policy" + ); + assert_eq!( + get_header_str(&response, header::CONTENT_TYPE), + Some("application/javascript; charset=utf-8"), + "should identify rewritten JavaScript" + ); + assert_eq!( + take_body_bytes(response), + br#"var api="/integrations/sourcepoint/cdn/consent/tcfv2";"#, + "should rewrite with either header state" + ); + } + }); + } + + #[test] + fn handle_javascript_cache_policy_follows_static_path_classification() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let integration = SourcepointIntegration::new(Arc::new(config(true))); + for (path, is_static) in [ + ("/wrapper/v2/script", false), + ("/consent/tcfv2", false), + ("/mms/v2/get_site_data/other", false), + ("/wrapper.js", true), + ("/module/sourcepoint.mjs", true), + ("/unified/bundle", true), + ] { + for (upstream_cache, forwarded_cookies, sets_cookie, dynamic_cache) in [ + (Some("no-store"), false, false, "no-store"), + ( + Some("private, max-age=60"), + true, + false, + "private, max-age=60", + ), + (None, true, false, "private, max-age=0"), + (None, false, false, "public, max-age=3600"), + ( + Some("public, max-age=3600"), + true, + true, + "private, no-store", + ), + ] { + for has_length in [false, true] { + let client = Arc::new(StreamingHttpClient::new()); + let input = + format!(r#"var api="https://{SOURCEPOINT_CDN_HOST}/consent/tcfv2";"#); + let length = input.len().to_string(); + let mut headers = vec![("content-type", "application/javascript")]; + if has_length { + headers.push(("content-length", length.as_str())); + } + if let Some(cache) = upstream_cache { + headers.push(("cache-control", cache)); + } + if sets_cookie { + headers.push(("set-cookie", "consentUUID=example; Path=/")); + } + client + .stub + .push_response_with_headers(200, input.into_bytes(), headers); + let services = build_services_with_http_client(client.clone()); + let mut request = make_req( + Method::GET, + &format!("https://publisher.example.com{SOURCEPOINT_CDN_PREFIX}{path}"), + ); + if forwarded_cookies { + set_req_header(&mut request, header::COOKIE, "consentUUID=example"); + } + + let response = integration + .handle(&settings, &services, request) + .await + .expect("should rewrite JavaScript with path-aware cache policy"); + let expected_cache = if is_static && !sets_cookie { + "public, max-age=3600" + } else { + dynamic_cache + }; + assert_eq!( + get_header_str(&response, header::CACHE_CONTROL), + Some(expected_cache), + "should apply cache policy for {path}, upstream {upstream_cache:?}, \ + forwarded cookies {forwarded_cookies}, Set-Cookie {sets_cookie}, \ + declared length {has_length}" + ); + assert_eq!( + take_body_bytes(response), + br#"var api="/integrations/sourcepoint/cdn/consent/tcfv2";"#, + "should rewrite JavaScript at {path} regardless of cache classification" + ); + } + } + } + }); + } + + #[test] + fn handle_site_data_requests_identity_and_preserves_dynamic_cache_policy() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let integration = SourcepointIntegration::new(Arc::new(config(true))); + for (upstream_cache, forwarded_cookies, sets_cookie, expected_cache) in [ + (Some("no-store"), false, false, "no-store"), + ( + Some("private, max-age=60"), + true, + false, + "private, max-age=60", + ), + (None, true, false, "private, max-age=0"), + (None, false, false, "public, max-age=3600"), + ( + Some("public, max-age=3600"), + false, + true, + "private, no-store", + ), + ] { + let client = Arc::new(StreamingHttpClient::new()); + let input = format!(r#"var api="https://{SOURCEPOINT_CDN_HOST}/consent/tcfv2";"#); + let mut headers = vec![("content-type", "application/javascript")]; + if let Some(cache) = upstream_cache { + headers.push(("cache-control", cache)); + } + if sets_cookie { + headers.push(("set-cookie", "consentUUID=example; Path=/")); + } + client + .stub + .push_response_with_headers(200, input.into_bytes(), headers); + let services = build_services_with_http_client(client.clone()); + let mut request = make_req( + Method::GET, + "https://publisher.example.com/integrations/sourcepoint/cdn/mms/v2/get_site_data?account_id=123", + ); + set_req_header(&mut request, header::ACCEPT_ENCODING, "gzip, br"); + if forwarded_cookies { + set_req_header(&mut request, header::COOKIE, "consentUUID=example"); + } + + let response = integration + .handle(&settings, &services, request) + .await + .expect("should rewrite dynamic site data"); + + assert!( + client.stub.recorded_request_headers()[0] + .iter() + .any(|(name, value)| name == "accept-encoding" && value == "identity"), + "should request uncompressed site data despite its extensionless path" + ); + assert_eq!( + get_header_str(&response, header::CACHE_CONTROL), + Some(expected_cache), + "should use the dynamic endpoint's cache policy" + ); + assert_eq!( + take_body_bytes(response), + br#"var api="/integrations/sourcepoint/cdn/consent/tcfv2";"#, + "should rewrite unknown-length site data" + ); + } + }); + } + + #[test] + fn handle_preserves_invalid_utf8_bytes_and_headers() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let client = Arc::new(StreamingHttpClient::new()); + let bytes = vec![b'a', 0xff, b'b']; + client.stub.push_response_with_headers( + 200, + bytes.clone(), + vec![ + ("content-type", "application/javascript"), + ("content-encoding", "identity"), + ("cache-control", "no-store"), + ], + ); + let services = build_services_with_http_client(client.clone()); + + let response = integration + .handle( + &settings, + &services, + make_req( + Method::GET, + "https://publisher.example.com/integrations/sourcepoint/cdn/wrapper.js", + ), + ) + .await + .expect("should retain non-UTF-8 content unchanged"); + + assert!( + client.reads.load(Ordering::Relaxed) > 0, + "should exercise UTF-8 fallback after bounded collection" + ); + + assert_eq!( + get_header_str(&response, header::CONTENT_ENCODING), + Some("identity"), + "should preserve encoding when no rewrite occurs" + ); + assert_eq!( + get_header_str(&response, header::CACHE_CONTROL), + Some("no-store"), + "should preserve cache policy when no rewrite occurs" + ); + assert_eq!( + take_body_bytes(response), + bytes, + "should preserve invalid UTF-8 bytes" + ); + }); + } fn config(enabled: bool) -> SourcepointConfig { SourcepointConfig { @@ -1444,9 +2278,12 @@ mod tests { assert!(SourcepointIntegration::is_likely_javascript_path( "/module/sourcepoint.mjs" )); - assert!(!SourcepointIntegration::is_likely_javascript_path( + assert!(SourcepointIntegration::is_likely_javascript_path( "/mms/v2/get_site_data" )); + assert!(!SourcepointIntegration::is_likely_javascript_path( + "/mms/v2/get_site_data/other" + )); assert!(!SourcepointIntegration::is_likely_javascript_path( "/consent/tcfv2" )); @@ -1950,7 +2787,12 @@ mod tests { set_header(&mut response, header::CACHE_CONTROL, "no-store"); *response.body_mut() = EdgeBody::from(b"payload".to_vec()); - integration.rewrite_javascript_response(&mut response, "rewritten".to_string()); + integration.rewrite_javascript_response( + &mut response, + "rewritten".to_string(), + "/wrapper.js", + false, + ); assert_eq!(response.status(), StatusCode::OK); assert_eq!( @@ -1989,7 +2831,12 @@ mod tests { set_header(&mut response, header::CACHE_CONTROL, "public, max-age=3600"); *response.body_mut() = EdgeBody::from(b"payload".to_vec()); - integration.rewrite_javascript_response(&mut response, "rewritten".to_string()); + integration.rewrite_javascript_response( + &mut response, + "rewritten".to_string(), + "/wrapper.js", + false, + ); assert_eq!( get_header_str(&response, header::CACHE_CONTROL), @@ -2009,7 +2856,12 @@ mod tests { set_header(&mut response, header::VARY, "Accept-Encoding"); *response.body_mut() = EdgeBody::from(b"payload".to_vec()); - integration.rewrite_javascript_response(&mut response, "rewritten".to_string()); + integration.rewrite_javascript_response( + &mut response, + "rewritten".to_string(), + "/wrapper.js", + false, + ); assert!( response.headers().get(header::VARY).is_none(), diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 1c2017929..16cecb4b9 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -31,6 +31,7 @@ ) )] +pub mod access_telemetry; pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; @@ -48,6 +49,7 @@ pub mod creative_opportunities; pub mod ec; pub(crate) mod edge_cookie; pub mod error; +pub mod forwarder; pub mod geo; pub mod host_header; pub(crate) mod host_rewrite; @@ -62,6 +64,8 @@ pub mod proxy; pub mod publisher; pub mod redacted; pub mod request_signing; +pub mod request_timing; +pub(crate) mod response_header_rewrite; pub mod response_privacy; pub mod rsc_flight; pub(crate) mod s3_sigv4; @@ -72,6 +76,7 @@ pub mod streaming_processor; pub mod streaming_replacer; pub mod test_support; pub mod tester_cookie; +pub mod trace; pub mod tsjs; pub mod version_header; diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 4aded7488..d8351fecf 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -65,11 +65,20 @@ pub struct ConsentedProvidersSettings { } /// An Extended User ID entry from an identity provider. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Eid { /// Identity provider domain (e.g. `"id5-sync.com"`). pub source: String, + /// Entity that inserted this EID into the request. + #[serde(skip_serializing_if = "Option::is_none")] + pub inserter: Option, + /// Entity that matched this EID. An empty supplied string is preserved. + #[serde(skip_serializing_if = "Option::is_none")] + pub matcher: Option, + /// `OpenRTB` match method. Zero is a supplied value, not absence. + #[serde(skip_serializing_if = "Option::is_none")] + pub mm: Option, /// One or more user IDs from this provider. pub uids: Vec, } @@ -186,10 +195,23 @@ pub struct BidTrustedServerExt<'a> { #[derive(Debug, Serialize)] pub struct ResponseExt { pub orchestrator: OrchestratorExt, + /// Optional request-scoped trace transport under its own namespace. + #[serde(skip_serializing_if = "Option::is_none")] + pub trusted_server: Option, } impl ToExt for ResponseExt {} +/// Request-scoped trace data beside ordinary orchestration metadata. +/// +/// The closed [`crate::trace::TraceAuctionTransportV1`] contains bounded evidence +/// or its exclusive unavailable marker; provider payloads never use this type. +#[derive(Debug, Serialize)] +pub struct TraceResponseExt { + /// Checked evidence or its fixed projection-failure transport envelope. + pub trace_auction: crate::trace::TraceAuctionTransportV1, +} + #[cfg(test)] mod tests { use super::*; @@ -216,6 +238,7 @@ mod tests { }; let ext = ResponseExt { + trusted_server: None, orchestrator: OrchestratorExt { strategy: "parallel_only".to_owned(), providers: 2, @@ -394,6 +417,9 @@ mod tests { #[test] fn eid_serializes_correctly() { let eid = Eid { + inserter: None, + matcher: None, + mm: None, source: "id5-sync.com".to_owned(), uids: vec![Uid { id: "ID5-abc123".to_owned(), @@ -418,6 +444,9 @@ mod tests { #[test] fn eid_serializes_vendor_specific_atype() { let eid = Eid { + inserter: None, + matcher: None, + mm: None, source: "google.com".to_owned(), uids: vec![Uid { id: "pair-id".to_owned(), diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 63f5c2a94..2802bd3e5 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -43,6 +43,7 @@ mod template_assembly; mod template_cache; #[cfg(test)] pub(crate) mod test_support; +mod timed_kv; mod traits; mod types; @@ -72,6 +73,7 @@ pub use template_cache::{ TemplateCookieValue, TemplateEntry, TemplateMetadata, TemplateMetadataEncodeError, UnavailableTemplateCache, VaryHeaderValues, VarySpec, reader_url_surrogate_key, }; +pub use timed_kv::TimedKvStore; pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; pub use types::{ ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId, diff --git a/crates/trusted-server-core/src/platform/timed_kv.rs b/crates/trusted-server-core/src/platform/timed_kv.rs new file mode 100644 index 000000000..6d57a429c --- /dev/null +++ b/crates/trusted-server-core/src/platform/timed_kv.rs @@ -0,0 +1,253 @@ +//! Latency-only timing decorator for KV store handles. +//! +//! [`TimedKvStore`] wraps an inner store plus a [`RequestTimings`] handle and +//! records [`Phase::EcKv`] around every call. It implements both +//! [`PlatformKvStore`] (for consent-store access obtained through +//! [`RuntimeServices`](super::RuntimeServices)) and [`EcKvStore`] (for +//! [`KvIdentityGraph`](crate::ec::kv::KvIdentityGraph) construction sites), +//! because no single existing abstraction covers the whole `ts-kv` taxonomy: +//! EC graph operations go through [`EcKvStore`] while consent persistence +//! uses [`PlatformKvStore`] directly. +//! +//! The decorator measures store-call latency only: it never reads, parses, +//! or logs any value passing through it. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use bytes::Bytes; +use edgezero_core::key_value_store::{KvError, KvPage, KvStore as PlatformKvStore}; +use error_stack::Report; + +use crate::ec::kv_backend::{EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteOutcome}; +use crate::error::TrustedServerError; +use crate::request_timing::{Phase, RequestTimings}; + +/// Wraps `inner` plus a [`RequestTimings`] handle, recording [`Phase::EcKv`] +/// around every store call made through it. +pub struct TimedKvStore { + /// The wrapped store handle. + inner: S, + /// The request's phase-timing collector. + timings: RequestTimings, +} + +impl TimedKvStore { + /// Creates a decorator around `inner` that records into `timings`. + #[must_use] + pub fn new(inner: S, timings: RequestTimings) -> Self { + Self { inner, timings } + } +} + +#[async_trait(?Send)] +impl PlatformKvStore for TimedKvStore> { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + let _span = self.timings.span(Phase::EcKv); + self.inner.get_bytes(key).await + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + let _span = self.timings.span(Phase::EcKv); + self.inner.put_bytes(key, value).await + } + + // Forwarded explicitly: the trait's default body falls back to + // `get_bytes`, which would silently downgrade a backend's cheap + // metadata-only existence probe (the Spin adapter has one) into a full + // value transfer just because the store was decorated. + async fn exists(&self, key: &str) -> Result { + let _span = self.timings.span(Phase::EcKv); + self.inner.exists(key).await + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + let _span = self.timings.span(Phase::EcKv); + self.inner.put_bytes_with_ttl(key, value, ttl).await + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + let _span = self.timings.span(Phase::EcKv); + self.inner.delete(key).await + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let _span = self.timings.span(Phase::EcKv); + self.inner.list_keys_page(prefix, cursor, limit).await + } +} + +impl EcKvStore for TimedKvStore { + fn store_name(&self) -> &str { + self.inner.store_name() + } + + fn lookup(&self, key: &str) -> Result, Report> { + let _span = self.timings.span(Phase::EcKv); + self.inner.lookup(key) + } + + fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result> { + let _span = self.timings.span(Phase::EcKv); + self.inner.insert(key, write) + } + + fn key_exists(&self, key: &str) -> Result> { + let _span = self.timings.span(Phase::EcKv); + self.inner.key_exists(key) + } + + fn list_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result, Report> { + let _span = self.timings.span(Phase::EcKv); + self.inner.list_keys_with_prefix(prefix, limit) + } + + fn delete(&self, key: &str) -> Result<(), Report> { + let _span = self.timings.span(Phase::EcKv); + self.inner.delete(key) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration as StdDuration; + + use super::*; + use crate::ec::kv_backend::test_support::InMemoryEcKv; + + #[test] + fn ec_kv_store_operations_accumulate_into_ec_kv_phase() { + let timings = RequestTimings::new(); + let store = TimedKvStore::new(InMemoryEcKv::new("test-store"), timings.clone()); + + store + .insert( + "key-a", + EcKvWrite { + body: "{}", + metadata: "{}", + ttl: StdDuration::from_secs(60), + mode: crate::ec::kv_backend::EcKvWriteMode::Add, + }, + ) + .expect("should insert into the in-memory store"); + store.lookup("key-a").expect("should read back the entry"); + + timings.mark_headers_ready(); + assert!( + timings.snapshot().kv_ms.is_some(), + "should record Phase::EcKv across both store calls" + ); + } + + #[test] + fn exists_delegates_to_the_inner_store_not_get_bytes() { + // A stub whose `exists` answer contradicts its `get_bytes` answer: + // if the decorator fell back to the trait's get-and-discard default + // body, this would return `false`. + struct ExistsOnlyStore; + + #[async_trait::async_trait(?Send)] + impl PlatformKvStore for ExistsOnlyStore { + async fn get_bytes(&self, _key: &str) -> Result, KvError> { + Ok(None) + } + async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { + Ok(()) + } + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: Bytes, + _ttl: StdDuration, + ) -> Result<(), KvError> { + Ok(()) + } + async fn delete(&self, _key: &str) -> Result<(), KvError> { + Ok(()) + } + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage { + keys: Vec::new(), + cursor: None, + }) + } + async fn exists(&self, _key: &str) -> Result { + Ok(true) + } + } + + let timings = RequestTimings::new(); + let inner: Arc = Arc::new(ExistsOnlyStore); + let store = TimedKvStore::new(inner, timings.clone()); + + let exists = futures::executor::block_on(store.exists("key")) + .expect("should forward the existence probe"); + assert!( + exists, + "should delegate to the inner exists, not the get_bytes default body" + ); + timings.mark_headers_ready(); + assert!( + timings.snapshot().kv_ms.is_some(), + "should time the existence probe like any other store operation" + ); + } + + #[test] + fn store_name_is_not_timed() { + let timings = RequestTimings::new(); + let store = TimedKvStore::new(InMemoryEcKv::new("test-store"), timings.clone()); + + assert_eq!(store.store_name(), "test-store"); + timings.mark_headers_ready(); + assert!( + timings.snapshot().kv_ms.is_none(), + "store_name is a metadata accessor, not a store operation" + ); + } + + #[test] + fn platform_kv_store_operations_accumulate_into_ec_kv_phase() { + let timings = RequestTimings::new(); + let inner: Arc = Arc::new(crate::platform::UnavailableKvStore); + let store = TimedKvStore::new(inner, timings.clone()); + + // UnavailableKvStore errors on every call; the decorator still times + // the attempt regardless of outcome. + futures::executor::block_on(async { + let _ = store.get_bytes("key").await; + let _ = store.put_bytes("key", Bytes::from_static(b"value")).await; + }); + + timings.mark_headers_ready(); + assert!( + timings.snapshot().kv_ms.is_some(), + "should record Phase::EcKv even when the inner store errors" + ); + } +} diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 5d659cdbf..04d5fa481 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -72,6 +72,15 @@ const PROXY_FORWARD_HEADERS: [header::HeaderName; 5] = [ HEADER_X_FORWARDED_FOR, ]; +/// Request headers that describe a body and must not ride on a bodyless redirect follow-up. +const REQUEST_BODY_HEADERS: [header::HeaderName; 5] = [ + header::CONTENT_TYPE, + header::CONTENT_LENGTH, + header::CONTENT_ENCODING, + header::CONTENT_LANGUAGE, + header::CONTENT_LOCATION, +]; + /// Curated request headers preserved for asset proxying. /// /// Unlike the HTML publisher fallback, asset requests need cache validation and @@ -651,6 +660,18 @@ fn finalize_proxied_response( let ct = meta.ct_raw.to_ascii_lowercase(); let compression = Compression::from_content_encoding(&meta.content_encoding); + // A HEAD response has no body to rewrite, and decoding its empty body would + // fail for a compressed type, so skip the rewrite. Its representation + // headers must still describe the body a GET would return: the GET path + // rewrites HTML and CSS and drops the upstream length, so drop it here too. + if req.method() == Method::HEAD { + if ct.contains("text/html") || ct.contains("text/css") { + beresp.headers_mut().remove(header::CONTENT_LENGTH); + } + apply_image_passthrough_metadata(req, target_url, &ct, &mut beresp, ""); + return Ok(beresp); + } + if ct.contains("text/html") { let processor = CreativeHtmlProcessor::new(settings); return process_response_with_pipeline( @@ -760,6 +781,13 @@ struct ProxyRedirectPolicy<'a> { /// the caller's EC ID as a `ts-ec` query parameter to the target URL. /// Optional bodies/headers can be supplied via [`ProxyRequestConfig`]. /// +/// Followed `307`/`308` redirects keep the original method and body. Followed +/// `301`/`302`/`303` redirects are sent as a bodyless `GET` (a `HEAD` stays +/// `HEAD`) without body-describing headers such as `Content-Type`. A `301`/`302` +/// answering any method other than `GET`, `HEAD` or `POST` (for example `PUT`) +/// is not followed: the redirect response is returned to the caller, so an +/// update is neither replayed elsewhere nor silently turned into a `GET`. +/// /// # Errors /// /// - [`TrustedServerError::Proxy`] if the target URL is invalid, uses an unsupported @@ -907,16 +935,8 @@ fn apply_asset_origin_auth( &config.region, &credentials, // s3_sigv4 converts this via chrono's `DateTime::::from`, which - // only accepts `std::time::SystemTime`. `std::time::SystemTime::now()` - // panics on `wasm32-unknown-unknown` (Cloudflare Workers), so derive an - // equivalent `std::time::SystemTime` from the wasm-safe `web_time` clock: - // `UNIX_EPOCH + elapsed` is pure arithmetic and never calls the panicking - // `now()`. On Fastly (wasm32-wasip1) and native, `web_time` delegates to - // the std clock, so behavior is unchanged there. - std::time::UNIX_EPOCH - + web_time::SystemTime::now() - .duration_since(web_time::UNIX_EPOCH) - .unwrap_or_default(), + // only accepts `std::time::SystemTime`. + crate::ec::std_system_time_now(), ) } } @@ -1225,7 +1245,7 @@ fn append_ec_id(req: &Request, target_url_parsed: &mut url::Url) { /// /// When `allowed_domains` is empty every host is permitted (open mode). /// When non-empty the host must match at least one pattern via [`is_host_allowed`]. -fn is_host_permitted>(allowed_domains: &[S], host: &str) -> bool { +pub(crate) fn is_host_permitted>(allowed_domains: &[S], host: &str) -> bool { allowed_domains.is_empty() || allowed_domains .iter() @@ -1265,6 +1285,9 @@ async fn proxy_with_redirects( let mut current_url = target_url_parsed.to_string(); let mut current_method: Method = req.method().clone(); + let mut current_body = body; + // Set once a redirect rewrites the request into a bodyless follow-up. + let mut body_dropped = false; for redirect_attempt in 0..=MAX_REDIRECTS { let parsed_url = url::Url::parse(¤t_url).map_err(|_| { @@ -1346,10 +1369,15 @@ async fn proxy_with_redirects( // insert() replaces any existing value, matching set_header() semantics. outbound_headers.insert(name.clone(), value.clone()); } + if body_dropped { + for header_name in &REQUEST_BODY_HEADERS { + outbound_headers.remove(header_name); + } + } for (name, value) in &outbound_headers { builder = builder.header(name, value); } - let body_bytes = body.map(<[u8]>::to_vec).unwrap_or_default(); + let body_bytes = current_body.map(<[u8]>::to_vec).unwrap_or_default(); let edge_req = builder .body(EdgeBody::from(body_bytes)) @@ -1418,12 +1446,39 @@ async fn proxy_with_redirects( ); }; + // Fetch keeps methods other than POST on a 301/302, but replaying the + // body would reach a target the caller did not address, and rewriting + // to GET would report success for an update that never ran. + if matches!(status, StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND) + && current_method != Method::GET + && current_method != Method::HEAD + && current_method != Method::POST + { + log::warn!( + "not following {} redirect for {current_method} request to {current_url}; returning redirect response", + status.as_u16() + ); + return finalize_response( + settings, + req, + ¤t_url, + beresp, + redirect_policy.stream_passthrough, + ); + } + if redirect_attempt == MAX_REDIRECTS { log::warn!( "redirect limit reached for {}; returning redirect response", current_url ); - return finalize_proxied_response(settings, req, ¤t_url, beresp); + return finalize_response( + settings, + req, + ¤t_url, + beresp, + redirect_policy.stream_passthrough, + ); } let next_url = url::Url::parse(location) @@ -1477,8 +1532,24 @@ async fn proxy_with_redirects( ); current_url = next_url.to_string(); - if status == StatusCode::SEE_OTHER { - current_method = Method::GET; + // 307/308 replay the original method and body by design. 301/302/303 + // become a bodyless GET (HEAD stays HEAD) so those redirects cannot + // carry the payload to a target the caller did not address. + if matches!( + status, + StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND | StatusCode::SEE_OTHER + ) { + if current_body.is_some_and(|bytes| !bytes.is_empty()) { + log::warn!( + "dropping {current_method} request body on {} redirect to {current_url}", + status.as_u16() + ); + } + if current_method != Method::HEAD { + current_method = Method::GET; + } + current_body = None; + body_dropped = true; } } @@ -1621,7 +1692,8 @@ pub async fn handle_first_party_click( /// # Errors /// /// Returns an error if JSON parsing fails, the URL cannot be parsed, the URL uses an -/// unsupported scheme, the URL lacks a host, or the host violates `proxy.allowed_domains`. +/// unsupported scheme, the URL lacks a host, the host violates `proxy.allowed_domains`, +/// or request metadata needed to determine the scheme contains a malformed host. pub async fn handle_first_party_proxy_sign( settings: &Settings, _services: &RuntimeServices, @@ -1632,18 +1704,19 @@ pub async fn handle_first_party_proxy_sign( // Capture the request's own scheme before the body is consumed: a // protocol-relative sign target inherits it. // - // Prefer the URI's explicit scheme, which adapters that deliver absolute - // request targets (Fastly, Spin after normalization) always carry, and fall - // back to TLS/forwarded metadata otherwise. What must never be used is the - // parsed request target: origin-form URIs — what browsers send and the Axum + // Prefer the authenticated public scheme, then the URI's explicit scheme + // for direct requests, and fall back to runtime ingress/TLS metadata. Avoid + // using the parsed request target: origin-form URIs — what browsers send and the Axum // adapter forwards verbatim — have no scheme of their own, so parsing would // hand back the placeholder base's `https` and make an HTTP dev server sign // an HTTPS target it then proxies over TLS against a plaintext service. - let request_scheme = req - .uri() - .scheme_str() - .map(str::to_ascii_lowercase) - .unwrap_or_else(|| RequestInfo::from_request(&req, _services.client_info()).scheme); + let request_scheme = match crate::forwarder::public_origin(&req) + .map(|origin| origin.scheme.clone()) + .or_else(|| req.uri().scheme_str().map(str::to_ascii_lowercase)) + { + Some(scheme) => scheme, + None => RequestInfo::from_request(&req, _services.client_info())?.scheme, + }; let payload = if method == Method::POST { let body_bytes = request_body_bytes(req.into_body(), "first-party sign")?; @@ -1670,37 +1743,33 @@ pub async fn handle_first_party_proxy_sign( }; let trimmed = payload.url.trim(); - let abs = if trimmed.starts_with("//") { - format!("{}:{}", request_scheme, trimmed) + let protocol_relative; + // A protocol-relative target inherits the signing request's scheme before + // normalization, so `//` and absolute input reach the same policy check. + let candidate = if trimmed.starts_with("//") { + protocol_relative = format!("{request_scheme}:{trimmed}"); + protocol_relative.as_str() } else { - crate::creative::to_abs(settings, trimmed).ok_or_else(|| { - Report::new(TrustedServerError::Proxy { - message: "unsupported url".to_string(), - }) - })? + trimmed }; - - if settings.rewrite.is_excluded(&abs) { - return Err(Report::new(TrustedServerError::Proxy { + let target = crate::creative::normalize_creative_url(candidate).ok_or_else(|| { + Report::new(TrustedServerError::Proxy { message: "unsupported url".to_string(), - })); - } - - let parsed = url::Url::parse(&abs).change_context(TrustedServerError::Proxy { - message: "invalid url".to_string(), + }) })?; - let scheme = parsed.scheme(); - if scheme != "http" && scheme != "https" { - return Err(Report::new(TrustedServerError::Proxy { - message: "unsupported scheme".to_string(), - })); - } - - let host = parsed.host_str().ok_or_else(|| { + let host = target.host_str().ok_or_else(|| { Report::new(TrustedServerError::Proxy { message: "missing host".to_string(), }) })?; + if !settings.rewrite.should_proxy_asset(host) { + log::debug!( + "sign request for `{host}` declined: host excluded or not in rewrite.include_domains" + ); + return Err(Report::new(TrustedServerError::Proxy { + message: "unsupported url".to_string(), + })); + } if !is_host_permitted(&settings.proxy.allowed_domains, host) { log::warn!( "sign request for `{}` blocked: host not in proxy.allowed_domains", @@ -1720,10 +1789,10 @@ pub async fn handle_first_party_proxy_sign( .to_string(); let extras = vec![(String::from("tsexp"), tsexp)]; - let mut base = parsed.clone(); + let mut base = target.clone(); base.set_query(None); base.set_fragment(None); - let proxied = crate::creative::build_proxy_url_with_extras(settings, &abs, &extras); + let proxied = crate::creative::build_proxy_url_with_extras(settings, target.as_str(), &extras); let resp = ProxySignResp { href: proxied, @@ -2695,20 +2764,171 @@ mod tests { let mut settings = create_test_settings(); settings.rewrite.exclude_domains = vec!["cdn.example".to_owned()]; - for url in ["https://cdn.example/asset.js", "//cdn.example/asset.js"] { - let body = serde_json::json!({ "url": url }); - let req = - build_http_post_json_request("https://edge.example/first-party/sign", &body); - let err: Report = - handle_first_party_proxy_sign(&settings, &noop_services(), req) - .await - .expect_err("should reject excluded URL"); + for method in [&Method::GET, &Method::POST] { + for url in [ + "https://cdn.example/asset.js", + "//cdn.example/asset.js", + "https://CDN.Example/asset.js", + ] { + let req = build_proxy_sign_request( + method, + "https://edge.example/first-party/sign", + url, + ); + let err: Report = + handle_first_party_proxy_sign(&settings, &noop_services(), req) + .await + .expect_err("should reject excluded URL"); + + assert_eq!( + err.current_context().status_code(), + StatusCode::BAD_GATEWAY, + "{} should reject excluded URL `{url}` as unsupported", + method.as_str() + ); + } + } + }); + } - assert_eq!( - err.current_context().status_code(), - StatusCode::BAD_GATEWAY, - "should reject excluded URL `{url}` as unsupported" - ); + #[test] + fn proxy_sign_rejects_excluded_urls_case_insensitively() { + futures::executor::block_on(async { + let mut settings = create_test_settings(); + settings.rewrite.exclude_domains = vec!["CDN.Example".to_owned()]; + + for method in [&Method::GET, &Method::POST] { + for url in ["https://cdn.example/asset.js", "//cdn.example/asset.js"] { + let req = build_proxy_sign_request( + method, + "https://edge.example/first-party/sign", + url, + ); + let err: Report = + handle_first_party_proxy_sign(&settings, &noop_services(), req) + .await + .expect_err("should reject a host excluded by a mixed-case entry"); + + assert_eq!( + err.current_context().status_code(), + StatusCode::BAD_GATEWAY, + "{} should reject `{url}` excluded by a mixed-case entry as unsupported", + method.as_str() + ); + } + } + }); + } + + #[test] + fn proxy_sign_applies_rewrite_include_domains_for_get_and_post() { + struct Case { + name: &'static str, + include_domains: &'static [&'static str], + exclude_domains: &'static [&'static str], + allowed_domains: &'static [&'static str], + target: &'static str, + expected: StatusCode, + } + + let cases = [ + Case { + name: "listed absolute", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &[], + target: "https://img.example.com/a.png", + expected: StatusCode::OK, + }, + Case { + name: "listed protocol-relative", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &[], + target: "//img.example.com/a.png", + expected: StatusCode::OK, + }, + Case { + name: "off-list absolute", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &[], + target: "https://img.example.net/a.png", + expected: StatusCode::BAD_GATEWAY, + }, + Case { + name: "off-list protocol-relative", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &[], + target: "//img.example.net/a.png", + expected: StatusCode::BAD_GATEWAY, + }, + Case { + name: "host in both lists", + include_domains: &["*.example.com"], + exclude_domains: &["img.example.com"], + allowed_domains: &[], + target: "https://img.example.com/a.png", + expected: StatusCode::BAD_GATEWAY, + }, + Case { + name: "off include list and off proxy allowlist", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &["*.example.org"], + target: "https://img.example.net/a.png", + expected: StatusCode::BAD_GATEWAY, + }, + Case { + name: "listed but off proxy allowlist", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &["*.example.org"], + target: "https://img.example.com/a.png", + expected: StatusCode::FORBIDDEN, + }, + Case { + name: "mixed-case host", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &[], + target: "https://IMG.Example.COM/a.png", + expected: StatusCode::OK, + }, + ]; + + let to_owned = |values: &[&str]| -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + }; + + futures::executor::block_on(async { + for case in &cases { + for method in [&Method::GET, &Method::POST] { + let label = format!("{} {}", method.as_str(), case.name); + let mut settings = create_test_settings(); + settings.rewrite.include_domains = to_owned(case.include_domains); + settings.rewrite.exclude_domains = to_owned(case.exclude_domains); + settings.proxy.allowed_domains = to_owned(case.allowed_domains); + let req = build_proxy_sign_request( + method, + "https://edge.example.com/first-party/sign", + case.target, + ); + + let status = + match handle_first_party_proxy_sign(&settings, &noop_services(), req).await + { + Ok(response) => response.status(), + Err(error) => error.current_context().status_code(), + }; + + assert_eq!( + status, case.expected, + "{label} should return {}", + case.expected + ); + } } }); } @@ -2743,6 +2963,43 @@ mod tests { }); } + #[test] + fn public_origin_proxy_sign_overrides_explicit_transport_scheme() { + futures::executor::block_on(async { + let mut settings = create_test_settings(); + settings.publisher.domain = "publisher.example.com".to_owned(); + settings.trusted_forwarder = Some(crate::settings::TrustedForwarderConfig { + auth_header: crate::forwarder::FORWARDER_AUTH_HEADER.to_owned(), + shared_secret: crate::redacted::Redacted::new( + "fictional-forwarder-secret-0123456789".to_owned(), + ), + }); + let body = serde_json::json!({ "url": "//cdn.example.com/asset.js" }); + let mut req = + build_http_post_json_request("http://internal.example.com/first-party/sign", &body); + req.headers_mut().insert( + crate::forwarder::FORWARDER_AUTH_HEADER, + HeaderValue::from_static("fictional-forwarder-secret-0123456789"), + ); + req.headers_mut().insert( + "x-forwarded-host", + HeaderValue::from_static("publisher.example.com:8443"), + ); + req.headers_mut() + .insert("x-forwarded-proto", HeaderValue::from_static("https")); + crate::forwarder::prepare_trusted_forwarder(&mut req, &settings); + + let resp = handle_first_party_proxy_sign(&settings, &noop_services(), req) + .await + .expect("should sign protocol-relative URL"); + let json = response_body_string(resp); + assert!( + json.contains("\"base\":\"https://cdn.example.com/asset.js\""), + "should inherit authenticated public HTTPS: {json}" + ); + }); + } + #[test] fn proxy_sign_preserves_non_standard_port() { futures::executor::block_on(async { @@ -3623,6 +3880,84 @@ mod tests { ); } + #[test] + fn proxied_html_click_wrapping_follows_explicit_rewrite_clicks() { + let html = r#"Excluded"#; + // (rewrite_creatives, rewrite_clicks, expect wrapped clicks) + let cases = [ + (true, None, true), + (false, None, true), + (true, Some(true), true), + (true, Some(false), false), + (false, Some(false), false), + (false, Some(true), true), + ]; + + for (rewrite_creatives, rewrite_clicks, expect_clicks) in cases { + let mut settings = create_test_settings(); + settings.auction.rewrite_creatives = rewrite_creatives; + settings.auction.rewrite_clicks = rewrite_clicks; + settings.rewrite.exclude_domains = vec!["excluded.example.com".to_owned()]; + let label = + format!("rewrite_creatives={rewrite_creatives} rewrite_clicks={rewrite_clicks:?}"); + let req = build_http_request(Method::GET, "https://edge.example.com/first-party/proxy"); + let mut response = build_http_response(StatusCode::OK, EdgeBody::from(html)); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + + let body = response_body_string( + finalize( + &settings, + &req, + "https://cdn.example.com/creative.html", + response, + ) + .expect("should finalize proxied HTML"), + ); + + assert!( + body.contains("/first-party/proxy?tsurl="), + "{label}: proxied HTML always proxies assets: {body}" + ); + // A wrapped landing link carries the click URL in href and data-tsclick. + assert_eq!( + body.matches("/first-party/click?tsurl=").count(), + if expect_clicks { 2 } else { 0 }, + "{label}: click wrapping in proxied HTML: {body}" + ); + assert_eq!( + body.matches("data-tsclick").count(), + usize::from(expect_clicks), + "{label}: data-tsclick in proxied HTML: {body}" + ); + if !expect_clicks { + let landing_href = body + .split("Excluded"#), + "{label}: excluded link always stays raw: {body}" + ); + assert!( + !body.contains(": {body}" + ); + assert!( + body.contains("/static/tsjs="), + "{label}: proxied HTML always receives the runtime: {body}" + ); + } + } + #[test] fn html_response_rewrite_preserves_non_standard_port() { // Verify that HTML rewriting preserves non-standard ports in sub-resource URLs. @@ -3989,6 +4324,423 @@ mod tests { }); } + const REDIRECT_TEST_BODY: &[u8] = b"fictional-payload=example"; + + /// Send `method` with a body through one redirect of `status` and return the stub. + fn proxy_through_redirect(method: Method, status: u16) -> Arc { + proxy_through_redirect_with_response(method, status).0 + } + + /// Like [`proxy_through_redirect`], but also return the proxied response. + fn proxy_through_redirect_with_response( + method: Method, + status: u16, + ) -> (Arc, Response) { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + status, + Vec::new(), + vec![("location", "https://redirect.example.com/final")], + ); + stub.push_response(200, b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let req = build_http_request(method, "https://edge.example/"); + + let response = futures::executor::block_on(proxy_request( + &settings, + req, + ProxyRequestConfig::new("https://source.example.com/start") + .without_ec_id() + .without_forward_headers() + .with_body(REDIRECT_TEST_BODY.to_vec()) + .with_header( + header::CONTENT_TYPE, + HeaderValue::from_static("application/x-www-form-urlencoded"), + ) + .with_header(header::CONTENT_LANGUAGE, HeaderValue::from_static("en-US")) + .with_header( + header::CONTENT_LENGTH, + HeaderValue::from(REDIRECT_TEST_BODY.len()), + ), + &services, + )) + .expect("should follow redirect"); + + (stub, response) + } + + fn recorded_header_names(stub: &StubHttpClient, index: usize) -> Vec { + stub.recorded_request_headers()[index] + .iter() + .map(|(name, _)| name.clone()) + .collect() + } + + #[test] + fn proxy_request_post_redirect_drops_body_for_301_302_303() { + for status in [301, 302, 303] { + let stub = proxy_through_redirect(Method::POST, status); + + assert_eq!( + stub.recorded_request_methods(), + vec!["POST".to_string(), "GET".to_string()], + "should follow a {status} POST redirect with GET" + ); + assert_eq!( + stub.recorded_request_uris()[1], + "https://redirect.example.com/final", + "should send the {status} follow-up to the Location target" + ); + assert_eq!( + stub.recorded_request_bodies(), + vec![REDIRECT_TEST_BODY.to_vec(), Vec::new()], + "should send the body only on the first hop for {status}" + ); + let first_hop_headers = recorded_header_names(&stub, 0); + let follow_up_headers = recorded_header_names(&stub, 1); + for body_header in ["content-type", "content-language", "content-length"] { + assert!( + first_hop_headers.iter().any(|name| name == body_header), + "should send {body_header} on the first {status} hop" + ); + assert!( + !follow_up_headers.iter().any(|name| name == body_header), + "should not forward {body_header} on a bodyless {status} follow-up" + ); + } + } + } + + #[test] + fn proxy_request_returns_301_302_unfollowed_for_non_post_body_methods() { + for method in [Method::PUT, Method::PATCH, Method::DELETE] { + for status in [301, 302] { + let (stub, response) = proxy_through_redirect_with_response(method.clone(), status); + + assert_eq!( + response.status().as_u16(), + status, + "should return the {status} redirect for {method} to the caller" + ); + assert_eq!( + stub.recorded_request_methods(), + vec![method.to_string()], + "should not follow a {status} redirect for {method}" + ); + assert_eq!( + stub.recorded_request_bodies(), + vec![REDIRECT_TEST_BODY.to_vec()], + "should send the {method} body only to the addressed target on {status}" + ); + } + } + } + + #[test] + fn proxy_request_put_redirect_becomes_bodyless_get_for_303() { + let (stub, response) = proxy_through_redirect_with_response(Method::PUT, 303); + + assert_eq!( + response.status().as_u16(), + 200, + "should return the 303 target's response" + ); + assert_eq!( + stub.recorded_request_methods(), + vec!["PUT".to_string(), "GET".to_string()], + "should follow a 303 PUT redirect with GET" + ); + assert_eq!( + stub.recorded_request_bodies(), + vec![REDIRECT_TEST_BODY.to_vec(), Vec::new()], + "should send the PUT body only on the first hop" + ); + } + + #[test] + fn proxy_request_post_redirect_retains_method_and_body_for_307_308() { + for status in [307, 308] { + let stub = proxy_through_redirect(Method::POST, status); + + assert_eq!( + stub.recorded_request_methods(), + vec!["POST".to_string(), "POST".to_string()], + "should keep POST across a {status} redirect" + ); + assert_eq!( + stub.recorded_request_bodies(), + vec![REDIRECT_TEST_BODY.to_vec(), REDIRECT_TEST_BODY.to_vec()], + "should replay the exact body across a {status} redirect" + ); + assert!( + recorded_header_names(&stub, 1) + .iter() + .any(|name| name == "content-type"), + "should keep Content-Type across a {status} redirect" + ); + } + } + + #[test] + fn proxy_request_post_body_stays_dropped_after_302_then_307() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 302, + Vec::new(), + vec![("location", "https://redirect.example.com/second")], + ); + stub.push_response_with_headers( + 307, + Vec::new(), + vec![("location", "https://redirect.example.com/final")], + ); + stub.push_response(200, b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let req = build_http_request(Method::POST, "https://edge.example/"); + + futures::executor::block_on(proxy_request( + &settings, + req, + ProxyRequestConfig::new("https://source.example.com/start") + .without_ec_id() + .without_forward_headers() + .with_body(REDIRECT_TEST_BODY.to_vec()) + .with_header( + header::CONTENT_TYPE, + HeaderValue::from_static("application/x-www-form-urlencoded"), + ) + .with_header( + header::CONTENT_LENGTH, + HeaderValue::from(REDIRECT_TEST_BODY.len()), + ) + .with_header( + header::CONTENT_ENCODING, + HeaderValue::from_static("identity"), + ) + .with_header(header::CONTENT_LANGUAGE, HeaderValue::from_static("en-US")) + .with_header( + header::CONTENT_LOCATION, + HeaderValue::from_static("https://source.example.com/payload"), + ), + &services, + )) + .expect("should follow the 302 then 307 chain"); + + assert_eq!( + stub.recorded_request_methods(), + vec!["POST".to_string(), "GET".to_string(), "GET".to_string()], + "should keep the 302 follow-up as GET across the later 307" + ); + assert_eq!( + stub.recorded_request_bodies(), + vec![REDIRECT_TEST_BODY.to_vec(), Vec::new(), Vec::new()], + "should not restore the dropped body on the 307 hop" + ); + let first_hop_headers = recorded_header_names(&stub, 0); + for body_header in [ + "content-type", + "content-length", + "content-encoding", + "content-language", + "content-location", + ] { + assert!( + first_hop_headers.iter().any(|name| name == body_header), + "should send {body_header} on the first hop" + ); + for hop in [1, 2] { + assert!( + !recorded_header_names(&stub, hop) + .iter() + .any(|name| name == body_header), + "should not send {body_header} on bodyless hop {hop}" + ); + } + } + } + + #[test] + fn proxy_request_redirect_limit_response_strips_cors_policy() { + let stub = Arc::new(StubHttpClient::new()); + for _ in 0..5 { + stub.push_response_with_headers( + 302, + Vec::new(), + vec![ + ("location", "https://redirect.example.com/next"), + ("access-control-allow-origin", "*"), + ], + ); + } + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let req = build_http_request(Method::GET, "https://edge.example/"); + + let response = futures::executor::block_on(proxy_request( + &settings, + req, + ProxyRequestConfig::new("https://source.example.com/start") + .without_ec_id() + .without_forward_headers(), + &services, + )) + .expect("should return the redirect once the limit is reached"); + + assert_eq!( + stub.recorded_request_methods().len(), + 5, + "should stop following after the redirect limit" + ); + assert_eq!( + response.status(), + StatusCode::FOUND, + "should return the last redirect response" + ); + assert!( + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none(), + "should strip CORS headers from the redirect-limit response" + ); + } + + #[test] + fn proxy_request_head_redirect_stays_head_for_301_302_303() { + for status in [301, 302, 303] { + let stub = proxy_through_redirect(Method::HEAD, status); + + assert_eq!( + stub.recorded_request_methods(), + vec!["HEAD".to_string(), "HEAD".to_string()], + "should keep HEAD across a {status} redirect" + ); + assert!( + stub.recorded_request_bodies()[1].is_empty(), + "should send no body on the {status} HEAD follow-up" + ); + } + } + + #[test] + fn proxy_request_head_redirect_keeps_header_only_compressed_response() { + for (content_type, content_encoding) in [("text/html", "gzip"), ("text/css", "br")] { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 303, + Vec::new(), + vec![("location", "https://redirect.example.com/final")], + ); + stub.push_response_with_headers( + 200, + Vec::new(), + vec![ + ("content-type", content_type), + ("content-encoding", content_encoding), + ("content-length", "1234"), + ("access-control-allow-origin", "*"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let req = build_http_request(Method::HEAD, "https://edge.example/"); + + let response = futures::executor::block_on(proxy_request( + &settings, + req, + ProxyRequestConfig::new("https://source.example.com/start") + .without_ec_id() + .without_forward_headers(), + &services, + )) + .expect("should return the header-only HEAD response"); + + assert_eq!( + stub.recorded_request_methods(), + vec!["HEAD".to_string(), "HEAD".to_string()], + "should keep HEAD across the 303 redirect" + ); + assert_eq!( + response.status(), + StatusCode::OK, + "should return the target's {content_type} HEAD status" + ); + let headers = response.headers(); + assert_eq!( + headers.get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static(content_type)), + "should keep the upstream Content-Type on a {content_type} HEAD response" + ); + assert_eq!( + headers.get(header::CONTENT_ENCODING), + Some(&HeaderValue::from_static(content_encoding)), + "should keep the upstream Content-Encoding on a {content_type} HEAD response" + ); + assert!( + headers.get(header::CONTENT_LENGTH).is_none(), + "should drop the upstream Content-Length on a {content_type} HEAD response because a GET rewrites the body" + ); + assert!( + headers.get(header::ACCESS_CONTROL_ALLOW_ORIGIN).is_none(), + "should still strip CORS headers from a {content_type} HEAD response" + ); + } + } + + #[test] + fn proxy_request_post_redirect_still_enforces_per_hop_allowlist() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 307, + Vec::new(), + vec![("location", "https://blocked.example.com/final")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let allowed_domains = vec!["source.example.com".to_string()]; + let req = build_http_request(Method::POST, "https://edge.example/"); + + let err = proxy_request( + &settings, + req, + ProxyRequestConfig::new("https://source.example.com/start") + .without_ec_id() + .without_forward_headers() + .with_body(REDIRECT_TEST_BODY.to_vec()) + .with_allowed_domains(&allowed_domains), + &services, + ) + .await + .expect_err("should block a redirect to a host outside the allowlist"); + + assert!( + matches!( + err.current_context(), + TrustedServerError::AllowlistViolation { host } if host == "blocked.example.com" + ), + "should report the blocked redirect host" + ); + assert_eq!( + stub.recorded_request_bodies().len(), + 1, + "should never send the body to the blocked host" + ); + }); + } + #[test] fn proxy_request_forwards_stream_response_flag_to_platform_request() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index d8c217076..fba4a0796 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -22,12 +22,12 @@ use std::borrow::Cow; use std::io::Write; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant, SystemTime}; +use std::time::{Duration, SystemTime}; use brotli::Decompressor; use brotli::enc::BrotliEncoderParams; use brotli::enc::writer::CompressorWriter; -use cookie::CookieJar; +use chrono::{DateTime, Utc}; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use flate2::read::ZlibDecoder; @@ -35,10 +35,9 @@ use flate2::write::{GzEncoder, ZlibEncoder}; use futures::StreamExt as _; use http::{HeaderValue, Method, Request, Response, StatusCode, Uri, header}; use sha2::Digest as _; +use web_time::Instant; -use crate::auction::endpoints::{ - merge_auction_eids, resolve_auction_eids, resolve_client_auction_eids, -}; +use crate::auction::endpoints::resolve_and_merge_auction_eids; use crate::auction::formats::sanitize_publisher_page_url; use crate::auction::orchestrator::{ AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, ERROR_TYPE_ALL, @@ -56,7 +55,7 @@ use crate::cache_policy::{ CachePolicy, EdgeCacheHeader, cache_control_headers_are_private_or_no_store, }; use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; -use crate::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; +use crate::constants::{COOKIE_SHAREDID, HEADER_X_COMPRESS_HINT}; use crate::cookies::handle_request_cookies; use crate::cookies::template_cache_policy::{TemplateCookieDecision, evaluate_cookie_policy}; use crate::creative_opportunities::{ @@ -71,11 +70,13 @@ use crate::html_processor::BodyCloseInjection; use crate::http_util::{RequestInfo, is_navigation_request, serve_static_with_etag}; use crate::integrations::IntegrationRegistry; use crate::platform::{ - GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices, + ClientInfo, GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices, TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY, VarySpec, contains_publisher_esi_directive, reader_url_surrogate_key, }; use crate::price_bucket::{PriceGranularity, price_bucket}; +use crate::request_timing::{AuctionWaitPlacement, Phase, RequestTimings}; +use crate::response_header_rewrite::{OriginHeaderRewrite, rewrite_origin_urls_in_headers}; use crate::response_privacy::{ apply_inactive_ad_stack_browser_cache_policy, cache_control_forbids_shared_storage, enforce_synthesized_html_cache_privacy, enforce_terminal_private_cache_privacy, @@ -90,6 +91,10 @@ use crate::streaming_processor::{ STREAM_CHUNK_SIZE, StreamProcessor, StreamingPipeline, }; use crate::streaming_replacer::create_url_replacer; +use crate::trace::{ + TraceAuctionCarry, TraceAuctionSource, TraceAuctionTerminalReason, TraceAuctionTerminalStatus, + TraceCaptureGate, project_request_context, +}; include!(concat!(env!("OUT_DIR"), "/template_build_digest.rs")); @@ -98,21 +103,44 @@ 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"; -#[derive(Clone, Copy, PartialEq, Eq)] -enum TemplateCacheResponseState { +/// Outcome of a template-cache lookup/store attempt for one response. +/// +/// Set on every response that passes through the assembly pipeline via +/// [`set_template_cache_response_state`], which writes both the +/// `x-ts-template-cache` response header and this same value as a typed +/// response extension, so the two can never drift. Access telemetry reads +/// the extension rather than the header, since operator-configured response +/// headers can override a managed header but cannot touch extensions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TemplateCacheResponseState { + /// The cached template was found and reused. Hit, + /// No cached template existed; the cache store is reserved for this + /// content type. MissReserved, + /// No cached template existed; one was stored after assembly. MissStored, + /// No cached template existed; storing the freshly assembled template + /// failed. MissStoreError, + /// The request bypassed the cache lookup. BypassRequest, + /// The response bypassed the cache store. BypassResponse, + /// The response's content type is not supported by the template cache. Unsupported, + /// The cached template entry was invalid and could not be reused. Invalid, + /// A backend error prevented the cache lookup or store. BackendError, } impl TemplateCacheResponseState { - const fn as_str(self) -> &'static str { + /// Renders this variant as the string written to the + /// `x-ts-template-cache` header and the `template_cache_state` access + /// telemetry column. + #[must_use] + pub const fn as_str(self) -> &'static str { match self { Self::Hit => "hit", Self::MissReserved => "miss-reserved", @@ -135,6 +163,7 @@ fn set_template_cache_response_state( HEADER_X_TS_TEMPLATE_CACHE, HeaderValue::from_static(state.as_str()), ); + response.extensions_mut().insert(state); } #[derive(Clone, Copy, PartialEq, Eq)] @@ -636,6 +665,7 @@ struct ProcessResponseParams<'a> { suppress_datadome_client_side_tag: bool, gpt_diagnostics: Option<&'a crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision>, + trace_bootstrap: Option<&'a str>, /// See [`HtmlStreamProcessorParams::shared_template_authorized`]. shared_template_authorized: bool, /// See [`HtmlStreamProcessorParams::csp_nonce_observed`]. @@ -678,6 +708,7 @@ 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(), + trace_bootstrap: params.trace_bootstrap.clone(), shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.clone(), deferred_inline_marker: inline_seam_token @@ -762,6 +793,7 @@ 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(), + trace_bootstrap: params.trace_bootstrap.map(str::to_owned), shared_template_authorized: params.shared_template_authorized, csp_nonce_observed: params.csp_nonce_observed.cloned(), deferred_inline_marker: None, @@ -961,6 +993,7 @@ fn passthrough_finish_segments( /// error paths that can still await (see [`abandon_hold_auction`]). struct DispatchedAuctionGuard { dispatched: Option, + trace: Option, /// Stays `true` from dispatch until collection (or telemetry-emitting /// abandonment) reaches a terminal result. [`Self::take`] removes the /// dispatched auction to hand it to the async collector but deliberately @@ -972,8 +1005,10 @@ struct DispatchedAuctionGuard { impl DispatchedAuctionGuard { fn new(dispatched: DispatchedAuction) -> Self { + let trace = dispatched.trace_carry(); Self { dispatched: Some(dispatched), + trace, armed: true, } } @@ -995,6 +1030,12 @@ impl DispatchedAuctionGuard { impl Drop for DispatchedAuctionGuard { fn drop(&mut self) { if self.armed { + if let Some(trace) = &self.trace { + trace.finish( + crate::trace::TraceAuctionTerminalStatus::Abandoned, + Some(crate::trace::TraceAuctionTerminalReason::Unknown), + ); + } log::warn!( "Dispatched server-side auction dropped without collection; SSP bid responses discarded (publisher body stream aborted or never polled)" ); @@ -1319,6 +1360,7 @@ struct HtmlStreamProcessorParams<'a> { ad_bids_state: Arc>>, suppress_datadome_client_side_tag: bool, gpt_diagnostics: Option, + trace_bootstrap: Option, /// Whether a shared template was authorized for this response. /// /// Carried rather than re-derived so both seams see the same answer. See @@ -1506,11 +1548,15 @@ fn create_html_stream_processor( ); let assembly_mode = effective_assembly_mode(params.settings, params.shared_template_authorized); + // A deliverable skipped trace auction needs the request-only body seam even + // when ordinary ad-stack policy withheld every slot definition. + let request_body_seam = params.ad_slots_script.is_some() + || (matches!(assembly_mode, AssemblyMode::Inline) && params.trace_bootstrap.is_some()); let body_close = match (assembly_mode, params.deferred_inline_marker) { (AssemblyMode::Inline, Some(marker)) if params.ad_slots_script.is_some() => { BodyCloseInjection::DeferredInlineMarker(marker) } - _ => body_close_injection(assembly_mode, params.ad_slots_script.is_some()), + _ => body_close_injection(assembly_mode, request_body_seam), }; let gpt_diagnostics = template_gpt_diagnostics(assembly_mode, params.gpt_diagnostics); @@ -1525,6 +1571,10 @@ fn create_html_stream_processor( let config = config .with_ad_state(params.ad_slots_script, params.ad_bids_state) .with_gpt_diagnostics(gpt_diagnostics) + .with_trace_bootstrap(match assembly_mode { + AssemblyMode::Inline => params.trace_bootstrap, + AssemblyMode::Esi => None, + }) .with_body_close(body_close) .with_csp_nonce_observer(csp_nonce_observed) .with_datadome_client_tag_suppression(params.suppress_datadome_client_side_tag); @@ -1723,12 +1773,20 @@ pub struct OwnedProcessResponseParams { /// Request-scoped conditional diagnostics delivery decision. pub(crate) gpt_diagnostics: Option, + /// Request-only trace bootstrap, excluded from shared templates and ESI. + pub(crate) trace_bootstrap: Option, /// Set by the transform when the document carries a response-bound CSP nonce. /// /// `None` wherever no transform runs. Recorded by the HTML parser rather than /// rescanned from the output, which cannot tell a `nonce` attribute from the same /// word inside a script. pub(crate) csp_nonce_observed: Option>, + /// Per-request phase-timing handle, carried into the streaming/buffered + /// finalizers so the `` seam wait can be recorded with the right + /// [`AuctionWaitPlacement`]. Cheap to clone (an `Arc` handle); a request that + /// never attached one to its extensions gets a fresh, unattached collector + /// that nothing ever renders. + pub(crate) timings: RequestTimings, } /// Response-authorized template cache insert inputs. The key is built before origin lookup; the @@ -1972,6 +2030,8 @@ pub async fn buffer_publisher_response_async( ¶ms.request_scheme, ¶ms.request_host, ), + timings: params.timings.clone(), + placement: AuctionWaitPlacement::PreHeader, }, ) .await; @@ -2138,6 +2198,7 @@ fn build_template_assembly_params( request_scheme: &str, price_granularity: PriceGranularity, ad_bids_state: AdBidsState, + timings: RequestTimings, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { csp_nonce_observed: None, @@ -2159,7 +2220,9 @@ fn build_template_assembly_params( dispatched_auction: None, price_granularity, gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings, } } @@ -2502,6 +2565,8 @@ pub async fn publisher_response_into_streaming_response( ¶ms.request_scheme, ¶ms.request_host, ), + timings: params.timings.clone(), + placement: AuctionWaitPlacement::InStream, }, ) .await; @@ -2620,6 +2685,7 @@ pub async fn publisher_response_into_streaming_response( &orchestrator, &services, &settings, + AuctionWaitPlacement::InStream, ) .await; // Collection reached a terminal result; disarm only now @@ -2645,6 +2711,8 @@ pub async fn publisher_response_into_streaming_response( ¶ms.request_scheme, ¶ms.request_host, ), + timings: params.timings.clone(), + placement: AuctionWaitPlacement::InStream, }; while let Some(step) = hold_step_next_chunk( @@ -2916,6 +2984,7 @@ pub fn stream_publisher_body( ad_bids_state: params.ad_bids_state.script_cell(), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, gpt_diagnostics: params.gpt_diagnostics.as_ref(), + trace_bootstrap: params.trace_bootstrap.as_deref(), shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.as_ref(), }; @@ -2989,6 +3058,7 @@ pub async fn stream_publisher_body_async( orchestrator, services, settings, + AuctionWaitPlacement::PreHeader, ) .await; if body.is_stream() { @@ -3021,6 +3091,7 @@ pub async fn stream_publisher_body_async( 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(), + trace_bootstrap: params.trace_bootstrap.clone(), shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.clone(), deferred_inline_marker: inline_seam_token @@ -3063,6 +3134,8 @@ pub async fn stream_publisher_body_async( services, settings, request_origin: request_origin(¶ms.request_scheme, ¶ms.request_host), + timings: params.timings.clone(), + placement: AuctionWaitPlacement::PreHeader, }, }, ) @@ -3090,6 +3163,24 @@ fn request_head_snapshot(req: &Request) -> Request { *snapshot.uri_mut() = req.uri().clone(); *snapshot.version_mut() = req.version(); *snapshot.headers_mut() = req.headers().clone(); + if let Some(prepared) = req + .extensions() + .get::() + { + snapshot.extensions_mut().insert(prepared.clone()); + } + if let Some(origin) = req + .extensions() + .get::() + { + snapshot.extensions_mut().insert(origin.clone()); + } + if let Some(ingress) = req + .extensions() + .get::() + { + snapshot.extensions_mut().insert(ingress.clone()); + } snapshot } @@ -3248,6 +3339,44 @@ fn request_origin(scheme: &str, host: &str) -> String { /// JSON for every non-empty map; `serde_json::from_str` failed and `unwrap_or_default()` /// turned the failure into `{}`. Shared modes therefore served **zero bids**, silently, /// on every request that had any. Every fixture had empty bids, so nothing caught it. +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct BrowserAuctionDiagnostics { + #[serde(skip_serializing_if = "Option::is_none")] + auction_dispatched_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auction_resolved_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auction_committed_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auction_wait_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auction_wait_placement: Option<&'static str>, +} + +const fn auction_wait_placement_wire(placement: AuctionWaitPlacement) -> &'static str { + match placement { + AuctionWaitPlacement::PreHeader => "pre_header", + AuctionWaitPlacement::InStream => "in_stream", + } +} + +impl BrowserAuctionDiagnostics { + fn from_request_timings(timings: &RequestTimings) -> Option { + let snapshot = timings.snapshot(); + snapshot.auction_dispatched_ms?; + Some(Self { + auction_dispatched_ms: snapshot.auction_dispatched_ms, + auction_resolved_ms: snapshot.auction_resolved_ms, + auction_committed_ms: snapshot.auction_committed_ms, + auction_wait_ms: snapshot.auction_wait_ms, + auction_wait_placement: snapshot + .auction_wait_placement + .map(auction_wait_placement_wire), + }) + } +} + #[derive(Clone, Default)] pub(crate) struct AdBidsState { /// Rendered bids `"); + bootstrap +} + +fn trace_document_context_script(context: &impl serde::Serialize) -> Option { + let json = match serde_json::to_string(context) { + Ok(json) => json, + Err(_) => { + log::warn!("trace_document_context_unavailable"); + return None; + } + }; + Some(format!( + "(function(){{var c=JSON.parse(\"{}\");Object.freeze(c.network);Object.freeze(c.cookies.ts_ec);Object.freeze(c.cookies.ts_eids);Object.freeze(c.cookies.ts_tester);Object.freeze(c.cookies.diagnostics_session);Object.freeze(c.cookies);window.__tsjs_trace_request_context=Object.freeze(c);}})();", + html_escape_for_script(&json), + )) +} + /// Maximum length Google Ad Manager accepts for a key-value targeting value. /// Longer values are rejected by GAM, so the key never reaches the creative and /// the `hb_adid` render handshake cannot complete. @@ -5934,9 +6351,18 @@ pub(crate) fn build_bid_map_with_auction_id( /// The JSON is embedded via `JSON.parse(…)` so the browser parser never sees /// raw `` sequences inside the string. pub(crate) fn build_bids_script(bid_map: &serde_json::Map) -> String { + build_bids_script_with_extras(bid_map, None, None) +} + +fn build_bids_script_with_extras( + bid_map: &serde_json::Map, + auction_diagnostics: Option<&BrowserAuctionDiagnostics>, + trace: Option<&TraceAuctionCarry>, +) -> String { let json = serde_json::to_string(bid_map) .expect("serde_json::to_string of Map should be infallible"); let escaped = html_escape_for_script(&json); + let extras = SchedulerExtras::new(auction_diagnostics, trace); // adInit() defines GPT slots on the publisher's `-container` wrappers, which // mutates those ad-slot subtrees. Calling it synchronously here (this script // runs at body-parse time) lands those mutations inside React's hydration @@ -5968,15 +6394,24 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Map (String::from("b"), " t.bids=b;"), + (extra, true) => ( + format!("b,void 0{extra}"), + "{t.bids=b;t.auctionDiagnostics=d;}", + ), + (extra, false) => (format!("b,void 0{extra}"), " t.bids=b;"), + }; format!( "", - escaped + escaped, extras.declarations, arguments, fallback ) } @@ -5999,26 +6434,130 @@ else t.bids=b;\ /// /// Slots are applied before bids inside the scheduler, because the scheduler may fire /// `adInit` and `adInit` reads `ts.adSlots`. +#[cfg(test)] pub(crate) fn build_seam_script( slots_json: &str, bid_map: &serde_json::Map, +) -> String { + build_seam_script_with_extras(slots_json, bid_map, None, None) +} + +/// Request-scoped payloads handed to `scheduleInitialAdInit` after bids and slots. +/// +/// The scheduler takes `(bids, slots, auctionDiagnostics, traceAuctionTransport)`. +/// Absent payloads are passed as `void 0` only when a later one is present, so a +/// script with neither keeps the original two-argument call. +struct SchedulerExtras { + declarations: String, + arguments: &'static str, + has_diagnostics: bool, +} + +impl SchedulerExtras { + fn new( + auction_diagnostics: Option<&BrowserAuctionDiagnostics>, + trace: Option<&TraceAuctionCarry>, + ) -> Self { + let mut declarations = String::new(); + if let Some(auction_diagnostics) = auction_diagnostics { + let diagnostics = serde_json::to_string(auction_diagnostics) + .expect("BrowserAuctionDiagnostics should serialize"); + declarations.push_str(&format!( + "var d=JSON.parse(\"{}\");", + html_escape_for_script(&diagnostics) + )); + } + let transport = trace_transport_script(trace); + if let Some(transport) = &transport { + declarations.push_str(&format!( + "var x=JSON.parse(\"{}\");", + html_escape_for_script(transport) + )); + } + let arguments = match (auction_diagnostics.is_some(), transport.is_some()) { + (false, false) => "", + (true, false) => ",d", + (false, true) => ",void 0,x", + (true, true) => ",d,x", + }; + Self { + declarations, + arguments, + has_diagnostics: auction_diagnostics.is_some(), + } + } +} + +fn trace_transport_script(trace: Option<&TraceAuctionCarry>) -> Option { + trace + .and_then(TraceAuctionCarry::transport) + .map(|transport| { + serde_json::to_string(&transport) + .unwrap_or_else(|_| trace_serialization_unavailable().to_string()) + }) +} + +fn build_trace_only_script(trace: Option<&TraceAuctionCarry>) -> String { + let Some(transport) = trace_transport_script(trace) else { + return String::new(); + }; + format!( + "", + html_escape_for_script(&transport) + ) +} + +fn trace_transport_value(transport: &crate::trace::TraceAuctionTransportV1) -> serde_json::Value { + serde_json::to_value(transport).unwrap_or_else(|_| trace_serialization_unavailable()) +} + +fn trace_serialization_unavailable() -> serde_json::Value { + log::warn!("trace evidence serialization fails"); + serde_json::json!({"schema_version":1,"unavailable_reason":"evidence_projection_failed"}) +} + +fn build_seam_script_with_extras( + slots_json: &str, + bid_map: &serde_json::Map, + auction_diagnostics: Option<&BrowserAuctionDiagnostics>, + trace: Option<&TraceAuctionCarry>, ) -> String { // The local test script probes the minified `var a=JSON.parse`, // `var b=JSON.parse`, and `s(b,a)` literals below. Update the harness with any // semantically equivalent rewrite so its black-box checks keep matching output. let bids = serde_json::to_string(bid_map) .expect("serde_json::to_string of Map should be infallible"); + let extras = SchedulerExtras::new(auction_diagnostics, trace); + let fallback_diagnostics = if extras.has_diagnostics { + "t.auctionDiagnostics=d;" + } else { + "" + }; format!( "", html_escape_for_script(slots_json), - html_escape_for_script(&bids) + html_escape_for_script(&bids), + extras.declarations, + extras.arguments, + fallback_diagnostics ) } @@ -6470,7 +7009,9 @@ fn origin_shared_ttl( headers: &edgezero_core::http::HeaderMap, max_age: Duration, ) -> Result { - origin_shared_ttl_at(headers, SystemTime::now(), max_age) + // `httpdate` parses `Date`/`Expires` into `std::time::SystemTime`, so take + // the wasm-safe std value rather than calling `SystemTime::now()`. + origin_shared_ttl_at(headers, crate::ec::std_system_time_now(), max_age) } fn origin_shared_ttl_at( @@ -6713,14 +7254,39 @@ pub(crate) fn build_ad_slots_script( matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, request_path: &str, +) -> String { + build_ad_slots_script_with_trace(matched_slots, co_config, request_path, None) +} + +fn request_slot_jsons( + matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + section: &str, + trace: Option<&TraceAuctionCarry>, +) -> Vec { + matched_slots + .iter() + .enumerate() + .filter_map(|(index, slot)| { + let mut value = build_slot_json(slot, co_config, section)?; + if let Some(slot_ref) = trace.and_then(|trace| trace.slot_ref(index)) { + value["ext"] = serde_json::json!({"trusted_server":{"trace_slot_ref":slot_ref}}); + } + Some(value) + }) + .collect() +} + +fn build_ad_slots_script_with_trace( + matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + request_path: &str, + trace: Option<&TraceAuctionCarry>, ) -> String { // `{section}` derives from the same raw path `page_patterns` matched // against; derive it once for every slot on this request. let section = co_config.section_for_path(request_path); - let slots: Vec = matched_slots - .iter() - .filter_map(|slot| build_slot_json(slot, co_config, §ion)) - .collect(); + let slots = request_slot_jsons(matched_slots, co_config, §ion, trace); let json = serde_json::to_string(&slots) .expect("serde_json::to_string of Vec should be infallible"); let escaped = html_escape_for_script(&json); @@ -6877,20 +7443,35 @@ fn normalize_page_bids_path(raw: &str) -> String { /// /// `kv` enriches the bid request with server-side EIDs from the EC identity /// graph. Only the Fastly adapter has a KV identity store, so Axum, Cloudflare, -/// and Spin pass `None`; client cookie EIDs are still resolved and consent-gated -/// on every adapter, so no adapter forwards unconsented EIDs. +/// and Spin pass `None` and cannot resolve server-side EIDs. Retired EID cookies +/// are ignored on every adapter. /// /// # Errors /// -/// Returns [`TrustedServerError`] if cookie parsing or EC ID generation fails. +/// Returns [`TrustedServerError`] if the request host is malformed, cookie +/// parsing fails, or EC ID generation fails. pub async fn handle_page_bids( settings: &Settings, services: &RuntimeServices, kv: Option<&KvIdentityGraph>, auction: AuctionDispatch<'_>, ec_context: &mut EcContext, - req: Request, + mut req: Request, ) -> Result, Report> { + // Keep a local collector for direct-handler callers, but only expose + // browser timings from an adapter-attached request clock. Otherwise an + // adapter that forgets the extension would silently report a different + // handler-entry timing origin. + let request_timings = req.extensions().get::().cloned(); + let has_request_timings = request_timings.is_some(); + let timings = request_timings.unwrap_or_default(); + + // Adapter fallbacks prepare this before routing. Keep this idempotent call as + // a direct-handler safety net and retain the session decision after the + // private activation cookie is stripped. + let gpt_diagnostics = + crate::integrations::gpt_diagnostics::prepare_request(settings, &mut req)?; + // CSRF-style gate: refuse cross-site invocations before any other work — // including the not-configured 404 below, which would otherwise tell a // cross-site caller whether this deployment has creative opportunities. @@ -6957,7 +7538,7 @@ pub async fn handle_page_bids( // Trusted request origin for absolute inline creative URLs — derived from the // origin the visitor is actually on (scheme, host, port), not the configured // publisher domain, which cannot carry a port and may differ by subdomain. - let request_info = RequestInfo::from_request(&req, services.client_info()); + let request_info = RequestInfo::from_request(&req, services.client_info())?; let page_bids_request_origin = request_origin(&request_info.scheme, &request_info.host); let path_param = req @@ -6989,7 +7570,7 @@ pub async fn handle_page_bids( Vec::new() }; - let request_info = crate::http_util::RequestInfo::from_request(&req, services.client_info()); + let request_info = crate::http_util::RequestInfo::from_request(&req, services.client_info())?; // Owned so the identity-graph snapshot can be stored back on `ec_context` // below without holding a borrow of it across the mutation. let ec_id = ec_context @@ -6998,7 +7579,6 @@ pub async fn handle_page_bids( .map(str::to_owned); let consent_context = ec_context.consent().clone(); let geo = ec_context.geo_info().cloned(); - let cookie_jar = handle_request_cookies(&req)?; // Same fail-closed jurisdiction-aware gate the publisher navigation path // uses — relies on the adapter's geo-aware EC context. @@ -7039,6 +7619,36 @@ pub async fn handle_page_bids( // unchanged) but skip the live auction, matching the existing behavior. let ad_stack_enabled = ad_templates_enabled && auction_enabled && consent_allows_auction; + let mut auction_diagnostics = None; + let mut trace_request = req + .extensions() + .get::() + .filter(|gate| gate.base_active()) + .map(|_| { + build_auction_request( + &MatchedSlotsContext { + matched_slots: &matched_slots, + request_path: &path_param, + }, + ec_id.as_deref(), + &consent_context, + &request_info, + &settings.publisher.domain, + req.headers() + .get("user-agent") + .and_then(|value| value.to_str().ok()), + ) + }); + let trace_auction = trace_request.as_ref().and_then(|request| { + TraceAuctionCarry::capture_if_enabled(true, TraceAuctionSource::SpaPageBids, &request.slots) + }); + if let Some(trace) = &trace_auction { + req.extensions_mut().insert(trace.clone()); + } + let _trace_cancellation = trace_auction + .as_ref() + .map(TraceAuctionCarry::cancellation_guard); + let (winning_bids, prebuilt_bid_map) = if matched_slots.is_empty() { (std::collections::HashMap::new(), None) } else { @@ -7056,6 +7666,11 @@ pub async fn handle_page_bids( user_agent, ec_context, ); + // Same T0-anchored timeline as the navigation path (spec section 18). + // This route runs the auction through `run_auction` rather than the + // dispatch/collect split, so dispatch and resolve bracket that one + // call instead of the origin fetch. + timings.set_auction_id(observation.auction_id); if ad_stack_enabled && !is_bot && !is_prefetch { let slots_ctx = MatchedSlotsContext { matched_slots: &matched_slots, @@ -7072,24 +7687,24 @@ pub async fn handle_page_bids( }; // Hand the loaded row to the request context so response // finalization — which runs on an EC context the adapter owns, - // after this handler returns — ingests `ts-eids`/`sharedId` updates + // after this handler returns — ingests sharedId updates // from this read instead of paying for a second lookup. if !matches!(page_bids_kv_snapshot, crate::ec::EcKvSnapshot::NotRead) { ec_context.set_kv_snapshot(page_bids_kv_snapshot.clone()); } - let mut auction_request = build_auction_request( - &slots_ctx, - ec_id.as_deref(), - &consent_context, - &request_info, - &settings.publisher.domain, - user_agent, - ); + let mut auction_request = trace_request.take().unwrap_or_else(|| { + build_auction_request( + &slots_ctx, + ec_id.as_deref(), + &consent_context, + &request_info, + &settings.publisher.domain, + user_agent, + ) + }); apply_auction_eids_and_device( &mut auction_request, &AuctionEidTargeting { - cookie_jar: cookie_jar.as_ref(), - ec_id: ec_id.as_deref(), kv_snapshot: &page_bids_kv_snapshot, partner_registry: auction.registry, ec_context, @@ -7109,14 +7724,37 @@ pub async fn handle_page_bids( provider_responses: None, services, }; - match auction + timings.mark_auction_dispatched(); + let result = auction .orchestrator .run_auction(&auction_request, &auction_context) - .await - { + .await; + timings.mark_auction_resolved(); + match result { Ok(result) => { + // A successful result proves at least one pending or immediate + // provider outcome. Failures can occur before any request leaves + // the edge, so they must not fabricate dispatch timing evidence. + if gpt_diagnostics.browser_session_active() && has_request_timings { + let timing_snapshot = timings.snapshot(); + auction_diagnostics = Some(BrowserAuctionDiagnostics { + auction_dispatched_ms: timing_snapshot.auction_dispatched_ms, + auction_resolved_ms: timing_snapshot.auction_resolved_ms, + auction_committed_ms: None, + auction_wait_ms: timing_snapshot + .auction_resolved_ms + .zip(timing_snapshot.auction_dispatched_ms) + .map(|(resolved, dispatched)| resolved.saturating_sub(dispatched)), + auction_wait_placement: Some(auction_wait_placement_wire( + AuctionWaitPlacement::PreHeader, + )), + }); + } let winning_bids = result.winning_bids.clone(); - let auction_id = diagnostics_auction_id(settings); + let auction_id = trace_auction + .as_ref() + .map(|trace| trace.token().to_string()) + .or_else(|| diagnostics_auction_id(settings)); let bid_map = build_bid_map_with_auction_id( &winning_bids, co_config.price_granularity, @@ -7125,7 +7763,15 @@ pub async fn handle_page_bids( settings.debug.inject_adm_for_testing, auction_id.as_deref(), ); + // Targeting is available to the response pipeline. + timings.mark_auction_committed(); + if let Some(diagnostics) = auction_diagnostics.as_mut() { + diagnostics.auction_committed_ms = timings.snapshot().auction_committed_ms; + } let delivered_winner_slots = bid_map.keys().cloned().collect(); + if let Some(trace) = &trace_auction { + trace.observe_delivery(&winning_bids, &delivered_winner_slots); + } emit_auction_events_best_effort_lazy(services, || { build_auction_events( observation, @@ -7158,6 +7804,12 @@ pub async fn handle_page_bids( } } } else { + if let Some(trace) = &trace_auction { + trace.finish( + TraceAuctionTerminalStatus::Skipped, + Some(TraceAuctionTerminalReason::PolicySkipped), + ); + } let skip_reason = if !ad_templates_enabled { "ad_templates_disabled" } else if !auction_enabled { @@ -7196,23 +7848,29 @@ pub async fn handle_page_bids( None, ) }); - // Gate slots on the ad-stack kill switch / consent: when disabled, return no // slots so the SPA hook does not call `adInit()` / create GPT slots. let slots_json: Vec = if ad_stack_enabled { let section = co_config.section_for_path(&path_param); - matched_slots - .iter() - .filter_map(|slot| build_slot_json(slot, co_config, §ion)) - .collect() + request_slot_jsons(&matched_slots, co_config, §ion, trace_auction.as_ref()) } else { Vec::new() }; - let body = serde_json::json!({ + let mut body = serde_json::json!({ "slots": slots_json, "bids": bid_map, }); + if let Some(auction_diagnostics) = auction_diagnostics { + body["auctionDiagnostics"] = serde_json::json!(auction_diagnostics); + } + if let Some(trace) = &trace_auction { + body["trace_auction"] = trace_transport_value( + &trace + .transport() + .unwrap_or_else(crate::trace::TraceAuctionTransportV1::unavailable), + ); + } let body = serde_json::to_string(&body).change_context(TrustedServerError::Proxy { message: "Failed to serialize page-bids response".to_string(), })?; @@ -7224,6 +7882,9 @@ pub async fn handle_page_bids( ); enforce_terminal_private_cache_privacy(&mut response); mark_deprecated_alias(&mut response, is_legacy_alias); + if let Some(trace) = trace_auction { + response.extensions_mut().insert(trace); + } Ok(response) } @@ -7260,6 +7921,7 @@ mod tests { use brotli::Decompressor; use brotli::enc::writer::CompressorWriter; + use cookie::CookieJar; use flate2::read::GzDecoder; use flate2::write::GzEncoder; @@ -7282,17 +7944,171 @@ mod tests { } #[test] - fn template_eligibility_implies_origin_shareability() { - for bits in 0u16..256 { - let inputs = SharedRequestInputs { - method_is_cacheable: bits & 1 != 0, - host_present: bits & 2 != 0, - authorization_disqualifies: bits & 4 != 0, - cookie_disqualifies: bits & 8 != 0, - request_requires_origin: bits & 16 != 0, - }; - let is_esi = bits & 32 != 0; - let reader_supports_assembly = bits & 64 != 0; + fn runtime_origin_snapshot_preserves_https_fallback() { + let mut request = Request::builder() + .uri("/article") + .header(header::HOST, "client-supplied.example.com") + .body(EdgeBody::empty()) + .expect("should construct normalized runtime request"); + request + .extensions_mut() + .insert(crate::http_util::RuntimeRequestOrigin::new( + edgezero_core::request::InboundOrigin::parse( + "https", + "publisher.example.com:8443", + edgezero_core::request::OriginSource::RuntimeUri, + ) + .expect("should validate runtime origin"), + )); + + let snapshot = request_head_snapshot(&request); + let info = RequestInfo::from_request(&snapshot, &crate::platform::ClientInfo::default()) + .expect("should extract request info"); + + assert_eq!( + info.scheme, "https", + "should preserve runtime HTTPS scheme in publisher snapshot" + ); + assert_eq!( + info.host, "publisher.example.com:8443", + "should preserve runtime authority in publisher snapshot" + ); + } + + #[test] + fn public_origin_snapshot_preserves_accepted_and_frozen_rejected_decisions() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.publisher.domain = "publisher.example.com".to_owned(); + settings.trusted_forwarder = Some(crate::settings::TrustedForwarderConfig { + auth_header: crate::forwarder::FORWARDER_AUTH_HEADER.to_owned(), + shared_secret: crate::redacted::Redacted::new( + "fictional-forwarder-secret-0123456789".to_owned(), + ), + }); + for accepted in [true, false] { + let mut request = http::Request::builder() + .uri("http://internal.example.com/article?x=1") + .header(header::HOST, "internal.example.com") + .header( + crate::forwarder::FORWARDER_AUTH_HEADER, + "fictional-forwarder-secret-0123456789", + ) + .header("x-forwarded-host", "publisher.example.com:8443") + .header("x-forwarded-proto", "https") + .body(EdgeBody::empty()) + .expect("should construct request"); + if !accepted { + request + .headers_mut() + .remove(crate::forwarder::FORWARDER_AUTH_HEADER); + } + let ingress = edgezero_core::request::RequestIngress::new( + edgezero_core::request::CapturedTarget::Unavailable( + edgezero_core::request::TargetUnavailable::NotExposed, + ), + Some( + edgezero_core::request::InboundOrigin::parse( + "http", + "internal.example.com", + edgezero_core::request::OriginSource::RuntimeUri, + ) + .expect("should construct transport origin"), + ), + edgezero_core::request::HeaderFidelity::default(), + vec![], + ) + .expect("should construct ingress"); + request.extensions_mut().insert(ingress); + crate::forwarder::prepare_trusted_forwarder(&mut request, &settings); + let mut snapshot = request_head_snapshot(&request); + assert!( + snapshot + .extensions() + .get::() + .is_some(), + "should preserve frozen forwarding decision" + ); + let retained = snapshot + .extensions() + .get::() + .expect("should preserve immutable ingress"); + let original = request + .extensions() + .get::() + .expect("should retain ingress on source request"); + assert_eq!( + retained.origin().map(|origin| ( + origin.scheme(), + origin.authority(), + origin.source() + )), + original.origin().map(|origin| ( + origin.scheme(), + origin.authority(), + origin.source() + )), + "should preserve actual transport origin" + ); + assert!( + matches!( + retained.target(), + edgezero_core::request::CapturedTarget::Unavailable( + edgezero_core::request::TargetUnavailable::NotExposed + ) + ), + "should preserve unavailable target evidence" + ); + assert_eq!( + retained.header_fidelity(&http::header::COOKIE), + original.header_fidelity(&http::header::COOKIE), + "should preserve cookie fidelity" + ); + snapshot.headers_mut().insert( + crate::forwarder::FORWARDER_AUTH_HEADER, + http::HeaderValue::from_static("fictional-forwarder-secret-0123456789"), + ); + snapshot.headers_mut().insert( + "x-forwarded-host", + http::HeaderValue::from_static("publisher.example.com:8443"), + ); + snapshot + .headers_mut() + .insert("x-forwarded-proto", http::HeaderValue::from_static("https")); + crate::forwarder::prepare_trusted_forwarder(&mut snapshot, &settings); + let info = crate::http_util::RequestInfo::from_request( + &snapshot, + &crate::platform::ClientInfo::default(), + ) + .expect("should extract snapshot request info"); + assert_eq!( + info.host, + if accepted { + "publisher.example.com:8443" + } else { + "internal.example.com" + }, + "should retain initial trust decision" + ); + assert_eq!( + info.scheme, + if accepted { "https" } else { "http" }, + "should retain initial public or ingress scheme" + ); + } + } + + #[test] + fn template_eligibility_implies_origin_shareability() { + for bits in 0u16..256 { + let inputs = SharedRequestInputs { + method_is_cacheable: bits & 1 != 0, + host_present: bits & 2 != 0, + authorization_disqualifies: bits & 4 != 0, + cookie_disqualifies: bits & 8 != 0, + request_requires_origin: bits & 16 != 0, + }; + let is_esi = bits & 32 != 0; + let reader_supports_assembly = bits & 64 != 0; let reader_requires_origin = bits & 128 != 0; let shareable = origin_response_is_shareable(inputs); @@ -7867,6 +8683,7 @@ mod tests { fn marker_partner(source_domain: &str) -> crate::settings::EcPartner { crate::settings::EcPartner { + identity_owner: None, name: format!("Marker partner {source_domain}"), source_domain: source_domain.to_owned(), openrtb_atype: crate::settings::EcPartner::default_openrtb_atype(), @@ -7979,6 +8796,90 @@ mod tests { lookups.load(Ordering::SeqCst) } + #[test] + fn publisher_and_page_bids_ignore_retired_cookie_ids() { + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let context = EcContext::new_for_test( + Some(ec_id.clone()), + crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }, + ); + let mut entry = + crate::ec::kv_types::KvEntry::minimal("ids.example.com", "expired-uid", 1_000); + entry + .ids + .get_mut("ids.example.com") + .expect("should contain record") + .expires_at = Some(1); + let snapshot = crate::ec::EcKvSnapshot::Present { + ec_id: ec_id.clone(), + entry: Box::new(entry.clone()), + generation: Some(1), + }; + let registry = PartnerRegistry::from_config(&[marker_partner("ids.example.com")]) + .expect("should build registry"); + let services = crate::platform::test_support::noop_services(); + for path_label in ["Server-side", "Page-bids"] { + for uid in ["expired-uid", "different-uid"] { + let encoded = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + serde_json::to_vec( + &serde_json::json!([{"source": "ids.example.com", "uids": [{"id": uid}]}]), + ) + .expect("should encode cookie EIDs"), + ); + let mut jar = CookieJar::new(); + jar.add(cookie::Cookie::new("ts-eids", encoded)); + let mut request = AuctionRequest { + id: "example-auction".to_owned(), + slots: Vec::new(), + publisher: crate::auction::types::PublisherInfo { + domain: "publisher.example.com".to_owned(), + page_url: None, + }, + user: crate::auction::types::UserInfo { + id: Some(ec_id.clone()), + consent: Some(context.consent().clone()), + eids: None, + }, + device: None, + site: None, + context: Default::default(), + }; + apply_auction_eids_and_device( + &mut request, + &AuctionEidTargeting { + kv_snapshot: &snapshot, + partner_registry: Some(®istry), + ec_context: &context, + services: &services, + geo: None, + path_label, + }, + ); + let uids: Vec<&str> = request + .user + .eids + .iter() + .flatten() + .flat_map(|eid| &eid.uids) + .map(|uid| uid.id.as_str()) + .collect(); + assert!( + uids.is_empty(), + "publisher and SPA must not consume retired EID cookies" + ); + assert_eq!( + snapshot.entry_for(&ec_id), + Some(&entry), + "should not mutate retained lifecycle state" + ); + } + } + } + #[tokio::test] async fn valid_completeness_marker_skips_pull_only_snapshot_lookup() { assert_eq!( @@ -8004,10 +8905,10 @@ mod tests { } #[tokio::test] - async fn valid_marker_does_not_skip_eid_cookie_persistence_lookup() { + async fn valid_marker_ignores_retired_eid_cookie() { assert_eq!( run_marker_lookup_probe(MarkerProbe::Valid, true, false).await, - 1 + 0 ); } @@ -8996,7 +9897,9 @@ mod tests { dispatched_auction: None, price_granularity: Default::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), } } @@ -9192,161 +10095,843 @@ mod tests { ); } - /// Drive `handle_publisher_request` with no creative opportunities — a plain - /// proxy with no server-side auction. Hides the auction/EC wiring so callers - /// read like a simple `(settings, services, req)` proxy. - async fn run_publisher_proxy( - settings: &Settings, - services: &RuntimeServices, - req: Request, - ) -> PublisherResponse { - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - let mut ec_context = - EcContext::read_from_request(settings, &req, services).expect("should read EC context"); - handle_publisher_request( - settings, - services, - None, - &mut ec_context, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &[], - registry: None, - }, - req, - EdgeCacheHeader::SurrogateControl, - ) - .await - .expect("should proxy publisher request") - } - - mod rendered_template_identity_tests { - //! The gate the plan's Task 3 Step 2 actually asks for. - //! - //! Every other test in this area exercises the decision functions with - //! hand-built inputs. That is how three HIGH review findings sat in covered, - //! passing code: the decisions were right and nothing checked what the - //! composition of them *renders*. - //! - //! These tests render whole documents through `create_html_processor`, - //! composing the same three decisions `create_html_stream_processor` uses, - //! and compare bytes. A future request-dependent injection added at either - //! seam fails here even if every decision function is left untouched. - - use super::template_neutrality_tests::{settings_with_slots, slot}; + mod trace_document_tests { use super::*; - use crate::creative_opportunities::AssemblyMode; - use crate::html_processor::{HtmlProcessorConfig, create_html_processor}; - use crate::integrations::IntegrationRegistry; - use crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision; - - const DOCUMENT: &[u8] = - b"t

content

"; + use std::net::{IpAddr, Ipv4Addr}; - /// One request's worth of variation. Everything here is request-scoped and - /// must not reach a shared template. - #[derive(Debug, Clone, Copy)] - struct RequestShape { - /// Folds in consent, bot classification, prefetch and the kill switch. - ad_stack_ran: bool, - /// Cookie- or query-activated. - diagnostics_active: bool, - /// A resolved auction, present only when one was dispatched. - bids_available: bool, - } + use chrono::TimeZone as _; + use edgezero_core::router::PreDispatchHook as _; + use futures::executor::block_on; - /// Build the config exactly as `create_html_stream_processor` does, so a - /// drift between a decision and its use is caught rather than hidden. - fn render(mode: AssemblyMode, shape: RequestShape) -> String { - let settings = settings_with_slots(); - let slots = [slot()]; + use crate::platform::test_support::{ + StubHttpClient, build_services_with_http_client_and_client_ip, + }; + use crate::trace::{TracePreDispatchHook, TraceTerminalResponse}; - let ad_slots_script = - template_ad_slots_script(mode, shape.ad_stack_ran, &settings, &slots, "/"); - let body_close = body_close_injection(mode, ad_slots_script.is_some()); - let gpt_diagnostics = template_gpt_diagnostics( - mode, - shape - .diagnostics_active - .then(GptDiagnosticsRequestDecision::active_for_tests), + fn gate_and_decision( + settings: &Settings, + ) -> ( + TraceCaptureGate, + crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision, + ) { + let mut request = request("", Some("__Host-ts-console=1"), "document"); + let hook = TracePreDispatchHook::new( + Arc::new(settings.clone()), + Arc::new(|_| panic!("should not query setup metadata for a publisher document")), ); - - let ad_bids_state = - std::sync::Arc::new(std::sync::Mutex::new(shape.bids_available.then(|| { - r#""#.to_string() - }))); - - let config = HtmlProcessorConfig { - csp_nonce_observed: None, - origin_host: "origin.example.com".to_string(), - request_host: "example.com".to_string(), - request_scheme: "https".to_string(), - integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script, - ad_bids_state, - max_buffered_body_bytes: 16 * 1024 * 1024, - gpt_diagnostics, - body_close, - suppress_datadome_client_side_tag: false, - }; - - let mut processor = create_html_processor(config); - let out = processor - .process_chunk(DOCUMENT, true) - .expect("should process the document"); - String::from_utf8(out).expect("output should be utf8") - } - - fn every_shape() -> Vec { - let mut shapes = Vec::new(); - for ad_stack_ran in [false, true] { - for diagnostics_active in [false, true] { - for bids_available in [false, true] { - shapes.push(RequestShape { - ad_stack_ran, - diagnostics_active, - bids_available, - }); - } - } + assert!( + block_on(hook.handle(&mut request)) + .expect("should freeze document gate") + .is_none(), + "should continue publisher routing" + ); + let decision = + crate::integrations::gpt_diagnostics::prepare_request(settings, &mut request) + .expect("should prepare active navigation decision"); + let gate = *request + .extensions() + .get::() + .expect("should retain frozen incoming gate after cookie sanitation"); + (gate, decision) + } + + fn fixed_clock() -> DateTime { + Utc.with_ymd_and_hms(2026, 10, 6, 1, 2, 3) + .single() + .expect("should construct a strict UTC example clock") + } + + fn context_from_script(script: &str) -> serde_json::Value { + let literal = script + .split_once("var c=JSON.parse(") + .expect("should parse context through a quoted JSON string") + .1 + .split_once(");Object.freeze(c.network)") + .expect("should freeze the newly parsed context before publishing it") + .0; + let json: String = serde_json::from_str(literal) + .expect("should recover the original JSON from the script-safe string literal"); + serde_json::from_str(&json).expect("should decode the exact bounded request context") + } + + struct UnserializableContext; + + impl serde::Serialize for UnserializableContext { + fn serialize(&self, _serializer: S) -> Result { + Err(::custom( + "fictional context serialization failure", + )) } - shapes } - #[test] - fn shared_template_ad_seam_is_readable_and_versioned() { - assert_eq!( - (crate::platform::TEMPLATE_SCHEMA_VERSION, AD_ASSEMBLY_SEAM,), - (5, ""), - "the core build digest in the template fingerprint isolates seam changes, \ - so updating the seam needs no schema bump; the schema moves only for \ - compatibility changes outside that digest (see TEMPLATE_SCHEMA_VERSION)" - ); - assert_eq!( - body_close_injection(AssemblyMode::Esi, false), - BodyCloseInjection::Marker(TEMPLATE_SEAM_PLACEHOLDER.to_string()), - "the seam's position must come from the parser, never from a byte search" - ); - assert_ne!( - TEMPLATE_SEAM_PLACEHOLDER, AD_ASSEMBLY_SEAM, - "a publisher document carrying the seam bytes must still receive \ - correctly positioned bids, which a shared marker would make impossible" - ); + fn settings(trace_enabled: bool) -> Settings { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "gpt_diagnostics", + &serde_json::json!({"enabled":true,"trace_page_enabled":trace_enabled}), + ) + .expect("should configure document trace gate"); + settings } - #[test] - fn shared_modes_render_byte_identical_documents_for_every_request_shape() { - let mode = AssemblyMode::Esi; - let shapes = every_shape(); - let baseline = render(mode, shapes[0]); - - for shape in &shapes[1..] { - let rendered = render(mode, *shape); - assert_eq!( - rendered, baseline, - "{mode:?}: rendered template differs for {shape:?}. A shared \ - template that varies by request freezes the first-filling \ - request's decision for every later reader." + fn request(query: &str, cookie: Option<&str>, destination: &str) -> Request { + let mut request = HttpRequest::builder() + .method(Method::GET) + .uri(format!("https://publisher.example/article{query}")) + .header(header::HOST, "publisher.example") + .header("sec-fetch-dest", destination) + .header(header::USER_AGENT, "example-browser") + .body(EdgeBody::empty()) + .expect("should build publisher document request"); + if let Some(cookie) = cookie { + request.headers_mut().insert( + header::COOKIE, + HeaderValue::from_str(cookie).expect("should encode fictional cookie input"), + ); + } + request + } + + fn render( + settings: &Settings, + mut request: Request, + content_type: &str, + ) -> (Response, String) { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers(200, b"Examplepublisher ads remain".to_vec(), + vec![("content-type",content_type),("cache-control","public, max-age=300"),("etag","\"origin-example\"")]); + let services = build_services_with_http_client_and_client_ip( + stub, + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 99)), + ); + let hook = TracePreDispatchHook::new( + Arc::new(settings.clone()), + Arc::new(|_| { + panic!("should not request setup metadata on ordinary publisher traffic") + }), + ); + assert!( + block_on(hook.handle(&mut request)) + .expect("should freeze ordinary request gate") + .is_none(), + "should continue ordinary publisher routing" + ); + crate::integrations::gpt_diagnostics::prepare_request(settings, &mut request) + .expect("should prepare effective diagnostics decision"); + assert!( + !request + .headers() + .get(header::COOKIE) + .is_some_and(|value| value + .as_bytes() + .windows(17) + .any(|part| part == b"__Host-ts-console")), + "should exercise sanitized cookies rather than re-reading the original session" + ); + let publisher = block_on(run_publisher_proxy(settings, &services, request)); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = + IntegrationRegistry::new(settings).expect("should build publisher registry"); + let response = block_on(buffer_publisher_response_async( + publisher, + &Method::GET, + settings, + ®istry, + &orchestrator, + &services, + )) + .expect("should finalize publisher response"); + let (parts, body) = response.into_parts(); + let text = String::from_utf8( + body.into_bytes() + .expect("should buffer transformed test body") + .to_vec(), + ) + .expect("should produce UTF8 publisher document"); + (Response::from_parts(parts, EdgeBody::empty()), text) + } + + #[test] + fn trace_document_gate_uses_frozen_cookie_and_effective_navigation_decision() { + for (trace_enabled, cookie, query, destination, prefetch, bot, active) in [ + ( + true, + Some("__Host-ts-console=1"), + "", + "document", + false, + false, + true, + ), + (true, None, "?ts_console=1", "document", false, false, false), + ( + true, + Some("__Host-ts-console=1"), + "?ts_console=0", + "document", + false, + false, + false, + ), + ( + true, + Some("__Host-ts-console=1"), + "?ts_console=invalid", + "document", + false, + false, + false, + ), + ( + true, + Some("__Host-ts-console=1"), + "?ts_console=1&ts_console=1", + "document", + false, + false, + false, + ), + ( + true, + Some("__Host-ts-console=1; __Host-ts-console=1"), + "", + "document", + false, + false, + false, + ), + ( + true, + Some("__Host-ts-console=invalid"), + "", + "document", + false, + false, + false, + ), + ( + true, + Some("__Host-ts-console=1, unrelated=example"), + "", + "document", + false, + false, + false, + ), + ( + true, + Some("__Host-ts-console=1; unrelated=\u{fffd}"), + "", + "document", + false, + false, + false, + ), + ( + true, + Some("__Host-ts-console=1"), + "", + "document", + true, + false, + false, + ), + ( + true, + Some("__Host-ts-console=1"), + "", + "document", + false, + true, + false, + ), + ( + true, + Some("__Host-ts-console=1"), + "", + "empty", + false, + false, + false, + ), + ( + false, + Some("__Host-ts-console=1"), + "", + "document", + false, + false, + false, + ), + (true, None, "", "document", false, false, false), + ] { + let settings = settings(trace_enabled); + let mut request = request(query, cookie, destination); + if prefetch { + request + .headers_mut() + .insert("sec-purpose", HeaderValue::from_static("prefetch")); + } + if bot { + request.headers_mut().insert( + header::USER_AGENT, + HeaderValue::from_static(BOT_USER_AGENT_FRAGMENTS[0]), + ); + assert!( + is_bot_user_agent(&request), + "should exercise the existing crawler opt-out policy" + ); + } + let (response, html) = render(&settings, request, "text/html; charset=utf-8"); + assert_eq!( + html.contains("window.__tsjs_trace_active=true"), + active, + "should evaluate frozen session plus effective decision for query={query} destination={destination} trace={trace_enabled} prefetch={prefetch} bot={bot} cookie={cookie:?}" + ); + assert_eq!( + html.contains("window.__tsjs_trace_request_context"), + active, + "should expose context only for an eligible document" + ); + assert!( + html.contains("publisher ads remain"), + "should preserve publisher advertising content" + ); + assert!( + !html.contains("/_ts/trace/assets/"), + "should not inject setup-page assets into publisher traffic" + ); + assert!( + response + .extensions() + .get::() + .is_none(), + "should retain ordinary publisher finalization rather than local trace terminal policy" + ); + if active { + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "no-store, private", + "should retain terminal diagnostics privacy" + ); + assert!( + !response.headers().contains_key(header::ETAG), + "should strip origin validators from private document" + ); + assert!( + html.contains("192.0.2.0/24") && !html.contains("192.0.2.99"), + "should project only masked trusted client IP" + ); + } + } + } + + #[test] + fn trace_document_non_html_responses_never_publish_bootstrap() { + for content_type in [ + "application/json", + "text/x-component", + "application/octet-stream", + ] { + let (_, html) = render( + &settings(true), + request("", Some("__Host-ts-console=1"), "document"), + content_type, + ); + assert!( + !html.contains("__tsjs_trace_"), + "should leave API, SPA Flight and binary responses without document globals" + ); + } + } + + #[test] + fn trace_document_context_is_script_safe_and_emitted_once_before_main_bundle() { + let settings = settings(true); + let (gate, decision) = gate_and_decision(&settings); + let sentinel = "", + "should omit failed context and retain the independent evaluated gate" + ); + assert!( + trace_document_context_script(&UnserializableContext).is_none(), + "should omit a serialization failure without publishing error text" + ); + let mut response = Response::new(EdgeBody::empty()); + crate::integrations::gpt_diagnostics::finalize_response(&decision, &mut response); + crate::response_privacy::apply_response_headers_with_cache_privacy( + &settings, + &mut response, + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "no-store, private", + "should retain diagnostics privacy after failed context and hostile operator cache policy" + ); + assert!( + !response.headers().contains_key("surrogate-control"), + "should prevent edge cache overrides after a failed context" + ); + assert!( + response + .extensions() + .get::() + .is_some(), + "should preserve ordinary diagnostics terminal privacy" + ); + assert!( + response + .extensions() + .get::() + .is_none(), + "should never substitute local trace terminal policy for a publisher document" + ); + let mut params = make_stream_params(&settings, ""); + params.content_type = "text/html".to_owned(); + params.gpt_diagnostics = Some(decision); + params.trace_bootstrap = Some(bootstrap); + let mut output = Vec::new(); + stream_publisher_body( + EdgeBody::from("publisher ads remain"), + &mut output, + ¶ms, + &settings, + &IntegrationRegistry::new(&settings).expect("should build registry"), + ) + .expect("should preserve advertising after context failure"); + let html = String::from_utf8(output).expect("should preserve UTF8 document"); + assert!( + html.contains("publisher ads remain") + && html.contains("window.__tsjs_trace_active=true") + && !html.contains("window.__tsjs_trace_request_context"), + "should retain advertising and the flag while omitting unavailable context" + ); + } + + #[test] + fn trace_document_shared_esi_processor_drops_request_bootstrap_explicitly() { + let mut settings = settings(true); + settings.creative_opportunities = Some( + serde_json::from_value( + serde_json::json!({"gam_network_id":"12345","assembly_mode":"esi"}), + ) + .expect("should configure fictional shared assembly"), + ); + let (gate, decision) = gate_and_decision(&settings); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let mut processor = create_html_stream_processor(HtmlStreamProcessorParams { + origin_host: "origin.example.com", + request_host: "publisher.example.com", + request_scheme: "https", + settings: &settings, + integration_registry: ®istry, + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + suppress_datadome_client_side_tag: false, + gpt_diagnostics: Some(decision), + trace_bootstrap: Some(trace_document_bootstrap( + &gate, + &ClientInfo::default(), + None, + fixed_clock(), + )), + shared_template_authorized: true, + csp_nonce_observed: None, + deferred_inline_marker: None, + }) + .expect("should create shared processor"); + let bytes = processor + .process_chunk( + b"shared example", + true, + ) + .expect("should transform shared example"); + let html = String::from_utf8(bytes).expect("should produce UTF8 shared template"); + assert!( + !html.contains("__tsjs_trace_") && !html.contains("__tsjs_gpt_diagnostics_active"), + "should explicitly exclude both request bootstraps from shared ESI bytes" + ); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + #[ignore = "requires the declared Node toolchain for emitted-script execution"] + fn trace_document_emitted_script_deep_freezes_owned_context_in_node() { + let settings = settings(true); + let (gate, _) = gate_and_decision(&settings); + let sentinel = "")) + .expect("should contain exactly one inline script wrapper"); + let probe = format!( + "'use strict';globalThis.window={{}};{script};const c=window.__tsjs_trace_request_context;if(window.__tsjs_trace_active!==true)throw Error('missing literal gate');if(c.network.edge_hostname!==process.argv[1])throw Error('metadata did not roundtrip');if(Object.hasOwn(c.network,'tls_protocol'))throw Error('unsupported metadata published');for(const v of [c,c.network,c.cookies,c.cookies.ts_ec,c.cookies.ts_eids,c.cookies.ts_tester,c.cookies.diagnostics_session])if(!Object.isFrozen(v))throw Error('mutable context');let rejected=false;try{{c.cookies.diagnostics_session.state='absent'}}catch{{rejected=true}}if(!rejected)throw Error('context mutation accepted');" + ); + let output = std::process::Command::new("node") + .arg("-e") + .arg(probe) + .arg(sentinel) + .output() + .expect("should execute emitted script with the declared Node toolchain"); + assert!( + output.status.success(), + "should deeply freeze freshly parsed context: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + + /// Drive `handle_publisher_request` with no creative opportunities — a plain + /// proxy with no server-side auction. Hides the auction/EC wiring so callers + /// read like a simple `(settings, services, req)` proxy. + async fn run_publisher_proxy( + settings: &Settings, + services: &RuntimeServices, + req: Request, + ) -> PublisherResponse { + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut ec_context = + EcContext::read_from_request(settings, &req, services).expect("should read EC context"); + handle_publisher_request( + settings, + services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, + }, + req, + EdgeCacheHeader::SurrogateControl, + ) + .await + .expect("should proxy publisher request") + } + + #[tokio::test] + async fn origin_span_covers_the_publisher_fetch() { + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![(header::CONTENT_TYPE.as_str(), "text/html; charset=utf-8")], + ); + let services = + build_services_with_http_client(stub as Arc); + let mut request = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/some-page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.clone()); + + let _response = run_publisher_proxy(&settings, &services, request).await; + + timings.mark_headers_ready(); + assert!( + timings.snapshot().origin_ms.is_some(), + "should record the Origin phase span around the publisher fetch" + ); + } + + #[tokio::test] + async fn origin_span_is_recorded_when_the_origin_send_fails() { + // Regression guard for the review finding that the origin span + // guard stayed alive through the error branch (and its + // abandonment-telemetry await): the span must close when the send + // resolves, so a failed fetch still records `origin_ms` and the + // error branch's own work is excluded from it. + let settings = create_test_settings(); + // No queued response: the stub client fails the origin send. + let stub = Arc::new(StubHttpClient::new()); + let services = + build_services_with_http_client(stub as Arc); + let mut request = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/some-page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.clone()); + + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut ec_context = EcContext::read_from_request(&settings, &request, &services) + .expect("should read EC context"); + let result = handle_publisher_request( + &settings, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, + }, + request, + EdgeCacheHeader::SurrogateControl, + ) + .await; + + assert!(result.is_err(), "should surface the origin failure"); + timings.mark_headers_ready(); + assert!( + timings.snapshot().origin_ms.is_some(), + "should record the Origin span even when the origin send fails" + ); + } + + mod rendered_template_identity_tests { + //! The gate the plan's Task 3 Step 2 actually asks for. + //! + //! Every other test in this area exercises the decision functions with + //! hand-built inputs. That is how three HIGH review findings sat in covered, + //! passing code: the decisions were right and nothing checked what the + //! composition of them *renders*. + //! + //! These tests render whole documents through `create_html_processor`, + //! composing the same three decisions `create_html_stream_processor` uses, + //! and compare bytes. A future request-dependent injection added at either + //! seam fails here even if every decision function is left untouched. + + use super::template_neutrality_tests::{settings_with_slots, slot}; + use super::*; + use crate::creative_opportunities::AssemblyMode; + use crate::html_processor::{HtmlProcessorConfig, create_html_processor}; + use crate::integrations::IntegrationRegistry; + use crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision; + + const DOCUMENT: &[u8] = + b"t

content

"; + + /// One request's worth of variation. Everything here is request-scoped and + /// must not reach a shared template. + #[derive(Debug, Clone, Copy)] + struct RequestShape { + /// Folds in consent, bot classification, prefetch and the kill switch. + ad_stack_ran: bool, + /// Cookie- or query-activated. + diagnostics_active: bool, + /// A resolved auction, present only when one was dispatched. + bids_available: bool, + } + + /// Build the config exactly as `create_html_stream_processor` does, so a + /// drift between a decision and its use is caught rather than hidden. + fn render(mode: AssemblyMode, shape: RequestShape) -> String { + let settings = settings_with_slots(); + let slots = [slot()]; + + let ad_slots_script = + template_ad_slots_script(mode, shape.ad_stack_ran, &settings, &slots, "/"); + let body_close = body_close_injection(mode, ad_slots_script.is_some()); + let gpt_diagnostics = template_gpt_diagnostics( + mode, + shape + .diagnostics_active + .then(GptDiagnosticsRequestDecision::active_for_tests), + ); + + let ad_bids_state = + std::sync::Arc::new(std::sync::Mutex::new(shape.bids_available.then(|| { + r#""#.to_string() + }))); + + let config = HtmlProcessorConfig { + csp_nonce_observed: None, + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script, + ad_bids_state, + max_buffered_body_bytes: 16 * 1024 * 1024, + gpt_diagnostics, + trace_bootstrap: None, + body_close, + suppress_datadome_client_side_tag: false, + }; + + let mut processor = create_html_processor(config); + let out = processor + .process_chunk(DOCUMENT, true) + .expect("should process the document"); + String::from_utf8(out).expect("output should be utf8") + } + + fn every_shape() -> Vec { + let mut shapes = Vec::new(); + for ad_stack_ran in [false, true] { + for diagnostics_active in [false, true] { + for bids_available in [false, true] { + shapes.push(RequestShape { + ad_stack_ran, + diagnostics_active, + bids_available, + }); + } + } + } + shapes + } + + #[test] + fn shared_template_ad_seam_is_readable_and_versioned() { + assert_eq!( + (crate::platform::TEMPLATE_SCHEMA_VERSION, AD_ASSEMBLY_SEAM,), + (5, ""), + "the core build digest in the template fingerprint isolates seam changes, \ + so updating the seam needs no schema bump; the schema moves only for \ + compatibility changes outside that digest (see TEMPLATE_SCHEMA_VERSION)" + ); + assert_eq!( + body_close_injection(AssemblyMode::Esi, false), + BodyCloseInjection::Marker(TEMPLATE_SEAM_PLACEHOLDER.to_string()), + "the seam's position must come from the parser, never from a byte search" + ); + assert_ne!( + TEMPLATE_SEAM_PLACEHOLDER, AD_ASSEMBLY_SEAM, + "a publisher document carrying the seam bytes must still receive \ + correctly positioned bids, which a shared marker would make impossible" + ); + } + + #[test] + fn shared_modes_render_byte_identical_documents_for_every_request_shape() { + let mode = AssemblyMode::Esi; + let shapes = every_shape(); + let baseline = render(mode, shapes[0]); + + for shape in &shapes[1..] { + let rendered = render(mode, *shape); + assert_eq!( + rendered, baseline, + "{mode:?}: rendered template differs for {shape:?}. A shared \ + template that varies by request freezes the first-filling \ + request's decision for every later reader." ); } } @@ -9865,6 +11450,10 @@ mod tests { NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, StubHttpClient, }; use crate::test_support::tests::crate_test_settings_str; + use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, RequestIngress, + TargetUnavailable, + }; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -10835,9 +12424,17 @@ mod tests { let settings = Arc::new(settings_with_readthrough_enabled("inline")); queue_shareable_html(&stub); let mut request = navigation_request(); - request - .headers_mut() - .insert("x-forwarded-proto", HeaderValue::from_static("https")); + let ingress = RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::NotExposed), + Some( + InboundOrigin::parse("https", "ts.example.com", OriginSource::RuntimeUri) + .expect("should capture the reader transport origin"), + ), + HeaderFidelity::default(), + vec![], + ) + .expect("should construct the reader ingress"); + request.extensions_mut().insert(ingress); *request.uri_mut() = "https://ts.example.com/article?b=2&a=1" .parse() .expect("should parse reader URI"); @@ -11125,6 +12722,109 @@ mod tests { } } + #[tokio::test] + async fn public_origin_publisher_rewrites_html_and_partitions_template_cache() { + let mut settings = settings_with_mode("esi"); + settings.publisher.domain = "publisher.example.com".to_owned(); + settings.publisher.origin_url = "http://origin.example.com".to_owned(); + settings.publisher.origin_host_header_override = Some("backend.example.com".to_owned()); + settings.trusted_forwarder = Some(crate::settings::TrustedForwarderConfig { + auth_header: crate::forwarder::FORWARDER_AUTH_HEADER.to_owned(), + shared_secret: crate::redacted::Redacted::new( + "fictional-forwarder-secret-0123456789".to_owned(), + ), + }); + let settings = Arc::new(settings); + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let services = services(Arc::clone(&stub), Arc::clone(&cache)); + for _ in 0..2 { + stub.push_response_with_headers(200, + b"next".to_vec(), + vec![("content-type", "text/html; charset=utf-8"), ("cache-control", "public, max-age=300")]); + } + for (authority, expected_fetches) in [ + ("publisher.example.com:8443", 1), + ("publisher.example.com:8443", 1), + ("publisher.example.com:9443", 2), + ] { + let mut request = navigation_request(); + *request.uri_mut() = "http://internal.example.com/article" + .parse() + .expect("should parse transport URI"); + request.headers_mut().insert( + header::HOST, + HeaderValue::from_static("internal.example.com"), + ); + request.headers_mut().insert( + crate::forwarder::FORWARDER_AUTH_HEADER, + HeaderValue::from_static("fictional-forwarder-secret-0123456789"), + ); + request + .headers_mut() + .insert("x-forwarded-host", HeaderValue::from_static(authority)); + request + .headers_mut() + .insert("x-forwarded-proto", HeaderValue::from_static("https")); + crate::forwarder::prepare_trusted_forwarder(&mut request, &settings); + let response = run(&settings, &services, request).await; + let body = String::from_utf8(body_of(response).await).expect("should decode HTML"); + assert!( + body.contains(&format!("https://{authority}/next")), + "should rewrite publisher links to public origin: {body}" + ); + assert!( + !body.contains("internal.example.com"), + "should not expose transport authority in HTML" + ); + assert_eq!( + stub.recorded_request_uris().len(), + expected_fetches, + "should hit cache only for the same public authority" + ); + } + for key in cache + .lookups + .lock() + .expect("should lock cache lookups") + .iter() + { + assert_eq!( + key.request_scheme, "https", + "should partition templates by public scheme" + ); + assert!( + matches!( + key.request_host.as_str(), + "publisher.example.com:8443" | "publisher.example.com:9443" + ), + "should partition templates by public authority" + ); + } + for uri in stub.recorded_request_uris() { + assert_eq!( + uri, "http://origin.example.com/article", + "should connect only to configured publisher origin" + ); + } + for fields in stub.recorded_request_headers() { + assert!( + fields + .iter() + .any(|(name, value)| name.eq_ignore_ascii_case("host") + && value == "backend.example.com"), + "should retain configured backend Host" + ); + assert!( + !fields.iter().any(|(name, _)| matches!( + name.as_str(), + "x-ts-forwarder-auth" | "x-forwarded-host" | "x-forwarded-proto" + )), + "should not forward public-origin inputs" + ); + } + } + #[tokio::test] async fn a_second_request_is_served_from_the_cache_without_touching_the_origin() { let stub = Arc::new(StubHttpClient::new()); @@ -11183,6 +12883,108 @@ mod tests { ); } + #[tokio::test] + async fn template_cache_response_extension_matches_the_header_on_every_transition() { + // The typed extension and the `x-ts-template-cache` header are written + // together by a single setter, so they must always agree — access + // telemetry reads the extension precisely because it cannot drift from + // what an operator-configured header override might otherwise show. + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let settings = Arc::new(settings_with_mode("esi")); + let services = services(Arc::clone(&stub), Arc::clone(&cache)); + + queue_shareable_html(&stub); + + let cold = run(&settings, &services, navigation_request()).await; + let cold_header = cold + .headers() + .get(HEADER_X_TS_TEMPLATE_CACHE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let cold_extension = cold + .extensions() + .get::() + .map(|state| state.as_str()); + assert_eq!( + cold_extension, + cold_header.as_deref(), + "the cold-fill extension must match the header" + ); + assert_eq!(cold_extension, Some("miss-stored")); + + let warm = run(&settings, &services, navigation_request()).await; + let warm_header = warm + .headers() + .get(HEADER_X_TS_TEMPLATE_CACHE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let warm_extension = warm + .extensions() + .get::() + .map(|state| state.as_str()); + assert_eq!( + warm_extension, + warm_header.as_deref(), + "the warm-hit extension must match the header" + ); + assert_eq!(warm_extension, Some("hit")); + } + + #[tokio::test] + async fn template_cache_span_recorded_only_when_lookup_runs() { + // Inline mode: no shared-cache key is ever computed, so the lookup + // never runs and the span is never recorded. + let inline_settings = create_test_settings(); + let inline_stub = Arc::new(StubHttpClient::new()); + inline_stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![(header::CONTENT_TYPE.as_str(), "text/html; charset=utf-8")], + ); + let inline_services = build_services_with_http_client( + inline_stub as Arc, + ); + let mut inline_request = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/some-page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + let inline_timings = RequestTimings::new(); + inline_request + .extensions_mut() + .insert(inline_timings.clone()); + + let _inline_response = + run_publisher_proxy(&inline_settings, &inline_services, inline_request).await; + + inline_timings.mark_headers_ready(); + assert!( + inline_timings.snapshot().template_cache_ms.is_none(), + "inline mode should never run the template-cache lookup" + ); + + // Shared-mode eligible: a matched slot plus a template-cache-eligible + // assembly mode compute a cache key, so the lookup runs and is timed. + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let settings = Arc::new(settings_with_mode("esi")); + let services = services(Arc::clone(&stub), Arc::clone(&cache)); + queue_shareable_html(&stub); + let mut request = navigation_request(); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.clone()); + + let _response = run(&settings, &services, request).await; + + timings.mark_headers_ready(); + assert!( + timings.snapshot().template_cache_ms.is_some(), + "a shared-cache-eligible request should record the TemplateCacheLookup span" + ); + } + #[tokio::test] async fn a_pass_through_authorization_never_reaches_the_shared_template() { let stub = Arc::new(StubHttpClient::new()); @@ -14123,7 +15925,6 @@ mod tests { Some(&graph), &partners, None, - None, &mut response, ); assert_eq!( @@ -14314,45 +16115,874 @@ mod tests { let _ = run(&settings, &services, diagnostics_navigation_request()).await; - assert!( - cache - .entries - .lock() - .expect("should lock entries") - .is_empty(), - "a reader running diagnostics must not contribute to a shared cache" - ); - } + assert!( + cache + .entries + .lock() + .expect("should lock entries") + .is_empty(), + "a reader running diagnostics must not contribute to a shared cache" + ); + } + + #[tokio::test] + async fn active_diagnostics_bypass_an_already_warm_template() { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let settings = Arc::new(settings_with_mode("esi")); + let services = services(Arc::clone(&stub), Arc::clone(&cache)); + queue_shareable_html(&stub); + queue_shareable_html(&stub); + + let _ = body_of(run(&settings, &services, navigation_request()).await).await; + let response = run(&settings, &services, diagnostics_navigation_request()).await; + let document = String::from_utf8(body_of(response).await) + .expect("diagnostics document should be UTF-8"); + + assert_eq!( + stub.recorded_request_uris().len(), + 2, + "request-private diagnostics must reach the origin even when an ordinary \ + shared template is warm" + ); + assert_eq!( + cache.lookups.lock().expect("should lock lookups").len(), + 1, + "the diagnostics request must not consult template cache at all" + ); + assert!( + document.contains("__tsjs_gpt_diagnostics_active"), + "origin fallback must retain the request-private diagnostics bootstrap" + ); + } + + mod trace_document_cache_tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr}; + + use crate::trace::TracePreDispatchHook; + use edgezero_core::router::PreDispatchHook as _; + use serde::Deserialize as _; + + fn decode_script_json(document: &str, prefix: &str) -> serde_json::Value { + let (_, source) = document + .split_once(prefix) + .expect("should locate request script JSON"); + let encoded = String::deserialize(&mut serde_json::Deserializer::from_str(source)) + .expect("should decode escaped JSON string"); + serde_json::from_str(&encoded).expect("should decode server-owned JSON") + } + + #[test] + fn trace_transport_scripts_preserve_all_terminal_envelopes_and_escape_payloads() { + for (status, reason) in [ + (TraceAuctionTerminalStatus::Completed, None), + ( + TraceAuctionTerminalStatus::Skipped, + Some(TraceAuctionTerminalReason::PolicySkipped), + ), + ( + TraceAuctionTerminalStatus::DispatchFailed, + Some(TraceAuctionTerminalReason::NoProviderLaunched), + ), + ( + TraceAuctionTerminalStatus::ExecutionFailed, + Some(TraceAuctionTerminalReason::CollectionFailed), + ), + ( + TraceAuctionTerminalStatus::Abandoned, + Some(TraceAuctionTerminalReason::Unknown), + ), + ] { + let carry = TraceAuctionCarry::capture_if_enabled( + true, + TraceAuctionSource::InitialNavigationSsat, + &[article_slot().to_ad_slot()], + ) + .expect("should capture trace model"); + carry.finish(status, reason); + let expected = + serde_json::to_value(carry.transport().expect("should finish model")) + .expect("should serialize checked evidence"); + let bids = serde_json::json!({"slot":{"adm":"\"\\\u{2028}\u{2029}"}}) + .as_object() + .expect("should construct bids map") + .clone(); + let slots = serde_json::json!([{"id":"slot","targeting":{"example":"\"\\\u{2028}\u{2029}"}}]); + let mut state = AdBidsState::default(); + state.set(bids.clone()); + state.attach_trace(carry); + for script in [ + inline_bids_script(&state), + state.build_seam_script(&slots.to_string()), + ] { + assert_eq!( + decode_script_json(&script, "var x=JSON.parse("), + expected, + "should preserve every checked terminal envelope" + ); + assert_eq!( + decode_script_json(&script, "var b=JSON.parse("), + serde_json::Value::Object(bids.clone()), + "should preserve ordinary bids" + ); + assert_eq!( + script.matches("").count(), + 1, + "should escape embedded closing tags" + ); + assert!( + !script.contains('\u{2028}') && !script.contains('\u{2029}'), + "should escape JavaScript separators" + ); + } + assert_eq!( + decode_script_json( + &state.build_seam_script(&slots.to_string()), + "var a=JSON.parse(" + ), + slots, + "should preserve ordinary slot definitions" + ); + } + } + + #[test] + fn trace_transport_projection_failure_keeps_the_ordinary_bids_and_slot_helper_clean() { + let settings = settings_with_mode("inline"); + let co = settings + .creative_opportunities + .as_ref() + .expect("should configure slots"); + let slot = article_slot(); + let mut auction_slot = slot.to_ad_slot(); + auction_slot.formats = + vec![auction_slot.formats[0].clone(); usize::from(u16::MAX) + 17]; + let carry = TraceAuctionCarry::capture_if_enabled( + true, + TraceAuctionSource::InitialNavigationSsat, + &[auction_slot], + ) + .expect("should capture source failure privately"); + carry.finish(TraceAuctionTerminalStatus::Completed, None); + let mut state = AdBidsState::default(); + let bids = serde_json::json!({"slot":{"hb_pb":"1.50"}}) + .as_object() + .expect("should construct bid map") + .clone(); + state.set(bids.clone()); + state.attach_trace(carry); + let script = inline_bids_script(&state); + assert_eq!( + decode_script_json(&script, "var x=JSON.parse("), + serde_json::json!({"schema_version":1,"unavailable_reason":"evidence_projection_failed"}), + "should deliver only the fixed unavailable envelope" + ); + assert_eq!( + decode_script_json(&script, "var b=JSON.parse("), + serde_json::Value::Object(bids), + "should preserve normal bid data" + ); + assert!( + build_slot_json(&slot, co, "article") + .expect("should render generic slot") + .get("ext") + .is_none(), + "should leave shared generic slot helper token-free" + ); + assert!( + !build_ad_slots_script(&[slot], co, "/article").contains("trace_slot_ref"), + "should leave template producer token-free" + ); + } + + #[tokio::test] + async fn trace_transport_live_document_binds_slots_and_reuses_token_for_actual_delivery() + { + for finalizer in [Finalizer::Buffered, Finalizer::Streaming] { + for winning in [false, true] { + let config = format!( + "{}\n[auction]\nenabled=true\n[auction.providers.bidder]\nprotocol=\"openrtb-2.6\"\nprofile=\"standard\"\nendpoint=\"https://bidder.example.com/auction\"\nrouting=\"all_eligible\"\n[creative_opportunities]\ngam_network_id=\"12345\"\n", + crate_test_settings_str() + ); + let mut settings = + Settings::from_toml(&config).expect("should configure actual bidder"); + settings.publisher.domain = "publisher.example.com".to_owned(); + settings.publisher.origin_url = "https://origin.example.com".to_owned(); + settings.proxy.allowed_domains = + vec!["*.example.com".to_owned(), "*.example".to_owned()]; + settings + .integrations + .insert_config( + "gpt_diagnostics", + &serde_json::json!({"enabled":true,"trace_page_enabled":true}), + ) + .expect("should enable trace"); + let settings = Arc::new(settings); + let orchestrator = Arc::new(AuctionOrchestrator::from_plan( + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile production bidder plan"), + ), + None, + )); + let stub = Arc::new(StubHttpClient::new()); + if winning { + stub.push_response(200, serde_json::to_vec(&serde_json::json!({"seatbid":[{"seat":"bidder","bid":[{"id":"bid","impid":"test-slot","price":1.5,"w":728,"h":90,"adm":"
Example creative
"}]}]})).expect("should encode winning provider response")); + } else { + stub.push_response(204, Vec::new()); + } + queue_shareable_html(&stub); + let services = services_for_ip( + Arc::clone(&stub), + Arc::new(MemoryTemplateCache::default()), + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 99)), + ); + let mut request = navigation_request_with_cookie("__Host-ts-console=1"); + TracePreDispatchHook::new( + Arc::clone(&settings), + Arc::new(|_| panic!("should not load setup metadata")), + ) + .handle(&mut request) + .await + .expect("should freeze document gate"); + crate::integrations::gpt_diagnostics::prepare_request( + &settings, + &mut request, + ) + .expect("should freeze diagnostics decision"); + let mut ec_context = EcContext::new_for_test(None, scheduling_consent()); + let slots = [article_slot()]; + let publisher = handle_publisher_request( + &settings, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &slots, + registry: None, + }, + request, + EdgeCacheHeader::SMaxageFallback, + ) + .await + .expect("should dispatch real publisher auction"); + let registry = IntegrationRegistry::new(&settings) + .expect("should build integration registry"); + let response = finalize_test_publisher_response( + publisher, + &settings, + &services, + ®istry, + orchestrator, + finalizer, + ) + .await; + assert!( + response + .extensions() + .get::() + .is_some(), + "should preserve ordinary terminal-private response" + ); + assert!( + response + .extensions() + .get::() + .is_none(), + "should keep publisher finalization active" + ); + let carry = response + .extensions() + .get::() + .expect("should retain request carry") + .clone(); + let token = carry.token().to_string(); + let document = String::from_utf8(body_of(response).await) + .expect("should deliver HTML"); + let transport = decode_script_json(&document, "var x=JSON.parse("); + let evidence = &transport["evidence"]; + assert_eq!( + evidence["source"], "initial_navigation_ssat", + "should retain source" + ); + assert_eq!( + evidence["terminal_status"], "completed", + "should finish actual provider work" + ); + assert_eq!( + evidence["diagnostic_auction_id"], token, + "should reuse pre-dispatch identity" + ); + let emitted = decode_script_json(&document, ".adSlots=JSON.parse("); + assert_eq!( + emitted[0]["ext"]["trusted_server"]["trace_slot_ref"], + evidence["slots"][0]["slot_ref"], + "should bind exact emitted slot occurrence" + ); + assert_eq!( + evidence["slots"][0]["candidate"], + if winning { "selected" } else { "no_candidate" }, + "should use actual bid-map delivery" + ); + let bids = decode_script_json(&document, "var b=JSON.parse("); + if winning { + assert_eq!( + bids["test-slot"]["hb_auction_id"], token, + "should share the opportunity token" + ); + } + assert_eq!( + document.matches("var x=JSON.parse(").count(), + 1, + "should deliver one request transport" + ); + assert_eq!( + stub.recorded_request_bodies().len(), + 2, + "should execute bidder and origin HTTP calls" + ); + } + } + } + + fn services_for_ip( + stub: Arc, + cache: Arc, + ip: IpAddr, + ) -> RuntimeServices { + 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(StubBackend)) + .http_client(stub) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: Some(ip), + ..ClientInfo::default() + }) + .template_cache(cache) + .build() + } + + async fn run_frozen( + settings: &Arc, + services: &RuntimeServices, + mut request: Request, + ) -> Response { + let hook = TracePreDispatchHook::new( + Arc::clone(settings), + Arc::new(|_| panic!("should never query setup metadata for publisher traffic")), + ); + assert!( + hook.handle(&mut request) + .await + .expect("should freeze incoming publisher cookie health") + .is_none(), + "should continue publisher routing" + ); + crate::integrations::gpt_diagnostics::prepare_request(settings, &mut request) + .expect( + "should freeze effective document diagnostics before publisher processing", + ); + run(settings, services, request).await + } + + fn settings_without_ad_stack() -> Arc { + let mut settings = settings_with_mode("inline"); + settings.auction.enabled = false; + settings.creative_opportunities = None; + settings + .integrations + .insert_config( + "gpt_diagnostics", + &serde_json::json!({"enabled":true,"trace_page_enabled":true}), + ) + .expect("should configure document tracing without the ad stack"); + Arc::new(settings) + } + + fn conditional_trace_request(cookie: bool) -> Request { + let mut request = if cookie { + navigation_request_with_cookie("__Host-ts-console=1") + } else { + navigation_request() + }; + for (name, value) in [ + (header::IF_NONE_MATCH, "\"cached-document\""), + (header::IF_MODIFIED_SINCE, "Wed, 21 Oct 2015 07:28:00 GMT"), + (header::RANGE, "bytes=0-99"), + (header::IF_RANGE, "\"cached-document\""), + ] { + request + .headers_mut() + .insert(name, HeaderValue::from_static(value)); + } + request + } + + #[tokio::test] + async fn trace_document_reload_gets_full_origin_body_without_the_ad_stack() { + for (cookie, query, destination, strip, active) in [ + (true, None, "document", true, true), + (false, Some("ts_console=1"), "document", true, false), + (true, Some("ts_console=0"), "document", true, false), + (false, None, "document", false, false), + (true, None, "script", false, false), + ] { + let stub = Arc::new(StubHttpClient::new()); + let services = services_for_ip( + Arc::clone(&stub), + Arc::new(MemoryTemplateCache::default()), + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 99)), + ); + let settings = settings_without_ad_stack(); + let mut request = conditional_trace_request(cookie); + if let Some(query) = query { + *request.uri_mut() = format!("https://ts.example.com/article?{query}") + .parse() + .expect("should construct an explicit diagnostics directive"); + } + request + .headers_mut() + .insert("sec-fetch-dest", HeaderValue::from_static(destination)); + queue_shareable_html(&stub); + + let response = run_frozen(&settings, &services, request).await; + + assert_eq!( + response.status(), + StatusCode::OK, + "should return origin HTML" + ); + let recorded = stub.recorded_request_headers(); + let outbound = recorded.first().expect("should record the origin request"); + for name in [ + header::IF_NONE_MATCH, + header::IF_MODIFIED_SINCE, + header::RANGE, + header::IF_RANGE, + ] { + assert_eq!( + outbound + .iter() + .any(|(key, _)| key.eq_ignore_ascii_case(name.as_str())), + !strip, + "should strip {name} only for request-scoped document changes" + ); + } + let document = String::from_utf8(body_of(response).await) + .expect("should render the complete publisher document"); + assert_eq!( + document.contains("window.__tsjs_trace_active=true"), + active, + "should bootstrap only a frozen active incoming document session" + ); + } + } + + #[tokio::test] + async fn trace_transport_document_gate_delivers_skip_facts_without_telemetry() { + for (cookie, query, active) in [ + (false, None, false), + (false, Some("ts_console=1"), false), + (true, Some("ts_console=0"), false), + (true, None, true), + ] { + let stub = Arc::new(StubHttpClient::new()); + let services = services_for_ip( + Arc::clone(&stub), + Arc::new(MemoryTemplateCache::default()), + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 99)), + ); + let settings = settings_without_ad_stack(); + let mut request = conditional_trace_request(cookie); + if let Some(query) = query { + *request.uri_mut() = format!("https://ts.example.com/article?{query}") + .parse() + .expect("should build a deliberate diagnostics directive"); + } + queue_shareable_html(&stub); + + let response = run_frozen(&settings, &services, request).await; + + assert_eq!( + response.status(), + StatusCode::OK, + "should preserve ordinary publisher delivery" + ); + let carry = response + .extensions() + .get::(); + assert_eq!( + carry.is_some(), + active, + "should allocate only for the frozen eligible document gate" + ); + let expected_token = if let Some(carry) = carry { + let value = serde_json::to_value( + carry + .transport() + .expect("should retain directly observed no-slot terminal facts"), + ) + .expect("should serialize the private transport"); + assert_eq!( + value["evidence"]["source"], "initial_navigation_ssat", + "should retain the observed source" + ); + assert_eq!( + value["evidence"]["terminal_status"], "skipped", + "should not require a telemetry observation for a skip" + ); + assert_eq!( + value["evidence"]["terminal_reason"], "no_eligible_slots", + "should preserve the definitive empty slot list" + ); + Some( + value["evidence"]["diagnostic_auction_id"] + .as_str() + .expect("should preserve auction token") + .to_owned(), + ) + } else { + None + }; + let document = String::from_utf8(body_of(response).await) + .expect("should preserve the publisher HTML"); + if let Some(token) = expected_token { + assert!( + document.contains(&token), + "should deliver the directly observed skipped auction even with zero bids" + ); + assert!( + !document.contains("s(b,void 0,void 0,x)"), + "should withhold the ad scheduler when ordinary ad slots are absent" + ); + } else { + assert!( + !document.contains("initial_navigation_ssat"), + "should omit inactive transport" + ); + } + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[tokio::test] + #[ignore = "requires the declared Node toolchain for emitted-script execution"] + async fn trace_document_skipped_emitted_script_leaves_ad_state_untouched() { + for gate in [ + "missing_opportunities", + "auction_disabled", + "consent_denied", + "unmatched_path", + ] { + for finalizer in [Finalizer::Buffered, Finalizer::Streaming] { + let stub = Arc::new(StubHttpClient::new()); + let services = services_for_ip( + Arc::clone(&stub), + Arc::new(MemoryTemplateCache::default()), + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 99)), + ); + queue_shareable_html(&stub); + let mut settings = settings_with_mode("inline"); + settings.auction.enabled = gate != "auction_disabled"; + if gate == "missing_opportunities" { + settings.creative_opportunities = None; + } + settings + .integrations + .insert_config( + "gpt_diagnostics", + &serde_json::json!({"enabled":true,"trace_page_enabled":true}), + ) + .expect("should enable trace"); + let settings = Arc::new(settings); + let mut request = conditional_trace_request(true); + if gate == "unmatched_path" { + *request.uri_mut() = "https://ts.example.com/unmatched" + .parse() + .expect("should build unmatched path"); + } + TracePreDispatchHook::new( + Arc::clone(&settings), + Arc::new(|_| panic!("should not load setup metadata")), + ) + .handle(&mut request) + .await + .expect("should freeze trace gate"); + crate::integrations::gpt_diagnostics::prepare_request( + &settings, + &mut request, + ) + .expect("should freeze diagnostics decision"); + let mut ec_context = EcContext::new_for_test( + None, + crate::consent::ConsentContext { + jurisdiction: if gate == "consent_denied" { + crate::consent::jurisdiction::Jurisdiction::Gdpr + } else { + crate::consent::jurisdiction::Jurisdiction::NonRegulated + }, + ..Default::default() + }, + ); + let orchestrator = + Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let registry = IntegrationRegistry::new(&settings) + .expect("should register integrations"); + let response = handle_publisher_request( + &settings, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[article_slot()], + registry: None, + }, + request, + EdgeCacheHeader::SMaxageFallback, + ) + .await + .expect("should preserve publisher response"); + let document = String::from_utf8( + body_of( + finalize_test_publisher_response( + response, + &settings, + &services, + ®istry, + orchestrator, + finalizer, + ) + .await, + ) + .await, + ) + .expect("should render publisher HTML"); + let script = document + .rsplit_once("") + .expect("should close the body script") + .0; + let probe = r#" +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const script = process.argv[1]; +for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { + const bids = {example: 'publisher-owned'}; + const slots = [{id: 'publisher-owned'}]; + const records = []; + let schedulerCalls = 0; + let adInitCalls = 0; + const api = {bids, adSlots: slots, initialAdInitScheduled: false, navGeneration: 0, + scheduleInitialAdInit() {schedulerCalls++;}, + adInit() {adInitCalls++;}}; + const bridge = {observeTransport(delivered, transport, source) { + if (mode === 'throws') throw Error('private diagnostic failure'); + assert.equal(delivered, undefined); + assert.equal(source, 'initial_navigation_ssat'); + assert.equal(transport.evidence.terminal_status, 'skipped'); + records.push(transport); + }}; + if (mode !== 'queued' && mode !== 'stale') api.traceGpt = bridge; + const window = {tsjs: api, __tsjs_trace_active: mode !== 'inactive'}; + vm.runInNewContext(script, {window}); + if (mode === 'queued' || mode === 'stale') { + assert.equal(records.length, 0); + assert.equal(api.que.length, 1); + api.traceGpt = bridge; + if (mode === 'stale') api.navGeneration = 1; + api.que[0](); + } + assert.equal(records.length, mode === 'ready' || mode === 'queued' ? 1 : 0); + assert.equal(api.bids, bids); + assert.equal(api.adSlots, slots); + assert.equal(api.initialAdInitScheduled, false); + assert.equal(schedulerCalls, 0); + assert.equal(adInitCalls, 0); +} +"#; + let output = std::process::Command::new("node") + .args(["-e", probe, script]) + .output() + .expect("should execute the emitted script with Node"); + assert!( + output.status.success(), + "should observe only trace evidence: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + } + + #[tokio::test] + async fn trace_document_reload_rejects_unexpected_origin_304_without_the_ad_stack() { + for content_type in [None, Some("text/html")] { + let stub = Arc::new(StubHttpClient::new()); + let services = services_for_ip( + Arc::clone(&stub), + Arc::new(MemoryTemplateCache::default()), + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 99)), + ); + let mut headers = vec![("etag", "\"cached-document\"")]; + if let Some(content_type) = content_type { + headers.push(("content-type", content_type)); + } + stub.push_response_with_headers(304, Vec::new(), headers); - #[tokio::test] - async fn active_diagnostics_bypass_an_already_warm_template() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - queue_shareable_html(&stub); + let response = run_frozen( + &settings_without_ad_stack(), + &services, + conditional_trace_request(true), + ) + .await; - let _ = body_of(run(&settings, &services, navigation_request()).await).await; - let response = run(&settings, &services, diagnostics_navigation_request()).await; - let document = String::from_utf8(body_of(response).await) - .expect("diagnostics document should be UTF-8"); + assert_eq!( + response.status(), + StatusCode::BAD_GATEWAY, + "should reject an origin that cannot deliver a fresh diagnostics document" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "no-store, private", + "should keep the terminal error private" + ); + assert!( + !response.headers().contains_key(header::ETAG), + "should not preserve stale document validators" + ); + assert!( + !String::from_utf8(body_of(response).await) + .expect("should return a fixed UTF-8 error") + .contains("__tsjs_trace_"), + "should not fabricate a publisher capture from an empty response" + ); + } + } - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "request-private diagnostics must reach the origin even when an ordinary \ - shared template is warm" - ); - assert_eq!( - cache.lookups.lock().expect("should lock lookups").len(), - 1, - "the diagnostics request must not consult template cache at all" - ); - assert!( - document.contains("__tsjs_gpt_diagnostics_active"), - "origin fallback must retain the request-private diagnostics bootstrap" - ); + #[tokio::test] + async fn trace_document_active_readers_bypass_warm_template_and_keep_distinct_contexts() + { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let mut settings = settings_with_mode("esi"); + settings + .creative_opportunities + .as_mut() + .expect("should configure shared assembly") + .origin_is_cookie_independent = Some(true); + settings + .integrations + .insert_config( + "gpt_diagnostics", + &serde_json::json!({"enabled":true,"trace_page_enabled":true}), + ) + .expect("should configure document trace"); + let settings = Arc::new(settings); + let first = services_for_ip( + Arc::clone(&stub), + Arc::clone(&cache), + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 99)), + ); + let second = services_for_ip( + Arc::clone(&stub), + Arc::clone(&cache), + IpAddr::V4(Ipv4Addr::new(198, 51, 100, 129)), + ); + for _ in 0..3 { + queue_shareable_html(&stub); + } + let warm = String::from_utf8( + body_of(run_frozen(&settings, &first, navigation_request()).await).await, + ) + .expect("should warm a neutral shared document"); + assert!( + !warm.contains("__tsjs_trace_"), + "should leave inactive shared documents without trace globals" + ); + for (services, mask, other_mask, raw_ip) in [ + (&first, "192.0.2.0/24", "198.51.100.0/24", "192.0.2.99"), + (&second, "198.51.100.0/24", "192.0.2.0/24", "198.51.100.129"), + ] { + let response = run_frozen( + &settings, + services, + navigation_request_with_cookie("__Host-ts-console=1"), + ) + .await; + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "no-store, private", + "should preserve per-reader terminal privacy" + ); + assert!( + response + .extensions() + .get::() + .is_some(), + "should retain existing publisher terminal guard" + ); + let document = String::from_utf8(body_of(response).await) + .expect("should produce active reader document"); + assert_eq!( + document.matches("window.__tsjs_trace_active=true").count(), + 1, + "should inject once per active origin response" + ); + assert!( + document.contains(mask) + && !document.contains(other_mask) + && !document.contains(raw_ip), + "should project only this reader's masked trusted facts" + ); + } + let inactive = String::from_utf8( + body_of(run_frozen(&settings, &second, navigation_request()).await).await, + ) + .expect("should reuse neutral cached document"); + assert!( + !inactive.contains("__tsjs_trace_") && !inactive.contains("198.51.100.0/24"), + "should never replay an active reader's context from shared storage" + ); + assert_eq!( + stub.recorded_request_uris().len(), + 3, + "should fetch origin for both active readers and use cached bytes only for inactive requests" + ); + assert_eq!( + cache + .lookups + .lock() + .expect("should lock cache lookup record") + .len(), + 2, + "should bypass shared cache lookup entirely for active trace documents" + ); + let entries = cache + .entries + .lock() + .expect("should lock stored shared entries"); + assert_eq!( + entries.len(), + 1, + "should retain only the original neutral shared template" + ); + for entry in entries.values() { + let shared = String::from_utf8_lossy(&entry.body); + assert!( + !shared.contains("__tsjs_trace_") + && !shared.contains("192.0.2.0/24") + && !shared.contains("198.51.100.0/24"), + "should never store trace globals or either reader's context in shared template bytes" + ); + } + } } #[tokio::test] @@ -14741,6 +17371,62 @@ mod tests { ); } + #[tokio::test] + async fn a_cache_hit_replays_policy_headers_rewritten_to_the_serving_host() { + // The header rewrite runs before the template-cache gate captures policy + // metadata, so a hit replays the serving-host CSP and Link rather than the + // origin-only values that would block or bypass the rewritten page. + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let settings = Arc::new(settings_with_mode("esi")); + let services = services(Arc::clone(&stub), Arc::clone(&cache)); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("cache-control", "public, max-age=300"), + ( + "content-security-policy", + "img-src https://origin.test-publisher.com", + ), + ( + "link", + "; rel=preload; as=style", + ), + ], + ); + + let cold = run(&settings, &services, navigation_request()).await; + let warm = run(&settings, &services, navigation_request()).await; + + assert_eq!( + stub.recorded_request_uris().len(), + 1, + "should serve the second navigation from the template cache" + ); + let value = |response: &Response, name: header::HeaderName| { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + .expect("should carry the policy header") + }; + for name in [header::CONTENT_SECURITY_POLICY, header::LINK] { + let replayed = value(&warm, name.clone()); + assert!( + replayed.contains("://ts.example.com"), + "should replay `{name}` rewritten to the serving host: {replayed}" + ); + assert_eq!( + replayed, + value(&cold, name.clone()), + "should replay the same `{name}` the miss delivered" + ); + } + } + #[tokio::test] async fn a_cache_hit_preserves_every_repeated_policy_header_in_order() { let stub = Arc::new(StubHttpClient::new()); @@ -16111,62 +18797,256 @@ mod tests { } #[tokio::test] - async fn publisher_asset_cache_policy_applies_to_non_html_response() { - let settings = Settings::from_toml(&format!( - r#"{} + async fn publisher_asset_cache_policy_applies_to_non_html_response() { + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "publisher-fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.png"] + fingerprint_style = "hex" + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + )) + .expect("should parse settings with cache rule"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"png".to_vec(), + vec![ + (header::CONTENT_TYPE.as_str(), "image/png"), + (header::CACHE_CONTROL.as_str(), "public, max-age=60"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let request = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/assets/logo.0123abcd.png") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = run_publisher_proxy(&settings, &services, request).await; + let PublisherResponse::PassThrough { response, .. } = response else { + panic!("should pass through non-HTML asset response"); + }; + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "matched publisher asset should receive immutable browser policy" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "matched publisher asset should receive Fastly edge policy" + ); + } + + fn proxy_response_headers(response: &PublisherResponse) -> &header::HeaderMap { + match response { + PublisherResponse::PassThrough { response, .. } + | PublisherResponse::Stream { response, .. } + | PublisherResponse::AssembleTemplate { response, .. } + | PublisherResponse::Buffered(response) => response.headers(), + } + } + + fn publisher_page_request() -> Request { + HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/redirect") + .header(header::HOST, "publisher.example") + .header("fastly-ssl", "1") + .body(EdgeBody::empty()) + .expect("should build request") + } + + #[tokio::test] + async fn transformed_page_headers_map_origin_urls_to_serving_host() { + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 302, + b"go" + .to_vec(), + vec![ + (header::CONTENT_TYPE.as_str(), "text/html; charset=utf-8"), + ( + header::LOCATION.as_str(), + "https://origin.test-publisher.com/landing.html", + ), + ( + header::REFRESH.as_str(), + "0; url=https://origin.test-publisher.com/refreshed.html", + ), + ( + header::LINK.as_str(), + "; rel=preload; as=style", + ), + ( + header::CONTENT_SECURITY_POLICY.as_str(), + "default-src https://origin.test-publisher.com 'unsafe-inline'", + ), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + + let response = run_publisher_proxy(&settings, &services, publisher_page_request()).await; + + assert!( + matches!(response, PublisherResponse::Stream { .. }), + "should stream the redirect HTML body through the rewriter" + ); + let headers = proxy_response_headers(&response); + let value = |name: header::HeaderName| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + assert_eq!( + value(header::LOCATION).as_deref(), + Some("https://publisher.example/landing.html"), + "should keep the redirect on the serving host" + ); + assert_eq!( + value(header::REFRESH).as_deref(), + Some("0; url=https://publisher.example/refreshed.html"), + "should keep the refresh navigation on the serving host" + ); + assert_eq!( + value(header::LINK).as_deref(), + Some("; rel=preload; as=style"), + "should preload through the serving host" + ); + assert_eq!( + value(header::CONTENT_SECURITY_POLICY).as_deref(), + Some( + "default-src https://origin.test-publisher.com https://publisher.example 'unsafe-inline'" + ), + "should permit the serving host alongside the origin" + ); + } + + #[tokio::test] + async fn bodiless_redirect_location_maps_origin_url_to_serving_host() { + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 301, + Vec::new(), + vec![( + header::LOCATION.as_str(), + "http://origin.test-publisher.com/moved?x=1", + )], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); - [[cache.asset_rules]] - id = "publisher-fingerprinted-assets" - enabled = true - path_globs = ["/assets/**/*.png"] - fingerprint_style = "hex" - visibility = "public" - browser_ttl_seconds = 31536000 - edge_ttl_seconds = 31536000 - immutable = true - "#, - crate_test_settings_str() - )) - .expect("should parse settings with cache rule"); + let response = run_publisher_proxy(&settings, &services, publisher_page_request()).await; + + assert!( + matches!(response, PublisherResponse::Buffered(_)), + "should return the bodiless redirect unmodified" + ); + assert_eq!( + proxy_response_headers(&response) + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()), + Some("https://publisher.example/moved?x=1"), + "should rewrite the redirect target even without a transformed body" + ); + } + + async fn redirect_location_for_current_url( + origin_url: &str, + method: Method, + location: &str, + ) -> String { + let mut settings = create_test_settings(); + settings.publisher.origin_url = origin_url.to_string(); let stub = Arc::new(StubHttpClient::new()); stub.push_response_with_headers( - 200, - b"png".to_vec(), - vec![ - (header::CONTENT_TYPE.as_str(), "image/png"), - (header::CACHE_CONTROL.as_str(), "public, max-age=60"), - ], + 303, + Vec::new(), + vec![(header::LOCATION.as_str(), location)], ); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); - let request = HttpRequest::builder() - .method(Method::GET) - .uri("https://publisher.example/assets/logo.0123abcd.png") - .header(header::HOST, "publisher.example") - .body(EdgeBody::empty()) - .expect("should build request"); + let mut request = publisher_page_request(); + *request.method_mut() = method; let response = run_publisher_proxy(&settings, &services, request).await; - let PublisherResponse::PassThrough { response, .. } = response else { - panic!("should pass through non-HTML asset response"); - }; + + proxy_response_headers(&response) + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()) + .expect("should keep a Location header") + .to_string() + } + + #[tokio::test] + async fn scheme_upgrade_redirect_to_current_url_is_not_rewritten_into_a_loop() { + let location = redirect_location_for_current_url( + "http://origin.test-publisher.com", + Method::GET, + "https://origin.test-publisher.com/redirect", + ) + .await; assert_eq!( - response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("public, max-age=31536000, immutable"), - "matched publisher asset should receive immutable browser policy" + location, "https://origin.test-publisher.com/redirect", + "should not redirect the browser back to the URL it requested" ); + } + + #[tokio::test] + async fn same_scheme_redirect_to_current_url_stays_on_serving_host() { + let location = redirect_location_for_current_url( + "https://origin.test-publisher.com", + Method::POST, + "https://origin.test-publisher.com/redirect", + ) + .await; + assert_eq!( - response - .headers() - .get("surrogate-control") - .and_then(|value| value.to_str().ok()), - Some("max-age=31536000"), - "matched publisher asset should receive Fastly edge policy" + location, "https://publisher.example/redirect", + "should rewrite a POST-redirect-GET to the same URL" + ); + } + + #[tokio::test] + async fn cookieless_get_redirect_to_current_url_is_not_rewritten_into_a_loop() { + // A canonical-host redirect triggered by `origin_host_header_override` + // answers the retry through Trusted Server the same way, so it must leave. + let location = redirect_location_for_current_url( + "https://origin.test-publisher.com", + Method::GET, + "https://origin.test-publisher.com/redirect", + ) + .await; + + assert_eq!( + location, "https://origin.test-publisher.com/redirect", + "should not redirect a cookieless GET back to the URL it requested" ); } @@ -18214,6 +21094,81 @@ mod tests { ); } + mod trace_auction_publisher_terminal_tests { + use super::*; + use crate::trace::{ + TraceAuctionCarry, TraceAuctionSource, TraceAuctionTerminalStatus, TraceProviderRole, + }; + + fn pending() -> (DispatchedAuction, TraceAuctionCarry) { + let request = test_auction_request(); + let trace = TraceAuctionCarry::capture_if_enabled( + true, + TraceAuctionSource::InitialNavigationSsat, + &request.slots, + ) + .expect("should capture the enabled publisher auction"); + let _observation = trace.launch_provider(TraceProviderRole::Bidder); + ( + DispatchedAuction::empty_for_test(request, 10).with_trace_for_test(trace.clone()), + trace, + ) + } + + fn status(trace: &TraceAuctionCarry) -> serde_json::Value { + serde_json::to_value( + trace + .transport() + .expect("should terminalize before telemetry"), + ) + .expect("should serialize the bounded transport")["evidence"]["terminal_status"] + .clone() + } + + #[tokio::test] + async fn trace_auction_abandons_without_a_telemetry_observation() { + let (dispatched, trace) = pending(); + emit_abandoned_auction( + &crate::platform::test_support::noop_services(), + None, + dispatched, + "example_disconnection", + ) + .await; + assert_eq!( + status(&trace), + "abandoned", + "should terminalize independently of telemetry construction" + ); + } + + #[test] + fn trace_auction_publisher_guard_preserves_a_clone_after_take() { + let (dispatched, trace) = pending(); + let mut guard = DispatchedAuctionGuard::new(dispatched); + let _taken = guard.take().expect("should begin collection"); + drop(guard); + assert_eq!( + status(&trace), + "abandoned", + "should synchronously terminalize cancellation during collection" + ); + } + + #[test] + fn trace_auction_publisher_drop_cannot_overwrite_completed_facts() { + let (dispatched, trace) = pending(); + let guard = DispatchedAuctionGuard::new(dispatched); + trace.finish(TraceAuctionTerminalStatus::Completed, None); + drop(guard); + assert_eq!( + status(&trace), + "completed", + "should preserve a completed terminal observation" + ); + } + } + fn response_body_string(response: http::Response) -> String { String::from_utf8( response @@ -18318,6 +21273,8 @@ mod tests { services: &services, settings: &settings, request_origin: String::new(), + timings: RequestTimings::new(), + placement: AuctionWaitPlacement::PreHeader, }, }; let mut output = Vec::new(); @@ -18394,6 +21351,8 @@ mod tests { services: &services, settings: &settings, request_origin: String::new(), + timings: RequestTimings::new(), + placement: AuctionWaitPlacement::InStream, }, }; let mut processor = RecordingProcessor { @@ -18474,6 +21433,8 @@ mod tests { services: &services, settings: &settings, request_origin: String::new(), + timings: RequestTimings::new(), + placement: AuctionWaitPlacement::InStream, }, }; let mut processor = RecordingProcessor { @@ -18568,6 +21529,8 @@ mod tests { services: &services, settings: &settings, request_origin: String::new(), + timings: RequestTimings::new(), + placement: AuctionWaitPlacement::PreHeader, }; // Passthrough processor: the ordering contract is about collection, not // HTML rewriting, so keep the emitted bytes verbatim. @@ -19509,7 +22472,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let mut output = Vec::new(); @@ -19568,7 +22533,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let mut output = Vec::new(); @@ -19616,7 +22583,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let body = EdgeBody::from_stream(futures::stream::iter(vec![Ok::<_, io::Error>( bytes::Bytes::from_static(b"live"), @@ -19742,7 +22711,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"body{background:url('https://origin.example.com/"), @@ -19806,7 +22777,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let compressed = gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -19873,7 +22846,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let compressed = deflate_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -19940,7 +22915,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let compressed = brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -20007,7 +22984,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let compressed = brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -20056,7 +23035,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), } } @@ -20279,7 +23260,9 @@ mod tests { )), price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"hello"), @@ -20354,7 +23337,9 @@ mod tests { )), price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; // The `` that triggers bid injection lives in the SECOND gzip // member. `flate2::read::GzDecoder` decodes only the first member, so @@ -20428,7 +23413,9 @@ mod tests { )), price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( b"body{background:url('https://origin.example.com/asset.png')}", @@ -20496,7 +23483,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let publisher_response = PublisherResponse::Stream { response, @@ -20652,7 +23641,9 @@ mod tests { dispatched_auction, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), } } @@ -21340,7 +24331,9 @@ mod tests { )), price_granularity: PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), } }; let make_stream_response = || PublisherResponse::Stream { @@ -21403,6 +24396,170 @@ mod tests { assert_bodiless_abandoned(&buffered_sink); } + #[test] + fn streaming_seam_wait_records_in_stream_placement() { + // The true Fastly streaming path: `publisher_response_into_streaming_response` + // hands back a lazy body after headers have already been committed by + // `stream_to_client()`. The `` seam wait polled from inside that body + // must therefore be attributed `InStream`, never `PreHeader`. + let settings = Arc::new(create_test_settings()); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let timings = RequestTimings::new(); + + let mut params = make_stream_params(&settings, ""); + params.content_type = "text/html; charset=utf-8".to_string(); + params.dispatched_auction = Some(DispatchedAuction::empty_for_test( + test_auction_request(), + 500, + )); + params.auction_request = Some(test_auction_request()); + params.timings = timings.clone(); + + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(EdgeBody::empty()) + .expect("should build response"); + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"hello"), + bytes::Bytes::from_static(b""), + ])); + + let response = futures::executor::block_on(publisher_response_into_streaming_response( + PublisherResponse::Stream { + response, + body, + params: Box::new(params), + }, + &Method::GET, + Arc::clone(&settings), + ®istry, + Arc::clone(&orchestrator), + noop_services(), + )) + .expect("streaming finalize should succeed"); + + // The wait is only recorded once the lazy body is actually polled — the + // finalizer call above only constructs it. + let drained = futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(settings.publisher.max_buffered_body_bytes), + ) + .expect("body should drain"); + assert!( + String::from_utf8_lossy(&drained).contains("hello"), + "should still stream the document" + ); + + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.auction_wait_placement, + Some(AuctionWaitPlacement::InStream), + "the streaming seam wait must be attributed InStream" + ); + assert!( + snapshot.auction_wait_ms.is_some(), + "should record an auction wait duration" + ); + // The T0 marks are recorded at this same collect site, so a collect + // path that stops calling them fails here rather than silently + // emitting null columns. + assert!( + snapshot.auction_resolved_ms.is_some(), + "should mark the auction resolved at the streaming collect site" + ); + assert!( + snapshot.auction_committed_ms.is_some(), + "should mark the auction committed at the streaming collect site" + ); + } + + #[test] + fn buffered_template_miss_records_pre_header_placement() { + // The buffered finalizer materializes the entire response — headers and + // body — before any of it reaches the client. Even though the wait runs + // through the same `` seam code path as the streaming finalizer + // above, headers have not committed here, so it must be attributed + // `PreHeader`. (This exercises the same collect step a shared-template + // authorized miss rides through: `template_cache_key`'s presence only + // changes what happens *after* collection — whether the transformed bytes + // are stored — not where the wait itself is measured.) + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let timings = RequestTimings::new(); + + let mut params = make_stream_params(&settings, ""); + params.content_type = "text/html; charset=utf-8".to_string(); + params.dispatched_auction = Some(DispatchedAuction::empty_for_test( + test_auction_request(), + 500, + )); + params.auction_request = Some(test_auction_request()); + params.timings = timings.clone(); + + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(EdgeBody::empty()) + .expect("should build response"); + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"hello"), + bytes::Bytes::from_static(b""), + ])); + + let response = futures::executor::block_on(buffer_publisher_response_async( + PublisherResponse::Stream { + response, + body, + params: Box::new(params), + }, + &Method::GET, + &settings, + ®istry, + &orchestrator, + &services, + )) + .expect("buffered finalize should succeed"); + + let html = String::from_utf8( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .to_vec(), + ) + .expect("should be valid UTF-8"); + assert!(html.contains("hello"), "should still assemble the document"); + + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.auction_wait_placement, + Some(AuctionWaitPlacement::PreHeader), + "the buffered finalizer's wait must be attributed PreHeader even though \ + it shares the seam code path with the streaming finalizer" + ); + assert!( + snapshot.auction_wait_ms.is_some(), + "should record an auction wait duration" + ); + // Same guard as the streaming test: both collect sites must mark, or + // one body mode quietly reports null offsets. + assert!( + snapshot.auction_resolved_ms.is_some(), + "should mark the auction resolved at the buffered collect site" + ); + assert!( + snapshot.auction_committed_ms.is_some(), + "should mark the auction committed at the buffered collect site" + ); + } + #[test] fn publisher_response_streaming_finalize_processes_gzip_stream() { let compressed = @@ -21531,7 +24688,9 @@ mod tests { )), price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let publisher_response = PublisherResponse::Stream { response, @@ -21609,7 +24768,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let mut output = Vec::new(); @@ -21670,7 +24831,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let bogus_body = EdgeBody::from(b"not gzip".to_vec()); @@ -21788,7 +24951,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -21855,7 +25020,9 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let mut output = Vec::new(); @@ -21897,8 +25064,10 @@ mod tests { }; use crate::http_util::RequestInfo; use crate::price_bucket::PriceGranularity; + use crate::request_timing::{AuctionWaitPlacement, RequestTimings}; use crate::settings::Settings; use std::collections::HashMap; + use std::time::Duration; // Default settings are enough for the creative boundary: the sanitize // pass needs no config, and `rewrite_creative_html` only signs URLs it @@ -22683,7 +25852,55 @@ mod tests { "https://ssp.example.com/bill", ); bid.creative = Some( - "" + "" + .to_string(), + ); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); + let adm = map + .get("atf_sidebar_ad") + .and_then(|v| v.as_object()) + .and_then(|o| o.get("adm")) + .and_then(|v| v.as_str()) + .expect("should include a rewritten adm"); + + assert!( + adm.contains("https://example.com/first-party/proxy?tsurl="), + "should emit an absolute first-party proxy URL for the foreign-origin render context, got: {adm}" + ); + assert!( + !adm.contains("src=\"/first-party/proxy"), + "should not emit a root-relative proxy URL that 404s under GAM's origin, got: {adm}" + ); + assert!( + !adm.contains("https://cdn.example.com/pixel.png"), + "should proxy the original absolute CDN URL, got: {adm}" + ); + assert!( + !adm.contains("/static/tsjs="), + "should not inject the tsjs bundle into a foreign-origin creative iframe, got: {adm}" + ); + } + + #[test] + fn build_bid_map_keeps_inline_anchors_raw_when_clicks_are_off() { + let mut settings = test_settings(); + settings.auction.rewrite_creatives = true; + settings.auction.rewrite_clicks = Some(false); + settings.publisher.domain = "example.com".to_string(); + + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "examplessp", + "abc123", + "https://ssp.example.com/win", + "https://ssp.example.com/bill", + ); + bid.creative = Some( + "" .to_string(), ); winning_bids.insert("atf_sidebar_ad".to_string(), bid); @@ -22698,19 +25915,15 @@ mod tests { assert!( adm.contains("https://example.com/first-party/proxy?tsurl="), - "should emit an absolute first-party proxy URL for the foreign-origin render context, got: {adm}" - ); - assert!( - !adm.contains("src=\"/first-party/proxy"), - "should not emit a root-relative proxy URL that 404s under GAM's origin, got: {adm}" + "should still proxy the image with an absolute URL, got: {adm}" ); assert!( - !adm.contains("https://cdn.example.com/pixel.png"), - "should proxy the original absolute CDN URL, got: {adm}" + adm.contains("href=\"https://landing.example.com/page\""), + "should leave the landing link raw, got: {adm}" ); assert!( - !adm.contains("/static/tsjs="), - "should not inject the tsjs bundle into a foreign-origin creative iframe, got: {adm}" + !adm.contains("data-tsclick") && !adm.contains("/first-party/click"), + "should not wrap the landing link, got: {adm}" ); } @@ -23236,6 +26449,53 @@ mod tests { ); } + #[test] + fn active_diagnostics_hands_auction_timing_to_the_generation_guarded_scheduler() { + let timings = RequestTimings::new(); + timings.mark_auction_dispatched(); + timings.record_auction_wait(AuctionWaitPlacement::InStream, Duration::from_millis(12)); + timings.mark_auction_resolved(); + timings.mark_auction_committed(); + let state = AdBidsState::with_diagnostics(true); + state.set(serde_json::Map::new()); + + state.set_auction_diagnostics(&timings); + + let script = state + .script_cell() + .lock() + .expect("should lock bid script") + .clone() + .expect("should render bid script"); + assert!(script.contains("s(b,void 0,d)")); + assert!(script.contains("auctionDispatchedMs")); + assert!(script.contains("auctionResolvedMs")); + assert!(script.contains("auctionCommittedMs")); + assert!(script.contains("auctionWaitMs")); + assert!(script.contains("in_stream")); + } + + #[test] + fn inactive_diagnostics_omits_auction_timing_from_the_bid_script() { + let timings = RequestTimings::new(); + timings.mark_auction_dispatched(); + timings.mark_auction_resolved(); + timings.mark_auction_committed(); + let state = AdBidsState::default(); + state.set(serde_json::Map::new()); + + state.set_auction_diagnostics(&timings); + + let script = state + .script_cell() + .lock() + .expect("should lock bid script") + .clone() + .expect("should render bid script"); + assert!(!script.contains("auctionDiagnostics")); + assert!(!script.contains("auctionDispatchedMs")); + } + #[test] fn bids_script_defers_ad_init_until_after_hydration() { let mut map = serde_json::Map::new(); @@ -23504,6 +26764,7 @@ mod tests { struct AuctionIdTestProvider { captured_request: Arc>>, winning_bid: bool, + creative: Option, } #[async_trait::async_trait(?Send)] @@ -23550,7 +26811,7 @@ mod tests { slot_id: "atf".to_string(), price: Some(1.50), currency: "USD".to_string(), - creative: None, + creative: self.creative.clone(), adomain: None, bidder: AUCTION_ID_TEST_PROVIDER.to_string(), returned_seat: None, @@ -23685,6 +26946,12 @@ mod tests { make_page_bids_request_on(PAGE_BIDS_PATH, path) } + fn make_active_page_bids_request(path: &str) -> Request { + let mut req = make_page_bids_request(path); + set_test_header(&mut req, "cookie", "__Host-ts-console=1"); + req + } + #[tokio::test] async fn page_bids_format_absent_or_json_returns_json() { let settings = settings_with_co(); @@ -23717,6 +26984,198 @@ mod tests { } } + #[tokio::test] + async fn trace_transport_spa_aliases_bind_exact_slots_and_reuse_pre_dispatch_token() { + for endpoint in [PAGE_BIDS_PATH, PAGE_BIDS_LEGACY_PATH] { + for winning_bid in [false, true] { + let config = format!( + "{}\n[auction]\nenabled=true\n[auction.providers.bidder]\nprotocol=\"openrtb-2.6\"\nprofile=\"standard\"\nendpoint=\"https://bidder.example.com/auction\"\nrouting=\"all_eligible\"\n[creative_opportunities]\ngam_network_id=\"12345\"\n", + crate_test_settings_str() + ); + let mut settings = + Settings::from_toml(&config).expect("should configure production bidder"); + settings + .integrations + .insert_config( + "gpt_diagnostics", + &serde_json::json!({"enabled":true,"trace_page_enabled":true}), + ) + .expect("should enable active trace"); + let settings = Arc::new(settings); + let plan = Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile actual provider plan"), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let http = Arc::new(StubHttpClient::new()); + if winning_bid { + http.push_response(200, serde_json::to_vec(&serde_json::json!({"seatbid":[{"seat":"bidder","bid":[{"id":"bid","impid":"atf","price":1.5,"w":300,"h":250,"adm":"
Example creative
"}]}]})).expect("should encode actual winning response")); + } else { + http.push_response(204, Vec::new()); + } + let services = build_services_with_http_client(Arc::clone(&http) as Arc<_>); + let mut request = make_page_bids_request_on(endpoint, "/2024/article"); + set_test_header(&mut request, "cookie", "__Host-ts-console=1"); + let hook = crate::trace::TracePreDispatchHook::new( + Arc::clone(&settings), + Arc::new(|_| panic!("should not load SPA setup metadata")), + ); + edgezero_core::router::PreDispatchHook::handle(&hook, &mut request) + .await + .expect("should freeze SPA gate"); + let mut ec_context = consent_allowing_ec_context(); + let slots = article_slot(); + let response = handle_page_bids( + &settings, + &services, + None, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &slots, + registry: None, + }, + &mut ec_context, + request, + ) + .await + .expect("should execute SPA auction"); + assert!( + response + .extensions() + .get::() + .is_some(), + "should keep both aliases private" + ); + let carry = response + .extensions() + .get::() + .expect("should retain request carry"); + let token = carry.token().to_string(); + let value: serde_json::Value = serde_json::from_slice( + &response + .into_body() + .into_bytes() + .expect("should collect SPA JSON"), + ) + .expect("should decode JSON"); + let evidence = &value["trace_auction"]["evidence"]; + assert_eq!( + evidence["diagnostic_auction_id"], token, + "should reuse original token" + ); + assert_eq!( + evidence["source"], "spa_page_bids", + "should preserve actual source" + ); + assert_eq!( + evidence["terminal_status"], "completed", + "should preserve completed zero bids and winners" + ); + assert_eq!( + evidence["slots"][0]["slot_ref"], + value["slots"][0]["ext"]["trusted_server"]["trace_slot_ref"], + "should bind exact emitted slot occurrence" + ); + assert_eq!( + evidence["slots"][0]["candidate"], + if winning_bid { + "selected" + } else { + "no_candidate" + }, + "should project actual delivered map" + ); + if winning_bid { + assert_eq!( + value["bids"]["atf"]["hb_auction_id"], token, + "should share opportunity and evidence token" + ); + } + assert_eq!( + http.recorded_request_bodies().len(), + 1, + "should execute one real provider launch" + ); + let source = String::from_utf8(http.recorded_request_bodies()[0].clone()) + .expect("should serialize ordinary provider JSON"); + assert!( + !source.contains("trace_slot_ref") && !source.contains(&token), + "should keep trace carry out of provider request" + ); + } + } + } + + #[tokio::test] + async fn trace_transport_spa_frozen_gate_delivers_empty_slots_without_telemetry() { + for active in [false, true] { + let mut settings = settings_with_co(); + settings + .integrations + .insert_config( + "gpt_diagnostics", + &serde_json::json!({"enabled":true,"trace_page_enabled":true}), + ) + .expect("should enable request-local trace evidence"); + let settings = Arc::new(settings); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut request = make_page_bids_request("/2024/article"); + if active { + set_test_header(&mut request, "cookie", "__Host-ts-console=1"); + } + let hook = crate::trace::TracePreDispatchHook::new( + Arc::clone(&settings), + Arc::new(|_| panic!("should not load setup metadata on SPA capture")), + ); + assert!( + edgezero_core::router::PreDispatchHook::handle(&hook, &mut request) + .await + .expect("should freeze SPA cookies") + .is_none(), + "should keep ordinary SPA routing" + ); + + let response = run_page_bids_response(&settings, &orchestrator, &[], request).await; + + let carry = response + .extensions() + .get::(); + assert_eq!( + carry.is_some(), + active, + "should capture only the frozen valid session" + ); + let expected = if let Some(carry) = carry { + let facts = serde_json::to_value( + carry.transport().expect("should finish without dispatch"), + ) + .expect("should serialize bounded facts"); + assert_eq!( + facts["evidence"]["source"], "spa_page_bids", + "should preserve the SPA source" + ); + assert_eq!( + facts["evidence"]["terminal_reason"], "no_eligible_slots", + "should preserve the definitive empty slot list" + ); + Some(facts) + } else { + None + }; + let bytes = response + .into_body() + .into_bytes() + .expect("should keep ordinary JSON"); + let value: serde_json::Value = + serde_json::from_slice(&bytes).expect("should decode SPA envelope"); + assert_eq!( + value.get("trace_auction"), + expected.as_ref(), + "should deliver checked transport only for the frozen base gate" + ); + } + } + #[tokio::test] async fn page_bids_format_rejects_removed_unknown_and_empty_values() { let settings = settings_with_co(); @@ -23803,10 +27262,186 @@ mod tests { orchestrator.register_provider(Arc::new(AuctionIdTestProvider { captured_request, winning_bid, + creative: None, })); orchestrator } + #[tokio::test] + async fn active_page_bids_omits_timings_when_no_provider_dispatches() { + let mut settings = settings_with_co(); + settings.auction.providers = + crate::auction::AuctionConfig::legacy_provider_map(&["missing-provider"]); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + + let body = run_page_bids_consent_allowed( + &settings, + &orchestrator, + &article_slot(), + make_active_page_bids_request("/2024/01/my-article/"), + ) + .await; + + assert!( + body.get("auctionDiagnostics").is_none(), + "a failed launch must not fabricate auction dispatch timings" + ); + } + + #[tokio::test] + async fn active_page_bids_omits_timings_without_adapter_collector() { + let mut settings = settings_with_co(); + settings.auction.providers = + crate::auction::AuctionConfig::legacy_provider_map(&[AUCTION_ID_TEST_PROVIDER]); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let slots = article_slot(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"winner".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let orchestrator = + auction_id_test_orchestrator(&settings, Arc::new(Mutex::new(None)), true); + let mut ec_context = consent_allowing_ec_context(); + + let response = handle_page_bids( + &settings, + &services, + None, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &slots, + registry: None, + }, + &mut ec_context, + make_active_page_bids_request("/2024/01/my-article/"), + ) + .await + .expect("should return page-bids response"); + let body: serde_json::Value = serde_json::from_slice( + &response + .into_body() + .into_bytes() + .expect("should read page-bids response body"), + ) + .expect("should serialize page-bids response as JSON"); + + assert!( + body.get("auctionDiagnostics").is_none(), + "missing adapter timing context must not emit handler-local timing facts" + ); + } + + #[tokio::test] + async fn public_origin_spa_creatives_use_public_port_and_preserve_publisher_identity() { + let mut settings = settings_with_co(); + settings.publisher.domain = "publisher.example.com".to_owned(); + settings.proxy.allowed_domains = vec!["*.example.com".to_owned()]; + settings.auction.providers = + crate::auction::AuctionConfig::legacy_provider_map(&[AUCTION_ID_TEST_PROVIDER]); + settings.auction.rewrite_creatives = true; + settings.trusted_forwarder = Some(crate::settings::TrustedForwarderConfig { + auth_header: crate::forwarder::FORWARDER_AUTH_HEADER.to_owned(), + shared_secret: crate::redacted::Redacted::new( + "fictional-forwarder-secret-0123456789".to_owned(), + ), + }); + for endpoint in [PAGE_BIDS_PATH, PAGE_BIDS_LEGACY_PATH] { + let captured = Arc::new(Mutex::new(None)); + let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + orchestrator.register_provider(Arc::new(AuctionIdTestProvider { + captured_request: Arc::clone(&captured), winning_bid: true, + creative: Some("".to_owned()), + })); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"winner".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let mut request = Request::builder() + .uri(format!( + "http://internal.example.com{endpoint}?path=/2024/article" + )) + .header(header::HOST, "internal.example.com") + .header("sec-fetch-site", "same-origin") + .header( + crate::forwarder::FORWARDER_AUTH_HEADER, + "fictional-forwarder-secret-0123456789", + ) + .header("x-forwarded-host", "reader.publisher.example.com:8443") + .header("x-forwarded-proto", "https") + .body(EdgeBody::empty()) + .expect("should construct SPA request"); + crate::forwarder::prepare_trusted_forwarder(&mut request, &settings); + let mut ec_context = EcContext::new_for_test( + None, + crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }, + ); + let response = handle_page_bids( + &settings, + &services, + None, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &article_slot(), + registry: None, + }, + &mut ec_context, + request, + ) + .await + .expect("should serve SPA bids"); + let json: serde_json::Value = serde_json::from_slice( + &response + .into_body() + .into_bytes() + .expect("should read SPA bids"), + ) + .expect("should decode SPA bids JSON"); + let creative = json["bids"]["atf"]["adm"] + .as_str() + .expect("should return rewritten winning creative"); + assert!( + creative + .contains("https://reader.publisher.example.com:8443/first-party/proxy?"), + "should proxy assets through authenticated public authority: {creative}" + ); + assert!( + creative + .contains("https://reader.publisher.example.com:8443/first-party/click?"), + "should proxy clicks through authenticated public authority: {creative}" + ); + assert!( + !creative.contains("internal.example.com"), + "should not leak transport authority into SPA creative" + ); + let auction = captured + .lock() + .expect("should lock captured request") + .clone() + .expect("should dispatch SPA auction"); + assert_eq!( + auction.publisher.domain, "publisher.example.com", + "should retain configured publisher identity" + ); + assert_eq!( + auction.publisher.page_url.as_deref(), + Some("https://publisher.example.com/2024/article"), + "should use public scheme with configured publisher page identity" + ); + } + } + #[tokio::test] async fn page_bids_response_includes_auction_id_only_for_winning_bids() { let mut settings = settings_with_co(); @@ -23819,6 +27454,7 @@ mod tests { let slots = article_slot(); let winning_stub = Arc::new(StubHttpClient::new()); winning_stub.push_response(200, b"winner".to_vec()); + winning_stub.push_response(200, b"winner".to_vec()); let winning_services = build_services_with_http_client( Arc::clone(&winning_stub) as Arc ); @@ -23833,6 +27469,14 @@ mod tests { }, ); + let mut winning_page_bids_request = + make_active_page_bids_request("/2024/01/my-article/"); + let request_timings = RequestTimings::new(); + winning_page_bids_request + .extensions_mut() + .insert(request_timings); + std::thread::sleep(std::time::Duration::from_millis(2)); + let winning_response = handle_page_bids( &settings, &winning_services, @@ -23843,7 +27487,7 @@ mod tests { registry: None, }, &mut ec_context, - make_page_bids_request("/2024/01/my-article/"), + winning_page_bids_request, ) .await .expect("should return winning page-bids response"); @@ -23868,6 +27512,33 @@ mod tests { .as_str() .expect("page-bids should expose an auction ID on the winner") .to_string(); + let auction_diagnostics = winning_body["auctionDiagnostics"] + .as_object() + .expect("an active session should expose page-bids auction diagnostics"); + let timing = |field| { + auction_diagnostics + .get(field) + .and_then(serde_json::Value::as_u64) + .unwrap_or_else(|| panic!("page-bids diagnostics should include {field}")) + }; + let dispatched_ms = timing("auctionDispatchedMs"); + let resolved_ms = timing("auctionResolvedMs"); + let committed_ms = timing("auctionCommittedMs"); + let wait_ms = timing("auctionWaitMs"); + assert!( + dispatched_ms > 0, + "page-bids dispatch should include time since adapter request entry" + ); + assert!( + dispatched_ms <= resolved_ms && resolved_ms <= committed_ms, + "page-bids auction milestones should be monotonic" + ); + assert_eq!( + wait_ms, + resolved_ms.saturating_sub(dispatched_ms), + "auction wait should exclude page-bids pre-dispatch work" + ); + assert_eq!(auction_diagnostics["auctionWaitPlacement"], "pre_header"); assert!( winning_auction_id.starts_with("ts-auc-"), "page-bids should expose a freshly minted diagnostics token, got `{winning_auction_id}`" @@ -23881,6 +27552,32 @@ mod tests { "browser-visible auction ID must not embed the EC ID" ); + let inactive_winning_response = handle_page_bids( + &settings, + &winning_services, + None, + AuctionDispatch { + orchestrator: &winning_orchestrator, + slots: &slots, + registry: None, + }, + &mut ec_context, + make_page_bids_request("/2024/01/my-article/"), + ) + .await + .expect("should return inactive winning page-bids response"); + let inactive_winning_body: serde_json::Value = serde_json::from_slice( + &inactive_winning_response + .into_body() + .into_bytes() + .expect("should read inactive winning page-bids response body"), + ) + .expect("should serialize inactive winning page-bids response as JSON"); + assert!( + inactive_winning_body.get("auctionDiagnostics").is_none(), + "an inactive session should not receive successful auction diagnostics" + ); + let no_winner_stub = Arc::new(StubHttpClient::new()); no_winner_stub.push_response(200, b"no-bid".to_vec()); let no_winner_services = build_services_with_http_client( @@ -23917,6 +27614,10 @@ mod tests { .is_empty(), "page-bids should not fabricate auction metadata without a winner" ); + assert!( + no_winner_body.get("auctionDiagnostics").is_none(), + "an inactive session should not receive page-bids auction diagnostics" + ); } /// The browser-visible auction ID is minted per auction and only for diff --git a/crates/trusted-server-core/src/request_timing.rs b/crates/trusted-server-core/src/request_timing.rs new file mode 100644 index 000000000..899674d7d --- /dev/null +++ b/crates/trusted-server-core/src/request_timing.rs @@ -0,0 +1,840 @@ +//! Per-request phase timing collection and Server-Timing rendering. +//! +//! Collection is always-on and infallible: saturating math, no panics. A +//! contended lock drops the one sample; a poisoned lock recovers (the +//! guarded data are plain counters), so a panic elsewhere cannot silence +//! the rest of the request's timing. See the design spec +//! `docs/superpowers/specs/2026-08-24-request-phase-timing-design.md`. + +use std::sync::{Arc, Mutex, TryLockError}; +use std::time::Duration; + +use http::{HeaderName, HeaderValue, Response}; +use uuid::Uuid; +// `std::time::Instant::now()` panics on `wasm32-unknown-unknown` (the +// Cloudflare adapter's target); `web_time` re-exports std's `Instant` on +// every other target. +use web_time::Instant; + +use crate::cache_policy::cache_control_headers_are_private_or_no_store; + +/// `Server-Timing` header name. Not present in the `http` crate's `header` +/// module (unlike `CACHE_CONTROL` etc.), so declared locally following the +/// same `HeaderName::from_static` pattern used in +/// `trusted_server_core::constants`. +const HEADER_SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing"); + +/// Number of [`Phase`] variants; sizes the fixed-slot duration array in +/// [`Inner`]. +const PHASE_COUNT: usize = 8; + +/// A distinct stage of request handling that duration can be attributed to. +/// +/// Variants map to fixed slots in [`RequestTimings`], in declaration order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + /// Time spent constructing the app/handler before request processing + /// begins. + AppBuild, + /// Time spent in request/response filtering (e.g. HTML rewriting). + Filter, + /// Time spent resolving geographic signals for the request. + Geo, + /// Time spent reading or writing the Edge Cookie key-value store. + EcKv, + /// Time spent waiting on the origin fetch. + Origin, + /// Time spent looking up a cached template. + TemplateCacheLookup, + /// Time spent waiting on the auction. Row-only: never rendered as a + /// `Server-Timing` header entry. + AuctionWait, + /// Time spent streaming the response body. Row-only: never rendered as + /// a `Server-Timing` header entry. + Stream, +} + +impl Phase { + /// Maps this variant to its fixed slot in the [`Inner::phases`] array. + fn index(self) -> usize { + match self { + Self::AppBuild => 0, + Self::Filter => 1, + Self::Geo => 2, + Self::EcKv => 3, + Self::Origin => 4, + Self::TemplateCacheLookup => 5, + Self::AuctionWait => 6, + Self::Stream => 7, + } + } + + /// `Server-Timing` header entry name; row-only phases return `None`. + fn header_name(self) -> Option<&'static str> { + match self { + Self::AppBuild => Some("ts-appbuild"), + Self::Filter => Some("ts-filter"), + Self::Geo => Some("ts-geo"), + Self::EcKv => Some("ts-kv"), + Self::Origin => Some("ts-origin"), + Self::TemplateCacheLookup => Some("ts-template-cache"), + Self::AuctionWait | Self::Stream => None, + } + } +} + +/// Where in the response the auction wait occurred. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuctionWaitPlacement { + /// The auction was awaited before response headers were sent. + PreHeader, + /// The auction was awaited while streaming the response body. + InStream, +} + +/// Mutable state behind [`RequestTimings`], guarded by a [`Mutex`]. +struct Inner { + /// Instant the collector was constructed; the reference point for + /// elapsed marks. Adapters construct the collector after their own + /// prologue (client-request acquisition, logger init, and any + /// short-circuit routes on Fastly), so `ts-total` and + /// `time_elapsed_ms` exclude that prologue rather than measuring + /// wall-clock-from-accept. + t0: Instant, + /// Accumulated duration per [`Phase`], indexed by [`Phase::index`]. + phases: [Option; PHASE_COUNT], + /// Elapsed time at the first [`RequestTimings::mark_headers_ready`] call. + headers_ready_total: Option, + /// Elapsed time at the first [`RequestTimings::mark_request_elapsed`] + /// call. + request_elapsed: Option, + /// Placement recorded by the most recent + /// [`RequestTimings::record_auction_wait`] call. + auction_wait_placement: Option, + /// Response body size in bytes, set via + /// [`RequestTimings::set_resp_bytes`]. + resp_bytes: Option, + /// Elapsed time at the first + /// [`RequestTimings::mark_auction_dispatched`] call. + auction_dispatched: Option, + /// Elapsed time at the first + /// [`RequestTimings::mark_auction_resolved`] call. + auction_resolved: Option, + /// Elapsed time at the first + /// [`RequestTimings::mark_auction_committed`] call. + auction_committed: Option, + /// Telemetry auction UUID recorded by the first + /// [`RequestTimings::set_auction_id`] call; joins the access row to the + /// per-bidder auction dataset. Set when the auction observation is + /// built, so it is present even for an auction that was skipped or + /// failed to dispatch, matching the row those outcomes emit to + /// `auction_events_raw`. + auction_id: Option, +} + +/// Per-request phase timing collector. +/// +/// Cheap to clone (an [`Arc`] handle) and safe to share across threads and +/// async tasks handling the same request. Every method is infallible: +/// contention silently drops the one sample rather than blocking, and a +/// poisoned lock is recovered rather than treated as permanent loss. +#[derive(Clone)] +pub struct RequestTimings(Arc>); + +impl RequestTimings { + /// Starts a new collector with its clock reference (`t0`) set to now. + #[must_use] + pub fn new() -> Self { + Self(Arc::new(Mutex::new(Inner { + t0: Instant::now(), + phases: [None; PHASE_COUNT], + headers_ready_total: None, + request_elapsed: None, + auction_wait_placement: None, + resp_bytes: None, + auction_dispatched: None, + auction_resolved: None, + auction_committed: None, + auction_id: None, + }))) + } + + /// Accumulates `dur` into `phase`'s running total. + /// + /// Repeated calls for the same phase saturate-add rather than overwrite. + /// Drops the sample silently on lock contention; a poisoned lock is recovered. + pub fn record(&self, phase: Phase, dur: Duration) { + let mut inner = match self.0.try_lock() { + Ok(guard) => guard, + // Poisoning is recoverable here: the guarded data are plain + // counters with no invariant a panic can break, so recording + // keeps working for the rest of the request instead of going + // silently dark. Contention still drops the one sample. + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + let index = phase.index(); + let accumulated = inner.phases[index] + .unwrap_or(Duration::ZERO) + .saturating_add(dur); + inner.phases[index] = Some(accumulated); + } + + /// Records an auction wait duration under [`Phase::AuctionWait`] and + /// stores its placement. + /// + /// Drops the sample silently on lock contention; a poisoned lock is recovered. + pub fn record_auction_wait(&self, placement: AuctionWaitPlacement, dur: Duration) { + let mut inner = match self.0.try_lock() { + Ok(guard) => guard, + // Poisoning is recoverable here: the guarded data are plain + // counters with no invariant a panic can break, so recording + // keeps working for the rest of the request instead of going + // silently dark. Contention still drops the one sample. + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + let index = Phase::AuctionWait.index(); + let accumulated = inner.phases[index] + .unwrap_or(Duration::ZERO) + .saturating_add(dur); + inner.phases[index] = Some(accumulated); + inner.auction_wait_placement = Some(placement); + } + + /// Starts a guard that records elapsed time into `phase` when dropped. + #[must_use] + pub fn span(&self, phase: Phase) -> PhaseSpan { + PhaseSpan { + timings: self.clone(), + phase, + started: Instant::now(), + } + } + + /// Stamps the elapsed time since `t0` as `headers_ready_total`, the + /// first time this is called. + /// + /// Subsequent calls are no-ops (first call wins). Drops the sample + /// silently on lock contention or poisoning. + pub fn mark_headers_ready(&self) { + let mut inner = match self.0.try_lock() { + Ok(guard) => guard, + // Poisoning is recoverable here: the guarded data are plain + // counters with no invariant a panic can break, so recording + // keeps working for the rest of the request instead of going + // silently dark. Contention still drops the one sample. + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + if inner.headers_ready_total.is_none() { + inner.headers_ready_total = Some(inner.t0.elapsed()); + } + } + + /// Stamps the elapsed time since `t0` as `request_elapsed`, the first + /// time this is called. + /// + /// Subsequent calls are no-ops (first call wins). Drops the sample + /// silently on lock contention or poisoning. + pub fn mark_request_elapsed(&self) { + let mut inner = match self.0.try_lock() { + Ok(guard) => guard, + // Poisoning is recoverable here: the guarded data are plain + // counters with no invariant a panic can break, so recording + // keeps working for the rest of the request instead of going + // silently dark. Contention still drops the one sample. + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + if inner.request_elapsed.is_none() { + inner.request_elapsed = Some(inner.t0.elapsed()); + } + } + + /// Records the telemetry auction id, the first time this is called. + /// + /// Called where the auction observation is built, which happens on every + /// auction-eligible request regardless of outcome, so the access row can + /// be joined to the `auction_events_raw` row even when the auction was + /// skipped or failed to dispatch. Kept separate from + /// [`RequestTimings::mark_auction_dispatched`] so a dropped dispatch + /// sample cannot also lose the join key. Subsequent calls are no-ops + /// (first call wins). Drops the sample silently on lock contention; a + /// poisoned lock is recovered. + pub fn set_auction_id(&self, auction_id: Uuid) { + let mut inner = match self.0.try_lock() { + Ok(guard) => guard, + // Poisoning is recoverable here: the guarded data are plain + // counters with no invariant a panic can break, so recording + // keeps working for the rest of the request instead of going + // silently dark. Contention still drops the one sample. + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + if inner.auction_id.is_none() { + inner.auction_id = Some(auction_id); + } + } + + /// Stamps the elapsed time since `t0` as the auction dispatch offset, + /// the first time this is called. + /// + /// Called where the bid requests leave the edge. A skipped auction and a + /// failed dispatch never stamp it, so a null dispatch offset alongside a + /// non-null `auction_id` means an auction was attempted but no bid + /// request went out. Subsequent calls are no-ops (first call wins). + /// Drops the sample silently on lock contention; a poisoned lock is + /// recovered. + pub fn mark_auction_dispatched(&self) { + let mut inner = match self.0.try_lock() { + Ok(guard) => guard, + // Poisoning is recoverable here: the guarded data are plain + // counters with no invariant a panic can break, so recording + // keeps working for the rest of the request instead of going + // silently dark. Contention still drops the one sample. + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + if inner.auction_dispatched.is_none() { + inner.auction_dispatched = Some(inner.t0.elapsed()); + } + } + + /// Stamps the elapsed time since `t0` as the auction resolve offset (the + /// final bid returned or the auction timed out), the first time this is + /// called. + /// + /// Stays `None` when a dispatched auction is abandoned before collection + /// (origin error, bodiless response, reader disconnect), so a non-null + /// dispatch offset with a null resolve offset is the "dispatched, never + /// collected" case rather than "no auction ran". Subsequent calls are + /// no-ops (first call wins). Drops the sample silently on lock + /// contention; a poisoned lock is recovered. + pub fn mark_auction_resolved(&self) { + let mut inner = match self.0.try_lock() { + Ok(guard) => guard, + // Poisoning is recoverable here: the guarded data are plain + // counters with no invariant a panic can break, so recording + // keeps working for the rest of the request instead of going + // silently dark. Contention still drops the one sample. + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + if inner.auction_resolved.is_none() { + inner.auction_resolved = Some(inner.t0.elapsed()); + } + } + + /// Stamps the elapsed time since `t0` as the auction commit offset + /// (winning bids available to the response pipeline), the first time + /// this is called. + /// + /// Subsequent calls are no-ops (first call wins). Drops the sample + /// silently on lock contention; a poisoned lock is recovered. + pub fn mark_auction_committed(&self) { + let mut inner = match self.0.try_lock() { + Ok(guard) => guard, + // Poisoning is recoverable here: the guarded data are plain + // counters with no invariant a panic can break, so recording + // keeps working for the rest of the request instead of going + // silently dark. Contention still drops the one sample. + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + if inner.auction_committed.is_none() { + inner.auction_committed = Some(inner.t0.elapsed()); + } + } + + /// Records the response body size in bytes. + /// + /// Drops the sample silently on lock contention; a poisoned lock is recovered. + pub fn set_resp_bytes(&self, bytes: u64) { + let mut inner = match self.0.try_lock() { + Ok(guard) => guard, + // Poisoning is recoverable here: the guarded data are plain + // counters with no invariant a panic can break, so recording + // keeps working for the rest of the request instead of going + // silently dark. Contention still drops the one sample. + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + inner.resp_bytes = Some(bytes); + } + + /// Renders a `Server-Timing` header value, or `None` before + /// [`RequestTimings::mark_headers_ready`] has been called. + /// + /// `ts-total` is rendered first from `headers_ready_total`, followed by + /// the recorded header-bearing phases (`ts-appbuild`, `ts-filter`, + /// `ts-geo`, `ts-kv`, `ts-origin`, `ts-template-cache`) in enum + /// declaration order. Unrecorded phases are omitted. Durations are + /// rendered as milliseconds with one decimal place. Drops the sample + /// silently (returning `None`) on lock contention or poisoning. + #[must_use] + pub fn server_timing_value(&self) -> Option { + let inner = match self.0.try_lock() { + Ok(guard) => guard, + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => return None, + }; + let total = inner.headers_ready_total?; + let mut entries = vec![format_entry("ts-total", total)]; + for phase in HEADER_PHASES { + let Some(name) = phase.header_name() else { + continue; + }; + if let Some(dur) = inner.phases[phase.index()] { + entries.push(format_entry(name, dur)); + } + } + Some(entries.join(", ")) + } + + /// Captures the current state as a [`TimingSnapshot`]. + /// + /// Returns an all-`None` snapshot on lock contention or poisoning, + /// consistent with the infallibility of every other method. + #[must_use] + pub fn snapshot(&self) -> TimingSnapshot { + let inner = match self.0.try_lock() { + Ok(guard) => guard, + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => return TimingSnapshot::default(), + }; + TimingSnapshot { + time_elapsed_ms: duration_ms(inner.headers_ready_total), + request_elapsed_ms: duration_ms(inner.request_elapsed), + appbuild_ms: duration_ms(inner.phases[Phase::AppBuild.index()]), + filter_ms: duration_ms(inner.phases[Phase::Filter.index()]), + geo_ms: duration_ms(inner.phases[Phase::Geo.index()]), + kv_ms: duration_ms(inner.phases[Phase::EcKv.index()]), + origin_ms: duration_ms(inner.phases[Phase::Origin.index()]), + template_cache_ms: duration_ms(inner.phases[Phase::TemplateCacheLookup.index()]), + auction_wait_ms: duration_ms(inner.phases[Phase::AuctionWait.index()]), + stream_ms: duration_ms(inner.phases[Phase::Stream.index()]), + auction_wait_placement: inner.auction_wait_placement, + resp_bytes: inner.resp_bytes, + auction_dispatched_ms: duration_ms(inner.auction_dispatched), + auction_resolved_ms: duration_ms(inner.auction_resolved), + auction_committed_ms: duration_ms(inner.auction_committed), + auction_id: inner.auction_id, + } + } +} + +impl Default for RequestTimings { + fn default() -> Self { + Self::new() + } +} + +/// Stamps [`RequestTimings::mark_headers_ready`] and, when `enabled` and the +/// response is conclusively private, appends the rendered `Server-Timing` +/// header. +/// +/// Always stamps `mark_headers_ready` regardless of whether the header is +/// rendered, so the collector's `ts-total` reflects the moment headers +/// commit. Appends rather than overwrites so a pre-existing `Server-Timing` +/// value set upstream survives alongside the TS-owned set. A response is +/// never promoted to shared-cacheable just because the header would +/// otherwise be omitted: this only gates emission, it does not touch +/// `Cache-Control`. +/// +/// Generic over the response body type so every adapter's terminal layer can +/// call the same emission logic regardless of which body type its HTTP stack +/// uses. +pub fn append_server_timing_if_private( + response: &mut Response, + timings: &RequestTimings, + enabled: bool, +) { + timings.mark_headers_ready(); + + let conclusively_private = cache_control_headers_are_private_or_no_store(response.headers()); + if !enabled || !conclusively_private { + return; + } + + let Some(value) = timings.server_timing_value() else { + return; + }; + match HeaderValue::from_str(&value) { + Ok(header_value) => { + response + .headers_mut() + .append(HEADER_SERVER_TIMING, header_value); + } + Err(error) => log::warn!("skipping server-timing header: {error}"), + } +} + +/// The header-bearing phases (see [`Phase::header_name`]), in the enum +/// declaration order [`RequestTimings::server_timing_value`] renders them in. +const HEADER_PHASES: [Phase; 6] = [ + Phase::AppBuild, + Phase::Filter, + Phase::Geo, + Phase::EcKv, + Phase::Origin, + Phase::TemplateCacheLookup, +]; + +/// Formats one `Server-Timing` entry as `name;dur=`. +fn format_entry(name: &str, dur: Duration) -> String { + format!("{name};dur={:.1}", dur.as_secs_f64() * 1000.0) +} + +/// Converts a recorded [`Duration`] to whole milliseconds, saturating to +/// [`u32::MAX`] instead of overflowing. +fn duration_ms(dur: Option) -> Option { + dur.map(|dur| u32::try_from(dur.as_millis()).unwrap_or(u32::MAX)) +} + +/// RAII guard returned by [`RequestTimings::span`] that records its own +/// elapsed lifetime into the originating phase when dropped. +pub struct PhaseSpan { + /// The collector this span reports into on drop. + timings: RequestTimings, + /// The phase this span's elapsed time is recorded under. + phase: Phase, + /// The instant the span was created. + started: Instant, +} + +impl Drop for PhaseSpan { + fn drop(&mut self) { + self.timings.record(self.phase, self.started.elapsed()); + } +} + +/// A point-in-time, plain-data view of a [`RequestTimings`] collector. +/// +/// All durations are whole milliseconds; unrecorded phases are `None`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TimingSnapshot { + /// Elapsed time from request start to + /// [`RequestTimings::mark_headers_ready`], in milliseconds. + pub time_elapsed_ms: Option, + /// Elapsed time from request start to + /// [`RequestTimings::mark_request_elapsed`], in milliseconds. + pub request_elapsed_ms: Option, + /// Accumulated [`Phase::AppBuild`] duration, in milliseconds. + pub appbuild_ms: Option, + /// Accumulated [`Phase::Filter`] duration, in milliseconds. + pub filter_ms: Option, + /// Accumulated [`Phase::Geo`] duration, in milliseconds. + pub geo_ms: Option, + /// Accumulated [`Phase::EcKv`] duration, in milliseconds. + pub kv_ms: Option, + /// Accumulated [`Phase::Origin`] duration, in milliseconds. + pub origin_ms: Option, + /// Accumulated [`Phase::TemplateCacheLookup`] duration, in milliseconds. + pub template_cache_ms: Option, + /// Accumulated [`Phase::AuctionWait`] duration, in milliseconds. + pub auction_wait_ms: Option, + /// Accumulated [`Phase::Stream`] duration, in milliseconds. + pub stream_ms: Option, + /// Placement recorded by the most recent + /// [`RequestTimings::record_auction_wait`] call. + pub auction_wait_placement: Option, + /// Response body size in bytes, set via + /// [`RequestTimings::set_resp_bytes`]. + pub resp_bytes: Option, + /// T0 offset at which the auction dispatched (bid requests left the + /// edge). `None` when no bid request went out, which covers both "no + /// auction was attempted" and "the auction was skipped or failed to + /// dispatch" — `auction_id` separates the two. + pub auction_dispatched_ms: Option, + /// T0 offset at which the auction resolved (final bid or timeout). + /// `None` when the milestone was never reached, including a dispatched + /// auction abandoned before collection. + pub auction_resolved_ms: Option, + /// T0 offset at which winning bids became available to the response + /// pipeline. `None` when the milestone was never reached. + pub auction_committed_ms: Option, + /// Telemetry auction UUID joining this row to the auction dataset. + /// `None` only when no auction was attempted at all; an attempted + /// auction carries the id whatever its outcome, matching the row it + /// emits to `auction_events_raw`. + pub auction_id: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_phase_index_is_unique_and_in_bounds() { + // `PHASE_COUNT` and `Phase::index()` are hand-synced; nothing at + // compile time ties them together. A new variant whose `index()` + // returns `PHASE_COUNT` would panic at runtime on first `record`, + // contradicting the module's no-panics claim, so this test fails + // first instead. (A variant missing from this list is a compile + // error via the exhaustive `match` in `index()` once added there.) + let phases = [ + Phase::AppBuild, + Phase::Filter, + Phase::Geo, + Phase::EcKv, + Phase::Origin, + Phase::TemplateCacheLookup, + Phase::AuctionWait, + Phase::Stream, + ]; + let mut seen = [false; PHASE_COUNT]; + for phase in phases { + let index = phase.index(); + assert!( + index < PHASE_COUNT, + "should be in bounds: {phase:?} -> {index}" + ); + assert!(!seen[index], "should be unique: {phase:?} -> {index}"); + seen[index] = true; + } + assert!( + seen.iter().all(|slot| *slot), + "should cover every phases-array slot" + ); + + // `HEADER_PHASES` is a second hand-synced list: a phase that gains + // a `header_name()` but is never added there silently renders + // nothing in the `Server-Timing` value. + let header_bearing: Vec = phases + .into_iter() + .filter(|phase| phase.header_name().is_some()) + .collect(); + assert_eq!( + header_bearing, + HEADER_PHASES.to_vec(), + "should render every header-bearing phase, in declaration order" + ); + } + + #[test] + fn auction_marks_are_first_call_wins_and_snapshot_maps_them() { + let timings = RequestTimings::new(); + timings.set_auction_id(uuid::uuid!("11111111-1111-1111-1111-111111111111")); + timings.mark_auction_dispatched(); + timings.mark_auction_resolved(); + timings.mark_auction_committed(); + let first = timings.snapshot(); + + // Sleep so a restamp would land on a different millisecond: without + // it both stamps fall in the same millisecond and an equality + // assertion would hold even under last-call-wins. + std::thread::sleep(Duration::from_millis(5)); + timings.set_auction_id(uuid::uuid!("22222222-2222-2222-2222-222222222222")); + timings.mark_auction_dispatched(); + timings.mark_auction_resolved(); + timings.mark_auction_committed(); + + let second = timings.snapshot(); + assert_eq!( + second.auction_dispatched_ms, first.auction_dispatched_ms, + "should not restamp the dispatch offset" + ); + assert_eq!( + second.auction_resolved_ms, first.auction_resolved_ms, + "should not restamp the resolve offset" + ); + assert_eq!( + second.auction_committed_ms, first.auction_committed_ms, + "should not restamp the commit offset" + ); + assert_eq!( + second.auction_id, + Some(uuid::uuid!("11111111-1111-1111-1111-111111111111")), + "should keep the first-recorded auction id" + ); + } + + #[test] + fn auction_id_survives_a_dropped_dispatch_mark() { + // The join key is stamped where the observation is built, so an + // auction that is skipped or fails to dispatch still carries the id + // that its `auction_events_raw` row was emitted under. + let timings = RequestTimings::new(); + timings.set_auction_id(uuid::uuid!("44444444-4444-4444-4444-444444444444")); + + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.auction_id, + Some(uuid::uuid!("44444444-4444-4444-4444-444444444444")), + "should carry the join key without a dispatch mark" + ); + assert_eq!( + snapshot.auction_dispatched_ms, None, + "should leave the dispatch offset null when nothing was sent" + ); + } + + #[test] + fn snapshot_without_auction_marks_yields_none_for_all_offsets() { + let timings = RequestTimings::new(); + timings.mark_headers_ready(); + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.auction_dispatched_ms, None, + "should stay None when no auction dispatched" + ); + assert_eq!( + snapshot.auction_resolved_ms, None, + "should stay None when no auction resolved" + ); + assert_eq!( + snapshot.auction_committed_ms, None, + "should stay None when no auction committed" + ); + assert_eq!( + snapshot.auction_id, None, + "should carry no auction id when no auction ran" + ); + } + + #[test] + fn render_omits_unrecorded_phases_and_orders_total_first() { + let timings = RequestTimings::new(); + timings.record(Phase::Filter, Duration::from_micros(9_100)); + timings.mark_headers_ready(); + let value = timings + .server_timing_value() + .expect("should render after mark_headers_ready"); + assert!( + value.starts_with("ts-total;dur="), + "should lead with ts-total: {value}" + ); + assert!( + value.contains("ts-filter;dur=9.1"), + "should render one decimal: {value}" + ); + assert!( + !value.contains("ts-geo"), + "should omit unrecorded phases: {value}" + ); + } + + #[test] + fn render_returns_none_before_headers_ready() { + let timings = RequestTimings::new(); + timings.record(Phase::Geo, Duration::from_millis(1)); + assert!( + timings.server_timing_value().is_none(), + "should require the snapshot" + ); + } + + #[test] + fn repeated_phases_accumulate_saturating() { + let timings = RequestTimings::new(); + timings.record(Phase::Geo, Duration::from_millis(2)); + timings.record(Phase::Geo, Duration::from_millis(3)); + timings.mark_headers_ready(); + let snapshot = timings.snapshot(); + assert_eq!(snapshot.geo_ms, Some(5), "should accumulate repeats"); + } + + #[test] + fn mark_headers_ready_is_first_call_wins() { + let timings = RequestTimings::new(); + timings.mark_headers_ready(); + let first = timings.snapshot().time_elapsed_ms; + std::thread::sleep(Duration::from_millis(5)); + timings.mark_headers_ready(); + assert_eq!( + timings.snapshot().time_elapsed_ms, + first, + "should not restamp" + ); + } + + #[test] + fn span_guard_records_on_drop() { + let timings = RequestTimings::new(); + { + let _span = timings.span(Phase::Origin); + std::thread::sleep(Duration::from_millis(2)); + } + timings.mark_headers_ready(); + assert!( + timings.snapshot().origin_ms.expect("should record on drop") >= 1, + "should measure elapsed span time" + ); + } + + #[test] + fn auction_wait_records_placement() { + let timings = RequestTimings::new(); + timings.record_auction_wait(AuctionWaitPlacement::PreHeader, Duration::from_millis(40)); + let snapshot = timings.snapshot(); + assert_eq!(snapshot.auction_wait_ms, Some(40), "should record wait"); + assert_eq!( + snapshot.auction_wait_placement, + Some(AuctionWaitPlacement::PreHeader), + "should record placement" + ); + } + + #[test] + fn rendered_names_never_include_vendor_terms() { + let timings = RequestTimings::new(); + for phase in [ + Phase::AppBuild, + Phase::Filter, + Phase::Geo, + Phase::EcKv, + Phase::Origin, + Phase::TemplateCacheLookup, + ] { + timings.record(phase, Duration::from_millis(1)); + } + timings.mark_headers_ready(); + let value = timings.server_timing_value().expect("should render"); + assert!( + !value.to_ascii_lowercase().contains("datadome"), + "should mask vendors" + ); + } + + #[test] + fn append_server_timing_emits_on_private_response_when_enabled() { + let mut response = Response::builder() + .header("cache-control", "private, no-store") + .body(()) + .expect("should build a private response fixture"); + let timings = RequestTimings::new(); + + append_server_timing_if_private(&mut response, &timings, true); + + let header = response + .headers() + .get("server-timing") + .and_then(|value| value.to_str().ok()) + .expect("should emit a Server-Timing header"); + assert!( + header.starts_with("ts-total;dur="), + "should lead with the stored total: {header}" + ); + } + + #[test] + fn append_server_timing_marks_headers_ready_even_when_not_emitted() { + let mut response = Response::builder() + .header("cache-control", "max-age=60") + .body(()) + .expect("should build a shared-cacheable response fixture"); + let timings = RequestTimings::new(); + + append_server_timing_if_private(&mut response, &timings, true); + + assert!( + response.headers().get("server-timing").is_none(), + "should not emit on a shared-cacheable response" + ); + assert!( + timings.server_timing_value().is_some(), + "should still stamp mark_headers_ready so ts-total reflects the freeze point" + ); + } +} diff --git a/crates/trusted-server-core/src/response_header_rewrite.rs b/crates/trusted-server-core/src/response_header_rewrite.rs new file mode 100644 index 000000000..ec74ac3cc --- /dev/null +++ b/crates/trusted-server-core/src/response_header_rewrite.rs @@ -0,0 +1,1056 @@ +//! Rewriting of publisher-origin URLs in proxied response headers. +//! +//! The publisher-page path rewrites origin URLs in the response body to the +//! serving host. Headers that carry URLs must follow the same mapping, or the +//! browser acts on them against the origin: it follows `Location` and `Refresh` +//! off the appliance, preloads `Link` targets from the origin, and enforces a +//! Content Security Policy that does not permit the rewritten resources. +//! +//! Two rules apply: +//! +//! - **Navigation and preload headers** (`Location`, `Content-Location`, +//! `Refresh`, `Link`): an absolute or protocol-relative URL whose authority is +//! the publisher origin is mapped to the serving host, using the same +//! scheme mapping as the HTML body rewriter. Relative URLs and other hosts +//! are left unchanged. +//! - **Content Security Policy** (`Content-Security-Policy`, +//! `Content-Security-Policy-Report-Only`): every source expression that names +//! the publisher origin gains a sibling expression for the serving host. +//! Nothing is removed, so resources the origin still serves directly remain +//! permitted. Host changes cannot authorize nonce-less inline inserts; that +//! is out of scope here. +//! +//! Each response's own header values are rewritten, so per-page policies are +//! preserved. Operator `[response_headers]` overrides are applied later and +//! still take precedence. + +use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header}; +use url::Url; + +/// Headers whose value is a Content Security Policy. +const CSP_HEADERS: &[HeaderName] = &[ + header::CONTENT_SECURITY_POLICY, + header::CONTENT_SECURITY_POLICY_REPORT_ONLY, +]; + +/// CSP directives whose values are report endpoints, not source lists. +/// +/// Adding a serving-host endpoint would duplicate every violation report. +const CSP_NON_SOURCE_DIRECTIVES: &[&str] = &["report-uri", "report-to"]; + +/// The origin-to-serving-host mapping applied to response headers. +#[derive(Debug, Clone, Copy)] +pub(crate) struct OriginHeaderRewrite<'a> { + /// Publisher origin authority (`host` or `host:port`). + pub origin_host: &'a str, + /// Serving host authority the browser addressed. + pub request_host: &'a str, + /// Scheme the browser used to reach the serving host. + pub request_scheme: &'a str, + /// Scheme Trusted Server uses to fetch the publisher origin. + pub origin_scheme: &'a str, + /// Path and query of the request being answered (`/page?x=1`). + /// + /// An origin redirect to this URL under a different scheme than + /// [`Self::origin_scheme`] (for example, forcing HTTPS) is left unchanged. + /// Rewriting it would send the browser back to the URL it just requested, + /// Trusted Server would refetch the origin over the same scheme, and the + /// origin would redirect again. + pub request_path_and_query: &'a str, + /// Whether the request being answered is a `GET` or `HEAD`. + /// + /// A same-scheme `Location` to the current URL is left unchanged for these + /// methods when the response sets no cookie. The retry through Trusted + /// Server would repeat the same request, so the origin would redirect again. + pub request_is_get_or_head: bool, +} + +impl OriginHeaderRewrite<'_> { + fn is_active(&self) -> bool { + !self.origin_host.is_empty() + && !self.request_host.is_empty() + && !self.request_scheme.is_empty() + && !self.origin_host.eq_ignore_ascii_case(self.request_host) + } + + /// Rewrite a URL reference that points at the publisher origin. + /// + /// Returns [`None`] when `url` is relative, names another host, or names the + /// origin host with a different port. + fn rewrite_url(&self, url: &str) -> Option { + let rest = strip_prefix_ignore_ascii_case(url, "https://") + .or_else(|| strip_prefix_ignore_ascii_case(url, "http://")); + if let Some(rest) = rest { + let suffix = self.strip_origin_authority(rest)?; + return Some(format!( + "{}://{}{suffix}", + self.request_scheme, self.request_host + )); + } + + let suffix = self.strip_origin_authority(url.strip_prefix("//")?)?; + Some(format!("//{}{suffix}", self.request_host)) + } + + /// Rewrite a navigation target (`Location`, `Refresh`), unless rewriting + /// it would loop. + /// + /// A target equal to the current request under a different scheme always + /// loops, because Trusted Server refetches the origin over the same scheme. + /// A same-scheme target equal to the current request (POST-redirect-GET, a + /// cookie-setting redirect, a periodic reload) depends on state and is + /// rewritten here; [`Self::rewrite_location`] adds the stateless case. + /// + /// `Set-Cookie` `Domain` attributes are not rewritten. A cookie-setting + /// redirect to the current URL whose cookie names a `Domain` the serving + /// host is not within is rejected by the browser, so the redirect repeats + /// until the browser stops it. + fn rewrite_navigation_url(&self, url: &str) -> Option { + let rewritten = self.rewrite_url(url)?; + if self.is_scheme_change_to_current_request(url) { + log::warn!( + "Keeping origin scheme-change redirect to the current request URL; the \ + browser leaves the serving host. Check that `publisher.origin_url` uses \ + the scheme the origin enforces" + ); + return None; + } + Some(rewritten) + } + + /// Rewrite a `Location` target, unless rewriting it would loop. + /// + /// Beyond [`Self::rewrite_navigation_url`], a same-scheme redirect of a + /// `GET` or `HEAD` to the current URL is left unchanged when the response + /// sets no cookie. Nothing the browser sends on the retry would differ, so + /// the origin is redirecting because of something Trusted Server sends + /// unchanged, such as `publisher.origin_host_header_override`. + fn rewrite_location(&self, url: &str, response_sets_cookie: bool) -> Option { + let rewritten = self.rewrite_navigation_url(url)?; + if self.request_is_get_or_head && !response_sets_cookie && self.targets_current_request(url) + { + log::warn!( + "Keeping origin redirect to the current request URL; the browser leaves \ + the serving host. Check what the origin expects from the request, such \ + as `publisher.origin_host_header_override`" + ); + return None; + } + Some(rewritten) + } + + fn is_scheme_change_to_current_request(&self, url: &str) -> bool { + let Some((scheme, rest)) = url.split_once("://") else { + return false; + }; + if scheme.eq_ignore_ascii_case(self.origin_scheme) { + return false; + } + if self.strip_origin_authority(rest).is_none() { + return false; + } + self.targets_current_request(url) + } + + /// Whether `url`, an absolute or protocol-relative URL on the origin, + /// names the path and query of the current request. + fn targets_current_request(&self, url: &str) -> bool { + // Normalize the target the way the browser will before following it: + // an empty path becomes `/`, dot segments are resolved, and the + // fragment is dropped. + let parsed = if url.starts_with("//") { + Url::parse(&format!("{}:{url}", self.origin_scheme)) + } else { + Url::parse(url) + }; + let Ok(target) = parsed else { + return false; + }; + let (request_path, request_query) = match self.request_path_and_query.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (self.request_path_and_query, None), + }; + target.path() == request_path && target.query() == request_query + } + + /// Strip the origin authority from the start of `rest`, returning the + /// path, query, and fragment that follow it. + fn strip_origin_authority<'b>(&self, rest: &'b str) -> Option<&'b str> { + let suffix = strip_prefix_ignore_ascii_case(rest, self.origin_host)?; + let at_boundary = suffix.is_empty() || matches!(suffix.as_bytes()[0], b'/' | b'?' | b'#'); + at_boundary.then_some(suffix) + } + + /// Build the serving-host CSP source for a source expression naming the + /// origin, or [`None`] when the expression names something else. + fn csp_source_for(&self, source: &str) -> Option { + if source.starts_with('\'') { + return None; + } + if source.contains("://") { + return self.rewrite_url(source); + } + let suffix = self.strip_origin_authority(source)?; + Some(format!("{}{suffix}", self.request_host)) + } +} + +/// Rewrite publisher-origin URLs in navigation, preload, and CSP headers. +/// +/// Values that are not valid UTF-8, or whose rewrite would not be a valid +/// header value, are left unchanged. Multiple values of the same header keep +/// their order. +/// +/// # Examples +/// +/// ```ignore +/// let rewrite = OriginHeaderRewrite { +/// origin_host: "origin.example.com", +/// request_host: "www.example.com", +/// request_scheme: "https", +/// origin_scheme: "https", +/// request_path_and_query: "/page", +/// request_is_get_or_head: true, +/// }; +/// rewrite_origin_urls_in_headers(response.headers_mut(), &rewrite); +/// ``` +pub(crate) fn rewrite_origin_urls_in_headers( + headers: &mut HeaderMap, + rewrite: &OriginHeaderRewrite<'_>, +) { + if !rewrite.is_active() { + return; + } + + let response_sets_cookie = headers.contains_key(header::SET_COOKIE); + rewrite_header_values(headers, &header::LOCATION, |value| { + rewrite.rewrite_location(value.trim(), response_sets_cookie) + }); + rewrite_header_values(headers, &header::CONTENT_LOCATION, |value| { + rewrite.rewrite_url(value.trim()) + }); + rewrite_header_values(headers, &header::REFRESH, |value| { + rewrite_refresh(value, rewrite) + }); + rewrite_header_values(headers, &header::LINK, |value| rewrite_link(value, rewrite)); + for name in CSP_HEADERS { + rewrite_header_values(headers, name, |value| rewrite_csp(value, rewrite)); + } +} + +fn rewrite_header_values( + headers: &mut HeaderMap, + name: &HeaderName, + rewrite_value: impl Fn(&str) -> Option, +) { + let mut changed = false; + let values: Vec = headers + .get_all(name) + .iter() + .map(|value| { + let rewritten = value + .to_str() + .ok() + .and_then(&rewrite_value) + .and_then(|rewritten| HeaderValue::from_str(&rewritten).ok()); + match rewritten { + Some(rewritten) => { + changed = true; + rewritten + } + None => value.clone(), + } + }) + .collect(); + + if !changed { + return; + } + + log::debug!("Rewriting publisher origin URLs in `{name}` response header"); + headers.remove(name); + for value in values { + headers.append(name.clone(), value); + } +} + +/// Rewrite the URL in a `Refresh` value such as `0; url=https://origin/next`. +/// +/// Accepts the forms browsers parse: whitespace or a `;` or `,` separator, an optional +/// case-insensitive `url=` prefix, and an optionally quoted URL. +fn rewrite_refresh(value: &str, rewrite: &OriginHeaderRewrite<'_>) -> Option { + let bytes = value.as_bytes(); + let skip_whitespace = |mut index: usize| { + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + index + }; + + let time_start = skip_whitespace(0); + let mut index = time_start; + while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') { + index += 1; + } + if index == time_start { + return None; + } + if !matches!(bytes.get(index), Some(b';' | b',')) + && !bytes.get(index).is_some_and(u8::is_ascii_whitespace) + { + return None; + } + index = skip_whitespace(index); + if matches!(bytes.get(index), Some(b';' | b',')) { + index += 1; + } + index = skip_whitespace(index); + + if strip_prefix_ignore_ascii_case(&value[index..], "url").is_some() { + let after_url = skip_whitespace(index + 3); + if bytes.get(after_url) == Some(&b'=') { + index = skip_whitespace(after_url + 1); + } + } + + let (start, end) = match bytes.get(index) { + Some("e @ (b'"' | b'\'')) => { + let start = index + 1; + let end = value[start..] + .find(char::from(quote)) + .map_or(value.len(), |offset| start + offset); + (start, end) + } + Some(_) => (index, value.trim_end().len()), + None => return None, + }; + + let rewritten = rewrite.rewrite_navigation_url(&value[start..end])?; + Some(format!("{}{rewritten}{}", &value[..start], &value[end..])) +} + +/// Rewrite every `` in a `Link` value, and the candidate URLs +/// of quoted `imagesrcset` parameters. +/// +/// `<` inside a quoted parameter value is not treated as a link target. +fn rewrite_link(value: &str, rewrite: &OriginHeaderRewrite<'_>) -> Option { + let bytes = value.as_bytes(); + let mut out = String::new(); + let mut copied_until = 0; + let mut index = 0; + let mut in_quotes = false; + let mut changed = false; + + while index < bytes.len() { + match bytes[index] { + b'\\' if in_quotes => index += 1, + b'"' if !in_quotes && is_imagesrcset_value(&value[..index]) => { + let start = index + 1; + let end = quoted_string_end(bytes, start); + if let Some(rewritten) = rewrite_srcset(&value[start..end], rewrite) { + out.push_str(&value[copied_until..start]); + out.push_str(&rewritten); + copied_until = end; + changed = true; + } + index = end; + } + b'"' => in_quotes = !in_quotes, + b'<' if !in_quotes => { + let start = index + 1; + let Some(offset) = value[start..].find('>') else { + break; + }; + let end = start + offset; + if let Some(rewritten) = rewrite.rewrite_url(value[start..end].trim()) { + out.push_str(&value[copied_until..start]); + out.push_str(&rewritten); + copied_until = end; + changed = true; + } + index = end; + } + _ => {} + } + index += 1; + } + + if !changed { + return None; + } + out.push_str(&value[copied_until..]); + Some(out) +} + +/// Whether the text before an opening quote ends with `imagesrcset=`. +fn is_imagesrcset_value(before_quote: &str) -> bool { + before_quote + .trim_end() + .strip_suffix('=') + .map(str::trim_end) + .and_then(|name| { + name.len() + .checked_sub("imagesrcset".len()) + .map(|start| &name[start..]) + }) + .is_some_and(|name| name.eq_ignore_ascii_case("imagesrcset")) +} + +/// Index of the closing quote of a quoted string whose content starts at +/// `start`, or the end of `bytes` when it is unterminated. +fn quoted_string_end(bytes: &[u8], start: usize) -> usize { + let mut index = start; + while index < bytes.len() { + match bytes[index] { + b'\\' => index += 1, + b'"' => return index, + _ => {} + } + index += 1; + } + bytes.len() +} + +/// Rewrite the URL of each comma-separated `srcset` candidate, keeping its +/// descriptor and spacing. +fn rewrite_srcset(srcset: &str, rewrite: &OriginHeaderRewrite<'_>) -> Option { + let mut changed = false; + let candidates: Vec = srcset + .split(',') + .map(|candidate| { + let leading = candidate.len() - candidate.trim_start().len(); + let url_end = candidate[leading..] + .find(|c: char| c.is_ascii_whitespace()) + .map_or(candidate.len(), |offset| leading + offset); + match rewrite.rewrite_url(&candidate[leading..url_end]) { + Some(rewritten) => { + changed = true; + format!( + "{}{rewritten}{}", + &candidate[..leading], + &candidate[url_end..] + ) + } + None => candidate.to_string(), + } + }) + .collect(); + changed.then(|| candidates.join(",")) +} + +/// Add serving-host sources next to every source expression naming the origin. +/// +/// Handles comma-separated policy lists and `;`-separated directives. A +/// serving-host source already present in the directive is not duplicated. +fn rewrite_csp(value: &str, rewrite: &OriginHeaderRewrite<'_>) -> Option { + let mut insertions: Vec<(usize, String)> = Vec::new(); + + let mut directive_start = 0; + for directive in value.split([';', ',']) { + let offset = directive_start; + directive_start += directive.len() + 1; + + let mut tokens = tokens_with_offsets(directive); + let Some((_, name)) = tokens.next() else { + continue; + }; + if CSP_NON_SOURCE_DIRECTIVES + .iter() + .any(|skipped| name.eq_ignore_ascii_case(skipped)) + { + continue; + } + + let sources: Vec<(usize, &str)> = tokens.collect(); + let directive_insertions_start = insertions.len(); + for (token_offset, source) in &sources { + let Some(added) = rewrite.csp_source_for(source) else { + continue; + }; + let already_present = sources + .iter() + .map(|(_, existing)| *existing) + .chain( + insertions[directive_insertions_start..] + .iter() + .map(|(_, queued)| queued.as_str()), + ) + .any(|existing| csp_sources_equal(existing, &added)); + if !already_present { + insertions.push((offset + token_offset + source.len(), added)); + } + } + } + + if insertions.is_empty() { + return None; + } + + let extra: usize = insertions.iter().map(|(_, added)| added.len() + 1).sum(); + let mut out = String::with_capacity(value.len() + extra); + let mut copied_until = 0; + for (position, added) in insertions { + out.push_str(&value[copied_until..position]); + out.push(' '); + out.push_str(&added); + copied_until = position; + } + out.push_str(&value[copied_until..]); + Some(out) +} + +/// Whether two CSP source expressions are the same source. +/// +/// Scheme and host compare case-insensitively; the path is case-sensitive, as +/// in CSP path matching, so `/A.js` and `/a.js` stay distinct. +fn csp_sources_equal(left: &str, right: &str) -> bool { + let (left_authority, left_path) = split_csp_source_path(left); + let (right_authority, right_path) = split_csp_source_path(right); + left_authority.eq_ignore_ascii_case(right_authority) && left_path == right_path +} + +/// Split a CSP source expression into its scheme-and-host part and its path. +fn split_csp_source_path(source: &str) -> (&str, &str) { + let authority_start = source.find("://").map_or(0, |index| index + 3); + source[authority_start..] + .find('/') + .map_or((source, ""), |offset| { + source.split_at(authority_start + offset) + }) +} + +fn tokens_with_offsets(text: &str) -> impl Iterator { + text.split(|c: char| c.is_ascii_whitespace()) + .filter(|token| !token.is_empty()) + .map(move |token| (token.as_ptr() as usize - text.as_ptr() as usize, token)) +} + +fn strip_prefix_ignore_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { + let head = text.get(..prefix.len())?; + head.eq_ignore_ascii_case(prefix) + .then(|| &text[prefix.len()..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REWRITE: OriginHeaderRewrite<'static> = OriginHeaderRewrite { + origin_host: "origin.example.com", + request_host: "www.example.com", + request_scheme: "https", + origin_scheme: "http", + request_path_and_query: "/current?page=1", + request_is_get_or_head: false, + }; + + fn rewritten(name: &HeaderName, values: &[&str]) -> Vec { + let mut headers = HeaderMap::new(); + for value in values { + headers.append( + name.clone(), + HeaderValue::from_str(value).expect("should build test header value"), + ); + } + rewrite_origin_urls_in_headers(&mut headers, &REWRITE); + headers + .get_all(name) + .iter() + .map(|value| { + value + .to_str() + .expect("should keep header value ascii") + .to_string() + }) + .collect() + } + + fn single(name: &HeaderName, value: &str) -> String { + rewritten(name, &[value]).remove(0) + } + + #[test] + fn location_rewrites_absolute_origin_urls() { + assert_eq!( + single(&header::LOCATION, "http://origin.example.com/landing.html"), + "https://www.example.com/landing.html", + "should map an http origin redirect to the serving host" + ); + assert_eq!( + single(&header::LOCATION, "HTTPS://Origin.Example.com?next=1#top"), + "https://www.example.com?next=1#top", + "should match scheme and host case-insensitively" + ); + assert_eq!( + single(&header::LOCATION, "https://origin.example.com"), + "https://www.example.com", + "should rewrite a bare origin URL" + ); + assert_eq!( + single(&header::CONTENT_LOCATION, "//origin.example.com/canonical"), + "//www.example.com/canonical", + "should keep protocol-relative URLs protocol-relative" + ); + } + + #[test] + fn location_leaves_other_targets_unchanged() { + for value in [ + "/relative/path", + "landing.html", + "https://other.example.com/landing.html", + "https://cdn.origin.example.com/landing.html", + "https://origin.example.com.example.net/landing.html", + "https://origin.example.com:8443/landing.html", + "https://other.example.com/?return=https://origin.example.com/", + "ftp://origin.example.com/file", + ] { + assert_eq!( + single(&header::LOCATION, value), + value, + "should not rewrite `{value}`" + ); + } + } + + #[test] + fn origin_with_port_requires_matching_port() { + let rewrite = OriginHeaderRewrite { + origin_host: "127.0.0.1:8301", + request_host: "127.0.0.1:3031", + request_scheme: "http", + origin_scheme: "http", + request_path_and_query: "/", + request_is_get_or_head: false, + }; + assert_eq!( + rewrite.rewrite_url("http://127.0.0.1:8301/landing.html"), + Some("http://127.0.0.1:3031/landing.html".to_string()), + "should map the origin authority including its port" + ); + assert_eq!( + rewrite.rewrite_url("http://127.0.0.1:83010/landing.html"), + None, + "should not match a longer port" + ); + assert_eq!( + rewrite.rewrite_url("http://127.0.0.1/landing.html"), + None, + "should not match the host without the origin port" + ); + } + + #[test] + fn navigation_headers_keep_scheme_change_to_current_request() { + for value in [ + "https://origin.example.com/current?page=1", + "HTTPS://origin.example.com/current?page=1#top", + ] { + assert_eq!( + single(&header::LOCATION, value), + value, + "should keep scheme-change redirect `{value}` to avoid a loop" + ); + } + assert_eq!( + single( + &header::REFRESH, + "0; url=https://origin.example.com/current?page=1" + ), + "0; url=https://origin.example.com/current?page=1", + "should keep a scheme-change refresh to the same URL" + ); + } + + #[test] + fn navigation_headers_rewrite_same_scheme_targets_to_current_request() { + assert_eq!( + single( + &header::LOCATION, + "http://origin.example.com/current?page=1" + ), + "https://www.example.com/current?page=1", + "should rewrite a POST-redirect-GET to the same URL" + ); + assert_eq!( + single(&header::LOCATION, "//origin.example.com/current?page=1"), + "//www.example.com/current?page=1", + "should rewrite a protocol-relative self target" + ); + assert_eq!( + single( + &header::REFRESH, + "60; url=http://origin.example.com/current?page=1" + ), + "60; url=https://www.example.com/current?page=1", + "should keep a periodic reload on the serving host" + ); + assert_eq!( + single( + &header::LOCATION, + "https://origin.example.com/current?page=2" + ), + "https://www.example.com/current?page=2", + "should still rewrite a redirect to a different query" + ); + assert_eq!( + single( + &header::CONTENT_LOCATION, + "https://origin.example.com/current?page=1" + ), + "https://www.example.com/current?page=1", + "should rewrite non-navigation self references" + ); + } + + #[test] + fn location_keeps_stateless_get_redirect_to_current_request() { + let rewrite = OriginHeaderRewrite { + request_is_get_or_head: true, + ..REWRITE + }; + let location_after_rewrite = |headers: &[(HeaderName, &str)]| { + let mut map = HeaderMap::new(); + for (name, value) in headers { + map.append( + name.clone(), + HeaderValue::from_str(value).expect("should build test header value"), + ); + } + rewrite_origin_urls_in_headers(&mut map, &rewrite); + map.get(header::LOCATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + .expect("should keep the Location header") + }; + + for value in [ + "http://origin.example.com/current?page=1", + "//origin.example.com/current?page=1", + "http://origin.example.com/a/../current?page=1#top", + ] { + assert_eq!( + location_after_rewrite(&[(header::LOCATION, value)]), + value, + "should keep a cookieless GET redirect `{value}` to the current URL" + ); + } + assert_eq!( + location_after_rewrite(&[ + (header::LOCATION, "http://origin.example.com/current?page=1"), + (header::SET_COOKIE, "gate=1; Path=/"), + ]), + "https://www.example.com/current?page=1", + "should rewrite a cookie-setting GET redirect to the current URL" + ); + assert_eq!( + location_after_rewrite(&[( + header::LOCATION, + "http://origin.example.com/current?page=2" + )]), + "https://www.example.com/current?page=2", + "should rewrite a GET redirect to a different URL" + ); + assert_eq!( + rewrite_refresh("60; url=http://origin.example.com/current?page=1", &rewrite), + Some("60; url=https://www.example.com/current?page=1".to_string()), + "should keep a periodic GET reload on the serving host" + ); + } + + #[test] + fn navigation_headers_normalize_scheme_change_targets_before_comparing() { + let rewrite = OriginHeaderRewrite { + request_path_and_query: "/?x=1", + ..REWRITE + }; + assert_eq!( + rewrite.rewrite_navigation_url("https://origin.example.com?x=1"), + None, + "should treat an empty path before a query as `/`" + ); + assert_eq!( + rewrite_refresh("0; url=https://origin.example.com?x=1", &rewrite), + None, + "should keep a scheme-change refresh with an empty path" + ); + for value in [ + "https://origin.example.com/a/../current?page=1", + "https://origin.example.com/./current?page=1#top", + ] { + assert_eq!( + single(&header::LOCATION, value), + value, + "should resolve dot segments in `{value}` before comparing" + ); + } + assert_eq!( + single( + &header::REFRESH, + "0; url=https://origin.example.com/a/../current?page=1" + ), + "0; url=https://origin.example.com/a/../current?page=1", + "should resolve dot segments in a refresh target before comparing" + ); + assert_eq!( + single( + &header::LOCATION, + "https://origin.example.com/a/../other?page=1" + ), + "https://www.example.com/a/../other?page=1", + "should still rewrite a normalized target that differs" + ); + } + + #[test] + fn navigation_to_origin_root_is_compared_as_slash() { + let rewrite = OriginHeaderRewrite { + request_path_and_query: "/", + ..REWRITE + }; + assert_eq!( + rewrite.rewrite_navigation_url("https://origin.example.com"), + None, + "should treat an empty path as `/`" + ); + } + + #[test] + fn refresh_rewrites_url_part() { + assert_eq!( + single( + &header::REFRESH, + "0; url=https://origin.example.com/refreshed.html" + ), + "0; url=https://www.example.com/refreshed.html", + "should rewrite the url= target" + ); + assert_eq!( + single( + &header::REFRESH, + "5,URL = 'http://origin.example.com/next' " + ), + "5,URL = 'https://www.example.com/next' ", + "should handle comma separator, spacing, case, and quotes" + ); + assert_eq!( + single(&header::REFRESH, "1.5; \"//origin.example.com/next\""), + "1.5; \"//www.example.com/next\"", + "should handle a quoted URL without url=" + ); + assert_eq!( + single(&header::REFRESH, "0 url=https://origin.example.com/next"), + "0 url=https://www.example.com/next", + "should accept whitespace as the separator" + ); + assert_eq!( + single(&header::REFRESH, "3; https://origin.example.com/next"), + "3; https://www.example.com/next", + "should handle a bare URL without url=" + ); + } + + #[test] + fn refresh_leaves_other_values_unchanged() { + for value in [ + "30", + "0; url=/relative", + "0; url=https://other.example.com/next", + "0x; url=https://origin.example.com/next", + "; url=https://origin.example.com/next", + ] { + assert_eq!( + single(&header::REFRESH, value), + value, + "should not rewrite `{value}`" + ); + } + } + + #[test] + fn link_rewrites_each_origin_target() { + assert_eq!( + single( + &header::LINK, + "; rel=preload; as=style, \ + ; rel=preload; as=script, \ + ; rel=preload; as=font, \ + ; rel=prefetch" + ), + "; rel=preload; as=style, \ + ; rel=preload; as=script, \ + ; rel=preload; as=font, \ + ; rel=prefetch", + "should rewrite origin targets and keep params and other targets" + ); + } + + #[test] + fn link_rewrites_imagesrcset_candidates() { + assert_eq!( + single( + &header::LINK, + "; rel=preload; as=image; \ + imagesrcset=\"https://origin.example.com/a.jpg 1x, /b.jpg 2x, \ + https://cdn.example.net/c.jpg 3x\"; imagesizes=\"100vw\"" + ), + "; rel=preload; as=image; \ + imagesrcset=\"https://www.example.com/a.jpg 1x, /b.jpg 2x, \ + https://cdn.example.net/c.jpg 3x\"; imagesizes=\"100vw\"", + "should rewrite origin srcset candidates and keep the rest" + ); + } + + #[test] + fn link_ignores_angle_brackets_in_quoted_params() { + let value = "; rel=preload; title=\"x \\\" y\""; + assert_eq!( + single(&header::LINK, value), + value, + "should not treat quoted text as a link target" + ); + } + + #[test] + fn link_rewrites_every_value_and_keeps_order() { + assert_eq!( + rewritten( + &header::LINK, + &[ + "; rel=preload; as=script", + "; rel=preload; as=style", + ], + ), + vec![ + "; rel=preload; as=script".to_string(), + "; rel=preload; as=style".to_string(), + ], + "should rewrite per value and preserve ordering" + ); + } + + #[test] + fn csp_adds_serving_host_beside_origin_sources() { + assert_eq!( + single( + &header::CONTENT_SECURITY_POLICY, + "default-src http://origin.example.com 'unsafe-inline'; \ + img-src origin.example.com/images/ data:; script-src 'self'" + ), + "default-src http://origin.example.com https://www.example.com 'unsafe-inline'; \ + img-src origin.example.com/images/ www.example.com/images/ data:; script-src 'self'", + "should add the serving host and keep the origin sources" + ); + } + + #[test] + fn csp_adds_one_serving_host_source_for_equivalent_origin_sources() { + assert_eq!( + single( + &header::CONTENT_SECURITY_POLICY, + "img-src http://origin.example.com https://origin.example.com" + ), + "img-src http://origin.example.com https://www.example.com https://origin.example.com", + "should not add the same serving-host source twice" + ); + } + + #[test] + fn csp_keeps_path_case_when_deduplicating() { + assert_eq!( + single( + &header::CONTENT_SECURITY_POLICY, + "script-src https://origin.example.com/A.js https://origin.example.com/a.js" + ), + "script-src https://origin.example.com/A.js https://www.example.com/A.js \ + https://origin.example.com/a.js https://www.example.com/a.js", + "should add a serving-host source for each path that differs only by case" + ); + assert_eq!( + single( + &header::CONTENT_SECURITY_POLICY, + "script-src https://origin.example.com/A.js https://WWW.Example.com/a.js \ + https://origin.example.com/a.js" + ), + "script-src https://origin.example.com/A.js https://www.example.com/A.js \ + https://WWW.Example.com/a.js https://origin.example.com/a.js", + "should match an existing serving-host source by host case-insensitively \ + and by path case-sensitively" + ); + } + + #[test] + fn csp_handles_policy_lists_and_report_only() { + assert_eq!( + single( + &header::CONTENT_SECURITY_POLICY_REPORT_ONLY, + "img-src https://origin.example.com, connect-src https://origin.example.com/api" + ), + "img-src https://origin.example.com https://www.example.com, \ + connect-src https://origin.example.com/api https://www.example.com/api", + "should rewrite each policy in a comma-separated list" + ); + } + + #[test] + fn csp_does_not_duplicate_or_touch_unrelated_sources() { + for value in [ + "default-src https://origin.example.com https://www.example.com", + "default-src 'self' https://cdn.example.net *.origin.example.com", + "default-src 'self'; report-uri https://origin.example.com/csp-report", + "img-src https://origin.example.com:8443", + ] { + assert_eq!( + single(&header::CONTENT_SECURITY_POLICY, value), + value, + "should leave `{value}` unchanged" + ); + } + } + + #[test] + fn skips_rewrite_when_hosts_match_or_are_missing() { + let mut headers = HeaderMap::new(); + headers.insert( + header::LOCATION, + HeaderValue::from_static("https://origin.example.com/next"), + ); + for rewrite in [ + OriginHeaderRewrite { + request_host: "ORIGIN.example.com", + ..REWRITE + }, + OriginHeaderRewrite { + request_host: "", + ..REWRITE + }, + OriginHeaderRewrite { + origin_host: "", + ..REWRITE + }, + ] { + rewrite_origin_urls_in_headers(&mut headers, &rewrite); + assert_eq!( + headers.get(header::LOCATION), + Some(&HeaderValue::from_static("https://origin.example.com/next")), + "should leave headers untouched for {rewrite:?}" + ); + } + } + + #[test] + fn leaves_unrelated_headers_untouched() { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + headers.insert( + "x-origin", + HeaderValue::from_static("https://origin.example.com/"), + ); + let before = headers.clone(); + rewrite_origin_urls_in_headers(&mut headers, &REWRITE); + assert_eq!(headers, before, "should only rewrite URL-bearing headers"); + } +} diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 3efc15770..5cc3336e1 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -22,7 +22,10 @@ use crate::constants::INTERNAL_HEADERS; use crate::creative_opportunities::CreativeOpportunitiesConfig; use crate::error::TrustedServerError; use crate::host_header::validate_host_header_override_value; +use crate::http_util::SPOOFABLE_FORWARDED_HEADERS; +use crate::integrations::datadome::HEADER_DATADOME_TEST_BYPASS; use crate::platform::PlatformImageOptimizerRegion; +use crate::proxy::{is_host_allowed, is_host_permitted}; use crate::redacted::Redacted; #[cfg(test)] @@ -400,6 +403,12 @@ pub struct EcPartner { /// Whether this partner's UIDs appear in auction `user.eids`. #[serde(default, deserialize_with = "from_value_or_str")] pub bidstream_enabled: bool, + /// Integration selected as the authoritative identity writer for this source. + /// + /// An owned source retains token-authenticated read access, rejects legacy + /// batch writes and cannot enable legacy pull acquisition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity_owner: Option, /// Plaintext API token used by inbound batch sync and identify requests. /// /// When present, the token is hashed at startup for auth lookups. Omitting @@ -533,6 +542,98 @@ impl EcPartner { } } +/// One signing key for the authenticated identity capture handoff. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityHandoffKey { + /// Stable key identifier carried in authenticated handoff envelopes. + pub id: String, + /// URL-safe unpadded base64 key material, resolved from the secret store at runtime. + pub secret: Redacted, +} + +/// Configuration for the optional authenticated identity capture handoff. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +#[validate(schema(function = validate_identity_handoff_config))] +pub struct IdentityHandoffConfig { + /// Whether handoff issuance, auction delivery, and replay are enabled. + #[serde(default)] + pub enabled: bool, + /// Identifier of the key used to issue new handoff envelopes. + #[serde(default)] + pub active_key_id: String, + /// Verification keys retained for issuance and rotation overlap. + #[serde(default)] + pub keys: Vec, +} + +impl IdentityHandoffConfig { + fn is_default(&self) -> bool { + !self.enabled && self.active_key_id.is_empty() && self.keys.is_empty() + } +} + +fn validate_identity_handoff_config(config: &IdentityHandoffConfig) -> Result<(), ValidationError> { + let validation_error = |message: String| { + let mut error = ValidationError::new("invalid_identity_handoff_config"); + error.message = Some(message.into()); + error + }; + + if config.keys.len() > 4 { + return Err(validation_error( + "ec.identity_handoff.keys must contain at most 4 keys".to_owned(), + )); + } + + let mut key_ids = HashSet::new(); + for key in &config.keys { + if key.id.is_empty() + || key.id.len() > 32 + || !key + .id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(validation_error( + "ec.identity_handoff key ids must be 1 to 32 ASCII alphanumeric, '_' or '-' characters" + .to_owned(), + )); + } + if !key_ids.insert(key.id.as_str()) { + return Err(validation_error(format!( + "ec.identity_handoff key id `{}` is configured more than once", + key.id + ))); + } + } + + if !config.active_key_id.is_empty() + && (config.active_key_id.len() > 32 + || !config + .active_key_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))) + { + return Err(validation_error( + "ec.identity_handoff.active_key_id must be 1 to 32 ASCII alphanumeric, '_' or '-' characters when set" + .to_owned(), + )); + } + + if config.enabled + && (config.active_key_id.is_empty() || !key_ids.contains(config.active_key_id.as_str())) + { + return Err(validation_error( + "enabled ec.identity_handoff requires active_key_id to name a configured key" + .to_owned(), + )); + } + + Ok(()) +} + /// Edge Cookie (EC) configuration. /// /// Mapped from the `[ec]` TOML section. Controls EC identity generation, @@ -566,6 +667,11 @@ pub struct Ec { #[serde(default = "Ec::default_cluster_recheck_secs")] pub cluster_recheck_secs: u64, + /// Optional authenticated handoff for validated server-side identity capture. + #[serde(default, skip_serializing_if = "IdentityHandoffConfig::is_default")] + #[validate(nested)] + pub identity_handoff: IdentityHandoffConfig, + /// Partners (SSPs, DSPs, identity vendors) for EC identity sync. #[serde(default, deserialize_with = "vec_from_seq_or_map")] #[validate(nested)] @@ -636,37 +742,84 @@ impl Ec { #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Rewrite { - /// List of domains to exclude from rewriting. Supports wildcards (e.g., "*.example.com"). - /// URLs from these domains will not be proxied through first-party endpoints. + /// Hosts never rewritten, for assets and click-through links. + /// + /// Each entry is an exact host or `*.example.com`, which matches the apex + /// and any subdomain. Matching is case-insensitive. Wins over + /// [`Self::include_domains`]. #[serde(default)] pub exclude_domains: Vec, + /// When non-empty, the only hosts whose asset URLs are rewritten to + /// `/first-party/proxy`. + /// + /// Same pattern syntax as [`Self::exclude_domains`]. Empty (the default) + /// proxies every eligible asset URL. Never applies to click-through links. + /// The default is omitted from serialized config so older binaries, which + /// reject unknown fields, can still read it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[validate(custom(function = validate_include_domains))] + pub include_domains: Vec, } impl Rewrite { - /// Checks if a URL should be excluded from rewriting based on domain matching - #[allow(dead_code)] + /// Returns `true` when an asset URL on `host` should be rewritten to + /// `/first-party/proxy`. + /// + /// The host must not match [`Self::exclude_domains`], and must match + /// [`Self::include_domains`] when that list is non-empty. Matching is + /// case-insensitive; see `crate::proxy::is_host_allowed` for the pattern rules. #[must_use] - pub fn is_excluded(&self, url: &str) -> bool { - // Parse URL to extract host - let Ok(parsed) = url::Url::parse(url) else { - return false; - }; + pub fn should_proxy_asset(&self, host: &str) -> bool { + !self.is_excluded_host(host) && is_host_permitted(&self.include_domains, host) + } + + /// Returns `true` when a click-through URL on `host` should be wrapped in + /// `/first-party/click`. + /// + /// Only [`Self::exclude_domains`] applies to click-through links. + #[must_use] + pub fn should_wrap_click(&self, host: &str) -> bool { + !self.is_excluded_host(host) + } - let host = parsed.host_str().unwrap_or(""); + fn is_excluded_host(&self, host: &str) -> bool { + self.exclude_domains + .iter() + .any(|pattern| is_host_allowed(host, pattern)) + } - // Check exact domain matches (with wildcard support) - for domain in &self.exclude_domains { - if let Some(suffix) = domain.strip_prefix("*.") { - // Wildcard: *.example.com matches both example.com and sub.example.com - if host == suffix || host.ends_with(&format!(".{}", suffix)) { - return true; - } - } else if host == domain { - return true; + /// Trims and lowercases host patterns in place. + /// + /// Empty and bare `*` entries in `exclude_domains` are dropped with a + /// warning: neither can match a host, so dropping them changes no behavior. + fn normalize(&mut self) { + let before = self.exclude_domains.len(); + self.exclude_domains = self + .exclude_domains + .iter() + .map(|pattern| pattern.trim().to_ascii_lowercase()) + .filter(|pattern| !pattern.is_empty() && pattern != "*") + .collect(); + if self.exclude_domains.len() < before { + log::warn!( + "rewrite.exclude_domains: removed empty or bare \"*\" entries, which never match a host" + ); + } + self.include_domains = self + .include_domains + .iter() + .map(|pattern| pattern.trim().to_ascii_lowercase()) + .collect(); + // An `exclude_domains` entry that can never equal a URL host keeps its + // host proxied and receiving `ts-ec`. Rejecting it would stop existing + // blobs from loading, so warn once per entry and keep it. + for pattern in &self.exclude_domains { + if let Err(reason) = check_include_domain(pattern) { + log::warn!( + "rewrite.exclude_domains: entry `{pattern}` never matches a URL host ({reason})" + ); } } - - false } } @@ -1900,9 +2053,20 @@ impl Proxy { /// Direct Tinybird Events API telemetry configuration. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TinybirdSettings { - /// Master enablement for auction telemetry ingestion. + /// Master enablement for Tinybird telemetry. Required by both auction and + /// access-log emission; each is independently toggled below. #[serde(default)] pub enabled: bool, + /// Emit auction telemetry when `enabled`. Defaults to `true` so existing + /// configs preserve their current auction-emission behavior after + /// upgrading; set `false` to silence auction events while keeping + /// `enabled` on for other Tinybird telemetry (e.g. `access_enabled`). + /// Serialized only when `false`: older binaries ignore the key rather + /// than reject it, so writing the default `true` into every pushed + /// config would let a rollback silently resume auction telemetry after + /// an operator disabled it. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub auction_enabled: bool, /// Regional Tinybird API host, without scheme or path. #[serde(default)] pub api_host: String, @@ -1915,19 +2079,24 @@ pub struct TinybirdSettings { /// Secret reference containing the auction datasource APPEND token. #[serde(default, skip_serializing_if = "Option::is_none")] pub auction_token_secret: Option>, - /// Reserved for future access-log telemetry. + /// Emit access-log telemetry when `enabled`, independent of `auction_enabled`. /// - /// `true` is rejected until an access-log emitter is wired, so operators - /// cannot enable a setting that silently emits nothing. + /// `true` requires `enabled`, non-empty `api_host`/ + /// `access_dataset`/`access_token_secret`, `max_body_bytes > 0`, and + /// `access_sample_rate > 0.0`. This prevents an armed-but-silent sampler + /// that enables the flag but emits nothing. #[serde(default)] pub access_enabled: bool, - /// Future access-log Events API datasource name. + /// Access-log Events API datasource name. Required non-empty when + /// `access_enabled`. #[serde(default = "default_tinybird_access_dataset")] pub access_dataset: String, - /// Deprecated placeholder for the unwired access-log APPEND token. - #[serde(default, skip_serializing)] + /// Secret reference containing the access-log datasource APPEND token. + /// Required non-empty when `access_enabled`. + #[serde(default, skip_serializing_if = "Option::is_none")] pub access_token_secret: Option>, - /// Future fraction of requests to emit for optional access telemetry. + /// Fraction of requests to emit for access telemetry. Must be greater + /// than `0.0` when `access_enabled`. #[serde(default)] pub access_sample_rate: f64, /// Defensive maximum NDJSON body size for one Events API request. @@ -1951,6 +2120,7 @@ impl Default for TinybirdSettings { fn default() -> Self { Self { enabled: false, + auction_enabled: default_true(), api_host: String::new(), secret_store: None, auction_dataset: default_tinybird_auction_dataset(), @@ -1978,9 +2148,18 @@ impl TinybirdSettings { (!value.is_empty()).then(|| Redacted::new(value)) }); self.access_dataset = self.access_dataset.trim().to_owned(); - self.access_token_secret = None; + self.access_token_secret = self.access_token_secret.take().and_then(|value| { + let value = value.expose().trim().to_owned(); + (!value.is_empty()).then(|| Redacted::new(value)) + }); } + /// Validate this settings block, including the access-telemetry matrix: + /// `access_enabled` requires `enabled`, a non-empty `api_host`, + /// `access_dataset`, and `access_token_secret`, a + /// `max_body_bytes` above the defensive floor enforced below, and an + /// `access_sample_rate` greater than `0.0`. Auction emission is + /// independently gated by `auction_enabled` and validated the same way. fn prepare_runtime(&mut self) -> Result<(), Report> { self.normalize(); if !(0.0..=1.0).contains(&self.access_sample_rate) { @@ -1993,24 +2172,41 @@ impl TinybirdSettings { message: "tinybird.max_body_bytes must be at least 1024".to_owned(), })); } - if self.access_enabled { + if self.access_enabled && !self.enabled { return Err(Report::new(TrustedServerError::Configuration { - message: "tinybird.access_enabled is reserved for future access-log telemetry; no emitter is currently wired".to_owned(), + message: "tinybird.access_enabled requires tinybird.enabled".to_owned(), })); } if !self.enabled { return Ok(()); } validate_tinybird_api_host(&self.api_host)?; - validate_tinybird_dataset(&self.auction_dataset, "tinybird.auction_dataset")?; - let token = self.auction_token_secret.as_ref().ok_or_else(|| { - Report::new(TrustedServerError::Configuration { - message: - "tinybird.auction_token_secret is required when Tinybird telemetry is enabled" - .to_owned(), - }) - })?; - validate_tinybird_secret(token.expose(), "tinybird.auction_token_secret") + if self.auction_enabled { + validate_tinybird_dataset(&self.auction_dataset, "tinybird.auction_dataset")?; + let token = self.auction_token_secret.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "tinybird.auction_token_secret is required when auction telemetry is enabled".to_owned(), + }) + })?; + validate_tinybird_secret(token.expose(), "tinybird.auction_token_secret")?; + } + if self.access_enabled { + validate_tinybird_dataset(&self.access_dataset, "tinybird.access_dataset")?; + let token = self.access_token_secret.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: + "tinybird.access_token_secret is required when access telemetry is enabled" + .to_owned(), + }) + })?; + validate_tinybird_secret(token.expose(), "tinybird.access_token_secret")?; + if self.access_sample_rate <= 0.0 { + return Err(Report::new(TrustedServerError::Configuration { + message: "tinybird.access_sample_rate must be > 0 when tinybird.access_enabled is true".to_owned(), + })); + } + } + Ok(()) } } @@ -2656,6 +2852,14 @@ pub(crate) const AUCTION_DEBUG_UPSTREAM_METADATA_KEYS: &[&str] = &[ "upstream_message_truncated", ]; +/// `skip_serializing_if` helper: true is the serde default for the fields +/// that use it, so serializing it would only widen the pushed config's +/// rollback surface. +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_true(value: &bool) -> bool { + *value +} + fn default_true() -> bool { true } @@ -2803,6 +3007,36 @@ pub enum AuctionDebugCommentFormat { Pretty, } +/// Request-observability toggles exposed to operators. +/// +/// The default table must stay omitted from serialized config blobs: this +/// struct denies unknown fields, so an older binary loading a config blob +/// carrying an `[observability]` table it does not know would reject it, +/// breaking rollback. See [`Settings::observability`]. +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ObservabilitySettings { + /// Emit the `Server-Timing` response header with per-phase request + /// timing. Defaults to `false` (off). + #[serde(default)] + pub server_timing_enabled: bool, + /// Section names whose publisher paths keep a named route template in + /// access telemetry (`/{section}/*`); everything else collapses to + /// `/other/*`. Matching is ASCII case-insensitive on the first path + /// segment, and a match requires at least one further segment. Defaults + /// to empty, which collapses every publisher path. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub route_sections: Vec, +} + +impl ObservabilitySettings { + /// True when every field is at its default, i.e. observability is fully + /// disabled and the table can be omitted from serialized output. + fn is_default(&self) -> bool { + *self == Self::default() + } +} + /// Tester-cookie endpoint configuration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct TesterCookieConfig { @@ -2954,8 +3188,123 @@ fn validate_trusted_client_ip_shared_secret( Ok(()) } +/// Authenticated forwarding configuration for the public request host and scheme. +/// +/// The upstream proxy must replace client-supplied authentication and forwarding +/// headers before forwarding a request to Trusted Server. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +#[validate(schema(function = validate_trusted_forwarder, skip_on_field_errors = false))] +pub struct TrustedForwarderConfig { + /// Explicit authentication header name, normally `x-ts-forwarder-auth`. + pub auth_header: String, + /// Shared secret required before accepting forwarded host and scheme values. + #[validate(custom(function = validate_trusted_forwarder_shared_secret))] + pub shared_secret: Redacted, +} + +impl TrustedForwarderConfig { + /// Placeholder shared secrets shipped in example configuration and docs. + pub const SHARED_SECRET_PLACEHOLDERS: &[&str] = &["replace-with-a-random-shared-secret"]; + + /// Returns whether the shared secret is a known placeholder, ignoring case. + #[must_use] + pub fn is_placeholder_shared_secret(shared_secret: &str) -> bool { + Self::SHARED_SECRET_PLACEHOLDERS + .iter() + .any(|placeholder| placeholder.eq_ignore_ascii_case(shared_secret)) + } + + /// Returns whether `candidate` exactly matches the configured shared secret. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::redacted::Redacted; + /// use trusted_server_core::settings::TrustedForwarderConfig; + /// + /// let config = TrustedForwarderConfig { + /// auth_header: "x-ts-forwarder-auth".to_owned(), + /// shared_secret: Redacted::new("fictional-forwarder-secret-0123456789".to_owned()), + /// }; + /// assert!(config.authenticates("fictional-forwarder-secret-0123456789")); + /// assert!(!config.authenticates("fictional-wrong-secret")); + /// ``` + #[must_use] + pub fn authenticates(&self, candidate: &str) -> bool { + let configured_digest = Sha256::digest(self.shared_secret.expose().as_bytes()); + let candidate_digest = Sha256::digest(candidate.as_bytes()); + configured_digest.ct_eq(&candidate_digest).into() + } +} + +fn validate_trusted_forwarder(config: &TrustedForwarderConfig) -> Result<(), ValidationError> { + let auth_header = http::HeaderName::from_bytes(config.auth_header.as_bytes()) + .map_err(|_| ValidationError::new("invalid_trusted_forwarder_auth_header"))?; + let name = auth_header.as_str(); + if !name.starts_with("x-") { + return Err(ValidationError::new("unsafe_trusted_forwarder_auth_header")); + } + if SPOOFABLE_FORWARDED_HEADERS.contains(&name) + || INTERNAL_HEADERS.contains(&name) + || name == HEADER_DATADOME_TEST_BYPASS + || matches!(name, "x-ts-trace-action" | "x-ts-original-scheme") + || name.starts_with("x-ts-tls-") + { + return Err(ValidationError::new( + "reserved_trusted_forwarder_auth_header", + )); + } + Ok(()) +} + +fn validate_trusted_forwarder_shared_secret( + shared_secret: &Redacted, +) -> Result<(), ValidationError> { + let shared_secret = shared_secret.expose(); + if shared_secret.len() < Ec::MIN_PASSPHRASE_LENGTH { + return Err(ValidationError::new( + "short_trusted_forwarder_shared_secret", + )); + } + if !shared_secret + .bytes() + .all(|byte| matches!(byte, b'!'..=b'~')) + { + return Err(ValidationError::new( + "invalid_trusted_forwarder_shared_secret", + )); + } + Ok(()) +} + +fn validate_trust_header_collisions(settings: &Settings) -> Result<(), ValidationError> { + let (Some(forwarder), Some(client_ip)) = + (&settings.trusted_forwarder, &settings.trusted_client_ip) + else { + return Ok(()); + }; + for header in [&client_ip.ip_header, &client_ip.auth_header] { + if forwarder.auth_header.eq_ignore_ascii_case(header) { + return Err(ValidationError::new( + "colliding_trusted_forwarder_client_ip_header", + )); + } + if ["forwarded", "x-forwarded-host", "x-forwarded-proto"] + .iter() + .any(|forwarded| forwarded.eq_ignore_ascii_case(header)) + { + return Err(ValidationError::new( + "colliding_trusted_client_ip_forwarded_header", + )); + } + } + Ok(()) +} + #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] +#[validate(schema(function = validate_trust_header_collisions, skip_on_field_errors = false))] pub struct Settings { #[validate(nested)] pub publisher: Publisher, @@ -2973,6 +3322,12 @@ pub struct Settings { #[serde(default, skip_serializing_if = "Option::is_none")] #[validate(nested)] pub trusted_client_ip: Option, + /// Optional authenticated forwarding of the public request host and scheme. + /// + /// Omitted when unset to preserve config compatibility with older binaries. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(nested)] + pub trusted_forwarder: Option, #[serde(default)] #[validate(nested)] pub ec: Ec, @@ -3004,6 +3359,12 @@ pub struct Settings { pub tinybird: TinybirdSettings, #[serde(default)] pub debug: DebugConfig, + /// Request-observability toggles. The default table is omitted from + /// serialized config blobs so a config round-tripped without change + /// still parses under a prior binary's schema; see + /// [`ObservabilitySettings`]. + #[serde(default, skip_serializing_if = "ObservabilitySettings::is_default")] + pub observability: ObservabilitySettings, } impl Settings { @@ -3081,6 +3442,7 @@ impl Settings { pub(crate) fn normalize_deserialized(&mut self) -> Result<(), Report> { self.cache.normalize(); self.proxy.normalize(); + self.rewrite.normalize(); self.image_optimizer.normalize()?; self.debug.auction_html_comment_options.normalize(); self.tinybird.normalize(); @@ -3203,6 +3565,13 @@ impl Settings { { insecure_fields.push("trusted_client_ip.shared_secret".to_owned()); } + if let Some(trusted_forwarder) = &self.trusted_forwarder + && TrustedForwarderConfig::is_placeholder_shared_secret( + trusted_forwarder.shared_secret.expose(), + ) + { + insecure_fields.push("trusted_forwarder.shared_secret".to_owned()); + } for partner in &self.ec.partners { if partner .api_token @@ -3475,6 +3844,98 @@ impl Settings { } } +/// Rejects `rewrite.include_domains` entries that can never equal a URL host. +/// +/// Runs after [`Rewrite::normalize`], so entries are already trimmed and +/// lowercased. Entries are matched against [`Url::host_str`], so each one must +/// be: +/// +/// - a DNS name, optionally prefixed with `*.`: one or more `.`-separated, +/// non-empty labels of ASCII `a-z`, `0-9` and `-`; +/// - an IPv4 address in dotted-quad form, such as `192.0.2.1`; or +/// - an IPv6 address in brackets and compressed form, such as `[2001:db8::1]`. +/// +/// A scheme, port, path, space, leading or trailing `.`, empty label, wildcard +/// IP address or unbracketed IPv6 address is rejected with +/// `invalid_rewrite_include_domain`. A non-ASCII (IDN) name is rejected with +/// `non_ascii_rewrite_include_domain`; write it in punycode (`xn--`) form. +fn validate_include_domains(patterns: &[String]) -> Result<(), ValidationError> { + for pattern in patterns { + if !pattern.is_ascii() { + let mut err = ValidationError::new("non_ascii_rewrite_include_domain"); + err.add_param("value".into(), pattern); + err.message = Some( + "rewrite.include_domains entries must be ASCII; write internationalized domain names in punycode (xn--) form" + .into(), + ); + return Err(err); + } + if let Err(reason) = check_include_domain(pattern) { + let mut err = ValidationError::new("invalid_rewrite_include_domain"); + err.add_param("value".into(), pattern); + err.add_param("reason".into(), &reason); + err.message = Some( + "rewrite.include_domains entries must be a host name, an optional leading `*.`, a dotted-quad IPv4 address or a bracketed IPv6 address" + .into(), + ); + return Err(err); + } + } + Ok(()) +} + +/// Checks one normalized ASCII `include_domains` entry, returning why it can +/// never equal a URL host. +fn check_include_domain(pattern: &str) -> Result<(), &'static str> { + if let Some(bracketed) = pattern.strip_prefix('[') { + bracketed + .strip_suffix(']') + .and_then(|inner| inner.parse::().ok()) + .ok_or("not a bracketed IPv6 address")?; + return check_host_round_trip(pattern); + } + if pattern.parse::().is_ok() { + return Err("IPv6 address without brackets"); + } + + let name = pattern.strip_prefix("*.").unwrap_or(pattern); + if name.is_empty() { + return Err("no host"); + } + if !name.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'.') + }) { + return Err("character outside a-z, 0-9, `-` and `.`"); + } + if name.split('.').any(str::is_empty) { + return Err("leading or trailing `.` or empty label"); + } + + // A URL host whose last label is numeric is parsed as an IPv4 address, so + // such an entry only matches as an exact dotted-quad address. + let last_label = name.rsplit('.').next().unwrap_or(name); + let numeric_last_label = + last_label.bytes().all(|byte| byte.is_ascii_digit()) || last_label.starts_with("0x"); + if numeric_last_label + && (name.len() != pattern.len() || name.parse::().is_err()) + { + return Err("numeric host that is not a dotted-quad IPv4 address"); + } + check_host_round_trip(name) +} + +/// Parses `host` with the parser behind [`Url::host_str`] and requires the +/// rendered form to equal the input, so an entry that `url` rewrites (an +/// IPv4-mapped IPv6 address in dotted form, an invalid `xn--` label) or rejects +/// can never load and then silently fail to match. +fn check_host_round_trip(host: &str) -> Result<(), &'static str> { + let parsed = url::Host::parse(host).map_err(|_| "not a valid URL host")?; + if parsed.to_string() != host { + return Err("not in the form Url::host_str() renders"); + } + Ok(()) +} + fn validate_publisher_domain(value: &str) -> Result<(), ValidationError> { if value.trim() != value || value.is_empty() || value.len() > 253 { return Err(ValidationError::new("invalid_publisher_domain")); @@ -3877,39 +4338,267 @@ mod tests { use crate::redacted::Redacted; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; - fn trusted_client_ip_toml(ip_header: &str, auth_header: &str, shared_secret: &str) -> String { - format!( - "{}\n[trusted_client_ip]\nip_header = \"{ip_header}\"\nauth_header = \"{auth_header}\"\nshared_secret = \"{shared_secret}\"\n", - crate_test_settings_str() - ) + /// Parses `extra` appended to the shared test fixture TOML, mirroring the + /// `format!("{}\n...", crate_test_settings_str())` pattern used throughout + /// this module's other tests. + fn settings_from_toml_with(extra: &str) -> Result> { + let toml = format!("{}\n{extra}", crate_test_settings_str()); + Settings::from_toml(&toml) + } + + fn trusted_forwarder_json(auth_header: &str, shared_secret: &str) -> JsonValue { + let settings = + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); + let mut value = serde_json::to_value(settings).expect("should serialize test settings"); + value["trusted_forwarder"] = json!({ + "auth_header": auth_header, + "shared_secret": shared_secret, + }); + value } #[test] - fn trusted_client_ip_is_absent_by_default() { - let settings = Settings::from_toml(&crate_test_settings_str()) - .expect("should parse settings without trusted client IP configuration"); + fn trusted_forwarder_accepts_valid_configuration_and_redacts_debug_output() { + let secret = "fictional-shared-secret-01234567"; + let settings = + Settings::from_json_value(trusted_forwarder_json("x-ts-forwarder-auth", secret)) + .expect("should accept trusted forwarder configuration"); + let config = settings + .trusted_forwarder + .as_ref() + .expect("should retain the forwarder configuration"); + assert!( + config.authenticates(secret), + "should authenticate the exact shared secret" + ); + for candidate in [ + secret.to_uppercase(), + format!(" {secret}"), + format!("{secret} "), + secret[..secret.len() - 1].to_owned(), + format!("{secret}extra"), + String::new(), + ] { + assert!( + !config.authenticates(&candidate), + "should reject altered authentication values" + ); + } + let debug = format!("{settings:?}"); + assert!( + !debug.contains(secret), + "should redact the forwarder shared secret" + ); + let value = serde_json::to_value(settings).expect("should serialize configured settings"); + assert_eq!( + value["trusted_forwarder"]["auth_header"], "x-ts-forwarder-auth", + "should retain the configured authentication header" + ); + } + + #[test] + fn trusted_forwarder_rejects_invalid_shared_secrets_without_exposing_them() { + for secret in [ + "fictional-shared-secret-0123456", + "fictional-secret-with space-012345", + "fictional-secret-withé-0123456789", + "fictional-secret-with\t-0123456789", + "fictional-secret-with\u{007f}-0123456789", + ] { + let error = + Settings::from_json_value(trusted_forwarder_json("x-ts-forwarder-auth", secret)) + .expect_err("should reject an invalid forwarder shared secret"); + let message = format!("{error:?}"); + assert!( + message.contains("trusted_forwarder_shared_secret"), + "should identify shared-secret validation: {message}" + ); + assert!( + !message.contains(secret), + "should not expose the shared secret: {message}" + ); + } + } + #[test] + fn trusted_forwarder_rejects_placeholder_shared_secret() { + let settings = Settings::from_json_value(trusted_forwarder_json( + "x-ts-forwarder-auth", + "REPLACE-WITH-A-RANDOM-SHARED-SECRET", + )) + .expect("should parse the placeholder before rejecting insecure defaults"); + let error = settings + .reject_placeholder_secrets() + .expect_err("should reject a forwarder placeholder"); assert!( - settings.trusted_client_ip.is_none(), - "should leave trusted client IP configuration disabled by default" + format!("{error:?}").contains("trusted_forwarder.shared_secret"), + "should identify the placeholder field" ); } - /// Mirrors the `Settings` schema of the revision that predates - /// `trusted_client_ip`: every key that revision knew, and - /// `deny_unknown_fields` so an extra key fails deserialization exactly as an - /// older binary would reject a pushed config blob. - // The fields exist to model the accepted key set, never to be read. - #[allow(dead_code)] - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct BaseRevisionSettings { - #[serde(default)] - publisher: serde::de::IgnoredAny, - #[serde(default)] - tester_cookie: serde::de::IgnoredAny, - #[serde(default)] - ec: serde::de::IgnoredAny, + #[test] + fn trusted_forwarder_requires_explicit_fields_and_rejects_unknown_fields() { + for section in [ + json!({"shared_secret": "fictional-forwarder-secret-0123456789"}), + json!({"auth_header": "x-ts-forwarder-auth"}), + json!({"auth_header": "x-ts-forwarder-auth", "shared_secret": "fictional-forwarder-secret-0123456789", "enabled": true}), + ] { + let mut value = trusted_forwarder_json( + "x-ts-forwarder-auth", + "fictional-forwarder-secret-0123456789", + ); + value["trusted_forwarder"] = section; + let _ = Settings::from_json_value(value) + .expect_err("should reject incomplete or unknown forwarder fields"); + } + } + + #[test] + fn trusted_forwarder_rejects_unsafe_authentication_header() { + for header in [ + "authorization", + "host", + "Forwarded", + "Fastly-SSL", + "Fastly-Client-IP", + "", + "x-invalid header", + "x-invalid\r\nheader", + ] { + let error = Settings::from_json_value(trusted_forwarder_json( + header, + "fictional-forwarder-secret-0123456789", + )) + .expect_err("should reject unsafe authentication headers"); + assert!( + format!("{error:?}").contains("trusted_forwarder_auth_header"), + "should identify authentication-header validation" + ); + } + } + + #[test] + fn trusted_forwarder_rejects_reserved_header_collisions_case_insensitively() { + for header in [ + "X-Forwarded-Host", + "X-Forwarded-Proto", + "X-Forwarded-For", + "X-TS-Trace-Action", + "X-TS-DataDome-Bypass", + "X-TS-Original-Scheme", + "X-TS-TLS-Protocol", + "X-TS-TLS-Cipher", + "X-TS-TLS-Future", + "X-TS-EC", + "X-Request-ID", + ] { + let error = Settings::from_json_value(trusted_forwarder_json( + header, + "fictional-forwarder-secret-0123456789", + )) + .expect_err("should reject reserved forwarder authentication headers"); + assert!( + format!("{error:?}").contains("reserved_trusted_forwarder_auth_header"), + "should identify the reserved header collision" + ); + } + } + + #[test] + fn trusted_forwarder_rejects_client_ip_header_collisions_case_insensitively() { + for header in ["X-TS-Client-IP", "X-TS-Client-IP-Auth"] { + let mut value = trusted_forwarder_json(header, "fictional-forwarder-secret-0123456789"); + value["trusted_client_ip"] = json!({"ip_header": "x-ts-client-ip", "auth_header": "x-ts-client-ip-auth", "shared_secret": "fictional-client-ip-secret-0123456789"}); + let error = Settings::from_json_value(value) + .expect_err("should reject overlapping trust headers"); + assert!( + format!("{error:?}").contains("colliding_trusted_forwarder_client_ip_header"), + "should identify colliding trust headers" + ); + } + } + + #[test] + fn trusted_forwarder_rejects_client_ip_overlap_with_forwarded_metadata() { + for (ip_header, auth_header) in [ + ("Forwarded", "x-ts-client-ip-auth"), + ("x-ts-client-ip", "FORWARDED"), + ("X-Forwarded-Host", "x-ts-client-ip-auth"), + ("X-Forwarded-Proto", "x-ts-client-ip-auth"), + ("x-ts-client-ip", "X-Forwarded-Host"), + ("x-ts-client-ip", "X-Forwarded-Proto"), + ] { + let mut value = trusted_forwarder_json( + "x-ts-forwarder-auth", + "fictional-forwarder-secret-0123456789", + ); + value["trusted_client_ip"] = json!({"ip_header": ip_header, "auth_header": auth_header, "shared_secret": "fictional-client-ip-secret-0123456789"}); + let error = Settings::from_json_value(value) + .expect_err("should reject client IP headers that overlap forwarded metadata"); + assert!( + format!("{error:?}").contains("colliding_trusted_client_ip_forwarded_header"), + "should identify forwarded metadata collisions" + ); + } + } + + #[test] + fn trusted_forwarder_allows_distinct_client_ip_configuration() { + let mut value = trusted_forwarder_json( + "x-ts-forwarder-auth", + "fictional-forwarder-secret-0123456789", + ); + value["trusted_client_ip"] = json!({"ip_header": "fastly-client-ip", "auth_header": "x-ts-client-ip-auth", "shared_secret": "fictional-client-ip-secret-0123456789"}); + Settings::from_json_value(value) + .expect("should accept distinct forwarder and client IP trust headers"); + } + + #[test] + fn trusted_forwarder_is_omitted_when_unconfigured() { + let settings = + Settings::from_toml(&crate_test_settings_str()).expect("should parse settings"); + let value = serde_json::to_value(settings).expect("should serialize settings"); + assert!( + value.get("trusted_forwarder").is_none(), + "should omit an unconfigured forwarder" + ); + serde_json::from_value::(value) + .expect("should preserve backward compatibility without forwarder configuration"); + } + + fn trusted_client_ip_toml(ip_header: &str, auth_header: &str, shared_secret: &str) -> String { + format!( + "{}\n[trusted_client_ip]\nip_header = \"{ip_header}\"\nauth_header = \"{auth_header}\"\nshared_secret = \"{shared_secret}\"\n", + crate_test_settings_str() + ) + } + + #[test] + fn trusted_client_ip_is_absent_by_default() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without trusted client IP configuration"); + + assert!( + settings.trusted_client_ip.is_none(), + "should leave trusted client IP configuration disabled by default" + ); + } + + /// Mirrors the `Settings` schema of the revision that predates + /// `trusted_client_ip`: every key that revision knew, and + /// `deny_unknown_fields` so an extra key fails deserialization exactly as an + /// older binary would reject a pushed config blob. + // The fields exist to model the accepted key set, never to be read. + #[allow(dead_code)] + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct BaseRevisionSettings { + #[serde(default)] + publisher: serde::de::IgnoredAny, + #[serde(default)] + tester_cookie: serde::de::IgnoredAny, + #[serde(default)] + ec: serde::de::IgnoredAny, #[serde(default)] integrations: serde::de::IgnoredAny, #[serde(default)] @@ -4009,12 +4698,15 @@ mod tests { fn settings_debug_output_redacts_every_secret_field() { const CANARY_PROXY_SECRET: &str = "CANARY-PROXY-SECRET-0123456789"; const CANARY_EC_PASSPHRASE: &str = "CANARY-EC-PASSPHRASE-0123456789"; + const CANARY_IDENTITY_HANDOFF_KEY: &str = "CANARY-IDENTITY-HANDOFF-KEY-0123456789"; const CANARY_HANDLER_USERNAME: &str = "CANARY-HANDLER-USERNAME-0123456789"; const CANARY_HANDLER_PASSWORD: &str = "CANARY-HANDLER-PASSWORD-0123456789"; const CANARY_EC_PARTNER_API_TOKEN: &str = "CANARY-EC-PARTNER-API-TOKEN-0123456789"; const CANARY_EC_PARTNER_TS_PULL_TOKEN: &str = "CANARY-EC-PARTNER-TS-PULL-TOKEN-0123456789"; const CANARY_TRUSTED_CLIENT_IP_SHARED_SECRET: &str = "CANARY-TRUSTED-CLIENT-IP-SHARED-SECRET-0123456789"; + const CANARY_TRUSTED_FORWARDER_SHARED_SECRET: &str = + "CANARY-TRUSTED-FORWARDER-SHARED-SECRET-0123456789"; const CANARY_S3_ACCESS_KEY_ID: &str = "CANARY-S3-ACCESS-KEY-ID-0123456789"; const CANARY_S3_SECRET_ACCESS_KEY: &str = "CANARY-S3-SECRET-ACCESS-KEY-0123456789"; const CANARY_S3_SESSION_TOKEN: &str = "CANARY-S3-SESSION-TOKEN-0123456789"; @@ -4026,6 +4718,14 @@ mod tests { settings.publisher.proxy_secret = Redacted::new(CANARY_PROXY_SECRET.to_string()); settings.ec.passphrase = Redacted::new(CANARY_EC_PASSPHRASE.to_string()); + settings.ec.identity_handoff = IdentityHandoffConfig { + enabled: true, + active_key_id: "canary-key".to_owned(), + keys: vec![IdentityHandoffKey { + id: "canary-key".to_owned(), + secret: Redacted::new(CANARY_IDENTITY_HANDOFF_KEY.to_owned()), + }], + }; settings.handlers = vec![Handler { path: "^/secure".to_string(), @@ -4046,6 +4746,7 @@ mod tests { pull_sync_allowed_domains: Vec::new(), pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), + identity_owner: None, ts_pull_token: Some(Redacted::new(CANARY_EC_PARTNER_TS_PULL_TOKEN.to_string())), }]; @@ -4054,6 +4755,10 @@ mod tests { auth_header: "x-trusted-client-auth".to_string(), shared_secret: Redacted::new(CANARY_TRUSTED_CLIENT_IP_SHARED_SECRET.to_string()), }); + settings.trusted_forwarder = Some(TrustedForwarderConfig { + auth_header: "x-ts-forwarder-auth".to_owned(), + shared_secret: Redacted::new(CANARY_TRUSTED_FORWARDER_SHARED_SECRET.to_string()), + }); let mut asset_route = ProxyAssetRoute::new("/s3-assets/", "https://s3.canary.example"); asset_route.auth = Some(AssetOriginAuth::S3SigV4(S3SigV4AuthConfig { @@ -4070,6 +4775,7 @@ mod tests { auction_token_secret: Some(Redacted::new(CANARY_TINYBIRD_AUCTION_TOKEN.to_string())), access_token_secret: Some(Redacted::new(CANARY_TINYBIRD_ACCESS_TOKEN.to_string())), enabled: false, + auction_enabled: true, api_host: String::new(), secret_store: None, auction_dataset: String::new(), @@ -4108,6 +4814,10 @@ mod tests { let canaries = [ ("publisher.proxy_secret", CANARY_PROXY_SECRET), ("ec.passphrase", CANARY_EC_PASSPHRASE), + ( + "ec.identity_handoff.keys[].secret", + CANARY_IDENTITY_HANDOFF_KEY, + ), ("handlers[].username", CANARY_HANDLER_USERNAME), ("handlers[].password", CANARY_HANDLER_PASSWORD), ("ec.partners[].api_token", CANARY_EC_PARTNER_API_TOKEN), @@ -4119,6 +4829,10 @@ mod tests { "trusted_client_ip.shared_secret", CANARY_TRUSTED_CLIENT_IP_SHARED_SECRET, ), + ( + "trusted_forwarder.shared_secret", + CANARY_TRUSTED_FORWARDER_SHARED_SECRET, + ), ( "proxy.asset_routes[].auth.access_key_id", CANARY_S3_ACCESS_KEY_ID, @@ -4799,17 +5513,124 @@ mod tests { } #[test] - fn tinybird_access_enabled_is_rejected_until_emitter_is_wired() { - let toml = format!( - "{}\n[tinybird]\naccess_enabled = true\n", - crate_test_settings_str() + fn tinybird_access_enabled_with_full_config_is_accepted() { + let settings = settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_token_secret = \"example-auction-token\"\naccess_token_secret = \"example-access-token\"\naccess_enabled = true\naccess_sample_rate = 1.0\n", + ) + .expect("should accept a fully-specified access telemetry config"); + assert!( + settings.tinybird.access_enabled, + "should enable access emission" ); + } - let err = Settings::from_toml(&toml) - .expect_err("should reject access telemetry before emitter exists"); + #[test] + fn access_enabled_requires_tinybird_enabled() { + // access_enabled = true with tinybird.enabled omitted (defaults + // false) must be rejected: access telemetry cannot run without the + // master toggle on. + let err = settings_from_toml_with( + "[tinybird]\napi_host = \"api.example.com\"\naccess_enabled = true\naccess_sample_rate = 1.0\n", + ) + .expect_err("should reject access telemetry without tinybird.enabled"); assert!( format!("{err:?}").contains("tinybird.access_enabled"), - "should report unsupported tinybird.access_enabled setting: {err:?}" + "should name the field: {err:?}" + ); + } + + #[test] + fn access_enabled_requires_positive_sample_rate() { + // access_enabled = true with access_sample_rate = 0 is armed-but-silent: an error. + let err = settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_token_secret = \"example-auction-token\"\naccess_token_secret = \"example-access-token\"\naccess_enabled = true\naccess_sample_rate = 0.0\n", + ) + .expect_err("should reject armed-but-silent access telemetry"); + assert!( + format!("{err:?}").contains("access_sample_rate"), + "should name the field" + ); + } + + #[test] + fn access_and_auction_emission_are_independent() { + let settings = settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_enabled = false\naccess_token_secret = \"example-access-token\"\naccess_enabled = true\naccess_sample_rate = 1.0\n", + ) + .expect("should accept access without auction"); + assert!( + !settings.tinybird.auction_enabled, + "should disable auction emission" + ); + assert!( + settings.tinybird.access_enabled, + "should enable access emission" + ); + } + + #[test] + fn auction_enabled_defaults_true_for_existing_configs() { + let settings = + settings_from_toml_with("[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_token_secret = \"example-auction-token\"\n") + .expect("should parse a pre-decoupling config"); + assert!( + settings.tinybird.auction_enabled, + "should preserve current behavior" + ); + } + + #[test] + fn observability_defaults_off_and_serializes_away() { + let settings = create_test_settings(); + assert!( + !settings.observability.server_timing_enabled, + "should default off" + ); + let toml = toml::to_string(&settings).expect("should serialize settings"); + assert!( + !toml.contains("[observability]"), + "should omit the default table so a prior binary can parse the config" + ); + } + + #[test] + fn auction_enabled_serializes_only_when_disabled() { + let mut settings = create_test_settings(); + assert!(settings.tinybird.auction_enabled, "should default on"); + let toml = toml::to_string(&settings).expect("should serialize settings"); + assert!( + !toml.contains("auction_enabled"), + "should omit the default-true key: a rollback must not silently \ + re-enable auction telemetry an operator disabled" + ); + + settings.tinybird.auction_enabled = false; + let toml = toml::to_string(&settings).expect("should serialize settings"); + assert!( + toml.contains("auction_enabled = false"), + "should serialize the operator's explicit disable" + ); + } + + #[test] + fn route_sections_serialize_only_when_configured() { + let mut settings = create_test_settings(); + assert!( + settings.observability.route_sections.is_empty(), + "should default to the collapse-everything allowlist" + ); + settings.observability.server_timing_enabled = true; + let toml = toml::to_string(&settings).expect("should serialize settings"); + assert!( + !toml.contains("route_sections"), + "should omit the empty allowlist so a prior binary can parse the config" + ); + + settings.observability.route_sections = vec!["news".to_owned()]; + let toml = toml::to_string(&settings).expect("should serialize settings"); + assert!( + toml.contains("route_sections"), + "should serialize a configured allowlist" ); } @@ -5696,6 +6517,146 @@ source_domain = "partner.example.com" ); } + #[test] + fn identity_handoff_defaults_disabled_without_keys() { + let config = IdentityHandoffConfig::default(); + + assert!(!config.enabled, "handoff should default off"); + assert!( + config.active_key_id.is_empty(), + "handoff should not select a key by default" + ); + assert!(config.keys.is_empty(), "handoff should default to no keys"); + config + .validate() + .expect("disabled empty handoff config should validate"); + } + + #[test] + fn identity_handoff_default_is_omitted_but_provisioned_disabled_keys_serialize() { + let mut settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without identity handoff configuration"); + let value = serde_json::to_value(&settings).expect("should serialize default settings"); + assert!( + value["ec"].get("identity_handoff").is_none(), + "default handoff should remain absent from pushed config" + ); + + settings.ec.identity_handoff = IdentityHandoffConfig { + enabled: false, + active_key_id: "next".to_owned(), + keys: vec![IdentityHandoffKey { + id: "next".to_owned(), + secret: Redacted::new("next-secret-reference".to_owned()), + }], + }; + let value = serde_json::to_value(&settings) + .expect("should serialize provisioned disabled handoff settings"); + assert_eq!(value["ec"]["identity_handoff"]["enabled"], false); + assert_eq!( + value["ec"]["identity_handoff"]["keys"][0]["secret"], + "next-secret-reference" + ); + } + + #[test] + fn identity_handoff_structure_accepts_rotation_keys() { + let config = IdentityHandoffConfig { + enabled: true, + active_key_id: "current_key-2".to_owned(), + keys: vec![ + IdentityHandoffKey { + id: "previous-key".to_owned(), + secret: Redacted::new("previous-secret-reference".to_owned()), + }, + IdentityHandoffKey { + id: "current_key-2".to_owned(), + secret: Redacted::new("current-secret-reference".to_owned()), + }, + ], + }; + + config + .validate() + .expect("rotation config should retain an old verification key"); + } + + #[test] + fn identity_handoff_structure_rejects_unsafe_duplicate_and_missing_active_ids() { + let cases = [ + IdentityHandoffConfig { + enabled: false, + active_key_id: String::new(), + keys: vec![IdentityHandoffKey { + id: String::new(), + secret: Redacted::new("secret-reference".to_owned()), + }], + }, + IdentityHandoffConfig { + enabled: false, + active_key_id: String::new(), + keys: vec![IdentityHandoffKey { + id: "x".repeat(33), + secret: Redacted::new("secret-reference".to_owned()), + }], + }, + IdentityHandoffConfig { + enabled: false, + active_key_id: String::new(), + keys: vec![IdentityHandoffKey { + id: "unsafe key".to_owned(), + secret: Redacted::new("secret-reference".to_owned()), + }], + }, + IdentityHandoffConfig { + enabled: false, + active_key_id: String::new(), + keys: vec![ + IdentityHandoffKey { + id: "duplicate".to_owned(), + secret: Redacted::new("first-secret-reference".to_owned()), + }, + IdentityHandoffKey { + id: "duplicate".to_owned(), + secret: Redacted::new("second-secret-reference".to_owned()), + }, + ], + }, + IdentityHandoffConfig { + enabled: true, + active_key_id: "missing".to_owned(), + keys: vec![IdentityHandoffKey { + id: "configured".to_owned(), + secret: Redacted::new("secret-reference".to_owned()), + }], + }, + ]; + + for config in cases { + config + .validate() + .expect_err("invalid handoff key selection should fail validation"); + } + } + + #[test] + fn identity_handoff_structure_rejects_more_than_four_keys() { + let config = IdentityHandoffConfig { + enabled: false, + active_key_id: String::new(), + keys: (0..5) + .map(|index| IdentityHandoffKey { + id: format!("key-{index}"), + secret: Redacted::new(format!("secret-reference-{index}")), + }) + .collect(), + }; + + config + .validate() + .expect_err("handoff should cap the rotation key ring at four keys"); + } + #[test] fn validate_passphrase_rejects_under_32_characters() { let passphrase = Redacted::new("a".repeat(31)); @@ -5858,6 +6819,7 @@ source_domain = "partner.example.com" pull_sync_allowed_domains: vec!["partner.example.com".to_owned()], pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), + identity_owner: None, ts_pull_token: Some(Redacted::new(ts_pull_token.to_owned())), } } @@ -6673,29 +7635,246 @@ source_domain = "partner.example.com" } #[test] - fn test_rewrite_is_excluded() { - let rewrite = Rewrite { - exclude_domains: vec!["cdn.example.com".to_string(), "*.example2.com".to_string()], - }; + fn rewrite_policy_matches_exclude_patterns_case_insensitively() { + let mut rewrite = Rewrite::default(); + rewrite + .exclude_domains + .extend(["cdn.example.com", "*.example.org"].map(str::to_owned)); - // Exact domain match - assert!(rewrite.is_excluded("http://cdn.example.com/image.png")); + for (host, expected) in [ + ("cdn.example.com", false), + ("CDN.EXAMPLE.COM", false), + ("example.org", false), + ("a.b.example.org", false), + ("evil-example.org", true), + ("example.org.evil.example.net", true), + ("sub.cdn.example.com", true), + ("other.example.com", true), + ] { + assert_eq!( + rewrite.should_proxy_asset(host), + expected, + "should_proxy_asset(`{host}`) should be {expected}" + ); + assert_eq!( + rewrite.should_wrap_click(host), + expected, + "should_wrap_click(`{host}`) should be {expected}" + ); + } + } + + #[test] + fn rewrite_normalize_trims_lowercases_and_drops_inert_exclude_entries() { + let mut rewrite = Rewrite::default(); + rewrite + .exclude_domains + .extend([" CDN.Example.com ", "", "*", "*.Example.ORG"].map(str::to_owned)); + + rewrite.normalize(); + + assert_eq!( + rewrite.exclude_domains, + vec!["cdn.example.com".to_owned(), "*.example.org".to_owned()], + "should trim, lowercase, and drop empty and bare `*` entries" + ); + } - // Wildcard match - base domain - assert!(rewrite.is_excluded("https://example2.com/cdn.js")); - // Wildcard match - subdomains - assert!(rewrite.is_excluded("https://cdnjs.example2.com/lib.js")); - assert!(rewrite.is_excluded("https://sub.domain.example2.com/asset.js")); + #[test] + fn rewrite_exclude_domains_match_mixed_case_entries_from_toml() { + let toml_str = crate_test_settings_str() + + r#" + [rewrite] + exclude_domains = ["CDN.Example.com"] + "#; - // Should NOT match - assert!(!rewrite.is_excluded("https://other.example.com/asset.js")); - assert!(!rewrite.is_excluded("https://sub.cdn.example.com/asset.js")); - assert!(!rewrite.is_excluded("https://example2.com.fake.com/asset.js")); - assert!(!rewrite.is_excluded("https://notexample.com/asset.js")); + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); - // Invalid URLs should not crash and should return false - assert!(!rewrite.is_excluded("not a url")); - assert!(!rewrite.is_excluded("")); + assert!( + !settings.rewrite.should_proxy_asset("cdn.example.com"), + "should exclude a host whose config entry was written in mixed case" + ); + } + + #[test] + fn settings_load_normalizes_rewrite_exclude_domains() { + let toml_str = crate_test_settings_str() + + r#" + [rewrite] + exclude_domains = [" CDN.Example.com ", "*", ""] + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert_eq!( + settings.rewrite.exclude_domains, + vec!["cdn.example.com".to_owned()], + "should trim, lowercase, and drop inert entries when settings load" + ); + } + + #[test] + fn rewrite_should_proxy_asset_honors_include_list() { + let mut rewrite = Rewrite::default(); + rewrite + .include_domains + .extend(["*.example.com", "img.example.net", "*.UPPER.Example.org"].map(str::to_owned)); + rewrite + .exclude_domains + .push("blocked.example.com".to_owned()); + + for (host, expected) in [ + ("example.com", true), + ("a.b.example.com", true), + ("img.example.net", true), + ("IMG.EXAMPLE.NET", true), + ("cdn.upper.example.org", true), + ("evil-example.com", false), + ("cdn.example.org", false), + ("blocked.example.com", false), + ] { + assert_eq!( + rewrite.should_proxy_asset(host), + expected, + "should_proxy_asset(`{host}`) should be {expected}" + ); + } + for host in ["cdn.example.org", "evil-example.com", "example.com"] { + assert!( + rewrite.should_wrap_click(host), + "should wrap click for `{host}` regardless of include_domains" + ); + } + assert!( + !rewrite.should_wrap_click("blocked.example.com"), + "should not wrap a click to an excluded host" + ); + } + + #[test] + fn rewrite_exclude_domains_keep_entries_that_never_match() { + let toml_str = crate_test_settings_str() + + r#" + [rewrite] + exclude_domains = ["https://cdn.example.com", "cdn.example.com:443", "[2001:0db8::1]"] + "#; + + let settings = Settings::from_toml(&toml_str) + .expect("should load inert exclude_domains entries with a warning"); + + assert_eq!( + settings.rewrite.exclude_domains, + vec![ + "https://cdn.example.com".to_owned(), + "cdn.example.com:443".to_owned(), + "[2001:0db8::1]".to_owned(), + ], + "should keep inert exclude_domains entries unchanged" + ); + } + + #[test] + fn rewrite_include_domains_are_normalized_from_toml() { + let toml_str = crate_test_settings_str() + + r#" + [rewrite] + include_domains = ["*.CDN.Example.com", " img.example.net "] + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert_eq!( + settings.rewrite.include_domains, + vec!["*.cdn.example.com".to_owned(), "img.example.net".to_owned()], + "should trim and lowercase include_domains entries" + ); + } + + fn include_domains_toml(entry: &str) -> String { + crate_test_settings_str() + &format!("\n[rewrite]\ninclude_domains = [\"{entry}\"]\n") + } + + #[test] + fn rewrite_include_domains_reject_malformed_entries() { + for entry in [ + "", + " ", + "*", + "*.", + "cdn.*.example.com", + "**.example.com", + "https://cdn.example.com", + "cdn.example.com:443", + "cdn.example.com/x", + ".example.com", + "example.com.", + "cdn..example.com", + "cdn example.com", + "cdn_assets.example.com", + "::1", + "[::1", + "[2001:0db8::1]", + "[::ffff:192.0.2.1]", + "xn--zz.example", + "*.192.0.2.1", + "192.0.2", + "256.0.2.1", + ] { + let result = Settings::from_toml(&include_domains_toml(entry)); + + let err = result.expect_err(&format!("should reject include_domains entry `{entry}`")); + assert!( + format!("{err:?}").contains("invalid_rewrite_include_domain"), + "should report `{entry}` as an invalid include_domains entry: {err:?}" + ); + } + } + + #[test] + fn rewrite_include_domains_reject_non_ascii_entries_with_a_punycode_hint() { + for entry in ["bücher.example", "*.bücher.example"] { + let result = Settings::from_toml(&include_domains_toml(entry)); + + let err = result.expect_err(&format!("should reject non-ASCII entry `{entry}`")); + assert!( + format!("{err:?}").contains("non_ascii_rewrite_include_domain"), + "should report `{entry}` as non-ASCII: {err:?}" + ); + } + } + + #[test] + fn rewrite_include_domains_accept_hosts_and_ip_literals() { + for entry in [ + "cdn.example.com", + "*.example.com", + "xn--bcher-kva.example", + "192.0.2.1", + "[2001:db8::1]", + "[::1]", + "[::ffff:c000:201]", + ] { + let settings = + Settings::from_toml(&include_domains_toml(entry)).unwrap_or_else(|err| { + panic!("should accept include_domains entry `{entry}`: {err:?}") + }); + + assert_eq!( + settings.rewrite.include_domains, + vec![entry.to_owned()], + "should keep accepted entry `{entry}` as written" + ); + } + } + + #[test] + fn rewrite_include_domains_default_to_empty() { + let settings = create_test_settings(); + + assert!( + settings.rewrite.include_domains.is_empty(), + "should default include_domains to an empty list" + ); } #[test] @@ -6716,6 +7895,32 @@ source_domain = "partner.example.com" !settings.auction.sanitize_creatives, "creative sanitization is opt-in when the setting is omitted" ); + assert_eq!( + settings.auction.rewrite_clicks, None, + "click rewriting stays unset when the setting is omitted" + ); + } + + #[test] + fn test_auction_rewrite_clicks_accepts_explicit_values() { + for value in [true, false] { + let toml_str = crate_test_settings_str() + + &format!( + r#" + [auction] + enabled = true + rewrite_clicks = {value} + "# + ); + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert_eq!( + settings.auction.rewrite_clicks, + Some(value), + "should parse explicit rewrite_clicks = {value}" + ); + } } #[test] diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 07108fac6..2999eccf3 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -42,10 +42,10 @@ pub fn config_key(env: &EnvConfig) -> String { /// Resolves the `EdgeZero` app-config store name from the process environment. /// -/// Native adapters such as Axum use this wrapper. Fastly instead supplies an -/// `EnvConfig` populated from service-scoped entries in `edgezero_runtime_env` -/// and resolves the store name from that configuration. Without an override, -/// both paths use the manifest default logical store ID. +/// Native adapters such as Axum use this wrapper. Fastly reads no selector at +/// runtime: it opens the store under the logical ID that a managed deployment +/// links to the selected physical store. Without an override, this wrapper +/// also returns the manifest default logical store ID. #[must_use] pub fn default_config_store_name() -> StoreName { config_store_name(&EnvConfig::from_env()) @@ -53,10 +53,10 @@ pub fn default_config_store_name() -> StoreName { /// Resolves the app-config blob key from the process environment. /// -/// Native adapters such as Axum use this wrapper. Fastly resolves the key from -/// service-scoped entries in `edgezero_runtime_env` instead. A runtime `__KEY` -/// override must match `ts config push --key`; an ordinary push without that -/// flag writes at the logical store ID, regardless of the `__KEY` override. +/// Native adapters such as Axum use this wrapper and honour a `__KEY` +/// override. The Fastly runtime always reads the entry under the logical store +/// ID, and its CLI rejects any other `--key` or `__KEY` value, so no override +/// applies there. #[must_use] pub fn default_config_key() -> String { config_key(&EnvConfig::from_env()) diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 37b8a9e65..12268eba3 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -101,11 +101,14 @@ pub mod tests { /// drive byte-identical input. #[cfg(any(test, feature = "test-utils"))] pub mod nextjs_auction { + use std::io::{Read as _, Write as _}; use std::net::IpAddr; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; + use bytes::Bytes; use error_stack::Report; + use flate2::{read::GzDecoder, write::GzEncoder}; use crate::geo::GeoInfo; use crate::platform::{ @@ -229,13 +232,150 @@ pub mod nextjs_auction { ) } + /// Isolated Next.js/GTM settings without auction dispatch. + /// + /// # Panics + /// + /// Panics if the static GTM configuration is invalid. + #[must_use] + pub fn script_composition_settings() -> Settings { + let mut settings = settings(); + settings.auction.enabled = false; + settings.creative_opportunities = None; + settings + .integrations + .insert_config( + "google_tag_manager", + &serde_json::json!({ + "enabled": true, "container_id": "GTM-MIX1" + }), + ) + .expect("should enable fixture GTM"); + settings + } + + const SCRIPT_COMPOSITION_BOOTSTRAP: &str = + "(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])"; + + /// HTML with standard bootstrap controls and a mixed Flight T record, + /// crossing parser-internal boundaries with same-delimiter query escapes. + #[must_use] + pub fn script_composition_html() -> String { + let content = serde_json::json!({ + "url": format!("https://{ORIGIN_HOST}/app"), + "tag": "http://www.googletagmanager.com/gtm.js?q=\"x\"&v=é", + "ga": "//www.google-analytics.com/g/collect?v=2", + "text": "é😀", "padding": "_".repeat(11000), + }) + .to_string(); + let payload = serde_json::json!(format!("1:T{:x},{content}", content.len())); + format!( + "

script-composition-suffix

" + ) + } + + /// Assert fully consumed adapter output, decoded JSON and recomputed T length. + /// + /// # Panics + /// + /// Panics if response coding, Flight syntax, rewrites, or completion are wrong. + pub fn assert_script_composition_response(bytes: &[u8], gzip: bool) { + let mut decoded = Vec::new(); + if gzip { + GzDecoder::new(bytes) + .read_to_end(&mut decoded) + .expect("should decode complete gzip response"); + } else { + decoded.extend_from_slice(bytes); + } + let html = String::from_utf8(decoded).expect("should emit UTF-8 HTML"); + assert!( + html.contains("

script-composition-suffix

"), + "should deliver suffix sentinel" + ); + assert!(html.ends_with(""), "should finish HTML"); + assert!( + !html.contains("__ts_rsc_"), + "should resolve every captured payload" + ); + assert!( + html.contains(SCRIPT_COMPOSITION_BOOTSTRAP), + "should preserve inert bootstrap controls" + ); + assert_eq!( + html.matches("self.__next_f.push([1,").count(), + 1, + "should emit Flight once" + ); + let json = html + .rsplit("self.__next_f.push(") + .next() + .expect("should keep push") + .split(")") + .next() + .expect("should close push"); + let push: serde_json::Value = + serde_json::from_str(json).expect("should retain valid push JSON"); + let payload = push[1].as_str().expect("should retain payload"); + let (header, body) = payload.split_once(',').expect("should retain T record"); + assert_eq!( + usize::from_str_radix(&header[3..], 16).expect("should parse hex length"), + body.len(), + "should recount decoded T bytes" + ); + let data: serde_json::Value = + serde_json::from_str(body).expect("should preserve valid model JSON"); + assert!( + data["url"] + .as_str() + .expect("should retain URL") + .contains(&format!("{PUBLISHER_HOST}/app")), + "should rewrite origin" + ); + assert_eq!( + data["tag"], + "/integrations/google_tag_manager/gtm.js?q=\"x\"&v=é" + ); + assert_eq!(data["ga"], "/integrations/google_tag_manager/g/collect?v=2"); + assert_eq!(data["text"], "é😀"); + assert_eq!(data["padding"], "_".repeat(11000)); + } + /// Upstream that serves [`origin_html`] and one deterministic bid. #[derive(Default)] pub struct NextJsAuctionOrigin { auction_requests: AtomicUsize, + html_response: Option>, + gzip: bool, + streaming: bool, } impl NextJsAuctionOrigin { + /// Serve canned HTML instead of the default fixture, optionally gzip-encoded + /// and streamed in small chunks. The default fixture remains unchanged. + /// + /// # Panics + /// + /// Panics if in-memory gzip encoding fails. + #[must_use] + pub fn with_html_response(html: &str, gzip: bool, streaming: bool) -> Self { + let bytes = if gzip { + let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(html.as_bytes()) + .expect("should encode fixture gzip"); + encoder.finish().expect("should finish fixture gzip") + } else { + html.as_bytes().to_vec() + }; + Self { + html_response: Some(bytes), + gzip, + streaming, + ..Self::default() + } + } + /// Number of auction requests this fixture has answered. #[must_use] pub fn auction_requests(&self) -> usize { @@ -250,6 +390,10 @@ pub mod nextjs_auction { #[async_trait::async_trait(?Send)] impl PlatformHttpClient for NextJsAuctionOrigin { + fn supports_streaming_responses(&self) -> bool { + self.streaming + } + async fn send( &self, request: PlatformHttpRequest, @@ -276,11 +420,31 @@ pub mod nextjs_auction { .attach(format!("unexpected fixture upstream: {host:?}"))); } }; + let canned_origin = + request.request.uri().host() == Some(ORIGIN_HOST) && self.html_response.is_some(); + let bytes = if canned_origin { + self.html_response + .as_ref() + .expect("should have canned response") + .clone() + } else { + body.into_bytes() + }; + let mut response = http::Response::builder() + .status(200) + .header("content-type", content_type); + if canned_origin && self.gzip { + response = response.header("content-encoding", "gzip"); + } + let body = if request.stream_response && self.streaming { + let chunks: Vec = bytes.chunks(97).map(Bytes::copy_from_slice).collect(); + edgezero_core::body::Body::stream(futures::stream::iter(chunks)) + } else { + edgezero_core::body::Body::from(bytes) + }; Ok(PlatformResponse::new( - http::Response::builder() - .status(200) - .header("content-type", content_type) - .body(edgezero_core::body::Body::from(body)) + response + .body(body) .expect("should build deterministic upstream response"), ) .with_backend_name(request.backend_name)) diff --git a/crates/trusted-server-core/src/trace/actions.rs b/crates/trusted-server-core/src/trace/actions.rs new file mode 100644 index 000000000..9ae500fd9 --- /dev/null +++ b/crates/trusted-server-core/src/trace/actions.rs @@ -0,0 +1,1024 @@ +//! Deliberate, authenticated trace cookie mutations. + +use edgezero_core::body::Body as EdgeBody; +use edgezero_core::request::RequestIngress; +use futures::StreamExt as _; +use http::{HeaderMap, HeaderName, HeaderValue, Request, Response, StatusCode, header}; +use url::Url; + +use crate::forwarder::{PublicOrigin, public_origin}; +use crate::integrations::gpt_diagnostics::GptDiagnosticsCookieAction; + +use super::routes::TraceRoute; + +const ACTION_HEADER: HeaderName = HeaderName::from_static("x-ts-trace-action"); +const FETCH_SITE_HEADER: HeaderName = HeaderName::from_static("sec-fetch-site"); + +/// Validate one deliberate mutation after authenticated route preflight. +/// +/// The caller must apply [`super::TraceDispatch::respond`] to every result. +/// Cookie health never prevents a valid enable or end request. +/// +/// # Performance +/// +/// Rejected controls and framing never poll the body. Streaming validation +/// skips empty chunks and stops on the first nonempty chunk or read error; +/// it does not allocate a body buffer or introduce a transport deadline. +pub(crate) async fn action_response( + request: &mut Request, + route: TraceRoute, +) -> Response { + let (action, cookie_action) = match route { + TraceRoute::Enable => (b"enable".as_slice(), GptDiagnosticsCookieAction::SetSession), + TraceRoute::End => (b"end".as_slice(), GptDiagnosticsCookieAction::ClearSession), + _ => return failure(StatusCode::INTERNAL_SERVER_ERROR), + }; + if request.uri().query().is_some() + || single_header(request.headers(), ACTION_HEADER).map(HeaderValue::as_bytes) + != Some(action) + || single_header(request.headers(), FETCH_SITE_HEADER).map(HeaderValue::as_bytes) + != Some(b"same-origin".as_slice()) + || !matches_trusted_origin(request) + { + return failure(StatusCode::FORBIDDEN); + } + if !accepts_empty_framing(request.headers()) { + return failure(StatusCode::PAYLOAD_TOO_LARGE); + } + let body = std::mem::replace(request.body_mut(), EdgeBody::empty()); + if let Some(status) = empty_body_rejection(body).await { + return failure(status); + } + let Some(cookie) = cookie_action.set_cookie_header() else { + return failure(StatusCode::INTERNAL_SERVER_ERROR); + }; + let mut response = Response::new(EdgeBody::from(br#"{"mutation_requested":true}"#.as_slice())); + response.headers_mut().insert(header::SET_COOKIE, cookie); + response +} + +fn failure(status: StatusCode) -> Response { + let body: &'static [u8] = match status { + StatusCode::FORBIDDEN => br#"{"error":"trace action rejected"}"#, + StatusCode::PAYLOAD_TOO_LARGE => br#"{"error":"trace body must be empty"}"#, + StatusCode::BAD_REQUEST => br#"{"error":"trace body unavailable"}"#, + _ => br#"{"error":"trace action unavailable"}"#, + }; + let mut response = Response::new(EdgeBody::from(body)); + *response.status_mut() = status; + response +} + +fn single_header(headers: &HeaderMap, name: HeaderName) -> Option<&HeaderValue> { + let mut fields = headers.get_all(name).iter(); + let value = fields.next()?; + fields.next().is_none().then_some(value) +} + +fn matches_trusted_origin(request: &Request) -> bool { + let Some(trusted) = request + .extensions() + .get::() + .and_then(RequestIngress::origin) + else { + return false; + }; + let Some(expected) = canonical_authority_origin(trusted.scheme(), trusted.authority()) else { + return false; + }; + let Some(origin) = single_header(request.headers(), header::ORIGIN) + .and_then(|origin| core::str::from_utf8(origin.as_bytes()).ok()) + .and_then(canonical_origin) + else { + return false; + }; + let public_expected = + public_origin(request).map_or_else(|| expected.clone(), PublicOrigin::serialized); + // Public origin authorizes the browser. The received URI and Host must + // still agree with immutable transport ingress below. + if origin != public_expected + || request + .uri() + .scheme_str() + .is_some_and(|scheme| !scheme.eq_ignore_ascii_case(trusted.scheme())) + { + return false; + } + let authority = request.uri().authority(); + if authority.is_some_and(|authority| { + canonical_authority_origin(trusted.scheme(), authority.as_str()).as_ref() != Some(&expected) + }) { + return false; + } + match single_header(request.headers(), header::HOST) { + Some(host) => { + core::str::from_utf8(host.as_bytes()) + .ok() + .and_then(|host| canonical_authority_origin(trusted.scheme(), host)) + .as_ref() + == Some(&expected) + } + None if request.headers().contains_key(header::HOST) => false, + None => request.uri().scheme().is_some() && authority.is_some(), + } +} + +fn canonical_origin(value: &str) -> Option { + let (scheme, authority) = value.split_once("://")?; + canonical_authority_origin(scheme, authority) +} + +fn canonical_authority_origin(scheme: &str, authority: &str) -> Option { + if !(scheme.eq_ignore_ascii_case("https") || scheme.eq_ignore_ascii_case("http")) + || authority.is_empty() + || authority.chars().any(|character| { + character.is_whitespace() + || character.is_control() + || matches!(character, '/' | '?' | '#' | '\\' | '@' | ',') + }) + || !valid_port(authority) + { + return None; + } + let url = Url::parse(&format!("{scheme}://{authority}")).ok()?; + if url.host_str().is_none_or(str::is_empty) { + return None; + } + Some(url.origin().ascii_serialization()) +} + +fn valid_port(authority: &str) -> bool { + let port = if authority.starts_with('[') { + let Some(end) = authority.find(']') else { + return false; + }; + let suffix = &authority[end + 1..]; + if suffix.is_empty() { + return true; + } + let Some(port) = suffix.strip_prefix(':') else { + return false; + }; + Some(port) + } else { + authority.split_once(':').map(|(_, port)| port) + }; + port.is_none_or(|port| { + !port.is_empty() + && port.bytes().all(|byte| byte.is_ascii_digit()) + && port.parse::().is_ok() + }) +} + +fn accepts_empty_framing(headers: &HeaderMap) -> bool { + if headers.contains_key(header::TRANSFER_ENCODING) { + return false; + } + let mut lengths = headers.get_all(header::CONTENT_LENGTH).iter(); + let Some(length) = lengths.next() else { + return true; + }; + lengths.next().is_none() + && !length.as_bytes().is_empty() + && length.as_bytes().iter().all(|byte| *byte == b'0') +} + +async fn empty_body_rejection(body: EdgeBody) -> Option { + match body { + EdgeBody::Once(bytes) if bytes.is_empty() => None, + EdgeBody::Once(_) => Some(StatusCode::PAYLOAD_TOO_LARGE), + EdgeBody::Stream(mut stream) => { + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) if bytes.is_empty() => {} + Ok(_) => return Some(StatusCode::PAYLOAD_TOO_LARGE), + Err(_) => return Some(StatusCode::BAD_REQUEST), + } + } + None + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::io; + use std::rc::Rc; + use std::task::Poll; + + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use bytes::Bytes; + use edgezero_core::body::Body as EdgeBody; + use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, RequestIngress, + TargetUnavailable, + }; + use futures::{executor::block_on, stream}; + use http::{HeaderName, HeaderValue, Method, Request, Response, StatusCode, header}; + use serde_json::{Value, json}; + + use crate::settings::{Handler, Settings}; + use crate::test_support::tests::create_test_settings; + use crate::trace::{TracePreflight, TraceTerminalResponse, inspect_cookies, preflight}; + + fn settings(enabled: bool, auth: bool) -> Settings { + let mut settings = create_test_settings(); + settings.integrations.insert( + "gpt_diagnostics".to_owned(), + json!({"enabled":true, "trace_page_enabled":enabled}), + ); + if auth { + let handler: Handler = serde_json::from_value( + json!({"path":"^/", "username":"example-user", "password":"example-password"}), + ) + .expect("should create an example authentication rule"); + settings.handlers.insert(0, handler); + } + settings + } + + fn metadata(scheme: &str, authority: &str) -> RequestIngress { + RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::NotExposed), + Some( + InboundOrigin::parse(scheme, authority, OriginSource::RuntimeUri) + .expect("should validate the trusted fictional origin"), + ), + HeaderFidelity::default(), + vec![], + ) + .expect("should construct origin-only ingress metadata") + } + + fn request(action: &str) -> Request { + let mut request = Request::builder() + .method(Method::POST) + .uri(format!("/_ts/trace/{action}")) + .header(header::HOST, "publisher.example") + .header(header::ORIGIN, "https://publisher.example") + .header("sec-fetch-site", "same-origin") + .header("x-ts-trace-action", action) + .body(EdgeBody::empty()) + .expect("should create a deliberate action request"); + request + .extensions_mut() + .insert(metadata("https", "publisher.example")); + request + } + + fn respond(settings: &Settings, mut request: Request) -> Response { + match preflight(settings, &mut request) { + TracePreflight::Response(response) => response, + TracePreflight::Ready(context) => { + let response = block_on(super::super::action_response(&mut request, context.route)); + context.respond(response) + } + TracePreflight::NotTrace => panic!("should preflight every exact action route locally"), + } + } + + fn private_error(response: Response, expected: StatusCode) { + assert_eq!( + response.status(), + expected, + "should return the exact bounded action failure status" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "no-store, private", + "should harden action errors locally" + ); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "application/json; charset=utf-8", + "should use bounded JSON errors" + ); + assert_eq!( + response.headers()[header::X_CONTENT_TYPE_OPTIONS], + "nosniff", + "should preserve content type hardening" + ); + assert_eq!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .count(), + 0, + "should never mutate cookies on rejection" + ); + assert!( + response + .extensions() + .get::() + .is_some(), + "should bypass ordinary lifecycle finalization" + ); + let body = response + .into_body() + .into_bytes() + .expect("should return a bounded buffered error"); + assert!( + body.len() < 128 && !String::from_utf8_lossy(&body).contains("fictional-secret"), + "should expose no controls, stream errors or parser text" + ); + } + + fn unpolled_body(polls: Rc>) -> EdgeBody { + EdgeBody::stream(stream::poll_fn(move |_| -> Poll> { + polls.set(polls.get() + 1); + panic!("should reject unsafe headers before polling a body"); + })) + } + + fn assert_mutation(response: Response, action: &str) { + assert_eq!( + response.status(), + StatusCode::OK, + "should accept only the complete deliberate action" + ); + let values: Vec<_> = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .collect(); + assert_eq!( + values.len(), + 1, + "should request exactly one host-only cookie mutation" + ); + let expected = if action == "enable" { + "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800" + } else { + "__Host-ts-console=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0" + }; + assert_eq!( + values[0], expected, + "should reuse the exact shared cookie lifetime and scope" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "no-store, private", + "should keep mutation responses private" + ); + let value: Value = response + .into_body() + .to_json() + .expect("should parse a bounded mutation result"); + assert_eq!( + value, + json!({"mutation_requested":true}), + "should claim only the requested mutation" + ); + } + + fn forwarded_settings() -> Settings { + let mut value = + serde_json::to_value(settings(true, false)).expect("should serialize trace settings"); + value["publisher"]["domain"] = json!("publisher.example"); + value["trusted_forwarder"] = json!({ + "auth_header": "x-ts-forwarder-auth", + "shared_secret": "fictional-forwarder-secret-0123456789", + }); + Settings::from_json_value(value).expect("should parse authenticated trace settings") + } + + fn forwarded_request(action: &str, scheme: &str, authority: &str) -> Request { + let mut request = request(action); + *request.uri_mut() = format!("{scheme}://{authority}/_ts/trace/{action}") + .parse() + .expect("should construct a transport request URI"); + request.headers_mut().insert( + header::HOST, + HeaderValue::from_str(authority).expect("should construct transport Host"), + ); + request.headers_mut().insert( + header::ORIGIN, + HeaderValue::from_static("https://publisher.example:8443"), + ); + request.headers_mut().insert( + "x-ts-forwarder-auth", + HeaderValue::from_static("fictional-forwarder-secret-0123456789"), + ); + request.headers_mut().insert( + "x-forwarded-host", + HeaderValue::from_static("publisher.example:8443"), + ); + request + .headers_mut() + .insert("x-forwarded-proto", HeaderValue::from_static("https")); + request.extensions_mut().insert(metadata(scheme, authority)); + request + } + + #[test] + fn trace_actions_accept_authenticated_public_origin_across_transports() { + let settings = forwarded_settings(); + for action in ["enable", "end"] { + for scheme in ["http", "https"] { + for authority in ["publisher.example", "upstream.example.com:7676"] { + let mut request = forwarded_request(action, scheme, authority); + let cookies_before = inspect_cookies( + request.headers(), + request.extensions().get::(), + ); + crate::forwarder::prepare_trusted_forwarder(&mut request, &settings); + assert_eq!( + inspect_cookies( + request.headers(), + request.extensions().get::() + ), + cookies_before, + "should retain transport cookie health after forwarding" + ); + assert_mutation(respond(&settings, request), action); + } + } + } + } + + #[test] + fn trace_forwarding_keeps_transport_and_browser_controls_independent() { + let settings = forwarded_settings(); + for (name, value) in [ + ("origin", "https://other.example.com"), + ("origin", "null"), + ("host", "other.example.com"), + ("x-ts-forwarder-auth", "fictional-wrong-secret-0123456789"), + ("sec-fetch-site", "cross-site"), + ("x-ts-trace-action", "end"), + ] { + let mut request = forwarded_request("enable", "http", "upstream.example.com:7676"); + request.headers_mut().insert( + HeaderName::from_bytes(name.as_bytes()).expect("should validate field name"), + HeaderValue::from_static(value), + ); + crate::forwarder::prepare_trusted_forwarder(&mut request, &settings); + private_error(respond(&settings, request), StatusCode::FORBIDDEN); + } + for target in [ + "https://upstream.example.com:7676/_ts/trace/enable", + "http://other.example.com/_ts/trace/enable", + "http://upstream.example.com:7676/_ts/trace/enable?", + ] { + let mut request = forwarded_request("enable", "http", "upstream.example.com:7676"); + *request.uri_mut() = target + .parse() + .expect("should construct conflicting transport target"); + crate::forwarder::prepare_trusted_forwarder(&mut request, &settings); + private_error(respond(&settings, request), StatusCode::FORBIDDEN); + } + } + + #[test] + fn trace_actions_accept_enable_and_end_independently_of_cookie_health() { + for action in ["enable", "end"] { + for field in [ + None, + Some("__Host-ts-console=invalid-example"), + Some("__Host-ts-console=1; __Host-ts-console=1"), + Some("__Host-ts-console=1, unrelated=a"), + Some("__Host-ts-console=1; unrelated=�"), + ] { + let mut request = request(action); + if let Some(field) = field { + request.headers_mut().insert( + header::COOKIE, + HeaderValue::from_bytes(field.as_bytes()) + .expect("should construct runtime-visible Cookie text"), + ); + } + let frozen = inspect_cookies( + request.headers(), + request.extensions().get::(), + ); + assert!( + !frozen.observed_active(), + "should keep invalid or ambiguous follow-up observation inactive" + ); + assert_mutation(respond(&settings(true, false), request), action); + for _ in 0..2 { + let state = super::super::state_response(&frozen); + assert_eq!( + state + .into_body() + .to_json::() + .expect("should decode the separate observation"), + json!({"observed_active":false}), + "should not infer browser activation from a successful mutation request" + ); + } + } + } + } + + #[test] + fn trace_actions_origin_canonicalization_accepts_case_default_ports_and_ipv6() { + for (scheme, authority, origin, host) in [ + ( + "https", + "publisher.example", + "HTTPS://PUBLISHER.EXAMPLE:443", + "PUBLISHER.EXAMPLE:443", + ), + ( + "http", + "publisher.example", + "http://PUBLISHER.EXAMPLE:80", + "publisher.example:80", + ), + ( + "https", + "publisher.example:8443", + "https://PUBLISHER.EXAMPLE:8443", + "publisher.example:8443", + ), + ( + "https", + "[2001:db8::1]", + "https://[2001:0DB8:0:0:0:0:0:1]:443", + "[2001:DB8::1]:443", + ), + ] { + let mut request = request("enable"); + request.extensions_mut().insert(metadata(scheme, authority)); + request.headers_mut().insert( + header::ORIGIN, + HeaderValue::from_str(origin) + .expect("should construct a canonical-equivalent origin"), + ); + request.headers_mut().insert( + header::HOST, + HeaderValue::from_str(host).expect("should construct a canonical-equivalent host"), + ); + assert_mutation(respond(&settings(true, false), request), "enable"); + } + } + + #[test] + fn trace_actions_origin_syntax_is_rejected_before_url_normalization_and_body_polling() { + for origin in [ + "https://publisher.example/", + "https://publisher.example/path", + "https://publisher.example?", + "https://publisher.example?fictional-secret=1", + "https://publisher.example#", + "https://publisher.example#fictional-secret", + "https://@publisher.example", + "https://example-user@publisher.example", + "https://publisher.example\\", + "https://publisher.example,https://publisher.example", + " https://publisher.example", + "https://publisher.example ", + "https://publisher.example\t", + "https://publisher.example:", + "https://publisher.example:+443", + "https://publisher.example:65536", + "null", + "file://publisher.example", + "https://", + "https:///publisher.example", + "https:publisher.example", + "http://publisher.example", + "https://other.example", + "https://publisher.example:8443", + ] { + let polls = Rc::new(Cell::new(0)); + let mut request = request("enable"); + request.headers_mut().insert( + header::ORIGIN, + HeaderValue::from_str(origin) + .expect("should construct the visible malformed origin"), + ); + *request.body_mut() = unpolled_body(Rc::clone(&polls)); + private_error( + respond(&settings(true, false), request), + StatusCode::FORBIDDEN, + ); + assert_eq!( + polls.get(), + 0, + "should reject the entire Origin value before body inspection" + ); + } + } + + #[test] + fn trace_actions_controls_require_exact_single_values_and_no_query() { + for name in ["origin", "sec-fetch-site", "x-ts-trace-action"] { + let mut request = request("enable"); + request.headers_mut().remove(name); + private_error( + respond(&settings(true, false), request), + StatusCode::FORBIDDEN, + ); + for second in [ + if name == "origin" { + "https://publisher.example" + } else if name == "sec-fetch-site" { + "same-origin" + } else { + "enable" + }, + "fictional-secret-conflict", + ] { + let mut request = self::request("enable"); + request.headers_mut().append( + HeaderName::from_bytes(name.as_bytes()) + .expect("should construct the control name"), + HeaderValue::from_str(second).expect("should construct a repeated control"), + ); + private_error( + respond(&settings(true, false), request), + StatusCode::FORBIDDEN, + ); + } + } + for (name, value) in [ + ("sec-fetch-site", "same-site"), + ("sec-fetch-site", "Same-Origin"), + ("sec-fetch-site", "same-origin, same-origin"), + ("sec-fetch-site", " same-origin"), + ("x-ts-trace-action", "end"), + ("x-ts-trace-action", "Enable"), + ("x-ts-trace-action", "enable, enable"), + ("x-ts-trace-action", "enable "), + ] { + let mut request = request("enable"); + request.headers_mut().insert( + HeaderName::from_bytes(name.as_bytes()).expect("should construct the control name"), + HeaderValue::from_str(value).expect("should construct a visible control value"), + ); + private_error( + respond(&settings(true, false), request), + StatusCode::FORBIDDEN, + ); + } + for target in [ + "/_ts/trace/enable?", + "/_ts/trace/enable?ts_console=1", + "/_ts/trace/enable?fictional-secret=1", + ] { + let mut request = request("enable"); + *request.uri_mut() = target.parse().expect("should retain the visible query"); + private_error( + respond(&settings(true, false), request), + StatusCode::FORBIDDEN, + ); + } + } + + #[test] + fn trace_actions_host_is_single_unfolded_valid_and_matches_trusted_authority() { + for value in [ + "other.example", + "publisher.example:8443", + "publisher.example:", + "publisher.example:+443", + "publisher.example:65536", + "publisher.example, publisher.example", + "publisher.example/", + "publisher.example?", + "publisher.example#", + "@publisher.example", + "publisher.example\\", + " publisher.example", + "publisher.example ", + "", + ] { + let mut request = request("enable"); + request.headers_mut().insert( + header::HOST, + HeaderValue::from_str(value).expect("should construct the visible malformed Host"), + ); + private_error( + respond(&settings(true, false), request), + StatusCode::FORBIDDEN, + ); + } + for value in ["publisher.example", "other.example"] { + let mut request = request("enable"); + request.headers_mut().append( + header::HOST, + HeaderValue::from_str(value).expect("should construct the second Host"), + ); + private_error( + respond(&settings(true, false), request), + StatusCode::FORBIDDEN, + ); + } + let mut request = request("enable"); + request.headers_mut().remove(header::HOST); + private_error( + respond(&settings(true, false), request), + StatusCode::FORBIDDEN, + ); + } + + #[test] + fn trace_actions_absolute_uri_requires_all_present_facts_to_agree() { + for host in [None, Some("PUBLISHER.EXAMPLE:443")] { + let mut request = request("enable"); + *request.uri_mut() = "https://PUBLISHER.EXAMPLE:443/_ts/trace/enable" + .parse() + .expect("should build a matching absolute URI"); + if let Some(host) = host { + request.headers_mut().insert( + header::HOST, + HeaderValue::from_str(host).expect("should construct the matching Host"), + ); + } else { + request.headers_mut().remove(header::HOST); + } + assert_mutation(respond(&settings(true, false), request), "enable"); + } + for target in [ + "http://publisher.example/_ts/trace/enable", + "https://other.example/_ts/trace/enable", + "https://publisher.example:8443/_ts/trace/enable", + "ftp://publisher.example/_ts/trace/enable", + "https://@publisher.example/_ts/trace/enable", + ] { + let mut request = request("enable"); + *request.uri_mut() = target + .parse() + .expect("should construct conflicting visible absolute URI facts"); + private_error( + respond(&settings(true, false), request), + StatusCode::FORBIDDEN, + ); + } + let mut request = request("enable"); + *request.uri_mut() = "https://publisher.example/_ts/trace/enable" + .parse() + .expect("should construct an absolute request"); + request + .headers_mut() + .insert(header::HOST, HeaderValue::from_static("other.example")); + private_error( + respond(&settings(true, false), request), + StatusCode::FORBIDDEN, + ); + } + + #[test] + fn trace_actions_forwarded_headers_and_runtime_compatibility_cannot_supply_trust() { + for missing_origin in [false, true] { + let mut request = request("enable"); + request.extensions_mut().remove::(); + if !missing_origin { + request.extensions_mut().insert( + RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::NotExposed), + None, + HeaderFidelity::default(), + vec![], + ) + .expect("should construct metadata without origin trust"), + ); + } + for (name, value) in [ + ("forwarded", "proto=https;host=publisher.example"), + ("x-forwarded-proto", "https"), + ("x-forwarded-host", "publisher.example"), + ("x-ts-original-scheme", "https"), + ] { + request.headers_mut().insert( + HeaderName::from_bytes(name.as_bytes()) + .expect("should construct an untrusted compatibility header name"), + HeaderValue::from_static(value), + ); + } + private_error( + respond(&settings(true, false), request), + StatusCode::FORBIDDEN, + ); + } + } + + #[test] + fn trace_actions_header_failures_never_poll_a_hostile_stream() { + for (name, value, status) in [ + ("origin", "https://other.example", StatusCode::FORBIDDEN), + ( + "x-ts-trace-action", + "invalid-example", + StatusCode::FORBIDDEN, + ), + ("sec-fetch-site", "cross-site", StatusCode::FORBIDDEN), + ("host", "other.example", StatusCode::FORBIDDEN), + ("transfer-encoding", "", StatusCode::PAYLOAD_TOO_LARGE), + ( + "transfer-encoding", + "chunked", + StatusCode::PAYLOAD_TOO_LARGE, + ), + ("content-length", "1", StatusCode::PAYLOAD_TOO_LARGE), + ("content-length", "01", StatusCode::PAYLOAD_TOO_LARGE), + ("content-length", "+0", StatusCode::PAYLOAD_TOO_LARGE), + ("content-length", "-0", StatusCode::PAYLOAD_TOO_LARGE), + ("content-length", "0,0", StatusCode::PAYLOAD_TOO_LARGE), + ("content-length", " 0", StatusCode::PAYLOAD_TOO_LARGE), + ("content-length", "0 ", StatusCode::PAYLOAD_TOO_LARGE), + ("content-length", "", StatusCode::PAYLOAD_TOO_LARGE), + ("content-length", "zero", StatusCode::PAYLOAD_TOO_LARGE), + ] { + let polls = Rc::new(Cell::new(0)); + let mut request = request("enable"); + request.headers_mut().insert( + HeaderName::from_bytes(name.as_bytes()) + .expect("should construct the visible header name"), + HeaderValue::from_str(value).expect("should construct the malformed visible field"), + ); + *request.body_mut() = unpolled_body(Rc::clone(&polls)); + private_error(respond(&settings(true, false), request), status); + assert_eq!( + polls.get(), + 0, + "should reject headers without polling the stream" + ); + } + for second in ["0", "1"] { + let polls = Rc::new(Cell::new(0)); + let mut request = request("enable"); + request + .headers_mut() + .append(header::CONTENT_LENGTH, HeaderValue::from_static("0")); + request + .headers_mut() + .append(header::CONTENT_LENGTH, HeaderValue::from_static(second)); + *request.body_mut() = unpolled_body(Rc::clone(&polls)); + private_error( + respond(&settings(true, false), request), + StatusCode::PAYLOAD_TOO_LARGE, + ); + assert_eq!( + polls.get(), + 0, + "should reject visible repeated lengths without body polls" + ); + } + } + + #[test] + fn trace_actions_empty_buffered_body_accepts_absent_and_all_zero_lengths() { + for length in [None, Some("0"), Some("00"), Some("000")] { + let mut request = request("end"); + if let Some(length) = length { + request + .headers_mut() + .insert(header::CONTENT_LENGTH, HeaderValue::from_static(length)); + } + assert_mutation(respond(&settings(true, false), request), "end"); + } + } + + #[test] + fn trace_actions_nonempty_buffered_body_rejects_even_with_zero_framing() { + for length in [None, Some("0"), Some("000")] { + let mut request = request("end"); + *request.body_mut() = EdgeBody::from("fictional-secret-body"); + if let Some(length) = length { + request + .headers_mut() + .insert(header::CONTENT_LENGTH, HeaderValue::from_static(length)); + } + private_error( + respond(&settings(true, false), request), + StatusCode::PAYLOAD_TOO_LARGE, + ); + } + } + + #[test] + fn trace_actions_stream_requires_clean_eof_after_empty_chunks() { + let polls = Rc::new(Cell::new(0)); + let captured = Rc::clone(&polls); + let mut request = request("enable"); + *request.body_mut() = EdgeBody::stream(stream::poll_fn(move |_| { + captured.set(captured.get() + 1); + Poll::Ready(if captured.get() <= 2 { + Some(Bytes::new()) + } else { + None + }) + })); + assert_mutation(respond(&settings(true, false), request), "enable"); + assert_eq!( + polls.get(), + 3, + "should skip empty chunks and prove clean EOF" + ); + } + + #[test] + fn trace_actions_stream_stops_on_first_nonempty_chunk_without_later_polling() { + let polls = Rc::new(Cell::new(0)); + let captured = Rc::clone(&polls); + let mut request = request("enable"); + *request.body_mut() = EdgeBody::stream(stream::poll_fn(move |_| { + captured.set(captured.get() + 1); + Poll::Ready(Some(match captured.get() { + 1 => Bytes::new(), + 2 => Bytes::from_static(b"x"), + _ => panic!("should not poll after the first nonempty body chunk"), + })) + })); + private_error( + respond(&settings(true, false), request), + StatusCode::PAYLOAD_TOO_LARGE, + ); + assert_eq!(polls.get(), 2, "should stop at the first real byte"); + } + + #[test] + fn trace_actions_stream_error_is_bounded_bad_request_without_later_polling() { + let polls = Rc::new(Cell::new(0)); + let captured = Rc::clone(&polls); + let mut request = request("end"); + *request.body_mut() = EdgeBody::from_stream(stream::poll_fn( + move |_| -> Poll>> { + captured.set(captured.get() + 1); + Poll::Ready(Some(match captured.get() { + 1 => Ok(Bytes::new()), + 2 => Err(io::Error::other("fictional-secret-stream-error")), + _ => panic!("should not poll after the stream error"), + })) + }, + )); + private_error( + respond(&settings(true, false), request), + StatusCode::BAD_REQUEST, + ); + assert_eq!( + polls.get(), + 2, + "should require successful EOF and stop after error" + ); + } + + #[test] + fn trace_actions_preflight_authentication_precedes_controls_flag_and_body() { + for enabled in [false, true] { + for credentials in [ + None, + Some("example-user:incorrect-example"), + Some("example-user:example-password"), + ] { + let polls = Rc::new(Cell::new(0)); + let mut request = request("enable"); + request.headers_mut().remove(header::ORIGIN); + *request.body_mut() = unpolled_body(Rc::clone(&polls)); + if let Some(credentials) = credentials { + request.headers_mut().insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Basic {}", STANDARD.encode(credentials))) + .expect("should create fictional credentials"), + ); + } + let response = respond(&settings(enabled, true), request); + let expected = if credentials != Some("example-user:example-password") { + StatusCode::UNAUTHORIZED + } else if !enabled { + StatusCode::NOT_FOUND + } else { + StatusCode::FORBIDDEN + }; + assert_eq!( + response.headers().contains_key(header::WWW_AUTHENTICATE), + expected == StatusCode::UNAUTHORIZED, + "should preserve challenges only when auth rejects" + ); + private_error(response, expected); + assert_eq!( + polls.get(), + 0, + "should not read bodies before successful auth and header validation" + ); + } + } + let mut request = request("enable"); + *request.method_mut() = Method::HEAD; + request.headers_mut().remove(header::ORIGIN); + let response = respond(&settings(true, false), request); + assert_eq!( + response.status(), + StatusCode::METHOD_NOT_ALLOWED, + "should reject unsupported methods before action controls" + ); + assert_eq!( + response.headers()[header::ALLOW], + "POST", + "should retain path-specific Allow" + ); + assert!( + response + .into_body() + .into_bytes() + .expect("should return a local HEAD error") + .is_empty(), + "should keep HEAD action errors bodyless" + ); + } +} diff --git a/crates/trusted-server-core/src/trace/auction.rs b/crates/trusted-server-core/src/trace/auction.rs new file mode 100644 index 000000000..221324c82 --- /dev/null +++ b/crates/trusted-server-core/src/trace/auction.rs @@ -0,0 +1,2038 @@ +//! Closed public auction evidence and exact opaque correlation tokens. + +use error_stack::Report; +use serde::{ + Deserialize, Deserializer, Serialize, + de::{Error as _, MapAccess, SeqAccess, Visitor}, +}; +use std::{fmt, marker::PhantomData}; +use uuid::Uuid; + +const PROVIDER_LIMIT: usize = 16; +const SLOT_LIMIT: usize = 64; +const REQUESTED_SIZE_LIMIT: usize = 16; +const MAXIMUM_DIMENSION: u32 = 100_000; + +/// A bounded failure to validate an exact opaque trace token. +#[derive(Debug, derive_more::Display)] +#[display("invalid trace token")] +pub struct TraceTokenError; + +impl core::error::Error for TraceTokenError {} + +/// An exact lowercase UUID-v4 auction token, independent of internal IDs. +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize, derive_more::Display)] +#[serde(try_from = "String")] +pub struct DiagnosticAuctionId(String); + +impl DiagnosticAuctionId { + /// Parse an exact auction token without normalization. + /// + /// # Errors + /// + /// Returns [`TraceTokenError`] when the supplied bytes are not the public + /// lowercase, unhyphenated UUID-v4 auction-token shape. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::trace::DiagnosticAuctionId; + /// let token = DiagnosticAuctionId::parse("ts-auc-00000000000040008000000000000000")?; + /// assert_eq!(token.as_str(), "ts-auc-00000000000040008000000000000000"); + /// # Ok::<(), error_stack::Report>(()) + /// ``` + /// + /// # Performance + /// + /// Validation checks a fixed byte shape and allocates only for accepted input. + pub fn parse(value: &str) -> Result> { + if !valid_token(value, "ts-auc-", false) { + return Err(Report::new(TraceTokenError)); + } + Ok(Self(value.to_owned())) + } + + /// Generate a fresh public auction token using a UUID v4. + /// + /// # Panics + /// + /// Panics if the UUID random source cannot provide randomness. + /// + /// # Examples + /// + /// ``` + /// let token = trusted_server_core::trace::DiagnosticAuctionId::generate(); + /// assert!(token.as_str().starts_with("ts-auc-")); + /// ``` + #[must_use] + pub fn generate() -> Self { + Self(format!("ts-auc-{}", Uuid::new_v4().simple())) + } + + /// Borrow the exact stored correlation bytes. + /// + /// # Examples + /// + /// ``` + /// let token = trusted_server_core::trace::DiagnosticAuctionId::generate(); + /// assert_eq!(token.as_str().len(), 39); + /// ``` + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for DiagnosticAuctionId { + type Error = Report; + + fn try_from(value: String) -> Result { + if !valid_token(&value, "ts-auc-", false) { + return Err(Report::new(TraceTokenError)); + } + Ok(Self(value)) + } +} + +impl From for String { + fn from(value: DiagnosticAuctionId) -> Self { + value.0 + } +} + +/// An exact lowercase hyphenated UUID-v4 reference for one accepted slot. +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize, derive_more::Display)] +#[serde(try_from = "String")] +pub struct TraceSlotRef(String); + +impl TraceSlotRef { + /// Parse an exact slot reference without normalization. + /// + /// # Errors + /// + /// Returns [`TraceTokenError`] for any other token shape, UUID version or + /// variant, including a normalized alternative. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::trace::TraceSlotRef; + /// let token = TraceSlotRef::parse("ts-slot-00000000-0000-4000-8000-000000000000")?; + /// assert_eq!(token.as_str().len(), 44); + /// # Ok::<(), error_stack::Report>(()) + /// ``` + /// + /// # Performance + /// + /// Validation checks a fixed byte shape and allocates only for accepted input. + pub fn parse(value: &str) -> Result> { + if !valid_token(value, "ts-slot-", true) { + return Err(Report::new(TraceTokenError)); + } + Ok(Self(value.to_owned())) + } + + /// Generate a fresh canonical slot reference using a UUID v4. + /// + /// # Panics + /// + /// Panics if the UUID random source cannot provide randomness. + /// + /// # Examples + /// + /// ``` + /// let token = trusted_server_core::trace::TraceSlotRef::generate(); + /// assert!(token.as_str().starts_with("ts-slot-")); + /// ``` + #[must_use] + pub fn generate() -> Self { + Self(format!("ts-slot-{}", Uuid::new_v4().hyphenated())) + } + + /// Borrow the exact stored reference bytes. + /// + /// # Examples + /// + /// ``` + /// let token = trusted_server_core::trace::TraceSlotRef::generate(); + /// assert_eq!(token.as_str().len(), 44); + /// ``` + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for TraceSlotRef { + type Error = Report; + + fn try_from(value: String) -> Result { + if !valid_token(&value, "ts-slot-", true) { + return Err(Report::new(TraceTokenError)); + } + Ok(Self(value)) + } +} + +impl From for String { + fn from(value: TraceSlotRef) -> Self { + value.0 + } +} + +fn valid_token(value: &str, prefix: &str, hyphenated: bool) -> bool { + let Some(uuid) = value.strip_prefix(prefix).map(str::as_bytes) else { + return false; + }; + let (length, version, variant) = if hyphenated { + (36, 14, 19) + } else { + (32, 12, 16) + }; + uuid.len() == length + && uuid[version] == b'4' + && matches!(uuid[variant], b'8' | b'9' | b'a' | b'b') + && uuid.iter().enumerate().all(|(index, byte)| { + if hyphenated && matches!(index, 8 | 13 | 18 | 23) { + *byte == b'-' + } else { + matches!(byte, b'0'..=b'9' | b'a'..=b'f') + } + }) +} + +/// The server call site that produced the auction observation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TraceAuctionSource { + /// The initial publisher document's server-side auction. + InitialNavigationSsat, + /// The server-side auction for a SPA page-bids request. + SpaPageBids, + /// The programmatic auction API. + AuctionApi, +} + +/// The directly observed auction terminal outcome. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TraceAuctionTerminalStatus { + /// Execution completed, including a zero-bid result. + Completed, + /// Auction execution or collection failed. + ExecutionFailed, + /// The split dispatch path could not start the auction. + DispatchFailed, + /// Dispatched work could not be collected or delivered. + Abandoned, + /// Consent, policy, or the definitive empty slot list skipped execution. + Skipped, +} + +/// A bounded terminal category without provider text or errors. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TraceAuctionTerminalReason { + /// An explicit policy or consent decision skipped execution. + PolicySkipped, + /// The definitive accepted slot list is empty. + NoEligibleSlots, + /// The unsuccessful split dispatch path launched no provider. + NoProviderLaunched, + /// A provider execution failure is directly known. + ProviderExecutionFailed, + /// Collecting dispatched work failed. + CollectionFailed, + /// No more specific allowlisted reason is observed. + Unknown, +} + +/// The role of one auction-wide provider call without its identity. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TraceProviderRole { + /// A bidder call. + Bidder, + /// A mediation call. + Mediator, + /// The role is not known. + Unknown, +} + +/// The directly observed status of one auction-wide provider call. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TraceProviderStatus { + /// The call returned a successful response with bids. + Success, + /// The call returned no bids. + NoBid, + /// The call failed. + Error, + /// The call remains in flight at the observed point. + Pending, + /// The in-flight call was abandoned. + Abandoned, + /// The status cannot be determined. + Unknown, +} + +/// The observed selection and final delivery disposition for an accepted slot. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TraceSlotCandidate { + /// The selected candidate entered the final response. + Selected, + /// No candidate was selected. + NoCandidate, + /// A selected winner could not safely enter the final response. + SelectedUnrenderable, + /// Existing observations cannot distinguish the accepted slot instance. + Unknown, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TraceProviderCall { + #[serde(deserialize_with = "deserialize_ordinal")] + provider_number: u16, + #[serde(deserialize_with = "deserialize_string_enum")] + role: TraceProviderRole, + #[serde(deserialize_with = "deserialize_string_enum")] + status: TraceProviderStatus, + #[serde( + default, + deserialize_with = "deserialize_optional_duration", + skip_serializing_if = "Option::is_none" + )] + response_time_ms: Option, + #[serde(deserialize_with = "deserialize_count")] + returned_bid_count: u16, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +struct TraceSize([u32; 2]); + +impl<'de> Deserialize<'de> for TraceSize { + fn deserialize>(deserializer: D) -> Result { + let dimensions = <[TraceDimension; 2]>::deserialize(deserializer)?; + Ok(Self(dimensions.map(|dimension| dimension.0))) + } +} + +struct TraceDimension(u32); + +impl<'de> Deserialize<'de> for TraceDimension { + fn deserialize>(deserializer: D) -> Result { + let dimension = deserializer.deserialize_any(UnsignedVisitor::)?; + if dimension == 0 { + return Err(D::Error::custom("invalid trace dimension")); + } + u32::try_from(dimension) + .map(Self) + .map_err(|_| D::Error::custom("invalid trace dimension")) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TraceAuctionSlot { + #[serde(deserialize_with = "deserialize_ordinal")] + slot_number: u16, + slot_ref: TraceSlotRef, + #[serde(deserialize_with = "deserialize_sizes")] + requested_sizes: Vec, + #[serde(deserialize_with = "deserialize_count")] + returned_bid_count: u16, + #[serde(deserialize_with = "deserialize_string_enum")] + candidate: TraceSlotCandidate, + #[serde( + default, + deserialize_with = "deserialize_present", + skip_serializing_if = "Option::is_none" + )] + selected_creative_size: Option, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TraceAuctionTruncation { + #[serde(deserialize_with = "deserialize_count")] + omitted_provider_calls: u16, + #[serde(deserialize_with = "deserialize_count")] + omitted_slots: u16, + #[serde(deserialize_with = "deserialize_count")] + omitted_nested_values: u16, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ProviderToSlotNoBid { + Unavailable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TraceAuctionCoverage { + #[serde(deserialize_with = "deserialize_string_enum")] + provider_to_slot_no_bid: ProviderToSlotNoBid, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TraceAuctionEvidenceFields { + #[serde(deserialize_with = "deserialize_version")] + schema_version: u8, + diagnostic_auction_id: DiagnosticAuctionId, + #[serde(deserialize_with = "deserialize_string_enum")] + source: TraceAuctionSource, + #[serde(deserialize_with = "deserialize_string_enum")] + terminal_status: TraceAuctionTerminalStatus, + #[serde( + default, + deserialize_with = "deserialize_optional_enum", + skip_serializing_if = "Option::is_none" + )] + terminal_reason: Option, + #[serde( + default, + deserialize_with = "deserialize_optional_duration", + skip_serializing_if = "Option::is_none" + )] + total_time_ms: Option, + #[serde(deserialize_with = "deserialize_providers")] + provider_calls: Vec, + #[serde(deserialize_with = "deserialize_slots")] + slots: Vec, + #[serde(deserialize_with = "deserialize_object")] + truncation: TraceAuctionTruncation, + #[serde(deserialize_with = "deserialize_object")] + coverage: TraceAuctionCoverage, +} + +/// Closed version-one server facts, separate from bid payloads and telemetry. +/// +/// Fields remain private so object-only deserialization and checked core +/// projection are the only constructors. Serialization includes only opaque +/// tokens, bounded counts, dimensions and durations, and allowlisted categories. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TraceAuctionEvidenceV1( + #[serde(deserialize_with = "deserialize_object")] TraceAuctionEvidenceFields, +); + +impl TraceAuctionEvidenceV1 { + /// Borrow this observation's exact public auction token. + /// + /// # Examples + /// + /// ``` + /// # use trusted_server_core::trace::TraceAuctionEvidenceV1; + /// fn token(evidence: &TraceAuctionEvidenceV1) -> &str { + /// evidence.diagnostic_auction_id().as_str() + /// } + /// ``` + #[must_use] + pub fn diagnostic_auction_id(&self) -> &DiagnosticAuctionId { + &self.0.diagnostic_auction_id + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum TraceUnavailableReason { + EvidenceProjectionFailed, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +enum TraceTransportOutcome { + Evidence { + #[serde(deserialize_with = "deserialize_version")] + schema_version: u8, + evidence: TraceAuctionEvidenceV1, + }, + Unavailable { + #[serde(deserialize_with = "deserialize_version")] + schema_version: u8, + #[serde(deserialize_with = "deserialize_string_enum")] + unavailable_reason: TraceUnavailableReason, + }, +} + +/// An exclusive evidence or fixed projection-failure transport envelope. +/// +/// The unavailable branch never includes raw input, errors, or partial evidence. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TraceAuctionTransportV1( + #[serde(deserialize_with = "deserialize_object")] TraceTransportOutcome, +); + +impl TraceAuctionTransportV1 { + /// Construct the fixed bounded projection-failure marker. + /// + /// # Examples + /// + /// ``` + /// let transport = trusted_server_core::trace::TraceAuctionTransportV1::unavailable(); + /// assert!(transport.evidence().is_none()); + /// ``` + #[must_use] + pub fn unavailable() -> Self { + Self(TraceTransportOutcome::Unavailable { + schema_version: 1, + unavailable_reason: TraceUnavailableReason::EvidenceProjectionFailed, + }) + } + + /// Borrow complete evidence, absent when projection was unavailable. + /// + /// # Examples + /// + /// ``` + /// let transport = trusted_server_core::trace::TraceAuctionTransportV1::unavailable(); + /// assert!(transport.evidence().is_none()); + /// ``` + #[must_use] + pub fn evidence(&self) -> Option<&TraceAuctionEvidenceV1> { + match &self.0 { + TraceTransportOutcome::Evidence { evidence, .. } => Some(evidence), + TraceTransportOutcome::Unavailable { .. } => None, + } + } +} + +impl From for TraceAuctionTransportV1 { + fn from(evidence: TraceAuctionEvidenceV1) -> Self { + Self(TraceTransportOutcome::Evidence { + schema_version: 1, + evidence, + }) + } +} + +struct UnsignedVisitor; + +impl<'de, const MAXIMUM: u32> Visitor<'de> for UnsignedVisitor { + type Value = u64; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded nonnegative integer") + } + + fn visit_u64(self, value: u64) -> Result { + if value <= u64::from(MAXIMUM) { + Ok(value) + } else { + Err(E::custom("invalid trace integer")) + } + } + + fn visit_i64(self, value: i64) -> Result { + let value = u64::try_from(value).map_err(|_| E::custom("invalid trace integer"))?; + self.visit_u64(value) + } + + fn visit_f64(self, value: f64) -> Result { + if !value.is_finite() || value < 0.0 || value > f64::from(MAXIMUM) || value.fract() != 0.0 { + return Err(E::custom("invalid trace integer")); + } + // The accepted value is an exact integer within the u32 range. + Ok(value as u64) + } +} + +fn deserialize_count<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let value = deserializer.deserialize_any(UnsignedVisitor::<{ u16::MAX as u32 }>)?; + u16::try_from(value).map_err(|_| D::Error::custom("invalid trace count")) +} + +fn deserialize_ordinal<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let ordinal = deserialize_count(deserializer)?; + if ordinal == 0 { + return Err(D::Error::custom("invalid trace ordinal")); + } + Ok(ordinal) +} + +fn deserialize_optional_duration<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + let value = deserializer.deserialize_any(UnsignedVisitor::<{ u32::MAX }>)?; + u32::try_from(value) + .map(Some) + .map_err(|_| D::Error::custom("invalid trace duration")) +} + +fn deserialize_version<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let version = deserializer.deserialize_any(UnsignedVisitor::<1>)?; + if version != 1 { + return Err(D::Error::custom("unsupported trace schema")); + } + Ok(1) +} + +fn deserialize_present<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + deserializer: D, +) -> Result, D::Error> { + T::deserialize(deserializer).map(Some) +} + +fn deserialize_string_enum<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + deserializer: D, +) -> Result { + let value = String::deserialize(deserializer)?; + T::deserialize(serde::de::value::StringDeserializer::::new(value)) +} + +fn deserialize_optional_enum<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + deserializer: D, +) -> Result, D::Error> { + deserialize_string_enum(deserializer).map(Some) +} + +struct ObjectValue(T); + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for ObjectValue { + fn deserialize>(deserializer: D) -> Result { + deserialize_object(deserializer).map(Self) + } +} + +struct ObjectVisitor(PhantomData); + +impl<'de, T: Deserialize<'de>> Visitor<'de> for ObjectVisitor { + type Value = T; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a trace object") + } + + fn visit_map>(self, object: A) -> Result { + T::deserialize(serde::de::value::MapAccessDeserializer::new(object)) + } +} + +fn deserialize_object<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + deserializer: D, +) -> Result { + deserializer.deserialize_map(ObjectVisitor(PhantomData)) +} + +struct BoundedSequence { + object_members: bool, + marker: PhantomData, +} + +impl<'de, T: Deserialize<'de>, const LIMIT: usize> Visitor<'de> for BoundedSequence { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded trace array") + } + + fn visit_seq>(self, mut sequence: A) -> Result { + let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(LIMIT)); + while let Some(value) = if self.object_members { + sequence + .next_element::>()? + .map(|value| value.0) + } else { + sequence.next_element::()? + } { + if values.len() == LIMIT { + return Err(A::Error::custom("oversized trace array")); + } + values.push(value); + } + Ok(values) + } +} + +fn deserialize_providers<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + deserializer.deserialize_seq(BoundedSequence:: { + object_members: true, + marker: PhantomData, + }) +} + +fn deserialize_slots<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + deserializer.deserialize_seq(BoundedSequence:: { + object_members: true, + marker: PhantomData, + }) +} + +fn deserialize_sizes<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + deserializer.deserialize_seq(BoundedSequence:: { + object_members: false, + marker: PhantomData, + }) +} + +pub(crate) mod projection { + use std::time::Duration; + + use error_stack::Report; + + use super::{ + DiagnosticAuctionId, MAXIMUM_DIMENSION, PROVIDER_LIMIT, ProviderToSlotNoBid, + REQUESTED_SIZE_LIMIT, SLOT_LIMIT, TraceAuctionCoverage, TraceAuctionEvidenceFields, + TraceAuctionEvidenceV1, TraceAuctionSlot, TraceAuctionSource, TraceAuctionTerminalReason, + TraceAuctionTerminalStatus, TraceAuctionTransportV1, TraceAuctionTruncation, + TraceProviderCall, TraceProviderRole, TraceProviderStatus, TraceSize, TraceSlotCandidate, + TraceSlotRef, + }; + + #[derive(Clone, Debug, Eq, PartialEq)] + pub(crate) struct ObservedTraceProviderCall { + pub(crate) provider_number: usize, + pub(crate) role: TraceProviderRole, + pub(crate) status: TraceProviderStatus, + pub(crate) response_time: Option, + pub(crate) returned_bid_count: usize, + } + + #[derive(Clone, Debug, Eq, PartialEq)] + pub(crate) struct ObservedTraceSlot { + pub(crate) slot_number: usize, + pub(crate) slot_ref: TraceSlotRef, + pub(crate) requested_sizes: Vec<[u32; 2]>, + pub(crate) returned_bid_count: usize, + pub(crate) candidate: TraceSlotCandidate, + pub(crate) selected_creative_size: Option<[u32; 2]>, + } + + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] + pub(crate) struct ObservedTraceTruncation { + pub(crate) omitted_provider_calls: usize, + pub(crate) omitted_slots: usize, + pub(crate) omitted_nested_values: usize, + } + + #[derive(Clone, Debug, Eq, PartialEq)] + pub(crate) struct ObservedTraceAuction { + pub(crate) diagnostic_auction_id: DiagnosticAuctionId, + pub(crate) source: TraceAuctionSource, + pub(crate) terminal_status: TraceAuctionTerminalStatus, + pub(crate) terminal_reason: Option, + pub(crate) total_time: Option, + pub(crate) provider_calls: Vec, + pub(crate) slots: Vec, + pub(crate) truncation: ObservedTraceTruncation, + } + + #[derive(Debug, derive_more::Display)] + #[display("trace evidence projection failed")] + struct TraceEvidenceProjectionError; + + impl core::error::Error for TraceEvidenceProjectionError {} + + pub(crate) fn project_auction_transport( + observed: &ObservedTraceAuction, + ) -> TraceAuctionTransportV1 { + match project_auction_evidence(observed) { + Ok(evidence) => TraceAuctionTransportV1::from(evidence), + Err(_) => TraceAuctionTransportV1::unavailable(), + } + } + + fn project_auction_evidence( + observed: &ObservedTraceAuction, + ) -> Result> { + // Required invalid source facts reject evidence even beyond its limits. + for provider in &observed.provider_calls { + checked_ordinal(provider.provider_number)?; + checked_count(provider.returned_bid_count)?; + } + for slot in &observed.slots { + checked_ordinal(slot.slot_number)?; + checked_count(slot.returned_bid_count)?; + } + let mut truncation = TraceAuctionTruncation { + omitted_provider_calls: checked_count(observed.truncation.omitted_provider_calls)?, + omitted_slots: checked_count(observed.truncation.omitted_slots)?, + omitted_nested_values: checked_count(observed.truncation.omitted_nested_values)?, + }; + let retained_providers = observed.provider_calls.len().min(PROVIDER_LIMIT); + add_omissions( + &mut truncation.omitted_provider_calls, + observed.provider_calls.len() - retained_providers, + )?; + let retained_slots = observed.slots.len().min(SLOT_LIMIT); + add_omissions( + &mut truncation.omitted_slots, + observed.slots.len() - retained_slots, + )?; + let total_time_ms = + project_duration(observed.total_time, &mut truncation.omitted_nested_values)?; + + let mut provider_calls = Vec::with_capacity(retained_providers); + for provider in observed.provider_calls.iter().take(PROVIDER_LIMIT) { + provider_calls.push(TraceProviderCall { + provider_number: checked_ordinal(provider.provider_number)?, + role: provider.role, + status: provider.status, + response_time_ms: project_duration( + provider.response_time, + &mut truncation.omitted_nested_values, + )?, + returned_bid_count: checked_count(provider.returned_bid_count)?, + }); + } + + let mut slots = Vec::with_capacity(retained_slots); + for slot in observed.slots.iter().take(SLOT_LIMIT) { + let retained_sizes = slot.requested_sizes.len().min(REQUESTED_SIZE_LIMIT); + add_omissions( + &mut truncation.omitted_nested_values, + slot.requested_sizes.len() - retained_sizes, + )?; + let mut requested_sizes = Vec::with_capacity(retained_sizes); + for size in slot.requested_sizes.iter().take(REQUESTED_SIZE_LIMIT) { + if let Some(size) = + project_size(Some(*size), &mut truncation.omitted_nested_values)? + { + requested_sizes.push(size); + } + } + slots.push(TraceAuctionSlot { + slot_number: checked_ordinal(slot.slot_number)?, + slot_ref: slot.slot_ref.clone(), + requested_sizes, + returned_bid_count: checked_count(slot.returned_bid_count)?, + candidate: slot.candidate, + selected_creative_size: project_size( + slot.selected_creative_size, + &mut truncation.omitted_nested_values, + )?, + }); + } + + Ok(TraceAuctionEvidenceV1(TraceAuctionEvidenceFields { + schema_version: 1, + diagnostic_auction_id: observed.diagnostic_auction_id.clone(), + source: observed.source, + terminal_status: observed.terminal_status, + terminal_reason: observed.terminal_reason, + total_time_ms, + provider_calls, + slots, + truncation, + coverage: TraceAuctionCoverage { + provider_to_slot_no_bid: ProviderToSlotNoBid::Unavailable, + }, + })) + } + + fn checked_count(count: usize) -> Result> { + u16::try_from(count).map_err(|_| Report::new(TraceEvidenceProjectionError)) + } + + fn checked_ordinal(ordinal: usize) -> Result> { + let ordinal = checked_count(ordinal)?; + if ordinal == 0 { + return Err(Report::new(TraceEvidenceProjectionError)); + } + Ok(ordinal) + } + + fn add_omissions( + counter: &mut u16, + additional: usize, + ) -> Result<(), Report> { + let additional = checked_count(additional)?; + *counter = counter + .checked_add(additional) + .ok_or_else(|| Report::new(TraceEvidenceProjectionError))?; + Ok(()) + } + + fn project_duration( + duration: Option, + omissions: &mut u16, + ) -> Result, Report> { + let Some(duration) = duration else { + return Ok(None); + }; + match u32::try_from(duration.as_millis()) { + Ok(milliseconds) => Ok(Some(milliseconds)), + Err(_) => { + add_omissions(omissions, 1)?; + Ok(None) + } + } + } + + fn project_size( + size: Option<[u32; 2]>, + omissions: &mut u16, + ) -> Result, Report> { + let Some(size) = size else { + return Ok(None); + }; + if size + .iter() + .all(|dimension| (1..=MAXIMUM_DIMENSION).contains(dimension)) + { + Ok(Some(TraceSize(size))) + } else { + add_omissions(omissions, 1)?; + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json::json; + use std::time::Duration; + + use super::projection::{ + ObservedTraceAuction, ObservedTraceProviderCall, ObservedTraceSlot, + ObservedTraceTruncation, project_auction_transport, + }; + + const AUCTION_TOKEN: &str = "ts-auc-00000000000040008000000000000000"; + const SLOT_TOKEN: &str = "ts-slot-00000000-0000-4000-8000-000000000000"; + + #[test] + fn trace_auction_tokens_accept_exact_v4_rfc_variants() { + for variant in ['8', '9', 'a', 'b'] { + let auction = format!("ts-auc-0123456789ab4def{variant}123456789abcdef"); + let slot = format!("ts-slot-01234567-89ab-4def-{variant}123-456789abcdef"); + assert_eq!( + DiagnosticAuctionId::parse(&auction) + .expect("should accept exact auction token") + .as_str(), + auction, + "should retain exact auction bytes" + ); + assert_eq!( + TraceSlotRef::parse(&slot) + .expect("should accept exact slot token") + .as_str(), + slot, + "should retain exact slot bytes" + ); + } + } + + #[test] + fn trace_auction_tokens_reject_normalized_and_internal_alternatives() { + for invalid in [ + "", + "00000000-0000-4000-8000-000000000000", + "ts-auc-00000000-0000-4000-8000-000000000000", + "ts-auc-00000000000010008000000000000000", + "ts-auc-00000000000040007000000000000000", + "ts-auc-0000000000004000c000000000000000", + "ts-auc-0000000000004000A000000000000000", + "ts-auc-0000000000004000800000000000000g", + " ts-auc-00000000000040008000000000000000", + "ts-auc-00000000000040008000000000000000\n", + "TS-AUC-00000000000040008000000000000000", + "ts-auc-00000000000040008000000000000000\u{202e}", + ] { + assert!( + DiagnosticAuctionId::parse(invalid).is_err(), + "should reject invalid auction token" + ); + } + for invalid in [ + "", + AUCTION_TOKEN, + "ts-slot-00000000000040008000000000000000", + "ts-slot-00000000-0000-1000-8000-000000000000", + "ts-slot-00000000-0000-4000-7000-000000000000", + "ts-slot-00000000-0000-4000-c000-000000000000", + "ts-slot-00000000-0000-4000-A000-000000000000", + "ts-slot-00000000-0000-4000-8000-00000000000g", + " ts-slot-00000000-0000-4000-8000-000000000000", + "ts-slot-00000000-0000-4000-8000-000000000000\n", + "TS-SLOT-00000000-0000-4000-8000-000000000000", + "ts-slot-00000000-0000-4000-8000-000000000000\u{202e}", + ] { + assert!( + TraceSlotRef::parse(invalid).is_err(), + "should reject invalid slot reference" + ); + } + } + + #[test] + fn trace_auction_tokens_generate_distinct_exact_owned_values() { + let auctions = [ + DiagnosticAuctionId::generate(), + DiagnosticAuctionId::generate(), + ]; + let slots = [TraceSlotRef::generate(), TraceSlotRef::generate()]; + assert_ne!( + auctions[0], auctions[1], + "should generate fresh auction IDs" + ); + assert_ne!(slots[0], slots[1], "should generate fresh slot references"); + for auction in auctions { + assert_eq!( + auction.as_str().len(), + 39, + "should retain simple UUID shape" + ); + assert_eq!( + DiagnosticAuctionId::parse(auction.as_str()) + .expect("should validate generated auction token"), + auction, + "should round-trip exact auction bytes" + ); + } + for slot in slots { + assert_eq!( + slot.as_str().len(), + 44, + "should retain hyphenated UUID shape" + ); + assert_eq!( + TraceSlotRef::parse(slot.as_str()).expect("should validate generated slot ref"), + slot, + "should round-trip exact slot bytes" + ); + } + } + + #[test] + fn trace_auction_tokens_use_strict_string_serde() { + let auction = + DiagnosticAuctionId::parse(AUCTION_TOKEN).expect("should accept exact auction token"); + let slot = TraceSlotRef::parse(SLOT_TOKEN).expect("should accept exact slot token"); + assert_eq!( + json!(auction), + json!(AUCTION_TOKEN), + "should serialize only opaque bytes" + ); + assert_eq!( + json!(slot), + json!(SLOT_TOKEN), + "should serialize only opaque bytes" + ); + for value in [ + json!(null), + json!(1), + json!({}), + json!([]), + json!("internal-example-id"), + ] { + assert!( + serde_json::from_value::(value.clone()).is_err(), + "should reject invalid auction JSON" + ); + assert!( + serde_json::from_value::(value).is_err(), + "should reject invalid slot JSON" + ); + } + } + + fn evidence_json() -> serde_json::Value { + json!({ + "schema_version": 1, + "diagnostic_auction_id": AUCTION_TOKEN, + "source": "initial_navigation_ssat", + "terminal_status": "completed", + "terminal_reason": "unknown", + "total_time_ms": 10, + "provider_calls": [{ + "provider_number": 1, + "role": "bidder", + "status": "success", + "response_time_ms": 5, + "returned_bid_count": 2 + }], + "slots": [{ + "slot_number": 1, + "slot_ref": SLOT_TOKEN, + "requested_sizes": [[300, 250]], + "returned_bid_count": 2, + "candidate": "selected", + "selected_creative_size": [300, 250] + }], + "truncation": { + "omitted_provider_calls": 0, + "omitted_slots": 0, + "omitted_nested_values": 0 + }, + "coverage": {"provider_to_slot_no_bid": "unavailable"} + }) + } + + fn assert_invalid_evidence(value: serde_json::Value) { + assert!( + serde_json::from_value::(value).is_err(), + "should reject invalid public auction evidence" + ); + } + + #[test] + fn trace_auction_evidence_round_trips_the_exact_owned_schema() { + let input = evidence_json(); + let original = input.clone(); + let evidence = serde_json::from_value::(input) + .expect("should accept complete evidence"); + assert_eq!( + json!(evidence), + original, + "should preserve only exact schema fields" + ); + assert_eq!( + evidence.diagnostic_auction_id().as_str(), + AUCTION_TOKEN, + "should expose the checked correlation token" + ); + let transport = TraceAuctionTransportV1::from(evidence); + assert_eq!( + json!(transport), + json!({"schema_version":1,"evidence":original}), + "should use exclusive evidence envelope" + ); + assert!( + transport.evidence().is_some(), + "should expose available evidence" + ); + } + + #[test] + fn trace_auction_evidence_rejects_unknown_keys_at_every_boundary() { + for pointer in [ + "", + "/provider_calls/0", + "/slots/0", + "/truncation", + "/coverage", + ] { + let mut input = evidence_json(); + input + .pointer_mut(pointer) + .expect("should find the schema object") + .as_object_mut() + .expect("should expose the schema object") + .insert("future_example_field".to_owned(), json!("example")); + assert_invalid_evidence(input); + } + } + + #[test] + fn trace_auction_evidence_accepts_only_documented_enum_members() { + for (pointer, members) in [ + ( + "/source", + &["initial_navigation_ssat", "spa_page_bids", "auction_api"][..], + ), + ( + "/terminal_status", + &[ + "completed", + "execution_failed", + "dispatch_failed", + "abandoned", + "skipped", + ][..], + ), + ( + "/terminal_reason", + &[ + "policy_skipped", + "no_eligible_slots", + "no_provider_launched", + "provider_execution_failed", + "collection_failed", + "unknown", + ][..], + ), + ( + "/provider_calls/0/role", + &["bidder", "mediator", "unknown"][..], + ), + ( + "/provider_calls/0/status", + &[ + "success", + "no_bid", + "error", + "pending", + "abandoned", + "unknown", + ][..], + ), + ( + "/slots/0/candidate", + &[ + "selected", + "no_candidate", + "selected_unrenderable", + "unknown", + ][..], + ), + ] { + for member in members { + let mut input = evidence_json(); + *input.pointer_mut(pointer).expect("should find enum member") = json!(member); + let accepted = serde_json::from_value::(input.clone()) + .expect("should accept documented enum member"); + assert_eq!(json!(accepted), input, "should preserve exact enum bytes"); + } + for invalid in [ + json!("future_example_member"), + json!("UNKNOWN"), + json!("unknown "), + json!("\u{202e}unknown"), + json!(null), + json!(0), + json!({}), + ] { + let mut input = evidence_json(); + *input.pointer_mut(pointer).expect("should find enum member") = invalid; + assert_invalid_evidence(input); + } + } + } + + #[test] + fn trace_auction_evidence_requires_primitive_strings_for_enum_fields() { + for (pointer, member) in [ + ("/source", "initial_navigation_ssat"), + ("/terminal_status", "completed"), + ("/terminal_reason", "unknown"), + ("/provider_calls/0/role", "bidder"), + ("/provider_calls/0/status", "success"), + ("/slots/0/candidate", "selected"), + ("/coverage/provider_to_slot_no_bid", "unavailable"), + ] { + let mut input = evidence_json(); + *input.pointer_mut(pointer).expect("should find enum field") = json!({(member): null}); + let encoded = + serde_json::to_string(&input).expect("should serialize object-form enum fixture"); + assert!( + serde_json::from_str::(&encoded).is_err(), + "should reject object-form enum from JSON bytes" + ); + assert_invalid_evidence(input); + } + } + + #[test] + fn trace_auction_evidence_requires_objects_at_every_object_boundary() { + let original = evidence_json(); + for (pointer, alternative) in [ + ( + "", + json!([ + 1, + AUCTION_TOKEN, + "initial_navigation_ssat", + "completed", + "unknown", + 10, + original["provider_calls"], + original["slots"], + original["truncation"], + original["coverage"] + ]), + ), + ("/provider_calls/0", json!([1, "bidder", "success", 5, 2])), + ( + "/slots/0", + json!([1, SLOT_TOKEN, [[300, 250]], 2, "selected", [300, 250]]), + ), + ("/truncation", json!([0, 0, 0])), + ("/coverage", json!(["unavailable"])), + ] { + let mut input = original.clone(); + *input + .pointer_mut(pointer) + .expect("should find object boundary") = alternative; + let encoded = + serde_json::to_string(&input).expect("should encode positional-array fixture"); + assert!( + serde_json::from_str::(&encoded).is_err(), + "should reject positional arrays from JSON bytes" + ); + assert_invalid_evidence(input); + } + } + + #[test] + fn trace_auction_evidence_rejects_null_optional_and_missing_required_fields() { + for pointer in [ + "/terminal_reason", + "/total_time_ms", + "/provider_calls/0/response_time_ms", + "/slots/0/selected_creative_size", + ] { + let mut input = evidence_json(); + *input + .pointer_mut(pointer) + .expect("should find optional field") = json!(null); + assert_invalid_evidence(input); + } + for pointer in [ + "", + "/provider_calls/0", + "/slots/0", + "/truncation", + "/coverage", + ] { + let source = evidence_json(); + let names: Vec<_> = source + .pointer(pointer) + .expect("should find required object") + .as_object() + .expect("should expose required object") + .keys() + .filter(|name| { + !matches!( + name.as_str(), + "terminal_reason" + | "total_time_ms" + | "response_time_ms" + | "selected_creative_size" + ) + }) + .cloned() + .collect(); + for name in names { + let mut input = source.clone(); + input + .pointer_mut(pointer) + .expect("should find required object") + .as_object_mut() + .expect("should expose required object") + .remove(&name); + assert_invalid_evidence(input); + } + } + let mut input = evidence_json(); + input + .as_object_mut() + .expect("should expose evidence") + .remove("terminal_reason"); + input + .as_object_mut() + .expect("should expose evidence") + .remove("total_time_ms"); + input["provider_calls"][0] + .as_object_mut() + .expect("should expose provider") + .remove("response_time_ms"); + input["slots"][0] + .as_object_mut() + .expect("should expose slot") + .remove("selected_creative_size"); + let accepted = serde_json::from_value::(input.clone()) + .expect("should accept absent optional facts"); + assert_eq!( + json!(accepted), + input, + "should leave unsupported optional facts absent" + ); + } + + #[test] + fn trace_auction_evidence_enforces_exact_cardinality_limits() { + for (pointer, limit) in [ + ("/provider_calls", 16), + ("/slots", 64), + ("/slots/0/requested_sizes", 16), + ] { + let mut input = evidence_json(); + let member = input.pointer(pointer).expect("should find bounded array")[0].clone(); + *input + .pointer_mut(pointer) + .expect("should find bounded array") = json!(vec![member.clone(); limit]); + assert!( + serde_json::from_value::(input.clone()).is_ok(), + "should accept exact array limit" + ); + input + .pointer_mut(pointer) + .expect("should find bounded array") + .as_array_mut() + .expect("should expose array") + .push(member); + assert_invalid_evidence(input); + } + let mut empty = evidence_json(); + empty["provider_calls"] = json!([]); + empty["slots"] = json!([]); + assert!( + serde_json::from_value::(empty).is_ok(), + "should accept completed zero-bid evidence" + ); + } + + #[test] + fn trace_auction_evidence_requires_positive_u16_ordinals_and_u16_counts() { + for pointer in ["/provider_calls/0/provider_number", "/slots/0/slot_number"] { + for valid in [1_u32, u32::from(u16::MAX)] { + let mut input = evidence_json(); + *input.pointer_mut(pointer).expect("should find ordinal") = json!(valid); + assert!( + serde_json::from_value::(input).is_ok(), + "should accept positive u16 ordinal" + ); + } + for invalid in [ + json!(0), + json!(-1), + json!(65536), + json!(1.5), + json!("1"), + json!(null), + ] { + let mut input = evidence_json(); + *input.pointer_mut(pointer).expect("should find ordinal") = invalid; + assert_invalid_evidence(input); + } + } + for pointer in [ + "/provider_calls/0/returned_bid_count", + "/slots/0/returned_bid_count", + "/truncation/omitted_provider_calls", + "/truncation/omitted_slots", + "/truncation/omitted_nested_values", + ] { + for valid in [0_u32, u32::from(u16::MAX)] { + let mut input = evidence_json(); + *input.pointer_mut(pointer).expect("should find count") = json!(valid); + assert!( + serde_json::from_value::(input).is_ok(), + "should accept u16 count" + ); + } + for invalid in [ + json!(-1), + json!(65536), + json!(1.5), + json!("0"), + json!(null), + json!(9007199254740992_u64), + ] { + let mut input = evidence_json(); + *input.pointer_mut(pointer).expect("should find count") = invalid; + assert_invalid_evidence(input); + } + } + } + + #[test] + fn trace_auction_evidence_requires_bounded_durations_dimensions_version_and_coverage() { + for pointer in ["/total_time_ms", "/provider_calls/0/response_time_ms"] { + for valid in [0_u64, u64::from(u32::MAX)] { + let mut input = evidence_json(); + *input.pointer_mut(pointer).expect("should find duration") = json!(valid); + assert!( + serde_json::from_value::(input).is_ok(), + "should accept u32 duration" + ); + } + for invalid in [ + json!(-1), + json!(4294967296_u64), + json!(0.5), + json!("0"), + json!(null), + ] { + let mut input = evidence_json(); + *input.pointer_mut(pointer).expect("should find duration") = invalid; + assert_invalid_evidence(input); + } + } + for pointer in [ + "/slots/0/requested_sizes/0", + "/slots/0/selected_creative_size", + ] { + for valid in [[1, 1], [100000, 100000]] { + let mut input = evidence_json(); + *input.pointer_mut(pointer).expect("should find dimensions") = json!(valid); + assert!( + serde_json::from_value::(input).is_ok(), + "should accept dimension bounds" + ); + } + for invalid in [ + json!([0, 1]), + json!([1, 0]), + json!([100001, 1]), + json!([1, 100001]), + json!([1]), + json!([1, 1, 1]), + json!([-1, 1]), + json!([1.5, 1]), + json!(["1", 1]), + json!(null), + ] { + let mut input = evidence_json(); + *input.pointer_mut(pointer).expect("should find dimensions") = invalid; + assert_invalid_evidence(input); + } + } + for invalid in [json!(0), json!(2), json!("1"), json!(null), json!(1.5)] { + let mut input = evidence_json(); + input["schema_version"] = invalid; + assert_invalid_evidence(input); + } + for invalid in [ + json!("available"), + json!("Unavailable"), + json!(null), + json!(0), + ] { + let mut input = evidence_json(); + input["coverage"]["provider_to_slot_no_bid"] = invalid; + assert_invalid_evidence(input); + } + } + + #[test] + fn trace_auction_evidence_accepts_integral_json_numbers_without_syntax_coercion() { + let mut input = evidence_json(); + for pointer in [ + "/schema_version", + "/total_time_ms", + "/provider_calls/0/provider_number", + "/provider_calls/0/returned_bid_count", + "/provider_calls/0/response_time_ms", + "/slots/0/slot_number", + "/slots/0/returned_bid_count", + "/truncation/omitted_slots", + ] { + *input + .pointer_mut(pointer) + .expect("should find bounded integer") = json!(1.0); + } + input["slots"][0]["requested_sizes"] = json!([[1.0, 100000.0]]); + input["slots"][0]["selected_creative_size"] = json!([1.0, 100000.0]); + assert!( + serde_json::from_value::(input.clone()).is_ok(), + "should accept finite integral JSON values like the browser" + ); + let scientific = serde_json::to_string(&input) + .expect("should serialize numeric fixture") + .replace("1.0", "1e0"); + assert!( + serde_json::from_str::(&scientific).is_ok(), + "should accept integral scientific notation" + ); + } + + #[test] + fn trace_auction_transport_is_exclusive_and_rejects_extra_keys() { + let marker = TraceAuctionTransportV1::unavailable(); + assert_eq!( + json!(marker), + json!({"schema_version":1,"unavailable_reason":"evidence_projection_failed"}), + "should expose only fixed bounded projection failure" + ); + assert!( + marker.evidence().is_none(), + "should distinguish failed projection" + ); + for input in [ + json!({"schema_version":1}), + json!({"schema_version":1,"evidence":evidence_json(),"unavailable_reason":"evidence_projection_failed"}), + json!({"schema_version":1,"evidence":null}), + json!({"schema_version":2,"evidence":evidence_json()}), + json!({"schema_version":1,"unavailable_reason":"example raw provider failure"}), + json!({"schema_version":1,"unavailable_reason":"evidence_projection_failed","future_example_field":0}), + json!({"schema_version":1,"evidence":evidence_json(),"future_example_field":0}), + ] { + assert!( + serde_json::from_value::(input).is_err(), + "should reject malformed whole envelope" + ); + } + for input in [ + json!(marker), + json!({"schema_version":1,"evidence":evidence_json()}), + ] { + let accepted = serde_json::from_value::(input.clone()) + .expect("should accept exactly one envelope branch"); + assert_eq!(json!(accepted), input, "should retain exact branch schema"); + } + } + + #[test] + fn trace_auction_transport_marker_requires_primitive_string_reason() { + let input = json!({ + "schema_version":1, + "unavailable_reason":{"evidence_projection_failed":null} + }); + let encoded = + serde_json::to_string(&input).expect("should serialize object-form marker fixture"); + assert!( + serde_json::from_str::(&encoded).is_err(), + "should reject object-form marker from JSON bytes" + ); + assert!( + serde_json::from_value::(input).is_err(), + "should reject object-form marker from a value" + ); + } + + #[test] + fn trace_auction_transport_requires_an_object_envelope() { + for input in [ + json!([1, evidence_json()]), + json!([1, "evidence_projection_failed"]), + ] { + let encoded = + serde_json::to_string(&input).expect("should encode positional transport fixture"); + assert!( + serde_json::from_str::(&encoded).is_err(), + "should reject positional transport from JSON bytes" + ); + assert!( + serde_json::from_value::(input).is_err(), + "should reject positional transport from a value" + ); + } + } + + #[test] + fn trace_auction_evidence_rejects_duplicate_members_and_invalid_unicode() { + let evidence = serde_json::to_string(&evidence_json()).expect("should serialize fixture"); + let duplicate = evidence.replacen( + "\"schema_version\":1", + "\"schema_version\":1,\"schema_version\":1", + 1, + ); + assert!( + serde_json::from_str::(&duplicate).is_err(), + "should reject duplicate schema members" + ); + let duplicate = evidence.replacen( + "\"provider_number\":1", + "\"provider_number\":1,\"provider_number\":1", + 1, + ); + assert!( + serde_json::from_str::(&duplicate).is_err(), + "should reject duplicate provider members" + ); + let invalid_unicode = evidence.replace(AUCTION_TOKEN, "\\ud800"); + assert!( + serde_json::from_str::(&invalid_unicode).is_err(), + "should reject invalid Unicode strings" + ); + for invalid in [ + "example".repeat(30), + format!("{AUCTION_TOKEN}\u{0085}"), + format!("{SLOT_TOKEN}\u{2066}"), + ] { + let mut input = evidence_json(); + input["diagnostic_auction_id"] = json!(invalid); + assert_invalid_evidence(input); + } + } + + fn observed_auction() -> ObservedTraceAuction { + ObservedTraceAuction { + diagnostic_auction_id: DiagnosticAuctionId::parse(AUCTION_TOKEN) + .expect("should parse observation token"), + source: TraceAuctionSource::InitialNavigationSsat, + terminal_status: TraceAuctionTerminalStatus::Completed, + terminal_reason: Some(TraceAuctionTerminalReason::Unknown), + total_time: Some(Duration::from_millis(10)), + provider_calls: vec![ObservedTraceProviderCall { + provider_number: 1, + role: TraceProviderRole::Bidder, + status: TraceProviderStatus::Success, + response_time: Some(Duration::from_millis(5)), + returned_bid_count: 2, + }], + slots: vec![ObservedTraceSlot { + slot_number: 1, + slot_ref: TraceSlotRef::parse(SLOT_TOKEN) + .expect("should parse observation slot ref"), + requested_sizes: vec![[300, 250]], + returned_bid_count: 2, + candidate: TraceSlotCandidate::Selected, + selected_creative_size: Some([300, 250]), + }], + truncation: ObservedTraceTruncation::default(), + } + } + + #[test] + fn trace_auction_projection_copies_exact_public_facts_without_mutation() { + let observed = observed_auction(); + let original = observed.clone(); + let projected = project_auction_transport(&observed); + assert_eq!( + json!(projected), + json!({"schema_version":1,"evidence":evidence_json()}), + "should project the closed public model" + ); + assert_eq!( + observed, original, + "should leave caller-owned facts unchanged" + ); + let validated = serde_json::from_value::(json!(projected)) + .expect("should validate core-produced transport"); + assert_eq!( + validated, projected, + "should produce the same strict contract accepted at ingress" + ); + } + + #[test] + fn trace_auction_projection_retains_order_and_counts_all_bounded_discards() { + let mut observed = observed_auction(); + let provider = observed.provider_calls[0].clone(); + observed.provider_calls = (1..=18) + .map(|provider_number| ObservedTraceProviderCall { + provider_number, + ..provider.clone() + }) + .collect(); + let mut slot = observed.slots[0].clone(); + slot.requested_sizes = (1..=20).map(|dimension| [dimension, 250]).collect(); + observed.slots = (1..=66) + .map(|slot_number| ObservedTraceSlot { + slot_number, + slot_ref: TraceSlotRef::generate(), + ..slot.clone() + }) + .collect(); + observed.truncation = ObservedTraceTruncation { + omitted_provider_calls: 3, + omitted_slots: 4, + omitted_nested_values: 5, + }; + let original = observed.clone(); + let projected = project_auction_transport(&observed); + let evidence = json!(projected.evidence().expect("should project bounded facts")); + assert_eq!( + evidence["provider_calls"] + .as_array() + .expect("should expose providers") + .len(), + 16, + "should retain first provider calls" + ); + assert_eq!( + evidence["slots"] + .as_array() + .expect("should expose slots") + .len(), + 64, + "should retain first definitive slots" + ); + for (index, provider) in evidence["provider_calls"] + .as_array() + .expect("should expose providers") + .iter() + .enumerate() + { + assert_eq!( + provider["provider_number"], + json!(index + 1), + "should preserve launch order" + ); + } + for (index, slot) in evidence["slots"] + .as_array() + .expect("should expose slots") + .iter() + .enumerate() + { + assert_eq!( + slot["slot_number"], + json!(index + 1), + "should preserve definitive slot order" + ); + assert_eq!( + slot["slot_ref"], + json!(observed.slots[index].slot_ref), + "should preserve the exact accepted token association" + ); + assert_eq!( + slot["requested_sizes"], + json!( + (1..=16) + .map(|dimension| [dimension, 250]) + .collect::>() + ), + "should retain first requested sizes" + ); + } + assert_eq!( + evidence["truncation"], + json!({"omitted_provider_calls":5,"omitted_slots":6,"omitted_nested_values":261}), + "should add every omitted retained-slot size with checked counters" + ); + assert_eq!( + observed, original, + "should preserve all ordinary caller work and facts" + ); + } + + #[test] + fn trace_auction_projection_omits_optional_overflow_and_invalid_sizes_with_counts() { + let mut observed = observed_auction(); + observed.total_time = Some(Duration::MAX); + observed.provider_calls[0].response_time = + Some(Duration::from_millis(u64::from(u32::MAX) + 1)); + observed.slots[0].requested_sizes = vec![ + [0, 250], + [300, 0], + [100001, 250], + [300, 100001], + [1, 1], + [100000, 100000], + ]; + observed.slots[0].selected_creative_size = Some([0, 250]); + let projected = project_auction_transport(&observed); + let evidence = json!( + projected + .evidence() + .expect("should preserve required facts") + ); + assert!( + evidence.get("total_time_ms").is_none(), + "should omit unrepresentable monotonic duration" + ); + assert!( + evidence["provider_calls"][0] + .get("response_time_ms") + .is_none(), + "should omit provider duration overflow" + ); + assert!( + evidence["slots"][0].get("selected_creative_size").is_none(), + "should omit invalid optional creative dimensions" + ); + assert_eq!( + evidence["slots"][0]["requested_sizes"], + json!([[1, 1], [100000, 100000]]), + "should omit invalid optional requested sizes without normalizing" + ); + assert_eq!( + evidence["truncation"]["omitted_nested_values"], + json!(7), + "should count every unsupported optional fact" + ); + assert_eq!( + evidence["slots"][0]["candidate"], + json!("selected"), + "should preserve observed candidate disposition" + ); + } + + #[test] + fn trace_auction_projection_keeps_exact_numeric_boundaries_and_zero_durations() { + let mut observed = observed_auction(); + observed.total_time = Some(Duration::ZERO); + observed.provider_calls[0].response_time = Some(Duration::from_millis(u64::from(u32::MAX))); + observed.provider_calls[0].provider_number = usize::from(u16::MAX); + observed.provider_calls[0].returned_bid_count = usize::from(u16::MAX); + observed.slots[0].slot_number = usize::from(u16::MAX); + observed.slots[0].returned_bid_count = usize::from(u16::MAX); + observed.slots[0].requested_sizes = vec![[1, 1], [100000, 100000]]; + observed.slots[0].selected_creative_size = Some([100000, 100000]); + let projected = project_auction_transport(&observed); + let evidence = json!(projected.evidence().expect("should preserve exact bounds")); + assert_eq!( + evidence["total_time_ms"], + json!(0), + "should preserve zero monotonic duration" + ); + assert_eq!( + evidence["provider_calls"][0]["response_time_ms"], + json!(u32::MAX), + "should preserve maximum duration without clamping" + ); + assert_eq!( + evidence["provider_calls"][0]["provider_number"], + json!(u16::MAX), + "should preserve maximum ordinal" + ); + assert_eq!( + evidence["slots"][0]["returned_bid_count"], + json!(u16::MAX), + "should preserve maximum bid count" + ); + assert_eq!( + evidence["truncation"], + json!({"omitted_provider_calls":0,"omitted_slots":0,"omitted_nested_values":0}), + "should not count representable values as omissions" + ); + } + + #[test] + fn trace_auction_projection_required_conversion_failure_emits_only_marker() { + for field in [ + "provider_number", + "provider_count", + "slot_number", + "slot_count", + ] { + for invalid in [usize::from(u16::MAX) + 1, usize::MAX] { + let mut observed = observed_auction(); + match field { + "provider_number" => observed.provider_calls[0].provider_number = invalid, + "provider_count" => observed.provider_calls[0].returned_bid_count = invalid, + "slot_number" => observed.slots[0].slot_number = invalid, + "slot_count" => observed.slots[0].returned_bid_count = invalid, + _ => panic!("should use the documented test field"), + } + assert_eq!( + json!(project_auction_transport(&observed)), + json!(TraceAuctionTransportV1::unavailable()), + "should reject required conversion without exposing partial facts" + ); + } + } + for provider_ordinal in [true, false] { + let mut observed = observed_auction(); + if provider_ordinal { + observed.provider_calls[0].provider_number = 0; + } else { + observed.slots[0].slot_number = 0; + } + assert!( + project_auction_transport(&observed).evidence().is_none(), + "should reject zero ordinal" + ); + } + } + + #[test] + fn trace_auction_projection_rejects_required_invalid_tail_facts() { + for field in [ + "provider_number", + "provider_count", + "slot_number", + "slot_count", + ] { + let mut observed = observed_auction(); + observed.provider_calls = vec![observed.provider_calls[0].clone(); 17]; + observed.slots = vec![observed.slots[0].clone(); 65]; + match field { + "provider_number" => observed.provider_calls[16].provider_number = 0, + "provider_count" => { + observed.provider_calls[16].returned_bid_count = usize::from(u16::MAX) + 1 + } + "slot_number" => observed.slots[64].slot_number = 0, + "slot_count" => observed.slots[64].returned_bid_count = usize::from(u16::MAX) + 1, + _ => panic!("should use the documented tail field"), + } + assert_eq!( + json!(project_auction_transport(&observed)), + json!(TraceAuctionTransportV1::unavailable()), + "should reject required invalid facts before tail truncation" + ); + } + let mut observed = observed_auction(); + observed.slots = vec![observed.slots[0].clone(); 65]; + observed.slots[64].requested_sizes = vec![[0, 0]]; + observed.slots[64].selected_creative_size = Some([0, 0]); + let projected = project_auction_transport(&observed); + let evidence = json!( + projected + .evidence() + .expect("should truncate optional tail facts") + ); + assert_eq!( + evidence["truncation"], + json!({"omitted_provider_calls":0,"omitted_slots":1,"omitted_nested_values":0}), + "should keep optional nested accounting within retained slots" + ); + } + + #[test] + fn trace_auction_projection_omission_overflow_never_wraps_or_saturates() { + for field in ["providers", "slots", "nested"] { + let mut observed = observed_auction(); + match field { + "providers" => observed.truncation.omitted_provider_calls = usize::from(u16::MAX), + "slots" => observed.truncation.omitted_slots = usize::from(u16::MAX), + "nested" => observed.truncation.omitted_nested_values = usize::from(u16::MAX), + _ => panic!("should use the documented omission field"), + } + assert!( + project_auction_transport(&observed).evidence().is_some(), + "should accept exact omission counter maximum" + ); + match field { + "providers" => { + observed.provider_calls = vec![observed.provider_calls[0].clone(); 17] + } + "slots" => observed.slots = vec![observed.slots[0].clone(); 65], + "nested" => observed.total_time = Some(Duration::MAX), + _ => panic!("should use the documented omission field"), + } + assert_eq!( + json!(project_auction_transport(&observed)), + json!(TraceAuctionTransportV1::unavailable()), + "should fail checked omission addition instead of saturation" + ); + } + for truncation in [ + ObservedTraceTruncation { + omitted_provider_calls: usize::from(u16::MAX) + 1, + ..ObservedTraceTruncation::default() + }, + ObservedTraceTruncation { + omitted_slots: usize::MAX, + ..ObservedTraceTruncation::default() + }, + ObservedTraceTruncation { + omitted_nested_values: usize::MAX, + ..ObservedTraceTruncation::default() + }, + ] { + let mut observed = observed_auction(); + observed.truncation = truncation; + assert!( + project_auction_transport(&observed).evidence().is_none(), + "should reject unrepresentable carried omissions" + ); + } + } + + #[test] + fn trace_auction_projection_counts_sizes_in_the_first_prefix_without_backfilling() { + let mut observed = observed_auction(); + observed.slots[0].requested_sizes = vec![[0, 0]; 16]; + observed.slots[0].requested_sizes.push([300, 250]); + let projected = project_auction_transport(&observed); + let evidence = json!( + projected + .evidence() + .expect("should omit malformed optional sizes") + ); + assert_eq!( + evidence["slots"][0]["requested_sizes"], + json!([]), + "should never backfill from beyond the requested-size prefix" + ); + assert_eq!( + evidence["truncation"]["omitted_nested_values"], + json!(17), + "should count invalid prefix and discarded tail separately" + ); + observed.slots[0].requested_sizes = vec![[300, 250]; usize::from(u16::MAX) + 17]; + assert!( + project_auction_transport(&observed).evidence().is_none(), + "should reject more than u16 omitted sizes" + ); + } +} diff --git a/crates/trusted-server-core/src/trace/carry.rs b/crates/trusted-server-core/src/trace/carry.rs new file mode 100644 index 000000000..87033a9be --- /dev/null +++ b/crates/trusted-server-core/src/trace/carry.rs @@ -0,0 +1,889 @@ +//! Private, request-local observations at the live auction boundary. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use web_time::Instant; + +use crate::auction::types::{AdSlot, AuctionResponse, Bid, BidStatus}; + +use super::auction::projection::{ + ObservedTraceAuction, ObservedTraceProviderCall, ObservedTraceSlot, ObservedTraceTruncation, + project_auction_transport, +}; + +use super::auction::{ + DiagnosticAuctionId, TraceAuctionSource, TraceAuctionTerminalReason, + TraceAuctionTerminalStatus, TraceAuctionTransportV1, TraceProviderRole, TraceProviderStatus, + TraceSlotCandidate, TraceSlotRef, +}; + +const PROVIDER_LIMIT: usize = 16; +const SLOT_LIMIT: usize = 64; +const SIZE_LIMIT: usize = 16; + +/// Ephemeral facts; provider identities, bid bodies, and creatives are never retained. +#[derive(Clone)] +pub(crate) struct TraceAuctionCarry { + token: DiagnosticAuctionId, + state: Arc>, +} + +struct TraceAuctionState { + observed: ObservedTraceAuction, + started_at: Instant, + provider_starts: Vec, + provider_count: usize, + slot_keys: Vec, + slot_refs: Vec, + buckets: HashMap, + terminal: bool, + invalid: bool, + collection_failed: bool, + provider_failed: bool, + transport: Option, +} + +#[derive(Default)] +struct SlotBucket { + accepted_instances: usize, + returned_bid_count: u16, +} + +impl TraceAuctionState { + fn refresh_projection(&mut self) { + if self.terminal { + self.transport = Some(if self.invalid { + TraceAuctionTransportV1::unavailable() + } else { + project_auction_transport(&self.observed) + }); + } + } +} + +/// One observation is consumed at its actual response or failure boundary. +pub(crate) struct TraceProviderObservation { + number: Option, + started_at: Instant, +} + +/// Cancellation terminalizes synchronously without constructing telemetry. +pub(crate) struct TraceAuctionCancellationGuard { + carry: TraceAuctionCarry, + armed: bool, +} + +impl TraceAuctionCancellationGuard { + pub(crate) fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TraceAuctionCancellationGuard { + fn drop(&mut self) { + if self.armed { + self.carry.finish( + TraceAuctionTerminalStatus::Abandoned, + Some(TraceAuctionTerminalReason::Unknown), + ); + } + } +} + +impl TraceAuctionCarry { + pub(crate) fn capture_if_enabled( + active: bool, + source: TraceAuctionSource, + slots: &[AdSlot], + ) -> Option { + if !active { + return None; + } + let token = DiagnosticAuctionId::generate(); + let mut state = TraceAuctionState { + observed: ObservedTraceAuction { + diagnostic_auction_id: token.clone(), + source, + terminal_status: TraceAuctionTerminalStatus::Completed, + terminal_reason: None, + total_time: None, + provider_calls: Vec::new(), + slots: Vec::new(), + truncation: ObservedTraceTruncation::default(), + }, + started_at: Instant::now(), + provider_starts: Vec::new(), + provider_count: 0, + slot_keys: Vec::new(), + slot_refs: Vec::new(), + buckets: HashMap::new(), + terminal: false, + invalid: slots.len() > usize::from(u16::MAX), + collection_failed: false, + provider_failed: false, + transport: None, + }; + if !state.invalid { + for (index, slot) in slots.iter().enumerate() { + state + .buckets + .entry(slot.id.clone()) + .or_default() + .accepted_instances += 1; + let slot_ref = TraceSlotRef::generate(); + state.slot_refs.push(slot_ref.clone()); + if index >= SLOT_LIMIT { + state.observed.truncation.omitted_slots += 1; + continue; + } + let retained_sizes = slot.formats.len().min(SIZE_LIMIT); + let omitted_sizes = slot.formats.len() - retained_sizes; + if let Some(omitted) = state + .observed + .truncation + .omitted_nested_values + .checked_add(omitted_sizes) + { + state.observed.truncation.omitted_nested_values = omitted; + state.invalid |= omitted > usize::from(u16::MAX); + } else { + state.invalid = true; + } + state.slot_keys.push(slot.id.clone()); + state.observed.slots.push(ObservedTraceSlot { + slot_number: index + 1, + slot_ref, + requested_sizes: slot + .formats + .iter() + .take(SIZE_LIMIT) + .map(|format| [format.width, format.height]) + .collect(), + returned_bid_count: 0, + candidate: TraceSlotCandidate::NoCandidate, + selected_creative_size: None, + }); + } + } + let carry = Self { + token, + state: Arc::new(Mutex::new(state)), + }; + if slots.is_empty() { + carry.finish( + TraceAuctionTerminalStatus::Skipped, + Some(TraceAuctionTerminalReason::NoEligibleSlots), + ); + } + Some(carry) + } + + pub(crate) fn token(&self) -> DiagnosticAuctionId { + self.token.clone() + } + + pub(crate) fn slot_ref(&self, index: usize) -> Option { + self.state.lock().ok()?.slot_refs.get(index).cloned() + } + + pub(crate) fn cancellation_guard(&self) -> TraceAuctionCancellationGuard { + TraceAuctionCancellationGuard { + carry: self.clone(), + armed: true, + } + } + + pub(crate) fn bind_client_refs(&self, refs: &[Option]) { + let Ok(mut state) = self.state.lock() else { + return; + }; + if refs.len() != state.slot_refs.len() { + return; + } + let mut seen = HashSet::new(); + let mut duplicates = HashSet::new(); + for token in refs.iter().flatten() { + if !seen.insert(token.clone()) { + duplicates.insert(token.clone()); + } + } + // Reserve even duplicated client refs so a fallback can never masquerade + // as a client association that was deliberately declined. + let mut used = seen; + for (index, client) in refs.iter().enumerate() { + let token = + if let Some(token) = client.as_ref().filter(|token| !duplicates.contains(*token)) { + token.clone() + } else { + let mut token = state.slot_refs[index].clone(); + while !used.insert(token.clone()) { + token = TraceSlotRef::generate(); + } + token + }; + state.slot_refs[index] = token.clone(); + if let Some(slot) = state.observed.slots.get_mut(index) { + slot.slot_ref = token; + } + } + state.refresh_projection(); + } + + pub(crate) fn observe_delivery( + &self, + winners: &HashMap, + delivered: &HashSet, + ) { + let Ok(mut state) = self.state.lock() else { + return; + }; + for index in 0..state.observed.slots.len() { + let key = &state.slot_keys[index]; + let ambiguous = state + .buckets + .get(key) + .is_none_or(|bucket| bucket.accepted_instances > 1); + let winner = (!ambiguous).then(|| winners.get(key)).flatten(); + let candidate = if ambiguous { + TraceSlotCandidate::Unknown + } else if winner.is_some() { + if delivered.contains(key) { + TraceSlotCandidate::Selected + } else { + TraceSlotCandidate::SelectedUnrenderable + } + } else { + TraceSlotCandidate::NoCandidate + }; + let selected_size = winner.map(|bid| [bid.width, bid.height]); + let slot = &mut state.observed.slots[index]; + slot.candidate = candidate; + slot.selected_creative_size = selected_size; + } + // Delivery is a later observation. It cannot remint identity or rewrite + // terminal status/time; pure projection also avoids accumulating omissions. + state.refresh_projection(); + } + + pub(crate) fn launch_provider(&self, role: TraceProviderRole) -> TraceProviderObservation { + let started_at = Instant::now(); + let mut provider = TraceProviderObservation { + number: None, + started_at, + }; + let Ok(mut state) = self.state.lock() else { + return provider; + }; + if state.terminal { + return provider; + } + let Some(number) = state.provider_count.checked_add(1) else { + state.invalid = true; + return provider; + }; + state.provider_count = number; + state.invalid |= number > usize::from(u16::MAX); + provider.number = Some(number); + if number <= PROVIDER_LIMIT { + state.provider_starts.push(started_at); + state + .observed + .provider_calls + .push(ObservedTraceProviderCall { + provider_number: number, + role, + status: TraceProviderStatus::Pending, + response_time: None, + returned_bid_count: 0, + }); + } else { + state.observed.truncation.omitted_provider_calls = number - PROVIDER_LIMIT; + } + provider + } + + pub(crate) fn observe_response( + &self, + provider: TraceProviderObservation, + response: &AuctionResponse, + ) { + let status = match response.status { + BidStatus::Success if response.bids.is_empty() => TraceProviderStatus::NoBid, + BidStatus::Success => TraceProviderStatus::Success, + BidStatus::NoBid => TraceProviderStatus::NoBid, + BidStatus::Error => TraceProviderStatus::Error, + BidStatus::Pending => TraceProviderStatus::Pending, + }; + self.record_provider_outcome( + provider, + status, + response.bids.len(), + response.bids.iter().map(|bid| bid.slot_id.as_str()), + ); + } + + pub(crate) fn observe_failure(&self, provider: TraceProviderObservation) { + self.record_provider_outcome(provider, TraceProviderStatus::Error, 0, std::iter::empty()); + } + + pub(crate) fn observe_collection_failure(&self) { + if let Ok(mut state) = self.state.lock() + && !state.terminal + { + state.collection_failed = true; + } + } + + // Consume the private observation so a completion cannot reuse its launch. + #[allow(clippy::needless_pass_by_value)] + fn record_provider_outcome<'a>( + &self, + provider: TraceProviderObservation, + status: TraceProviderStatus, + returned_bid_count: usize, + slot_ids: impl Iterator, + ) { + let Some(number) = provider.number else { + return; + }; + let Ok(mut state) = self.state.lock() else { + return; + }; + if state.terminal { + return; + } + state.invalid |= returned_bid_count > usize::from(u16::MAX); + state.provider_failed |= status == TraceProviderStatus::Error; + if let Some(observed) = state.observed.provider_calls.get_mut(number - 1) { + observed.status = status; + observed.response_time = Some(provider.started_at.elapsed()); + observed.returned_bid_count = returned_bid_count; + } + // Only accepted routing keys enter this bounded private table. Tail + // counts remain required source facts even when details are omitted. + for key in slot_ids { + if let Some(bucket) = state.buckets.get_mut(key) { + if let Some(count) = bucket.returned_bid_count.checked_add(1) { + bucket.returned_bid_count = count; + } else { + state.invalid = true; + } + } + } + } + + pub(crate) fn finish( + &self, + status: TraceAuctionTerminalStatus, + reason: Option, + ) { + let Ok(mut state) = self.state.lock() else { + return; + }; + if state.terminal { + return; + } + state.terminal = true; + state.observed.diagnostic_auction_id = self.token.clone(); + state.observed.terminal_status = + if status == TraceAuctionTerminalStatus::Completed && state.collection_failed { + TraceAuctionTerminalStatus::ExecutionFailed + } else { + status + }; + state.observed.terminal_reason = + if status == TraceAuctionTerminalStatus::Completed && state.collection_failed { + Some(TraceAuctionTerminalReason::CollectionFailed) + } else { + reason + }; + state.observed.total_time = Some(state.started_at.elapsed()); + for index in 0..state.observed.slots.len() { + let Some(bucket) = state.buckets.get(&state.slot_keys[index]) else { + continue; + }; + let count = usize::from(bucket.returned_bid_count); + let ambiguous = bucket.accepted_instances > 1; + let slot = &mut state.observed.slots[index]; + slot.returned_bid_count = count; + slot.candidate = if ambiguous || count > 0 { + TraceSlotCandidate::Unknown + } else { + TraceSlotCandidate::NoCandidate + }; + } + if status == TraceAuctionTerminalStatus::Abandoned { + for index in 0..state.observed.provider_calls.len() { + let duration = state.provider_starts[index].elapsed(); + let provider = &mut state.observed.provider_calls[index]; + if provider.status == TraceProviderStatus::Pending { + provider.status = TraceProviderStatus::Abandoned; + provider.response_time = Some(duration); + } + } + } + state.refresh_projection(); + } + + pub(crate) fn finish_dispatch_failed(&self, no_provider_launched: bool) { + let reason = if no_provider_launched { + TraceAuctionTerminalReason::NoProviderLaunched + } else if self.state.lock().is_ok_and(|state| state.provider_failed) { + TraceAuctionTerminalReason::ProviderExecutionFailed + } else { + TraceAuctionTerminalReason::Unknown + }; + self.finish(TraceAuctionTerminalStatus::DispatchFailed, Some(reason)); + } + + pub(crate) fn transport(&self) -> Option { + match self.state.lock() { + Ok(state) => state.transport.clone(), + Err(_) => Some(TraceAuctionTransportV1::unavailable()), + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use serde_json::{Value, json}; + + use crate::auction::types::{AdFormat, AdSlot, MediaType}; + + use super::*; + + fn slot(id: &str) -> AdSlot { + AdSlot { + id: id.to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + } + } + + fn capture(slots: &[AdSlot]) -> TraceAuctionCarry { + TraceAuctionCarry::capture_if_enabled(true, TraceAuctionSource::AuctionApi, slots) + .expect("should capture a gated auction") + } + + fn transport(carry: &TraceAuctionCarry) -> Value { + serde_json::to_value( + carry + .transport() + .expect("should project a terminal auction"), + ) + .expect("should serialize the bounded transport") + } + + #[test] + fn trace_auction_identity_is_stable_across_terminal_outcomes() { + for (status, reason) in [ + (TraceAuctionTerminalStatus::Completed, None), + ( + TraceAuctionTerminalStatus::Skipped, + Some(TraceAuctionTerminalReason::PolicySkipped), + ), + ( + TraceAuctionTerminalStatus::Skipped, + Some(TraceAuctionTerminalReason::NoEligibleSlots), + ), + ( + TraceAuctionTerminalStatus::DispatchFailed, + Some(TraceAuctionTerminalReason::NoProviderLaunched), + ), + ( + TraceAuctionTerminalStatus::ExecutionFailed, + Some(TraceAuctionTerminalReason::ProviderExecutionFailed), + ), + ( + TraceAuctionTerminalStatus::Abandoned, + Some(TraceAuctionTerminalReason::Unknown), + ), + ] { + let carry = capture(&[slot("example-slot")]); + assert!( + carry.slot_ref(0).is_some(), + "should retain the definitive accepted occurrence reference" + ); + let token = carry.token(); + let outer = carry.clone(); + assert!( + carry.transport().is_none(), + "should not invent a pending terminal result" + ); + + carry.finish(status, reason); + outer.finish( + TraceAuctionTerminalStatus::Abandoned, + Some(TraceAuctionTerminalReason::Unknown), + ); + + assert_eq!(outer.token(), token, "should retain the pre-dispatch token"); + let value = transport(&outer); + assert_eq!( + value["evidence"]["diagnostic_auction_id"], + token.as_str(), + "should use the same public token" + ); + assert_eq!( + value["evidence"]["terminal_status"], + json!(status), + "should terminalize idempotently" + ); + assert_eq!( + value["evidence"]["terminal_reason"], + json!(reason), + "should preserve the directly observed reason" + ); + } + } + + #[test] + fn trace_auction_disabled_allocates_no_carry() { + assert!( + TraceAuctionCarry::capture_if_enabled( + false, + TraceAuctionSource::AuctionApi, + &[slot("example-slot")] + ) + .is_none(), + "should not allocate trace state when the frozen gate is false" + ); + } + + #[test] + fn trace_auction_empty_definitive_slots_terminalize_without_dispatch() { + let carry = capture(&[]); + assert_eq!( + transport(&carry)["evidence"]["terminal_reason"], + "no_eligible_slots", + "should retain no-slot facts without fabricating telemetry or dispatch" + ); + } + + #[test] + fn trace_auction_provider_numbers_follow_launch_order() { + let carry = capture(&[slot("example-slot")]); + let first = carry.launch_provider(TraceProviderRole::Bidder); + let second = carry.launch_provider(TraceProviderRole::Bidder); + let third = carry.launch_provider(TraceProviderRole::Mediator); + + carry.record_provider_outcome(second, TraceProviderStatus::NoBid, 0, std::iter::empty()); + carry.record_provider_outcome( + third, + TraceProviderStatus::Success, + 2, + std::iter::repeat_n("example-slot", 2), + ); + carry.record_provider_outcome(first, TraceProviderStatus::Error, 0, std::iter::empty()); + carry.finish(TraceAuctionTerminalStatus::Completed, None); + + let value = transport(&carry); + let providers = value["evidence"]["provider_calls"] + .as_array() + .expect("should retain provider facts"); + assert_eq!( + providers + .iter() + .map(|provider| provider["provider_number"].clone()) + .collect::>(), + vec![json!(1), json!(2), json!(3)], + "should number actual launches rather than completions" + ); + assert_eq!( + providers[0]["status"], "error", + "should retain the first call's disposition" + ); + assert_eq!( + providers[1]["status"], "no_bid", + "should retain a zero-bid response" + ); + assert_eq!( + providers[2]["role"], "mediator", + "should distinguish mediation without an identity" + ); + assert_eq!( + value["evidence"]["slots"][0]["returned_bid_count"], 2, + "should count actual returned records" + ); + } + + #[test] + fn trace_auction_checks_tail_bucket_counts_before_prefix_projection() { + let slots = (0..65) + .map(|index| slot(&format!("example-slot-{index}"))) + .collect::>(); + let carry = capture(&slots); + for _ in 0..4 { + let provider = carry.launch_provider(TraceProviderRole::Bidder); + carry.record_provider_outcome( + provider, + TraceProviderStatus::Success, + 20_000, + std::iter::repeat_n("example-slot-64", 20_000), + ); + } + carry.finish(TraceAuctionTerminalStatus::Completed, None); + assert_eq!( + transport(&carry), + json!({"schema_version":1,"unavailable_reason":"evidence_projection_failed"}), + "should reject a required count overflow outside the retained prefix" + ); + } + + #[test] + fn trace_auction_retains_valid_disjoint_tail_counts_without_global_saturation() { + let slots = (0..66) + .map(|index| slot(&format!("example-slot-{index}"))) + .collect::>(); + let carry = capture(&slots); + for id in ["example-slot-64", "example-slot-65"] { + let provider = carry.launch_provider(TraceProviderRole::Bidder); + carry.record_provider_outcome( + provider, + TraceProviderStatus::Success, + usize::from(u16::MAX), + std::iter::repeat_n(id, usize::from(u16::MAX)), + ); + } + carry.finish(TraceAuctionTerminalStatus::Completed, None); + let value = transport(&carry); + assert_eq!( + value["evidence"]["slots"] + .as_array() + .expect("should retain the prefix") + .len(), + 64, + "should preserve the public slot bound" + ); + assert_eq!( + value["evidence"]["truncation"]["omitted_slots"], 2, + "should count omitted definitive slots exactly" + ); + assert_eq!( + value["evidence"]["provider_calls"][0]["returned_bid_count"], + u16::MAX, + "should retain valid per-provider counts without global clamping" + ); + } + + #[test] + fn trace_auction_duplicate_keys_share_non_disjoint_counts_and_unknown_candidates() { + let carry = capture(&[slot("example-duplicate"), slot("example-duplicate")]); + let provider = carry.launch_provider(TraceProviderRole::Bidder); + carry.record_provider_outcome( + provider, + TraceProviderStatus::Success, + 1, + std::iter::once("example-duplicate"), + ); + carry.finish(TraceAuctionTerminalStatus::Completed, None); + let value = transport(&carry); + let slots = value["evidence"]["slots"] + .as_array() + .expect("should retain both definitive slots"); + assert_ne!( + slots[0]["slot_ref"], slots[1]["slot_ref"], + "should retain distinct auction-local opaque refs" + ); + for slot in slots { + assert_eq!( + slot["returned_bid_count"], 1, + "should expose the shared bucket without splitting it" + ); + assert_eq!( + slot["candidate"], "unknown", + "should not infer a duplicate-key winner" + ); + } + } + + #[test] + fn trace_auction_abandonment_retains_launch_order_and_is_terminal() { + let carry = capture(&[slot("example-slot")]); + let _first = carry.launch_provider(TraceProviderRole::Bidder); + let _second = carry.launch_provider(TraceProviderRole::Mediator); + carry.finish( + TraceAuctionTerminalStatus::Abandoned, + Some(TraceAuctionTerminalReason::Unknown), + ); + carry.finish(TraceAuctionTerminalStatus::Completed, None); + let value = transport(&carry); + assert_eq!( + value["evidence"]["terminal_status"], "abandoned", + "should not overwrite abandoned facts" + ); + assert_eq!( + value["evidence"]["provider_calls"][0]["status"], "abandoned", + "should synchronously terminalize outstanding calls" + ); + assert_eq!( + value["evidence"]["provider_calls"][1]["provider_number"], 2, + "should retain mediator launch order" + ); + } + fn selected_bid(id: &str) -> Bid { + serde_json::from_value(json!({"slot_id":id,"price":1.0,"currency":"USD","creative":"
Example creative
","bidder":"fictional-bidder","width":300,"height":250,"metadata":{}})).expect("should build a fictional selected bid") + } + + #[test] + fn trace_slot_conversion_client_refs_echo_only_unique_accepted_tokens() { + let first = TraceSlotRef::parse("ts-slot-00000000-0000-4000-8000-000000000001") + .expect("should parse the first client ref"); + let repeated = TraceSlotRef::parse("ts-slot-00000000-0000-4000-8000-000000000002") + .expect("should parse the repeated client ref"); + let carry = capture(&[ + slot("first"), + slot("duplicate"), + slot("duplicate"), + slot("missing"), + ]); + + carry.bind_client_refs(&[ + Some(first.clone()), + Some(repeated.clone()), + Some(repeated.clone()), + None, + ]); + carry.finish(TraceAuctionTerminalStatus::Completed, None); + + let value = transport(&carry); + let slots = value["evidence"]["slots"] + .as_array() + .expect("should retain accepted slots"); + assert_eq!( + slots[0]["slot_ref"], + first.as_str(), + "should echo the exact unique accepted client ref" + ); + for slot in &slots[1..] { + assert_ne!( + slot["slot_ref"], + repeated.as_str(), + "should replace every repeated accepted token with a fresh server ref" + ); + } + assert_eq!( + slots + .iter() + .map(|slot| slot["slot_ref"] + .as_str() + .expect("should retain opaque string refs")) + .collect::>() + .len(), + 4, + "should keep all accepted occurrence refs distinct" + ); + } + + #[test] + fn trace_slot_conversion_delivery_reprojects_without_rewriting_terminal_facts() { + let carry = capture(&[ + slot("selected"), + slot("dropped"), + slot("lost"), + slot("duplicate"), + slot("duplicate"), + slot("invalid-size"), + ]); + let mut invalid_size = selected_bid("invalid-size"); + invalid_size.width = 0; + let bids = vec![ + selected_bid("selected"), + selected_bid("dropped"), + selected_bid("lost"), + selected_bid("duplicate"), + invalid_size, + ]; + let provider = carry.launch_provider(TraceProviderRole::Bidder); + carry.observe_response( + provider, + &AuctionResponse::success("fictional-provider", bids.clone(), 0), + ); + carry.finish(TraceAuctionTerminalStatus::Completed, None); + let before = transport(&carry); + let winners = bids + .into_iter() + .filter(|bid| bid.slot_id != "lost") + .map(|bid| (bid.slot_id.clone(), bid)) + .collect(); + + carry.observe_delivery( + &winners, + &HashSet::from([ + "selected".to_string(), + "duplicate".to_string(), + "invalid-size".to_string(), + ]), + ); + + let after = transport(&carry); + assert_eq!( + after["evidence"]["total_time_ms"], before["evidence"]["total_time_ms"], + "should preserve immutable terminal timing" + ); + assert_eq!( + after["evidence"]["terminal_status"], "completed", + "should preserve the observed terminal status" + ); + let slots = after["evidence"]["slots"] + .as_array() + .expect("should retain definitive slots"); + assert_eq!( + slots[0]["candidate"], "selected", + "should require actual delivery inclusion for selected" + ); + assert_eq!( + slots[0]["selected_creative_size"], + json!([300, 250]), + "should copy only the selected dimensions" + ); + assert_eq!( + slots[1]["candidate"], "selected_unrenderable", + "should preserve a winner omitted by response conversion" + ); + assert_eq!( + slots[2]["candidate"], "no_candidate", + "should preserve actual no-winner selection despite a returned bid" + ); + for slot in &slots[3..5] { + assert_eq!( + slot["candidate"], "unknown", + "should not infer an instance disposition from a duplicate routing key" + ); + assert!( + slot.get("selected_creative_size").is_none(), + "should omit ambiguous instance dimensions" + ); + } + assert!( + slots[5].get("selected_creative_size").is_none(), + "should omit malformed optional dimensions" + ); + assert_eq!( + after["evidence"]["truncation"]["omitted_nested_values"], 1, + "should count the malformed optional size exactly once" + ); + carry.observe_delivery( + &winners, + &HashSet::from([ + "selected".to_string(), + "duplicate".to_string(), + "invalid-size".to_string(), + ]), + ); + assert_eq!( + transport(&carry), + after, + "should reproject without accumulating omission counts or rewriting terminal timing" + ); + } +} diff --git a/crates/trusted-server-core/src/trace/context.rs b/crates/trusted-server-core/src/trace/context.rs new file mode 100644 index 000000000..c2c7648dc --- /dev/null +++ b/crates/trusted-server-core/src/trace/context.rs @@ -0,0 +1,362 @@ +//! Allowlisted, bounded request context projection. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use chrono::{DateTime, Datelike as _, SecondsFormat, Timelike as _, Utc}; +use error_stack::Report; + +use crate::platform::{ClientInfo, GeoInfo}; + +use super::types::{TraceCookies, TraceNetwork, TraceRequestContextV1}; + +/// Bounded failure to represent the supplied capture clock in version one. +#[derive(Debug, derive_more::Display)] +#[display("invalid trace capture clock")] +pub struct ContextProjectionError; + +impl core::error::Error for ContextProjectionError {} + +/// Project frozen cookie health and trusted read-only network facts. +/// +/// Full IPs, fingerprints, city, coordinates, and identity services never enter +/// the returned context. Unsupported optional facts remain absent. Invalid +/// platform strings are omitted with fixed field categories and no values. +/// +/// # Errors +/// +/// Returns a bounded error for a capture clock outside four-digit UTC years or +/// with leap-second serialization, which the version-one schema does not accept. +/// +/// # Examples +/// +/// ``` +/// use chrono::Utc; +/// use http::HeaderMap; +/// use trusted_server_core::platform::ClientInfo; +/// use trusted_server_core::trace::{inspect_cookies, project_request_context}; +/// let cookies = inspect_cookies(&HeaderMap::new(), None); +/// let context = project_request_context(&ClientInfo::default(), None, &cookies, Utc::now())?; +/// assert!(!context.cookies().observed_active()); +/// # Ok::<(), error_stack::Report>(()) +/// ``` +/// +/// # Performance +/// +/// Projection copies only bounded optional strings and the fixed cookie health. +pub fn project_request_context( + client: &ClientInfo, + geo: Option<&GeoInfo>, + cookies: &TraceCookies, + captured_at: DateTime, +) -> Result> { + if !(0..=9999).contains(&captured_at.year()) || captured_at.nanosecond() >= 1_000_000_000 { + return Err(Report::new(ContextProjectionError)); + } + Ok(TraceRequestContextV1 { + schema_version: 1, + captured_at: captured_at.to_rfc3339_opts(SecondsFormat::Millis, true), + network: TraceNetwork { + masked_client_ip: client.client_ip.map(mask_ip), + country: bounded_string( + geo.map(|geo| geo.country.as_str()), + 2, + true, + "trace_network_country_omitted", + ), + region: bounded_string( + geo.and_then(|geo| geo.region.as_deref()), + 32, + false, + "trace_network_region_omitted", + ), + asn: geo.and_then(|geo| geo.asn), + tls_protocol: bounded_string( + client.tls_protocol.as_deref(), + 32, + false, + "trace_network_tls_protocol_omitted", + ), + tls_cipher: bounded_string( + client.tls_cipher.as_deref(), + 32, + false, + "trace_network_tls_cipher_omitted", + ), + edge_hostname: bounded_string( + client.server_hostname.as_deref(), + 128, + false, + "trace_network_edge_hostname_omitted", + ), + edge_region: bounded_string( + client.server_region.as_deref(), + 128, + false, + "trace_network_edge_region_omitted", + ), + }, + cookies: *cookies, + }) +} + +fn mask_ip(ip: IpAddr) -> String { + match ip { + IpAddr::V4(ip) => { + let [first, second, third, _] = ip.octets(); + format!("{}/24", Ipv4Addr::new(first, second, third, 0)) + } + IpAddr::V6(ip) => { + let [first, second, third, ..] = ip.segments(); + format!("{}/48", Ipv6Addr::new(first, second, third, 0, 0, 0, 0, 0)) + } + } +} + +fn bounded_string( + value: Option<&str>, + limit: usize, + ascii: bool, + category: &'static str, +) -> Option { + let value = value?; + if value.len() > limit || (ascii && !value.is_ascii()) || value.chars().any(|character| { + character.is_control() + || matches!(character, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') + }) { + log::warn!("{category}"); + None + } else { + Some(value.to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use chrono::{TimeZone as _, Utc}; + use http::{HeaderMap, HeaderValue, header}; + use serde_json::json; + + use crate::platform::{ClientInfo, GeoInfo}; + use crate::trace::cookies::inspect_cookies; + + fn captured_at() -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 10, 5, 12, 34, 56) + .single() + .expect("should construct a valid fixed UTC capture clock") + } + + fn geo() -> GeoInfo { + GeoInfo { + city: "fictional-private-city".to_owned(), + country: "US".to_owned(), + continent: "fictional-private-continent".to_owned(), + latitude: 12.345, + longitude: 56.789, + metro_code: 123, + region: Some("CA".to_owned()), + asn: Some(64512), + } + } + + #[test] + fn trace_context_masks_and_omits_forbidden_fields() { + let cookies = inspect_cookies(&HeaderMap::new(), None); + for (ip, mask) in [ + (IpAddr::V4(Ipv4Addr::new(192, 0, 2, 129)), "192.0.2.0/24"), + ( + IpAddr::V6( + "2001:db8:1234:5678::1" + .parse::() + .expect("should parse the documentation IPv6 address"), + ), + "2001:db8:1234::/48", + ), + (IpAddr::V6(Ipv6Addr::LOCALHOST), "::/48"), + ] { + let client = ClientInfo { + client_ip: Some(ip), + tls_protocol: Some("TLSv1.3".to_owned()), + tls_cipher: Some("TLS_AES_128_GCM_SHA256".to_owned()), + tls_ja4: Some("fictional-private-ja4".to_owned()), + h2_fingerprint: Some("fictional-private-h2".to_owned()), + server_hostname: Some("edge.example".to_owned()), + server_region: Some("example-region".to_owned()), + }; + let context = project_request_context(&client, Some(&geo()), &cookies, captured_at()) + .expect("should project valid read-only request facts"); + let actual = serde_json::to_value(context).expect("should serialize the context"); + assert_eq!( + actual, + json!({ + "schema_version": 1, + "captured_at": "2026-10-05T12:34:56.000Z", + "network": { + "masked_client_ip": mask, + "country": "US", + "region": "CA", + "asn": 64512, + "tls_protocol": "TLSv1.3", + "tls_cipher": "TLS_AES_128_GCM_SHA256", + "edge_hostname": "edge.example", + "edge_region": "example-region" + }, + "cookies": cookies + }), + "should emit only the exact network and context allowlist" + ); + let serialized = actual.to_string(); + assert!( + !serialized.contains("fictional-private") + && !serialized.contains("12.345") + && !serialized.contains("56.789") + && !serialized.contains(&ip.to_string()), + "should omit full IP, fingerprints, city and coordinates" + ); + } + } + + #[test] + fn missing_optional_facts_stay_absent_without_fallback_metadata() { + let cookies = inspect_cookies(&HeaderMap::new(), None); + let actual = serde_json::to_value( + project_request_context(&ClientInfo::default(), None, &cookies, captured_at()) + .expect("should project unavailable optional facts"), + ) + .expect("should serialize the context"); + assert_eq!( + actual["network"], + json!({}), + "should not invent platform HTTP, POP, geo or IP fallback facts" + ); + } + + #[test] + fn invalid_optional_strings_are_omitted_without_shortening() { + let cookies = inspect_cookies(&HeaderMap::new(), None); + for invalid in [ + "x".repeat(129), + "fictional\nprivate".to_owned(), + "fictional\u{0085}private".to_owned(), + "fictional\u{061c}private".to_owned(), + "fictional\u{202e}private".to_owned(), + "fictional\u{2069}private".to_owned(), + ] { + let client = ClientInfo { + tls_protocol: Some(invalid.clone()), + tls_cipher: Some(invalid.clone()), + server_hostname: Some(invalid.clone()), + server_region: Some(invalid.clone()), + ..ClientInfo::default() + }; + let mut geo = geo(); + geo.country = invalid.clone(); + geo.region = Some(invalid); + let actual = serde_json::to_value( + project_request_context(&client, Some(&geo), &cookies, captured_at()) + .expect("should omit invalid optional fields"), + ) + .expect("should serialize the context"); + assert_eq!( + actual["network"], + json!({"asn": 64512}), + "should omit failing fields instead of shortening or exposing values" + ); + } + } + + #[test] + fn optional_string_bounds_measure_utf8_bytes() { + let cookies = inspect_cookies(&HeaderMap::new(), None); + for oversized in [false, true] { + let suffix = if oversized { "x" } else { "" }; + let short = format!("{}{suffix}", "é".repeat(16)); + let long = format!("{}{suffix}", "é".repeat(64)); + let client = ClientInfo { + tls_protocol: Some(short.clone()), + tls_cipher: Some(short.clone()), + server_hostname: Some(long.clone()), + server_region: Some(long.clone()), + ..ClientInfo::default() + }; + let mut geo = geo(); + geo.country = if oversized { + "é".to_owned() + } else { + "US".to_owned() + }; + geo.region = Some(short.clone()); + let actual = serde_json::to_value( + project_request_context(&client, Some(&geo), &cookies, captured_at()) + .expect("should apply per-field UTF-8 bounds"), + ) + .expect("should serialize the context"); + if oversized { + assert_eq!( + actual["network"], + json!({"asn": 64512}), + "should omit invalid ASCII or oversized UTF-8 fields" + ); + } else { + assert_eq!( + actual["network"], + json!({"asn": 64512, "country": "US", "region": short, "tls_protocol": short, "tls_cipher": short, "edge_hostname": long, "edge_region": long}), + "should retain values exactly at their byte limit" + ); + } + } + } + + #[test] + fn context_uses_frozen_cookie_inspection_after_header_sanitization() { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_static("__Host-ts-console=1; __Host-ts-console=invalid-example"), + ); + let cookies = inspect_cookies(&headers, None); + headers.remove(header::COOKIE); + let actual = serde_json::to_value( + project_request_context(&ClientInfo::default(), None, &cookies, captured_at()) + .expect("should project the frozen health"), + ) + .expect("should serialize the context"); + assert_eq!( + actual["cookies"]["diagnostics_session"], + json!({"source":"request", "state":"duplicate", "detail":"multiple_values"}), + "should never reread sanitized Cookie fields" + ); + } + + #[test] + fn invalid_capture_clock_representation_is_rejected() { + let cookies = inspect_cookies(&HeaderMap::new(), None); + for year in [-1, 10000] { + let clock = Utc + .with_ymd_and_hms(year, 1, 1, 0, 0, 0) + .single() + .expect("should construct a supported chrono extended year"); + assert!( + project_request_context(&ClientInfo::default(), None, &cookies, clock).is_err(), + "should reject years outside the exact four-digit UTC schema" + ); + } + } + + #[test] + fn leap_second_clock_is_rejected_before_schema_serialization() { + let cookies = inspect_cookies(&HeaderMap::new(), None); + let clock = captured_at() + .with_second(59) + .expect("should set the last ordinary second") + .with_nanosecond(1_000_000_000) + .expect("should construct chrono's leap-second representation"); + assert!( + project_request_context(&ClientInfo::default(), None, &cookies, clock).is_err(), + "should reject second60 rather than serialize an invalid UTC schema timestamp" + ); + } +} diff --git a/crates/trusted-server-core/src/trace/cookies.rs b/crates/trusted-server-core/src/trace/cookies.rs new file mode 100644 index 000000000..b1c14f0fb --- /dev/null +++ b/crates/trusted-server-core/src/trace/cookies.rs @@ -0,0 +1,680 @@ +//! Read-only health inspection of runtime-visible Cookie fields. + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use edgezero_core::request::{Preservation, RequestIngress}; +use http::{HeaderMap, header}; +use serde::Deserialize; + +use crate::ec::generation::is_valid_ec_id; + +use super::types::{ + CookieHealth, InvalidCookieDetail, TraceCookies, UnavailableCookieDetail, ValidCookieDetail, +}; + +const MAX_COOKIE_HEADER_BYTES: usize = 16 * 1024; +const COOKIE_NAMES: [&str; 4] = ["ts-ec", "ts-eids", "ts-tester", "__Host-ts-console"]; +const VALUE_LIMITS: [usize; 4] = [512, 8 * 1024, 16, 16]; + +/// Freeze redacted health from complete runtime-visible Cookie fields. +/// +/// Aggregate size, actual UTF-8, then preservation ambiguity take precedence +/// over cookie-specific results. Missing fidelity does not reject readable +/// marker-free fields. This never mutates headers or accesses runtime services. +/// +/// # Examples +/// +/// ``` +/// use http::{HeaderMap, HeaderValue, header}; +/// let mut headers = HeaderMap::new(); +/// headers.insert(header::COOKIE, HeaderValue::from_static("__Host-ts-console=1")); +/// let cookies = trusted_server_core::trace::inspect_cookies(&headers, None); +/// assert!(cookies.observed_active()); +/// ``` +/// +/// # Performance +/// +/// Inspection is linear in visible bytes, bounded to 16 KiB before parsing. +/// Only the existing bounded EID parser allocates while validating values. +#[must_use] +pub fn inspect_cookies(headers: &HeaderMap, ingress: Option<&RequestIngress>) -> TraceCookies { + let fields = headers.get_all(header::COOKIE); + let total = fields.iter().fold(0usize, |total, value| { + total.saturating_add(value.as_bytes().len()) + }); + if total > MAX_COOKIE_HEADER_BYTES { + return unavailable(UnavailableCookieDetail::HeaderTooLarge); + } + if fields + .iter() + .any(|value| core::str::from_utf8(value.as_bytes()).is_err()) + { + return unavailable(UnavailableCookieDetail::HeaderNotUtf8); + } + let fidelity = ingress.map(|ingress| ingress.header_fidelity(&header::COOKIE)); + let octets_preserved = + fidelity.is_some_and(|fidelity| fidelity.octets() == Preservation::Preserved); + let multiplicity_preserved = + fidelity.is_some_and(|fidelity| fidelity.field_multiplicity() == Preservation::Preserved); + let text = || { + fields + .iter() + .filter_map(|value| core::str::from_utf8(value.as_bytes()).ok()) + }; + if text().any(|value| { + (!octets_preserved && value.contains('\u{fffd}')) + || (!multiplicity_preserved && comma_may_fold_reserved(value)) + }) { + return unavailable(UnavailableCookieDetail::RuntimeHeaderAmbiguous); + } + let mut counts = [0usize; 4]; + let mut first_values = [None; 4]; + for field in text() { + for segment in field.split(';') { + let segment = segment.trim_matches(|character: char| character.is_ascii_whitespace()); + let (name, value) = match segment.split_once('=') { + Some((name, value)) => (name, Some(value)), + None => ( + segment.split_ascii_whitespace().next().unwrap_or_default(), + None, + ), + }; + let Some(index) = COOKIE_NAMES.iter().position(|reserved| *reserved == name) else { + continue; + }; + if counts[index] == 0 { + first_values[index] = value; + } + counts[index] += 1; + } + } + TraceCookies::new(std::array::from_fn(|index| match counts[index] { + 0 => CookieHealth::absent(), + 1 => validate_value(index, first_values[index]), + _ => CookieHealth::duplicate(), + })) +} + +/// Whether a comma could be a folded field boundary that changes a reserved +/// result: a comma inside a reserved cookie's own segment could truncate its +/// value, and a comma directly before a reserved name could hide an occurrence +/// inside another cookie's value. Commas inside unrelated values, such as +/// JSON-valued third-party cookies, can do neither. +fn comma_may_fold_reserved(field: &str) -> bool { + field.split(';').any(|segment| { + let mut pieces = segment.split(','); + let head = pieces.next().unwrap_or_default(); + segment.contains(',') + && (starts_with_reserved_name(head) || pieces.any(starts_with_reserved_name)) + }) +} + +fn starts_with_reserved_name(piece: &str) -> bool { + let name = piece + .trim_start_matches(|character: char| character.is_ascii_whitespace()) + .split(|character: char| character == '=' || character.is_ascii_whitespace()) + .next() + .unwrap_or_default(); + COOKIE_NAMES.contains(&name) +} + +fn unavailable(detail: UnavailableCookieDetail) -> TraceCookies { + TraceCookies::new([CookieHealth::unavailable(detail); 4]) +} + +/// A `ts-eids` entry as TSJS writes it. Legacy flat entries also match, +/// because unknown fields are ignored and `uids` defaults to empty. +#[derive(Deserialize)] +struct EidsCookieEntry { + #[serde(rename = "source")] + _source: String, + #[serde(default, rename = "uids")] + _uids: Vec, +} + +#[derive(Deserialize)] +struct EidsCookieUid { + #[serde(rename = "id")] + _id: String, + #[serde(default, rename = "atype")] + _atype: Option, +} + +/// Accept base64 JSON arrays of `ts-eids` entries; the server no longer +/// ingests this cookie, so the trace checks its format only. +fn is_valid_eids_cookie(value: &str) -> bool { + BASE64 + .decode(value) + .is_ok_and(|bytes| serde_json::from_slice::>(&bytes).is_ok()) +} + +fn validate_value(index: usize, value: Option<&str>) -> CookieHealth { + let Some(value) = value else { + return CookieHealth::invalid(InvalidCookieDetail::Malformed); + }; + if value.len() > VALUE_LIMITS[index] { + return CookieHealth::invalid(InvalidCookieDetail::Oversized); + } + let (valid, detail, invalid) = match index { + 0 => ( + is_valid_ec_id(value), + ValidCookieDetail::EcFormat, + InvalidCookieDetail::Malformed, + ), + 1 => ( + is_valid_eids_cookie(value), + ValidCookieDetail::EidsFormat, + InvalidCookieDetail::Malformed, + ), + 2 => ( + value == "true", + ValidCookieDetail::TesterValue, + InvalidCookieDetail::UnsupportedValue, + ), + _ => ( + value == "1", + ValidCookieDetail::DiagnosticsValue, + InvalidCookieDetail::UnsupportedValue, + ), + }; + if valid { + CookieHealth::valid(detail) + } else { + CookieHealth::invalid(invalid) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use base64::engine::general_purpose::STANDARD; + use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, Preservation, RequestIngress, TargetUnavailable, + }; + use http::{HeaderMap, HeaderValue, header}; + use serde_json::{Value, json}; + + const RESERVED: [(&str, &str, &str); 4] = [ + ("ts-ec", "ts_ec", "valid_ec_format"), + ("ts-eids", "ts_eids", "valid_eids_format"), + ("ts-tester", "ts_tester", "valid_tester_value"), + ( + "__Host-ts-console", + "diagnostics_session", + "valid_diagnostics_value", + ), + ]; + + fn headers(values: &[&[u8]]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for value in values { + headers.append( + header::COOKIE, + HeaderValue::from_bytes(value).expect("should construct visible Cookie bytes"), + ); + } + headers + } + + fn ingress( + common: HeaderFidelity, + overrides: Vec<(http::HeaderName, HeaderFidelity)>, + ) -> RequestIngress { + RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::NotExposed), + None, + common, + overrides, + ) + .expect("should construct distinct field fidelity overrides") + } + + fn fidelity(octets: Preservation, multiplicity: Preservation) -> HeaderFidelity { + HeaderFidelity::new( + octets, + multiplicity, + Preservation::Unavailable, + Preservation::Transformed, + ) + } + + fn inspect(headers: &HeaderMap, ingress: Option<&RequestIngress>) -> Value { + serde_json::to_value(inspect_cookies(headers, ingress)) + .expect("should serialize redacted cookie health") + } + + fn health(state: &str, detail: Option<&str>) -> Value { + let mut health = json!({"state": state, "source": "request"}); + if let Some(detail) = detail { + health["detail"] = json!(detail); + } + health + } + + fn assert_all(actual: &Value, state: &str, detail: Option<&str>) { + for (_, key, _) in RESERVED { + assert_eq!( + actual[key], + health(state, detail), + "should give {key} the aggregate or absent health result" + ); + } + assert_eq!( + actual + .as_object() + .expect("should serialize an object") + .len(), + 4, + "should serialize only the four reserved cookie names" + ); + } + + fn valid_values() -> [String; 4] { + [ + format!("{}.aB1234", "a".repeat(64)), + STANDARD.encode( + serde_json::to_vec(&json!([ + {"source": "identity.example", "uids": [{"id": "fictional-private-eid"}]} + ])) + .expect("should encode the fictional EID payload"), + ), + "true".to_owned(), + "1".to_owned(), + ] + } + + #[test] + fn valid_reserved_values_use_only_their_named_detail() { + for ((name, key, detail), value) in RESERVED.into_iter().zip(valid_values()) { + let value = format!("{name}={value}"); + let headers = headers(&[value.as_bytes()]); + let before = headers.clone(); + let actual = inspect(&headers, None); + assert_eq!( + actual[key], + health("present_valid", Some(detail)), + "should use the canonical validator for {name}" + ); + for (_, other, _) in RESERVED { + if other != key { + assert_eq!( + actual[other], + health("absent", None), + "should not infer another cookie" + ); + } + } + assert_eq!( + headers, before, + "should not sanitize or mutate incoming fields" + ); + let serialized = actual.to_string(); + assert!( + !serialized.contains("fictional-private-eid") + && !serialized.contains(&"a".repeat(64)), + "should serialize shape without values or IDs" + ); + } + } + + #[test] + fn observed_active_requires_exactly_one_valid_frozen_session() { + for (field, expected) in [ + ("__Host-ts-console=1", true), + ("__Host-ts-console=invalid-example", false), + ("__Host-ts-console", false), + ("__Host-ts-console=1; __Host-ts-console=1", false), + ("__Host-ts-console=1, unrelated=a", false), + ("__Host-ts-console=1; g_state={\"i_l\":0,\"i_ll\":1}", true), + ("ts-tester=true", false), + ("", false), + ] { + let cookies = inspect_cookies(&headers(&[field.as_bytes()]), None); + assert_eq!( + cookies.observed_active(), + expected, + "should derive activity only from frozen session health" + ); + } + } + + #[test] + fn duplicates_win_over_malformed_valid_and_oversized_values() { + for ((name, key, _), valid) in RESERVED.into_iter().zip(valid_values()) { + for invalid in [ + "".to_owned(), + "invalid-example".to_owned(), + "x".repeat(9000), + ] { + let first = format!("{name}={valid}"); + let second = format!("{name}={invalid}"); + for fields in [ + vec![first.clone(), second.clone()], + vec![format!("{first}; {second}")], + vec![format!("{name}; {first}")], + vec![second.clone(), first.clone()], + ] { + let fields: Vec<&[u8]> = fields.iter().map(String::as_bytes).collect(); + let actual = inspect(&headers(&fields), None); + assert_eq!( + actual[key], + health("duplicate", Some("multiple_values")), + "should count every exact occurrence before validating {name}" + ); + } + } + } + } + + #[test] + fn grammar_counts_malformed_reserved_tokens_and_keeps_values_complete() { + for (name, key, _) in RESERVED { + for segment in [name.to_owned(), format!("{name} invalid-example")] { + let actual = inspect(&headers(&[segment.as_bytes()]), None); + assert_eq!( + actual[key], + health("present_invalid", Some("malformed")), + "should count no-equals reserved tokens exactly" + ); + } + for segment in [ + format!("{name}-extra"), + format!("{name} =true"), + name.to_uppercase(), + ] { + let actual = inspect(&headers(&[segment.as_bytes()]), None); + assert_all(&actual, "absent", None); + } + } + let actual = inspect(&headers(&[b" bad pair; =invalid; unrelated==value; ts-tester=true=extra; __Host-ts-console=1=extra"]), None); + assert_eq!( + actual["ts_tester"], + health("present_invalid", Some("unsupported_value")), + "should retain additional equals in the tester value" + ); + assert_eq!( + actual["diagnostics_session"], + health("present_invalid", Some("unsupported_value")), + "should retain additional equals in the session value" + ); + let actual = inspect( + &headers(&[b"\t ts-tester=true ; __Host-ts-console= 1"]), + None, + ); + assert_eq!( + actual["ts_tester"], + health("present_valid", Some("valid_tester_value")), + "should trim only whole-segment ASCII whitespace" + ); + assert_eq!( + actual["diagnostics_session"], + health("present_invalid", Some("unsupported_value")), + "should not normalize value whitespace after equals" + ); + } + + #[test] + fn canonical_rejections_and_visible_per_value_caps_remain_independent() { + for ((name, key, _), limit) in RESERVED.into_iter().zip([512, 8192, 16, 16]) { + for (value, detail) in [ + ( + "x".repeat(limit), + if key == "ts_tester" || key == "diagnostics_session" { + "unsupported_value" + } else { + "malformed" + }, + ), + ("x".repeat(limit + 1), "oversized"), + ("é".repeat(limit / 2 + 1), "oversized"), + ] { + let field = format!("{name}={value}"); + let actual = inspect(&headers(&[field.as_bytes()]), None); + assert_eq!( + actual[key], + health("present_invalid", Some(detail)), + "should enforce the UTF-8 value byte limit for {name}" + ); + for (_, other, _) in RESERVED { + if other != key { + assert_eq!( + actual[other], + health("absent", None), + "should keep unrelated health independent" + ); + } + } + } + } + let uppercase = format!("ts-ec={}.aB1234", "A".repeat(64)); + let actual = inspect(&headers(&[uppercase.as_bytes()]), None); + assert_eq!( + actual["ts_ec"], + health("present_invalid", Some("malformed")), + "should reuse canonical lowercase EC validation" + ); + } + + #[test] + fn visible_aggregate_cap_precedes_actual_utf8_and_ambiguity() { + for length in [16384, 16385] { + let field = format!("unrelated={}", "x".repeat(length - "unrelated=".len())); + let actual = inspect(&headers(&[field.as_bytes()]), None); + if length == 16384 { + assert_all(&actual, "absent", None); + } else { + assert_all(&actual, "unavailable", Some("header_too_large")); + } + } + let first = "x".repeat(8192); + let second = "x".repeat(8192); + assert_all( + &inspect(&headers(&[first.as_bytes(), second.as_bytes()]), None), + "absent", + None, + ); + assert_all( + &inspect(&headers(&[first.as_bytes(), second.as_bytes(), b"x"]), None), + "unavailable", + Some("header_too_large"), + ); + let oversized = "x".repeat(16385); + assert_all( + &inspect(&headers(&[b"\xff", b",", oversized.as_bytes()]), None), + "unavailable", + Some("header_too_large"), + ); + assert_all( + &inspect(&headers(&[b"\xff", b",", b"__Host-ts-console=1"]), None), + "unavailable", + Some("header_not_utf8"), + ); + } + + #[test] + fn all_non_preserved_statuses_and_missing_metadata_allow_marker_free_fields() { + for status in [ + Preservation::Unknown, + Preservation::Transformed, + Preservation::Unavailable, + ] { + let metadata = ingress(fidelity(status, status), vec![]); + for metadata in [None, Some(&metadata)] { + assert_all(&inspect(&HeaderMap::new(), metadata), "absent", None); + let actual = inspect( + &headers(&["__Host-ts-console=1; unrelated=readable-é".as_bytes()]), + metadata, + ); + assert_eq!( + actual["diagnostics_session"], + health("present_valid", Some("valid_diagnostics_value")), + "should preserve ordinary activation without ambiguity markers" + ); + } + } + } + + #[test] + fn ambiguity_markers_anywhere_suppress_all_cookie_results_without_comma_splitting() { + for status in [ + Preservation::Unknown, + Preservation::Transformed, + Preservation::Unavailable, + ] { + let metadata = ingress(fidelity(status, status), vec![]); + for field in [ + "__Host-ts-console=1; unrelated=�", + "__Host-ts-console=1; unrelated=\"�\"", + "__Host-ts-console=1, __Host-ts-console=1", + "__Host-ts-console=1, unrelated=a", + "unrelated=a, __Host-ts-console=1", + "unrelated=a,__Host-ts-console=1", + "unrelated=a, ts-ec=b; __Host-ts-console=1", + "ts-eids=a,b; __Host-ts-console=1", + "__Host-ts-console,unrelated=a", + ] { + for metadata in [None, Some(&metadata)] { + assert_all( + &inspect(&headers(&[field.as_bytes()]), metadata), + "unavailable", + Some("runtime_header_ambiguous"), + ); + } + } + } + } + + #[test] + fn commas_inside_unrelated_values_keep_activation_without_comma_splitting() { + let metadata = ingress( + fidelity(Preservation::Unknown, Preservation::Unknown), + vec![], + ); + for field in [ + "g_state={\"i_l\":0,\"i_ll\":1700000000000}; __Host-ts-console=1", + "__Host-ts-console=1; unrelated=\"a,b\"", + "__Host-ts-console=1; malformed-unrelated,", + "unrelated=a, ts-ec-extra=b; __Host-ts-console=1", + "unrelated=a, , ; __Host-ts-console=1", + ] { + for metadata in [None, Some(&metadata)] { + let actual = inspect(&headers(&[field.as_bytes()]), metadata); + assert_eq!( + actual["diagnostics_session"], + health("present_valid", Some("valid_diagnostics_value")), + "should activate when no comma borders a reserved cookie: {field}" + ); + assert_eq!( + actual["ts_ec"], + health("absent", None), + "should never comma-split an unrelated value into a reserved cookie" + ); + } + } + } + + #[test] + fn cookie_specific_axes_are_independent_and_order_is_not_a_prerequisite() { + for (octets, multiplicity, field, expected) in [ + ( + Preservation::Preserved, + Preservation::Unknown, + "__Host-ts-console=1; unrelated=�", + "present_valid", + ), + ( + Preservation::Unknown, + Preservation::Preserved, + "unrelated=a, ts-tester=b; __Host-ts-console=1", + "present_valid", + ), + ( + Preservation::Unknown, + Preservation::Preserved, + "__Host-ts-console=1; unrelated=�", + "unavailable", + ), + ( + Preservation::Preserved, + Preservation::Unknown, + "unrelated=a, ts-tester=b; __Host-ts-console=1", + "unavailable", + ), + ] { + let metadata = ingress( + HeaderFidelity::default(), + vec![(header::COOKIE, fidelity(octets, multiplicity))], + ); + let actual = inspect(&headers(&[field.as_bytes()]), Some(&metadata)); + if expected == "unavailable" { + assert_all(&actual, "unavailable", Some("runtime_header_ambiguous")); + } else { + assert_eq!( + actual["diagnostics_session"], + health("present_valid", Some("valid_diagnostics_value")), + "should use only the relevant Cookie preservation axis" + ); + } + } + let preserved = fidelity(Preservation::Preserved, Preservation::Preserved); + let metadata = ingress( + preserved, + vec![(header::CONTENT_TYPE, HeaderFidelity::default())], + ); + let actual = inspect( + &headers(&["unrelated=é,�; __Host-ts-console=1".as_bytes()]), + Some(&metadata), + ); + assert_eq!( + actual["diagnostics_session"], + health("present_valid", Some("valid_diagnostics_value")), + "should permit preserved unrelated markers and valid other Unicode" + ); + let metadata = ingress( + HeaderFidelity::default(), + vec![(header::CONTENT_TYPE, preserved)], + ); + assert_all( + &inspect( + &headers(&["unrelated=�; __Host-ts-console=1".as_bytes()]), + Some(&metadata), + ), + "unavailable", + Some("runtime_header_ambiguous"), + ); + } + + #[test] + fn preserved_markers_follow_full_value_validation_and_exact_duplicate_counts() { + let metadata = ingress( + fidelity(Preservation::Preserved, Preservation::Preserved), + vec![], + ); + for (field, detail) in [ + ("__Host-ts-console=1, __Host-ts-console=1", "oversized"), + ("__Host-ts-console=1,x", "unsupported_value"), + ("__Host-ts-console=�", "unsupported_value"), + ] { + let actual = inspect(&headers(&[field.as_bytes()]), Some(&metadata)); + assert_eq!( + actual["diagnostics_session"], + health("present_invalid", Some(detail)), + "should never comma-split or select a partial session value" + ); + } + let actual = inspect( + &headers(&[b"__Host-ts-console=1", b"__Host-ts-console=1"]), + Some(&metadata), + ); + assert_eq!( + actual["diagnostics_session"], + health("duplicate", Some("multiple_values")), + "should count exact preserved fields independently of order fidelity" + ); + assert_all( + &inspect(&headers(&[b"unrelated=\xff"]), Some(&metadata)), + "unavailable", + Some("header_not_utf8"), + ); + } +} diff --git a/crates/trusted-server-core/src/trace/dispatch.rs b/crates/trusted-server-core/src/trace/dispatch.rs new file mode 100644 index 000000000..eaabc1bf7 --- /dev/null +++ b/crates/trusted-server-core/src/trace/dispatch.rs @@ -0,0 +1,499 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use edgezero_core::body::Body; +use edgezero_core::error::EdgeError; +use edgezero_core::request::RequestIngress; +use edgezero_core::router::PreDispatchHook; +use http::{Method, Request, Response, StatusCode}; +use trusted_server_js::trace_assets::trace_asset; + +use super::actions::action_response; +use super::routes::{TraceRoute, state_response}; +use super::shell::render_setup_shell; +use super::{TraceCookies, TracePreflight, inspect_cookies, preflight, project_request_context}; +use crate::forwarder::prepare_trusted_forwarder; +use crate::integrations::gpt_diagnostics::{ + GPT_DIAGNOSTICS_INTEGRATION_ID, GptDiagnosticsConfig, GptDiagnosticsRequestDecision, +}; +use crate::platform::{ClientInfo, GeoInfo}; +use crate::settings::Settings; + +/// Immutable incoming-cookie gate captured before ordinary request preparation. +#[derive(Clone, Copy, Debug)] +pub struct TraceCaptureGate { + cookies: TraceCookies, + base_active: bool, +} + +impl TraceCaptureGate { + /// Return cookie health frozen at the runtime-visible request boundary. + /// + /// # Examples + /// + /// ```ignore + /// let observed = gate.cookies().observed_active(); + /// ``` + #[must_use] + pub fn cookies(&self) -> &TraceCookies { + &self.cookies + } + + /// Whether the incoming session permits request-scoped trace evidence. + /// + /// # Examples + /// + /// ```ignore + /// if gate.base_active() { capture_optional_evidence(); } + /// ``` + #[must_use] + pub fn base_active(&self) -> bool { + self.base_active + } + + /// Combine frozen session validity with the existing navigation decision. + /// + /// # Examples + /// + /// ```ignore + /// let eligible = gate.document_eligible(&diagnostics_decision); + /// ``` + #[must_use] + pub fn document_eligible(&self, decision: &GptDiagnosticsRequestDecision) -> bool { + self.base_active && decision.active() + } +} + +/// Read-only platform facts supplied only for an authenticated setup GET. +pub struct TraceMetadata { + /// Trusted client connection metadata. + pub client_info: ClientInfo, + /// Optional read-only geolocation result. + pub geo: Option, +} + +type MetadataSupplier = dyn Fn(&Request) -> TraceMetadata + Send + Sync; + +/// Intercept the trace namespace before ordinary router lookup and middleware. +pub struct TracePreDispatchHook { + settings: Arc, + metadata: Arc, +} + +impl TracePreDispatchHook { + /// Capture application settings and a lazy read-only metadata supplier. + /// + /// # Examples + /// + /// ```ignore + /// let hook = TracePreDispatchHook::new(settings, Arc::new(read_only_metadata)); + /// ``` + /// + /// # Performance + /// + /// Metadata is requested only for authenticated setup-page GET requests. + #[must_use] + pub fn new(settings: Arc, metadata: Arc) -> Self { + Self { settings, metadata } + } +} + +#[async_trait(?Send)] +impl PreDispatchHook for TracePreDispatchHook { + async fn handle( + &self, + request: &mut Request, + ) -> Result>, EdgeError> { + prepare_trusted_forwarder(request, &self.settings); + let dispatch = match preflight(&self.settings, request) { + TracePreflight::NotTrace => { + let enabled = self + .settings + .integration_config::(GPT_DIAGNOSTICS_INTEGRATION_ID) + .ok() + .flatten() + .is_some_and(|config| config.enabled && config.trace_page_enabled); + if enabled { + let cookies = inspect_cookies( + request.headers(), + request.extensions().get::(), + ); + request.extensions_mut().insert(TraceCaptureGate { + base_active: cookies.observed_active(), + cookies, + }); + } + return Ok(None); + } + TracePreflight::Response(response) => return Ok(Some(response)), + TracePreflight::Ready(dispatch) => dispatch, + }; + let response = match dispatch.route { + TraceRoute::Javascript | TraceRoute::Stylesheet => { + return Ok(Some(match trace_asset(request.uri().path()) { + Some(asset) => dispatch.fixed_asset(asset.bytes), + None => dispatch.respond(unavailable_response()), + })); + } + TraceRoute::Enable | TraceRoute::End => action_response(request, dispatch.route).await, + TraceRoute::State => { + let cookies = inspect_cookies( + request.headers(), + request.extensions().get::(), + ); + state_response(&cookies) + } + TraceRoute::Shell if request.method() == Method::HEAD => Response::new(Body::empty()), + TraceRoute::Shell => { + let cookies = inspect_cookies( + request.headers(), + request.extensions().get::(), + ); + let metadata = (self.metadata)(request); + match project_request_context( + &metadata.client_info, + metadata.geo.as_ref(), + &cookies, + Utc::now(), + ) { + Ok(context) => match render_setup_shell(&context) { + Ok(shell) => Response::new(Body::from(shell)), + Err(_) => unavailable_response(), + }, + Err(_) => unavailable_response(), + } + } + }; + Ok(Some(dispatch.respond(response))) + } +} + +fn unavailable_response() -> Response { + let mut response = Response::new(Body::from(br#"{"error":"trace unavailable"}"#.as_slice())); + *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + response +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use edgezero_core::context::RequestContext; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + use http::{Method, StatusCode, header}; + use serde_json::json; + + use crate::test_support::tests::create_test_settings; + use crate::trace::TraceTerminalResponse; + + use super::*; + + fn hook(enabled: bool, calls: Arc) -> TracePreDispatchHook { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "gpt_diagnostics", + &json!({"enabled": true,"trace_page_enabled": enabled}), + ) + .expect("should insert trace settings"); + TracePreDispatchHook::new( + Arc::new(settings), + Arc::new(move |_| { + calls.fetch_add(1, Ordering::SeqCst); + TraceMetadata { + client_info: ClientInfo::default(), + geo: None, + } + }), + ) + } + + #[test] + fn forwarding_credentials_are_removed_before_every_preflight_outcome() { + let calls = Arc::new(AtomicUsize::new(0)); + let hook = hook(false, calls); + for path in [ + "/_ts/trace/enable", + "/_ts/trace/trace.js", + "/publisher", + "/_ts/trace/missing", + ] { + let mut request = Request::builder() + .uri(path) + .header( + "x-ts-forwarder-auth", + "fictional-forwarder-secret-0123456789", + ) + .body(Body::empty()) + .expect("should construct a preflight request"); + let _response = + block_on(hook.handle(&mut request)).expect("should complete local preflight"); + assert!( + !request.headers().contains_key("x-ts-forwarder-auth"), + "should sanitize credentials before any preflight return" + ); + } + } + + #[test] + fn trace_dispatch_terminal_routes_only_project_get_shell_metadata() { + let calls = Arc::new(AtomicUsize::new(0)); + let hook = hook(true, Arc::clone(&calls)); + for (method, path, status, projected) in [ + (Method::GET, "/_ts/trace", StatusCode::OK, 1), + (Method::HEAD, "/_ts/trace", StatusCode::OK, 0), + (Method::GET, "/_ts/trace/state", StatusCode::OK, 0), + (Method::GET, "/_ts/trace/assets/v1.js", StatusCode::OK, 0), + (Method::GET, "/_ts/trace/assets/v1.css", StatusCode::OK, 0), + (Method::POST, "/_ts/trace/enable", StatusCode::FORBIDDEN, 0), + (Method::POST, "/_ts/trace/end", StatusCode::FORBIDDEN, 0), + ( + Method::PATCH, + "/_ts/trace", + StatusCode::METHOD_NOT_ALLOWED, + 0, + ), + (Method::GET, "/_ts/trace/extra", StatusCode::NOT_FOUND, 0), + (Method::GET, "/%5Fts/trace", StatusCode::BAD_REQUEST, 0), + ] { + calls.store(0, Ordering::SeqCst); + let is_head = method == Method::HEAD; + let mut request = Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("should build trace request"); + let response = block_on(hook.handle(&mut request)) + .expect("should return local policy response") + .expect("should intercept namespace before lookup"); + assert_eq!( + response.status(), + status, + "should dispatch the exact trace route" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + projected, + "should lazily project metadata only for GET shell" + ); + assert!( + response + .extensions() + .get::() + .is_some(), + "should bypass ordinary finalization" + ); + assert!( + !response.headers().contains_key(header::SET_COOKIE), + "should not mutate cookies on read or rejected action" + ); + if is_head { + assert_eq!( + response + .into_body() + .into_bytes() + .expect("should buffer shell HEAD") + .len(), + 0, + "should remove the HEAD body" + ); + } + } + } + + #[test] + fn trace_dispatch_disabled_namespace_avoids_metadata() { + let calls = Arc::new(AtomicUsize::new(0)); + let hook = hook(false, Arc::clone(&calls)); + let mut request = Request::builder() + .uri("/_ts/trace") + .body(Body::empty()) + .expect("should build disabled request"); + let response = block_on(hook.handle(&mut request)) + .expect("should produce local response") + .expect("should reserve disabled namespace"); + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "should hide disabled trace routes" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "should never fetch disabled metadata" + ); + } + + #[test] + fn trace_dispatch_freezes_ordinary_cookie_gate_before_preparation() { + for (enabled, cookie, expected) in [ + (true, "__Host-ts-console=1", Some(true)), + (true, "__Host-ts-console=1, unrelated=value", Some(false)), + ( + true, + "__Host-ts-console=1; __Host-ts-console=1", + Some(false), + ), + (true, "__Host-ts-console=invalid", Some(false)), + (false, "__Host-ts-console=1", None), + ] { + let calls = Arc::new(AtomicUsize::new(0)); + let hook = hook(enabled, Arc::clone(&calls)); + let mut request = Request::builder() + .uri("/article") + .header(header::COOKIE, cookie) + .body(Body::empty()) + .expect("should build ordinary request"); + assert!( + block_on(hook.handle(&mut request)) + .expect("should continue ordinary dispatch") + .is_none(), + "should continue ordinary requests" + ); + let gate = request.extensions().get::().copied(); + assert_eq!( + gate.map(|gate| gate.base_active()), + expected, + "should freeze conservative incoming validity only when configured" + ); + request.headers_mut().remove(header::COOKIE); + assert_eq!( + gate.map(|gate| gate.cookies().observed_active()), + expected, + "should retain no dependency on sanitized headers" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "should never project ordinary metadata before auth" + ); + } + } + + struct OrdinaryLifecycleCounter(Arc); + + #[async_trait(?Send)] + impl Middleware for OrdinaryLifecycleCounter { + async fn handle( + &self, + context: RequestContext, + next: Next<'_>, + ) -> Result, EdgeError> { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(context).await + } + } + + #[test] + fn trace_dispatch_router_hook_bypasses_all_ordinary_middleware_and_handlers() { + let ordinary = Arc::new(AtomicUsize::new(0)); + let metadata = Arc::new(AtomicUsize::new(0)); + let handler_calls = Arc::clone(&ordinary); + let router = RouterService::builder() + .pre_dispatch_hook(Arc::new(hook(true, Arc::clone(&metadata)))) + .middleware(OrdinaryLifecycleCounter(Arc::clone(&ordinary))) + .route("/{*rest}", Method::GET, move |_context: RequestContext| { + let handler_calls = Arc::clone(&handler_calls); + async move { + handler_calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>(Response::new(Body::empty())) + } + }) + .build(); + for path in [ + "/_ts/trace", + "/_ts/trace/state", + "/_ts/trace/assets/v1.js", + "/_ts/trace/extra", + ] { + let request = Request::builder() + .uri(path) + .body(Body::empty()) + .expect("should build trace route"); + let response = + block_on(router.oneshot(request)).expect("should serve local trace response"); + assert!( + response + .extensions() + .get::() + .is_some(), + "should terminate before ordinary lifecycle" + ); + } + assert_eq!( + ordinary.load(Ordering::SeqCst), + 0, + "should bypass every ordinary middleware and publisher handler" + ); + assert_eq!( + metadata.load(Ordering::SeqCst), + 1, + "should project only the authenticated setup GET" + ); + let request = Request::builder() + .uri("/article") + .body(Body::empty()) + .expect("should build ordinary route"); + let _ = block_on(router.oneshot(request)).expect("should preserve ordinary lifecycle"); + assert_eq!( + ordinary.load(Ordering::SeqCst), + 2, + "should run middleware and handler for ordinary requests" + ); + } + + #[test] + fn trace_dispatch_document_gate_combines_incoming_session_and_effective_decision() { + for (cookie, query, expected_base, expected_document) in [ + (None, "?ts_console=1", false, false), + (Some("__Host-ts-console=1"), "", true, true), + (Some("__Host-ts-console=1"), "?ts_console=0", true, false), + ( + Some("__Host-ts-console=1, unrelated=value"), + "?ts_console=1", + false, + false, + ), + ] { + let hook = hook(true, Arc::new(AtomicUsize::new(0))); + let mut request = Request::builder() + .uri(format!("https://publisher.example.com/article{query}")) + .header("accept", "text/html") + .body(Body::empty()) + .expect("should build navigation"); + if let Some(cookie) = cookie { + request.headers_mut().insert( + header::COOKIE, + http::HeaderValue::from_str(cookie).expect("should parse example cookie"), + ); + } + let _ = + block_on(hook.handle(&mut request)).expect("should continue ordinary navigation"); + let gate = *request + .extensions() + .get::() + .expect("should freeze incoming gate"); + let _ = + crate::integrations::gpt_diagnostics::prepare_request(&hook.settings, &mut request) + .expect("should preserve console preparation"); + let decision = request + .extensions() + .get::() + .expect("should store existing decision"); + assert_eq!( + gate.base_active(), + expected_base, + "should evaluate incoming session only once" + ); + assert_eq!( + gate.document_eligible(decision), + expected_document, + "should combine frozen validity with effective navigation eligibility" + ); + } + } +} diff --git a/crates/trusted-server-core/src/trace/mod.rs b/crates/trusted-server-core/src/trace/mod.rs new file mode 100644 index 000000000..ac3ca7590 --- /dev/null +++ b/crates/trusted-server-core/src/trace/mod.rs @@ -0,0 +1,38 @@ +//! Application-visible mobile trace routing and response policy. + +mod actions; +mod auction; +mod carry; +mod slot_refs; +pub(crate) use carry::{TraceAuctionCarry, TraceProviderObservation}; +pub(crate) use slot_refs::TraceClientSlotRefs; +mod context; +mod cookies; +mod dispatch; +mod routes; +mod shell; +mod types; + +#[cfg(test)] +pub(crate) use actions::action_response; +pub use auction::{ + DiagnosticAuctionId, TraceAuctionEvidenceV1, TraceAuctionSource, TraceAuctionTerminalReason, + TraceAuctionTerminalStatus, TraceAuctionTransportV1, TraceProviderRole, TraceProviderStatus, + TraceSlotCandidate, TraceSlotRef, TraceTokenError, +}; +pub use context::{ContextProjectionError, project_request_context}; +pub use cookies::inspect_cookies; +pub use dispatch::{TraceCaptureGate, TraceMetadata, TracePreDispatchHook}; +#[cfg(test)] +pub(crate) use routes::state_response; +pub use routes::{TraceDispatch, TracePreflight, is_trace_path, preflight}; +pub use types::{ + CookieHealth, CookieHealthDetail, CookieHealthState, TraceCookies, TraceNetwork, + TraceRequestContextV1, +}; + +/// Marks a local trace response that bypasses ordinary adapter finalization. +/// +/// Adapters must preserve the trace response contract through conversion. +#[derive(Clone, Copy, Debug)] +pub struct TraceTerminalResponse; diff --git a/crates/trusted-server-core/src/trace/routes.rs b/crates/trusted-server-core/src/trace/routes.rs new file mode 100644 index 000000000..0b349ac80 --- /dev/null +++ b/crates/trusted-server-core/src/trace/routes.rs @@ -0,0 +1,1100 @@ +use std::borrow::Cow; + +use edgezero_core::body::Body as EdgeBody; +use http::{HeaderValue, Method, Request, Response, StatusCode, header}; +use sha2::{Digest as _, Sha256}; + +use crate::auth::{TraceAuthLogPolicy, enforce_basic_auth}; +use crate::integrations::gpt_diagnostics::{GPT_DIAGNOSTICS_INTEGRATION_ID, GptDiagnosticsConfig}; +use crate::response_privacy::enforce_terminal_private_cache_privacy; +use crate::settings::Settings; + +use super::TraceTerminalResponse; + +const NAMESPACE: &str = "/_ts/trace"; +const MAX_DECODE_ROUNDS: usize = 4; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TraceRoute { + Shell, + State, + Enable, + End, + Javascript, + Stylesheet, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Classification { + NotTrace, + Route(TraceRoute), + Rejected(StatusCode), +} + +/// Result of authenticated trace-route preflight. +pub enum TracePreflight { + /// Continue ordinary dispatch without inspecting diagnostics configuration. + NotTrace, + /// Terminate with a hardened local challenge or policy error. + Response(Response), + /// Invoke the route handler and finalize its response with this context. + Ready(TraceDispatch), +} + +/// Authenticated route context for a later trace handler. +/// +/// This is a response-policy seam, not a successful action or cookie observation. +pub struct TraceDispatch { + pub(crate) route: TraceRoute, + head: bool, + protected: bool, +} + +impl TraceDispatch { + /// Apply dynamic trace response policy after a handler produces its result. + /// + /// Successful assets require [`Self::fixed_asset`] instead. Errors are always + /// JSON and private; GET and HEAD responses cannot set cookies. + /// + /// # Examples + /// + /// ```ignore + /// let response = context.respond(handler_response); + /// ``` + #[must_use] + pub fn respond(self, mut response: Response) -> Response { + if self.route.is_asset() && response.status().is_success() { + response = error_response(StatusCode::INTERNAL_SERVER_ERROR); + } + let mime = if self.route == TraceRoute::Shell && response.status().is_success() { + "text/html; charset=utf-8" + } else { + "application/json; charset=utf-8" + }; + if !matches!(self.route, TraceRoute::Enable | TraceRoute::End) + || !response.status().is_success() + { + response.headers_mut().remove(header::SET_COOKIE); + } + harden_response(&mut response, mime, self.head); + response + } + + /// Build an asset response from immutable build bytes. + /// + /// This method does not locate an asset or manufacture a missing build. + /// An asset lookup must supply its verified bytes. Non-asset contexts fail + /// with a fixed private error. + /// + /// # Examples + /// + /// ```ignore + /// let response = context.fixed_asset(verified_asset_bytes); + /// ``` + /// + /// # Panics + /// + /// Panics if a quoted ASCII SHA-256 digest cannot be represented as a header. + /// The digest representation always satisfies that invariant. + #[must_use] + pub fn fixed_asset(self, bytes: &'static [u8]) -> Response { + let mime = match self.route { + TraceRoute::Javascript => "application/javascript; charset=utf-8", + TraceRoute::Stylesheet => "text/css; charset=utf-8", + _ => return self.respond(error_response(StatusCode::INTERNAL_SERVER_ERROR)), + }; + let mut response = Response::new(EdgeBody::from(bytes)); + harden_response(&mut response, mime, self.head); + if !self.protected { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + response + .extensions_mut() + .remove::(); + } + // Private assets also retain their byte-derived validator. The shared + // privacy helper removes validators from dynamic responses first. + let etag = format!("\"{:x}\"", Sha256::digest(bytes)); + response.headers_mut().insert( + header::ETAG, + HeaderValue::from_str(&etag).expect("should encode an ASCII SHA-256 ETag"), + ); + response + } +} + +impl TraceRoute { + fn is_asset(self) -> bool { + matches!(self, Self::Javascript | Self::Stylesheet) + } + + fn allows(self, method: &Method) -> bool { + if matches!(self, Self::Enable | Self::End) { + method == Method::POST + } else { + method == Method::GET || method == Method::HEAD + } + } + + fn allow(self) -> &'static str { + if matches!(self, Self::Enable | Self::End) { + "POST" + } else { + "GET, HEAD" + } + } +} + +fn error_response(status: StatusCode) -> Response { + let body: &'static [u8] = match status { + StatusCode::BAD_REQUEST => br#"{"error":"invalid trace path"}"#, + StatusCode::UNAUTHORIZED => br#"{"error":"unauthorized"}"#, + StatusCode::NOT_FOUND => br#"{"error":"trace route not found"}"#, + StatusCode::METHOD_NOT_ALLOWED => br#"{"error":"method not allowed"}"#, + _ => br#"{"error":"trace unavailable"}"#, + }; + let mut response = Response::new(EdgeBody::from(body)); + *response.status_mut() = status; + response +} + +/// Build the exact observed state from frozen cookie health after preflight. +/// +/// Apply [`TraceDispatch::respond`] for dynamic privacy and HEAD body removal. +/// A false result reports no valid observed session, never cookie absence. +pub(crate) fn state_response(cookies: &super::TraceCookies) -> Response { + let body: &'static [u8] = if cookies.observed_active() { + br#"{"observed_active":true}"# + } else { + br#"{"observed_active":false}"# + }; + Response::new(EdgeBody::from(body)) +} + +fn harden_response(response: &mut Response, mime: &'static str, head: bool) { + enforce_terminal_private_cache_privacy(response); + let headers = response.headers_mut(); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(mime)); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + headers.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; connect-src 'self'; img-src data:")); + headers.insert( + "permissions-policy", + HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=(), usb=()"), + ); + response.extensions_mut().insert(TraceTerminalResponse); + if head { + *response.body_mut() = EdgeBody::empty(); + } +} + +fn reject(mut response: Response, head: bool) -> TracePreflight { + response.headers_mut().remove(header::SET_COOKIE); + harden_response(&mut response, "application/json; charset=utf-8", head); + TracePreflight::Response(response) +} + +/// Return whether the visible pathname is reserved for local trace handling. +/// +/// Encoded aliases are reserved for rejection and never become supported routes. +/// No original-target metadata participates in this decision. +/// +/// # Examples +/// +/// ``` +/// assert!(trusted_server_core::trace::is_trace_path("/_ts/trace")); +/// assert!(!trusted_server_core::trace::is_trace_path("/article")); +/// ``` +/// +/// # Performance +/// +/// Ordinary paths without encoded or ambiguous segments avoid allocations. +#[must_use] +pub fn is_trace_path(path: &str) -> bool { + classify_path(path) != Classification::NotTrace +} + +/// Authenticate and validate a visible trace route before handler dispatch. +/// +/// A ready result does not observe cookies, mutate a session, or supply assets. +/// Route handlers implement those contracts in subsequent layers. +/// +/// # Examples +/// +/// ```ignore +/// match trusted_server_core::trace::preflight(&settings, &mut request) { +/// trusted_server_core::trace::TracePreflight::Response(response) => return response, +/// trusted_server_core::trace::TracePreflight::NotTrace => continue_dispatch(request), +/// trusted_server_core::trace::TracePreflight::Ready(context) => handle_trace(context, request), +/// } +/// ``` +#[must_use] +pub fn preflight(settings: &Settings, request: &mut Request) -> TracePreflight { + let classification = classify_path(request.uri().path()); + if classification == Classification::NotTrace { + return TracePreflight::NotTrace; + } + let head = request.method() == Method::HEAD; + request.extensions_mut().insert(TraceAuthLogPolicy); + match enforce_basic_auth(settings, request) { + Ok(Some(mut response)) => { + *response.body_mut() = error_response(StatusCode::UNAUTHORIZED).into_body(); + return reject(response, head); + } + Err(_) => return reject(error_response(StatusCode::INTERNAL_SERVER_ERROR), head), + Ok(None) => {} + } + let enabled = + match settings.integration_config::(GPT_DIAGNOSTICS_INTEGRATION_ID) { + Ok(Some(config)) => config.trace_page_enabled, + Ok(None) => false, + Err(_) => return reject(error_response(StatusCode::INTERNAL_SERVER_ERROR), head), + }; + if !enabled { + return reject(error_response(StatusCode::NOT_FOUND), head); + } + let route = match classification { + Classification::Route(route) => route, + Classification::Rejected(status) => return reject(error_response(status), head), + Classification::NotTrace => return TracePreflight::NotTrace, + }; + if !route.allows(request.method()) { + let mut response = error_response(StatusCode::METHOD_NOT_ALLOWED); + response + .headers_mut() + .insert(header::ALLOW, HeaderValue::from_static(route.allow())); + return reject(response, head); + } + let protected = match settings.handler_for_path(request.uri().path()) { + Ok(handler) => handler.is_some(), + Err(_) => return reject(error_response(StatusCode::INTERNAL_SERVER_ERROR), head), + }; + TracePreflight::Ready(TraceDispatch { + route, + head, + protected, + }) +} + +fn exact_route(path: &str) -> Option { + match path { + "/_ts/trace" => Some(TraceRoute::Shell), + "/_ts/trace/state" => Some(TraceRoute::State), + "/_ts/trace/enable" => Some(TraceRoute::Enable), + "/_ts/trace/end" => Some(TraceRoute::End), + "/_ts/trace/assets/v1.js" => Some(TraceRoute::Javascript), + "/_ts/trace/assets/v1.css" => Some(TraceRoute::Stylesheet), + _ => None, + } +} + +fn has_dot_segments(path: &str) -> bool { + path.split('/').any(|segment| matches!(segment, "." | "..")) +} + +fn reserves_namespace(path: &str) -> bool { + if path.starts_with(NAMESPACE) { + return true; + } + if !path.contains("//") && !has_dot_segments(path) && !path.contains('\\') { + return false; + } + // Alternative spellings are considered only to reserve and reject them. + // The resulting path is never used for routing or authentication. + let mut segments = Vec::new(); + for segment in path.split(['/', '\\']) { + match segment { + "" | "." => {} + ".." => { + segments.pop(); + } + _ => segments.push(segment), + } + // Once a visible alternative spelling reaches the namespace, later + // dot segments cannot erase its reservation at application dispatch. + if segments.first() == Some(&"_ts") + && segments + .get(1) + .is_some_and(|segment| segment.starts_with("trace")) + { + return true; + } + } + false +} + +fn encoded_separator(path: &str) -> bool { + path.as_bytes().windows(3).any(|bytes| { + bytes[0] == b'%' + && (bytes[1] == b'2' && bytes[2].eq_ignore_ascii_case(&b'f') + || bytes[1] == b'5' && bytes[2].eq_ignore_ascii_case(&b'c')) + }) +} + +fn valid_escapes(path: &str) -> bool { + let mut bytes = path.bytes(); + while let Some(byte) = bytes.next() { + if byte == b'%' + && (!bytes.next().is_some_and(|value| value.is_ascii_hexdigit()) + || !bytes.next().is_some_and(|value| value.is_ascii_hexdigit())) + { + return false; + } + } + true +} + +fn classify_path(path: &str) -> Classification { + if let Some(route) = exact_route(path) { + return Classification::Route(route); + } + let mut reserved = reserves_namespace(path); + if reserved && (has_dot_segments(path) || path.contains('\\')) { + return Classification::Rejected(StatusCode::BAD_REQUEST); + } + let mut current = Cow::Borrowed(path); + let mut invalid_encoding = false; + for _ in 0..MAX_DECODE_ROUNDS { + if !current.contains('%') { + break; + } + invalid_encoding |= !valid_escapes(¤t); + // Decode bytes so an unreadable suffix cannot hide an ASCII namespace + // prefix. Lossy text is used exclusively for reservation, never routing. + let decoded_bytes = urlencoding::decode_binary(current.as_bytes()); + invalid_encoding |= core::str::from_utf8(&decoded_bytes).is_err(); + let decoded = String::from_utf8_lossy(&decoded_bytes); + let decoded_reserved = reserves_namespace(&decoded); + if (reserved || decoded_reserved) + && (invalid_encoding + || encoded_separator(¤t) + || has_dot_segments(&decoded) + || !reserved + || exact_route(&decoded).is_some()) + { + return Classification::Rejected(StatusCode::BAD_REQUEST); + } + reserved |= decoded_reserved; + if decoded == current { + break; + } + current = Cow::Owned(decoded.into_owned()); + } + if !reserved { + Classification::NotTrace + } else if current.contains('%') { + Classification::Rejected(StatusCode::BAD_REQUEST) + } else { + Classification::Rejected(StatusCode::NOT_FOUND) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::{Mutex, Once}; + + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use http::{HeaderValue, header}; + use log::{LevelFilter, Log, Metadata, Record}; + use serde_json::json; + + use crate::auth::EdgeTerminatedAuthorization; + use crate::cache_policy::EDGE_CACHE_HEADER_NAMES; + use crate::settings::Handler; + use crate::test_support::tests::create_test_settings; + use crate::trace::TraceTerminalResponse; + + fn handler(pattern: &str, username: &str, password: &str) -> Handler { + serde_json::from_value(json!({"path": pattern, "username": username, "password": password})) + .expect("should deserialize the example authentication handler") + } + + fn settings(enabled: bool, pattern: Option<&str>) -> Settings { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "gpt_diagnostics", + &json!({"enabled": true, "trace_page_enabled": enabled}), + ) + .expect("should insert trace configuration"); + if let Some(pattern) = pattern { + settings + .handlers + .insert(0, handler(pattern, "example-user", "example-password")); + } + settings + } + + fn request(method: Method, path: &str, credentials: bool) -> Request { + let mut builder = Request::builder() + .method(method) + .uri(format!("https://publisher.example.com{path}")); + if credentials { + builder = builder.header( + header::AUTHORIZATION, + format!("Basic {}", STANDARD.encode("example-user:example-password")), + ); + } + builder + .body(EdgeBody::empty()) + .expect("should build trace request") + } + + fn rejected(settings: &Settings, request: &mut Request) -> Response { + match preflight(settings, request) { + TracePreflight::Response(response) => response, + _ => panic!("should terminate the rejected trace request"), + } + } + + fn ready(settings: &Settings, request: &mut Request) -> TraceDispatch { + match preflight(settings, request) { + TracePreflight::Ready(dispatch) => dispatch, + _ => panic!("should dispatch the accepted trace request"), + } + } + + fn assert_private(response: &Response) { + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "no-store, private", + "should enforce private non-storable responses" + ); + for name in EDGE_CACHE_HEADER_NAMES { + assert!( + !response.headers().contains_key(*name), + "should remove shared cache directive {name}" + ); + } + assert_eq!( + response.headers()[header::X_CONTENT_TYPE_OPTIONS], + "nosniff", + "should disable MIME sniffing" + ); + assert_eq!( + response.headers()[header::REFERRER_POLICY], + "no-referrer", + "should suppress referrers" + ); + assert_eq!( + response.headers()[header::CONTENT_SECURITY_POLICY], + "default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; connect-src 'self'; img-src data:", + "should retain the exact trace CSP" + ); + assert_eq!( + response.headers()["permissions-policy"], + "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + "should disable unrelated browser capabilities" + ); + assert!( + response + .extensions() + .get::() + .is_some(), + "should bypass ordinary finalizers" + ); + assert!( + !response.headers().contains_key(header::SET_COOKIE), + "should not mutate cookies during preflight" + ); + } + + #[test] + fn authentication_precedes_disabled_path_and_method_policy() { + for enabled in [false, true] { + let settings = settings(enabled, Some("^/")); + for path in [ + "/_ts/trace", + "/_ts/trace/state", + "/_ts/trace/enable", + "/_ts/trace/end", + "/_ts/trace/assets/v1.js", + "/_ts/trace/assets/v1.css", + "/%5Fts/trace", + "/_ts/trace/../secret-example", + ] { + let mut request = request( + Method::from_bytes(b"TRACE-EXAMPLE").expect("should parse extension method"), + path, + false, + ); + let response = rejected(&settings, &mut request); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "should authenticate before trace policies" + ); + assert_eq!( + response.headers()[header::WWW_AUTHENTICATE], + "Basic realm=\"Trusted Server\"", + "should preserve the configured challenge" + ); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "application/json; charset=utf-8", + "should harden challenge MIME" + ); + assert_private(&response); + } + } + } + + #[test] + fn literal_path_authentication_and_first_match_are_preserved() { + let mut request = request(Method::GET, "/%5Fts/trace?secret=example-sentinel", false); + let response = rejected(&settings(true, Some("^/_ts")), &mut request); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "should reject the alias without decoding the auth path" + ); + assert!( + !response.headers().contains_key(header::WWW_AUTHENTICATE), + "should not manufacture an auth rule" + ); + assert_private(&response); + let body = response + .into_body() + .into_bytes() + .expect("should return a fixed error body"); + assert!( + !String::from_utf8_lossy(&body).contains("example-sentinel"), + "should not reflect query or path data" + ); + + let mut request = self::request(Method::GET, "/%5Fts/trace", false); + let response = rejected(&settings(false, Some("^/_ts")), &mut request); + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "should apply disabled feature before path errors" + ); + + let mut settings = settings(true, Some("^/")); + settings + .handlers + .insert(0, handler("^/_ts/trace$", "narrow-user", "narrow-password")); + let mut request = self::request(Method::GET, "/_ts/trace", true); + let response = rejected(&settings, &mut request); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "should enforce the first matching handler" + ); + } + + #[test] + fn method_matrix_and_head_errors_are_local_and_bodyless() { + let settings = settings(true, None); + for (path, allow, supported) in [ + ("/_ts/trace", "GET, HEAD", Method::GET), + ("/_ts/trace/state", "GET, HEAD", Method::GET), + ("/_ts/trace/enable", "POST", Method::POST), + ("/_ts/trace/end", "POST", Method::POST), + ("/_ts/trace/assets/v1.js", "GET, HEAD", Method::GET), + ("/_ts/trace/assets/v1.css", "GET, HEAD", Method::GET), + ] { + let mut request = request(supported, path, false); + ready(&settings, &mut request); + for method in [ + Method::PUT, + Method::from_bytes(b"TRACE-EXAMPLE").expect("should parse extension method"), + ] { + let mut request = self::request(method, path, false); + let response = rejected(&settings, &mut request); + assert_eq!( + response.status(), + StatusCode::METHOD_NOT_ALLOWED, + "should reject unsupported visible methods locally" + ); + assert_eq!( + response.headers()[header::ALLOW], + allow, + "should retain path-specific allowed methods" + ); + assert_private(&response); + } + } + for (path, settings) in [ + ("/_ts/trace/enable", self::settings(true, None)), + ("/_ts/trace/end", self::settings(true, None)), + ("/_ts/trace/extra", self::settings(true, None)), + ("/_ts/trace", self::settings(false, None)), + ("/_ts/trace", self::settings(true, Some("^/"))), + ] { + let mut request = request(Method::HEAD, path, false); + let response = rejected(&settings, &mut request); + assert_private(&response); + assert!( + response + .into_body() + .into_bytes() + .expect("should return a fixed body") + .is_empty(), + "should suppress every HEAD error body" + ); + } + } + + #[test] + fn dynamic_finalization_preserves_response_contract_and_head_semantics() { + let settings = settings(true, Some("^/")); + for (path, mime) in [ + ("/_ts/trace", "text/html; charset=utf-8"), + ("/_ts/trace/state", "application/json; charset=utf-8"), + ] { + for method in [Method::GET, Method::HEAD] { + let mut request = request(method.clone(), path, true); + let dispatch = ready(&settings, &mut request); + assert!( + request + .extensions() + .get::() + .is_some(), + "should preserve successful authorization marker" + ); + let response = Response::builder() + .header(header::CACHE_CONTROL, "public, max-age=60") + .header("surrogate-control", "max-age=60") + .header(header::ETAG, "\"example\"") + .header(header::LAST_MODIFIED, "Wed, 12 Aug 2026 00:00:00 GMT") + .header(header::SET_COOKIE, "unrelated=example") + .body(EdgeBody::from("example content")) + .expect("should build handler response"); + let response = dispatch.respond(response); + assert_private(&response); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + mime, + "should use route-appropriate MIME" + ); + assert!( + !response.headers().contains_key(header::ETAG), + "should remove dynamic validators" + ); + assert!( + !response.headers().contains_key(header::LAST_MODIFIED), + "should remove dynamic validators" + ); + if method == Method::HEAD { + assert!( + response + .into_body() + .into_bytes() + .expect("should return a fixed body") + .is_empty(), + "should suppress successful HEAD body" + ); + } + } + } + } + + #[test] + fn fixed_asset_policy_depends_on_auth_coverage_and_build_bytes() { + for protected in [false, true] { + let settings = settings(true, protected.then_some("^/_ts")); + for (path, mime) in [ + ( + "/_ts/trace/assets/v1.js", + "application/javascript; charset=utf-8", + ), + ("/_ts/trace/assets/v1.css", "text/css; charset=utf-8"), + ] { + let mut request = request(Method::GET, path, protected); + let response = ready(&settings, &mut request).fixed_asset(b"example build bytes"); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + mime, + "should serve fixed assets with exact MIME" + ); + assert_eq!( + response.headers()[header::X_CONTENT_TYPE_OPTIONS], + "nosniff", + "should retain asset MIME protection" + ); + let etag = response.headers()[header::ETAG] + .to_str() + .expect("should return an ASCII ETag"); + assert!( + etag.starts_with('"') && etag.ends_with('"') && !etag.starts_with("W/"), + "should derive a strong quoted ETag" + ); + if protected { + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "no-store, private", + "should keep protected assets private" + ); + } else { + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "public, max-age=31536000, immutable", + "should cache only unprotected fixed assets" + ); + } + let mut other = self::request(Method::HEAD, path, protected); + let head = ready(&settings, &mut other).fixed_asset(b"example build bytes"); + assert_eq!( + head.headers(), + response.headers(), + "should preserve GET headers on HEAD" + ); + assert!( + head.into_body() + .into_bytes() + .expect("should return a fixed body") + .is_empty(), + "should suppress asset HEAD bodies" + ); + let mut other = self::request(Method::GET, path, protected); + let changed = + ready(&settings, &mut other).fixed_asset(b"changed example build bytes"); + assert_ne!( + changed.headers()[header::ETAG], + response.headers()[header::ETAG], + "should derive ETag from actual build bytes" + ); + } + } + } + + struct CapturedLogs(Mutex>); + + impl Log for CapturedLogs { + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + fn log(&self, record: &Record<'_>) { + let message = record.args().to_string(); + if message == "trace_auth_failed" + || message.contains("fictional-secret-sentinel") + || message == "Basic auth failed for path: /ordinary-example-sentinel" + { + let mut logs = self.0.lock().expect("should capture log messages"); + if logs.len() < 3 && !logs.contains(&message) { + logs.push(message); + } + } + } + fn flush(&self) {} + } + + static LOGS: CapturedLogs = CapturedLogs(Mutex::new(Vec::new())); + static LOG_INIT: Once = Once::new(); + + #[test] + fn trace_auth_failure_logs_a_fixed_category_without_the_path() { + LOG_INIT.call_once(|| { + log::set_logger(&LOGS).expect("should install the test logger"); + log::set_max_level(LevelFilter::Warn); + }); + let mut request = request(Method::GET, "/_ts/trace/fictional-secret-sentinel", false); + request.headers_mut().insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!( + "Basic {}", + STANDARD.encode("example-user:incorrect-example") + )) + .expect("should build incorrect credentials"), + ); + let response = rejected(&settings(true, Some("^/")), &mut request); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "should authenticate before reporting the reserved path error" + ); + let logs = LOGS.0.lock().expect("should inspect captured messages"); + assert!( + logs.iter().any(|message| message == "trace_auth_failed"), + "should emit only a fixed diagnostic category" + ); + assert!( + logs.iter() + .all(|message| !message.contains("fictional-secret-sentinel")), + "should not log the confidential trace path" + ); + drop(logs); + let mut ordinary = self::request(Method::GET, "/ordinary-example-sentinel", false); + ordinary.headers_mut().insert( + header::AUTHORIZATION, + request.headers()[header::AUTHORIZATION].clone(), + ); + assert!( + enforce_basic_auth(&settings(true, Some("^/")), &mut ordinary) + .expect("should evaluate ordinary auth") + .is_some(), + "should retain ordinary credential rejection" + ); + assert!( + LOGS.0 + .lock() + .expect("should inspect ordinary auth messages") + .iter() + .any(|message| message == "Basic auth failed for path: /ordinary-example-sentinel"), + "should retain ordinary path logging outside trace" + ); + let body = response + .into_body() + .into_bytes() + .expect("should return a fixed challenge"); + assert!( + !String::from_utf8_lossy(&body).contains("fictional-secret-sentinel"), + "should not reflect the trace path in a challenge" + ); + } + + #[test] + fn visible_trace_prefix_cannot_be_erased_by_dot_segments() { + for path in [ + "/_ts//trace/../ordinary", + "//_ts/trace/../../health", + "/%5Fts//trace/../ordinary", + "/_ts%2Ftrace/../ordinary", + "/ordinary/../_ts//trace/../../health", + ] { + assert_eq!( + classify_path(path), + Classification::Rejected(StatusCode::BAD_REQUEST), + "should retain the visible reserved prefix before later dot segments {path}" + ); + assert!( + is_trace_path(path), + "should suppress native shortcuts for visible reserved ambiguity {path}" + ); + let mut request = request(Method::GET, path, false); + let response = rejected(&settings(true, None), &mut request); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "should terminate the visible ambiguity locally" + ); + assert_private(&response); + } + } + + #[test] + fn encoded_namespace_with_unreadable_suffix_never_falls_through() { + for path in [ + "/%5Fts/trace/%GG", + "/%255Fts/trace/%GG", + "/%5Fts/trace/%FF", + "/%255Fts/trace/%FF", + ] { + assert_eq!( + classify_path(path), + Classification::Rejected(StatusCode::BAD_REQUEST), + "should reserve the encoded namespace despite an unreadable suffix {path}" + ); + } + } + + #[test] + fn unrelated_preflight_leaves_the_request_untouched() { + let mut settings = settings(true, None); + settings.integrations.insert( + "gpt_diagnostics".to_string(), + json!({"enabled": "invalid-example"}), + ); + let mut request = request(Method::GET, "/article?ts_console=1", true); + request.headers_mut().insert( + header::COOKIE, + HeaderValue::from_static("__Host-ts-console=1; ts-ec=example"), + ); + let before_uri = request.uri().clone(); + let before_headers = request.headers().clone(); + assert!( + matches!(preflight(&settings, &mut request), TracePreflight::NotTrace), + "should continue ordinary dispatch without parsing trace configuration" + ); + assert_eq!(request.uri(), &before_uri, "should retain the ordinary URI"); + assert_eq!( + request.headers(), + &before_headers, + "should retain ordinary headers" + ); + assert!( + request.extensions().get::().is_none(), + "should retain ordinary auth logging" + ); + assert!( + request + .extensions() + .get::() + .is_none(), + "should not authenticate ordinary requests early" + ); + } + + #[test] + fn authentication_failure_does_not_read_the_body() { + let mut request = request(Method::POST, "/_ts/trace/enable", false); + *request.body_mut() = EdgeBody::stream(futures::stream::poll_fn( + |_| -> std::task::Poll> { + panic!("should never inspect a body before authentication"); + }, + )); + let response = rejected(&settings(true, Some("^/")), &mut request); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "should authenticate without consuming the stream" + ); + } + + #[test] + fn trace_actions_state_response_uses_only_frozen_session_health() { + for (field, active) in [ + ("__Host-ts-console=1", true), + ("__Host-ts-console=invalid-example", false), + ("__Host-ts-console", false), + ("__Host-ts-console=1; __Host-ts-console=1", false), + ("__Host-ts-console=1, unrelated=a", false), + ("__Host-ts-console=1; unrelated=�", false), + ("", false), + ] { + for method in [Method::GET, Method::HEAD] { + let mut request = request(method.clone(), "/_ts/trace/state?ts_console=1", false); + request.headers_mut().insert( + header::COOKIE, + HeaderValue::from_bytes(field.as_bytes()) + .expect("should construct frozen runtime-visible Cookie text"), + ); + let frozen = super::super::inspect_cookies(request.headers(), None); + request.headers_mut().remove(header::COOKIE); + let context = ready(&settings(true, None), &mut request); + let response = context.respond(state_response(&frozen)); + assert_eq!( + response.status(), + StatusCode::OK, + "should observe the frozen state locally" + ); + assert_private(&response); + assert!( + !response.headers().contains_key(header::SET_COOKIE), + "should never refresh or clear a cookie on state requests" + ); + let body = response.into_body(); + if method == Method::HEAD { + assert!( + body.into_bytes() + .expect("should return a bounded HEAD response") + .is_empty(), + "should remove every state HEAD body" + ); + } else { + assert_eq!( + body.to_json::() + .expect("should parse exact observed state JSON"), + json!({"observed_active":active}), + "should emit only the frozen observed_active boolean" + ); + } + } + } + } + + #[test] + fn fixed_asset_queries_do_not_affect_bytes_or_headers() { + let settings = settings(true, None); + let mut plain = request(Method::GET, "/_ts/trace/assets/v1.js", false); + let mut queried = request( + Method::GET, + "/_ts/trace/assets/v1.js?secret=fictional-query-sentinel", + false, + ); + let plain = ready(&settings, &mut plain).fixed_asset(b"example build bytes"); + let queried = ready(&settings, &mut queried).fixed_asset(b"example build bytes"); + assert_eq!( + queried.headers(), + plain.headers(), + "should exclude query data from static headers" + ); + assert_eq!( + queried.into_body().into_bytes(), + plain.into_body().into_bytes(), + "should exclude query data from static bytes" + ); + } + + #[test] + fn exact_routes_and_reserved_shapes_are_classified_without_normalization() { + for (path, route) in [ + ("/_ts/trace", TraceRoute::Shell), + ("/_ts/trace/state", TraceRoute::State), + ("/_ts/trace/enable", TraceRoute::Enable), + ("/_ts/trace/end", TraceRoute::End), + ("/_ts/trace/assets/v1.js", TraceRoute::Javascript), + ("/_ts/trace/assets/v1.css", TraceRoute::Stylesheet), + ] { + assert_eq!( + classify_path(path), + Classification::Route(route), + "should classify the exact route {path}" + ); + } + for path in [ + "/_ts/trace/", + "/_ts/trace/state/extra", + "/_ts/trace-extra", + "/_ts/tracer", + "/_ts/trace/assets/v2.js", + "//_ts/trace", + "/_ts//trace", + "/_ts/trace//state", + ] { + assert_eq!( + classify_path(path), + Classification::Rejected(StatusCode::NOT_FOUND), + "should reserve the unsupported spelling {path}" + ); + } + for path in [ + "/%5Fts/trace", + "/_ts/%74race", + "/_ts/trace%2Fend", + "/_ts/trace%252Fend", + "/_ts/trace/../ordinary", + "/_ts/./trace", + "/ordinary/../_ts/trace", + "/_ts/trace/%2e%2e/ordinary", + "/_ts/trace%GG", + "/_ts/trace%FF", + "/_ts/trace%252525252Fend", + ] { + assert_eq!( + classify_path(path), + Classification::Rejected(StatusCode::BAD_REQUEST), + "should reject the ambiguous spelling {path}" + ); + } + for path in [ + "/", + "/article", + "/_ts/debug/ja4", + "/health", + "/article%2Fother", + "/article%GG", + "/%FF", + "/_ts/not-trace", + "/_ts/../ordinary", + ] { + assert_eq!( + classify_path(path), + Classification::NotTrace, + "should preserve unrelated traffic {path}" + ); + } + } +} diff --git a/crates/trusted-server-core/src/trace/shell.rs b/crates/trusted-server-core/src/trace/shell.rs new file mode 100644 index 000000000..cf2f9325d --- /dev/null +++ b/crates/trusted-server-core/src/trace/shell.rs @@ -0,0 +1,229 @@ +//! Escaped setup-page shell using only frozen, redacted request facts. + +use error_stack::{Report, ResultExt as _}; + +use super::types::TraceRequestContextV1; + +/// Bounded failure category for setup-shell projection. +#[derive(Debug, derive_more::Display)] +pub(crate) enum ShellError { + /// The redacted request context could not be serialized. + #[display("Trace setup facts are unavailable")] + Serialization, +} + +impl core::error::Error for ShellError {} + +/// Render setup controls and frozen redacted facts using fixed external assets. +/// +/// # Errors +/// +/// Returns [`ShellError::Serialization`] if the context cannot be serialized. +pub(crate) fn render_setup_shell( + context: &TraceRequestContextV1, +) -> Result> { + let context_json = serde_json::to_string(context).change_context(ShellError::Serialization)?; + let context_text = escape_text(&context_json); + let active = context.cookies().observed_active(); + let observed_state = if active { + "Tracing is on — cookie observed by server" + } else { + "Tracing is off — no valid diagnostics session observed" + }; + Ok(format!( + r#" + + + + +Trusted Server ad diagnostics + + + + + +
+

Trusted Server ad diagnostics

+

No previous ad failure can be recovered. Enable tracing, then reproduce the problem on a freshly reloaded page.

+
+

Tracing controls

+

{observed_state}

+
+ + + +
+

+

If history is not useful, reopen the affected article on the exact same hostname and in this same tab, then reload once.

+ +
+
+

Setup request

+

These facts describe this setup request. They do not describe a previously affected publisher page.

+

Approximate network facts

+

Masked network identifiers are approximate and may still identify a network.

+
+

Owned cookie health

+

Only the shape of Trusted Server cookies visible in this request is inspected. Cookie values and browser cookie attributes are excluded.

+ + +
+
+ +"# + )) +} + +fn escape_text(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(character), + } + } + escaped +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone as _, Utc}; + use http::{HeaderMap, HeaderValue, header}; + + use crate::platform::ClientInfo; + use crate::trace::context::project_request_context; + use crate::trace::cookies::inspect_cookies; + + use super::*; + + fn context(cookie: &str) -> crate::trace::types::TraceRequestContextV1 { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(cookie).expect("should encode the fictional cookie fixture"), + ); + let cookies = inspect_cookies(&headers, None); + project_request_context( + &ClientInfo { + tls_cipher: Some("".to_owned()), + server_hostname: Some("\" onload='x' <&>".to_owned()), + ..ClientInfo::default() + }, + None, + &cookies, + Utc.with_ymd_and_hms(2026, 10, 5, 12, 0, 0) + .single() + .expect("should create the fixed UTC capture clock"), + ) + .expect("should project only bounded request facts") + } + + #[test] + fn trace_shell_uses_fixed_assets_and_escapes_context_as_text() { + let html = render_setup_shell(&context( + "unrelated=fictional-cookie-secret; __Host-ts-console=1", + )) + .expect("should serialize the redacted setup shell"); + assert!( + html.contains("Trusted Server ad diagnostics"), + "should use the approved page title" + ); + assert!( + html.contains("Setup request"), + "should distinguish setup facts from a traced publisher document" + ); + assert!( + html.contains("data-observed-active=\"true\""), + "should derive state from frozen cookie health" + ); + assert!( + html.contains("Tracing is on — cookie observed by server"), + "should display the setup request observation" + ); + assert!( + html.contains("/_ts/trace/assets/v1.js"), + "should reference the exact immutable script route" + ); + assert!( + html.contains("/_ts/trace/assets/v1.css"), + "should reference the exact immutable stylesheet route" + ); + assert_eq!( + html.matches(""), + "should never execute reflected platform facts" + ); + assert!( + !html.contains("fictional-cookie-secret"), + "should never retain unrelated cookie values" + ); + assert!( + !html.contains("window."), + "should not inline executable request data" + ); + assert!( + !html.contains("style="), + "should respect the external-only stylesheet CSP" + ); + } + + #[test] + fn trace_shell_off_state_preserves_end_and_recovery_accessibility() { + let html = render_setup_shell(&context("unrelated=comma, __Host-ts-console=1")) + .expect("should retain setup controls when cookie inspection is ambiguous"); + assert!( + html.contains("data-observed-active=\"false\""), + "should never activate an ambiguous session" + ); + assert!( + html.contains("no valid diagnostics session observed"), + "should describe observation without claiming cookie absence" + ); + for id in ["trace-enable", "trace-end", "trace-back", "trace-status"] { + assert!( + html.contains(&format!("id=\"{id}\"")), + "should expose all deliberate controls and status" + ); + } + assert!( + html.contains("aria-live=\"polite\""), + "should announce action results accessibly" + ); + assert!( + html.contains("exact same hostname and in this same tab"), + "should retain history-independent recovery guidance" + ); + assert!( + html.contains("reload once"), + "should require a fresh cookie-bearing reproduction document" + ); + assert!( + html.contains("data:image/png;base64,"), + "should avoid an automatic publisher favicon request" + ); + assert!( + html.contains("width=device-width, initial-scale=1"), + "should preserve mobile zoom" + ); + assert!( + !html.contains("maximum-scale"), + "should not prevent browser zoom" + ); + } +} diff --git a/crates/trusted-server-core/src/trace/slot_refs.rs b/crates/trusted-server-core/src/trace/slot_refs.rs new file mode 100644 index 000000000..b110bcd1b --- /dev/null +++ b/crates/trusted-server-core/src/trace/slot_refs.rs @@ -0,0 +1,169 @@ +//! Optional client tokens follow the exact accepted conversion occurrence. + +use std::fmt; +use std::sync::{Arc, Mutex}; + +use serde::Deserialize; +use serde::de::{DeserializeSeed, Deserializer, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde_json::value::RawValue; + +use super::TraceSlotRef; + +/// Raw input occurrences and definitive accepted occurrences are separate. +/// Only the normal converter decides which source occurrence becomes a slot. +#[derive(Clone, Default)] +pub(crate) struct TraceClientSlotRefs { + source: Arc<[Option]>, + accepted: Arc>>>, +} + +impl TraceClientSlotRefs { + pub(crate) fn from_raw(body: &[u8]) -> Self { + let source = serde_json::from_slice::(body) + .map(|request| request.ad_units.0) + .unwrap_or_default(); + Self { + source: Arc::from(source), + accepted: Arc::new(Mutex::new(Vec::new())), + } + } + + pub(crate) fn begin_conversion(&self) { + if let Ok(mut accepted) = self.accepted.lock() { + accepted.clear(); + } + } + + pub(crate) fn record_accepted(&self, input_index: usize) { + if let Ok(mut accepted) = self.accepted.lock() { + accepted.push(self.source.get(input_index).cloned().flatten()); + } + } + + pub(crate) fn accepted_refs(&self) -> Option>> { + self.accepted.lock().ok().map(|accepted| accepted.clone()) + } +} + +#[derive(Deserialize)] +struct RawTraceRequest { + #[serde(rename = "adUnits")] + ad_units: RawTraceUnits, +} + +struct RawTraceUnits(Vec>); + +impl<'de> Deserialize<'de> for RawTraceUnits { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_seq(TraceUnitsVisitor) + } +} + +struct TraceUnitsVisitor; + +impl<'de> Visitor<'de> for TraceUnitsVisitor { + type Value = RawTraceUnits; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("the already-accepted adUnits array") + } + + fn visit_seq>(self, mut sequence: A) -> Result { + let mut units = Vec::new(); + while let Some(unit) = sequence.next_element::<&RawValue>()? { + // Borrow only during extraction. Optional numeric range failures + // cannot discard another unit's valid association. + let mut parser = serde_json::Deserializer::from_str(unit.get()); + units.push( + TraceMemberStage::Unit + .deserialize(&mut parser) + .unwrap_or(None), + ); + } + Ok(RawTraceUnits(units)) + } +} + +#[derive(Clone, Copy)] +enum TraceMemberStage { + Unit, + Extension, + Namespace, + Token, +} + +impl TraceMemberStage { + fn member(self) -> Option<(&'static str, Self)> { + match self { + Self::Unit => Some(("ext", Self::Extension)), + Self::Extension => Some(("trusted_server", Self::Namespace)), + Self::Namespace => Some(("trace_slot_ref", Self::Token)), + Self::Token => None, + } + } +} + +impl<'de> DeserializeSeed<'de> for TraceMemberStage { + type Value = Option; + + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_any(TraceMemberVisitor(self)) + } +} + +struct TraceMemberVisitor(TraceMemberStage); + +impl<'de> Visitor<'de> for TraceMemberVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an optional trace namespace member") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut seen = false; + let mut duplicate = false; + let mut token = None; + while let Some(key) = map.next_key::()? { + if let Some((member, stage)) = self.0.member() + && key == member + { + duplicate |= seen; + seen = true; + token = map.next_value_seed(stage)?; + } else { + map.next_value::()?; + } + } + Ok(if duplicate { None } else { token }) + } + + fn visit_seq>(self, mut sequence: A) -> Result { + while sequence.next_element::()?.is_some() {} + Ok(None) + } + + fn visit_str(self, value: &str) -> Result { + Ok(if matches!(self.0, TraceMemberStage::Token) { + TraceSlotRef::parse(value).ok() + } else { + None + }) + } + + fn visit_bool(self, _value: bool) -> Result { + Ok(None) + } + fn visit_i64(self, _value: i64) -> Result { + Ok(None) + } + fn visit_u64(self, _value: u64) -> Result { + Ok(None) + } + fn visit_f64(self, _value: f64) -> Result { + Ok(None) + } + fn visit_unit(self) -> Result { + Ok(None) + } +} diff --git a/crates/trusted-server-core/src/trace/types.rs b/crates/trusted-server-core/src/trace/types.rs new file mode 100644 index 000000000..5f880471e --- /dev/null +++ b/crates/trusted-server-core/src/trace/types.rs @@ -0,0 +1,358 @@ +//! Closed public request and cookie projection types. + +use serde::Serialize; + +/// Shape of one incoming reserved cookie without its value. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CookieHealthState { + /// No exact occurrence is visible. + Absent, + /// One complete value passes its canonical validator. + PresentValid, + /// One occurrence is malformed, oversized or unsupported. + PresentInvalid, + /// More than one exact occurrence is visible. + Duplicate, + /// Aggregate inspection cannot be trusted. + Unavailable, +} + +/// Bounded explanation of cookie shape without values or parser messages. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CookieHealthDetail { + /// The complete EC value passes the canonical validator. + ValidEcFormat, + /// The complete EID value passes the bounded existing parser. + ValidEidsFormat, + /// The tester value is exactly `true`. + ValidTesterValue, + /// The diagnostics value is exactly `1`. + ValidDiagnosticsValue, + /// The occurrence does not match the expected syntax. + Malformed, + /// The visible value exceeds its individual bound. + Oversized, + /// The complete literal value is unsupported. + UnsupportedValue, + /// More than one exact reserved occurrence is visible. + MultipleValues, + /// Visible Cookie fields exceed the aggregate byte bound. + HeaderTooLarge, + /// A visible Cookie field contains actual invalid UTF-8 bytes. + HeaderNotUtf8, + /// Markers make unproved runtime preservation ambiguous. + RuntimeHeaderAmbiguous, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum CookieSource { + Request, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub(super) enum ValidCookieDetail { + #[serde(rename = "valid_ec_format")] + EcFormat, + #[serde(rename = "valid_eids_format")] + EidsFormat, + #[serde(rename = "valid_tester_value")] + TesterValue, + #[serde(rename = "valid_diagnostics_value")] + DiagnosticsValue, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum InvalidCookieDetail { + Malformed, + Oversized, + UnsupportedValue, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum UnavailableCookieDetail { + HeaderTooLarge, + HeaderNotUtf8, + RuntimeHeaderAmbiguous, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum DuplicateCookieDetail { + MultipleValues, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +enum CookieOutcome { + Absent, + PresentValid { detail: ValidCookieDetail }, + PresentInvalid { detail: InvalidCookieDetail }, + Duplicate { detail: DuplicateCookieDetail }, + Unavailable { detail: UnavailableCookieDetail }, +} + +/// Immutable health with only legal state and detail combinations. +/// +/// Construction stays inside the scanner, which also binds valid details to +/// their reserved cookie names. Serialization always uses `source: request`. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub struct CookieHealth { + source: CookieSource, + #[serde(flatten)] + outcome: CookieOutcome, +} + +impl CookieHealth { + /// Return the observed shape of this cookie. + /// + /// # Examples + /// + /// ``` + /// use http::HeaderMap; + /// use trusted_server_core::trace::{CookieHealthState, inspect_cookies}; + /// let cookies = inspect_cookies(&HeaderMap::new(), None); + /// assert_eq!(cookies.ts_ec().state(), CookieHealthState::Absent); + /// ``` + #[must_use] + pub fn state(&self) -> CookieHealthState { + match self.outcome { + CookieOutcome::Absent => CookieHealthState::Absent, + CookieOutcome::PresentValid { .. } => CookieHealthState::PresentValid, + CookieOutcome::PresentInvalid { .. } => CookieHealthState::PresentInvalid, + CookieOutcome::Duplicate { .. } => CookieHealthState::Duplicate, + CookieOutcome::Unavailable { .. } => CookieHealthState::Unavailable, + } + } + + /// Return the bounded explanation, absent when no occurrence is visible. + /// + /// # Examples + /// + /// ``` + /// let cookies = trusted_server_core::trace::inspect_cookies(&http::HeaderMap::new(), None); + /// assert_eq!(cookies.ts_ec().detail(), None); + /// ``` + #[must_use] + pub fn detail(&self) -> Option { + Some(match self.outcome { + CookieOutcome::Absent => return None, + CookieOutcome::PresentValid { detail } => match detail { + ValidCookieDetail::EcFormat => CookieHealthDetail::ValidEcFormat, + ValidCookieDetail::EidsFormat => CookieHealthDetail::ValidEidsFormat, + ValidCookieDetail::TesterValue => CookieHealthDetail::ValidTesterValue, + ValidCookieDetail::DiagnosticsValue => CookieHealthDetail::ValidDiagnosticsValue, + }, + CookieOutcome::PresentInvalid { detail } => match detail { + InvalidCookieDetail::Malformed => CookieHealthDetail::Malformed, + InvalidCookieDetail::Oversized => CookieHealthDetail::Oversized, + InvalidCookieDetail::UnsupportedValue => CookieHealthDetail::UnsupportedValue, + }, + CookieOutcome::Duplicate { .. } => CookieHealthDetail::MultipleValues, + CookieOutcome::Unavailable { detail } => match detail { + UnavailableCookieDetail::HeaderTooLarge => CookieHealthDetail::HeaderTooLarge, + UnavailableCookieDetail::HeaderNotUtf8 => CookieHealthDetail::HeaderNotUtf8, + UnavailableCookieDetail::RuntimeHeaderAmbiguous => { + CookieHealthDetail::RuntimeHeaderAmbiguous + } + }, + }) + } + + pub(super) const fn absent() -> Self { + Self::new(CookieOutcome::Absent) + } + + pub(super) const fn valid(detail: ValidCookieDetail) -> Self { + Self::new(CookieOutcome::PresentValid { detail }) + } + + pub(super) const fn invalid(detail: InvalidCookieDetail) -> Self { + Self::new(CookieOutcome::PresentInvalid { detail }) + } + + pub(super) const fn duplicate() -> Self { + Self::new(CookieOutcome::Duplicate { + detail: DuplicateCookieDetail::MultipleValues, + }) + } + + pub(super) const fn unavailable(detail: UnavailableCookieDetail) -> Self { + Self::new(CookieOutcome::Unavailable { detail }) + } + + const fn new(outcome: CookieOutcome) -> Self { + Self { + source: CookieSource::Request, + outcome, + } + } +} + +/// Frozen redacted observations of the four reserved cookies. +/// +/// This owns no incoming headers, values, fidelity metadata or identity state. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub struct TraceCookies { + ts_ec: CookieHealth, + ts_eids: CookieHealth, + ts_tester: CookieHealth, + diagnostics_session: CookieHealth, +} + +impl TraceCookies { + /// Report whether exactly one valid diagnostics session was inspected. + /// + /// A false result means no valid session was observed, not cookie absence. + /// + /// # Examples + /// + /// ``` + /// let cookies = trusted_server_core::trace::inspect_cookies(&http::HeaderMap::new(), None); + /// assert!(!cookies.observed_active()); + /// ``` + #[must_use] + pub const fn observed_active(&self) -> bool { + matches!( + self.diagnostics_session.outcome, + CookieOutcome::PresentValid { + detail: ValidCookieDetail::DiagnosticsValue + } + ) + } + + pub(super) const fn new(health: [CookieHealth; 4]) -> Self { + Self { + ts_ec: health[0], + ts_eids: health[1], + ts_tester: health[2], + diagnostics_session: health[3], + } + } + + /// Borrow the frozen EC shape. + /// + /// # Examples + /// + /// ``` + /// let cookies = trusted_server_core::trace::inspect_cookies(&http::HeaderMap::new(), None); + /// assert_eq!(cookies.ts_ec().detail(), None); + /// ``` + #[must_use] + pub const fn ts_ec(&self) -> &CookieHealth { + &self.ts_ec + } + + /// Borrow the frozen EID shape. + /// + /// # Examples + /// + /// ``` + /// let cookies = trusted_server_core::trace::inspect_cookies(&http::HeaderMap::new(), None); + /// assert_eq!(cookies.ts_eids().detail(), None); + /// ``` + #[must_use] + pub const fn ts_eids(&self) -> &CookieHealth { + &self.ts_eids + } + + /// Borrow the frozen tester shape. + /// + /// # Examples + /// + /// ``` + /// let cookies = trusted_server_core::trace::inspect_cookies(&http::HeaderMap::new(), None); + /// assert_eq!(cookies.ts_tester().detail(), None); + /// ``` + #[must_use] + pub const fn ts_tester(&self) -> &CookieHealth { + &self.ts_tester + } + + /// Borrow the frozen diagnostics-session shape. + /// + /// # Examples + /// + /// ``` + /// let cookies = trusted_server_core::trace::inspect_cookies(&http::HeaderMap::new(), None); + /// assert_eq!(cookies.diagnostics_session().detail(), None); + /// ``` + #[must_use] + pub const fn diagnostics_session(&self) -> &CookieHealth { + &self.diagnostics_session + } +} + +/// Optional allowlisted network facts with bounded strings and a masked IP. +/// +/// Unsupported HTTP-version and POP enrichment remains absent in this phase. +#[derive(Clone, Debug, Serialize)] +pub struct TraceNetwork { + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) masked_client_ip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) country: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) region: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) asn: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) tls_protocol: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) tls_cipher: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) edge_hostname: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) edge_region: Option, +} + +/// Immutable version-one request context without page or identity properties. +#[derive(Clone, Debug, Serialize)] +pub struct TraceRequestContextV1 { + pub(super) schema_version: u8, + pub(super) captured_at: String, + pub(super) network: TraceNetwork, + pub(super) cookies: TraceCookies, +} + +impl TraceRequestContextV1 { + /// Borrow the frozen cookie observations used by state and capture gates. + /// + /// # Examples + /// + /// ```ignore + /// let observed_active = context.cookies().observed_active(); + /// ``` + #[must_use] + pub const fn cookies(&self) -> &TraceCookies { + &self.cookies + } + + /// Borrow the exact UTC capture timestamp. + /// + /// # Examples + /// + /// ```ignore + /// assert!(context.captured_at().ends_with('Z')); + /// ``` + #[must_use] + pub fn captured_at(&self) -> &str { + &self.captured_at + } + + /// Borrow the allowlisted network projection. + /// + /// # Examples + /// + /// ```ignore + /// let network = serde_json::to_value(context.network())?; + /// ``` + #[must_use] + pub const fn network(&self) -> &TraceNetwork { + &self.network + } +} diff --git a/crates/trusted-server-integration-tests/Cargo.toml b/crates/trusted-server-integration-tests/Cargo.toml index 39f985860..a1a632b58 100644 --- a/crates/trusted-server-integration-tests/Cargo.toml +++ b/crates/trusted-server-integration-tests/Cargo.toml @@ -25,14 +25,18 @@ edgezero-core = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } trusted-server-core = { workspace = true, features = ["test-utils"] } +url = { workspace = true } [dev-dependencies] async-trait = { workspace = true } +base64 = { workspace = true } axum = { workspace = true } bytes = { workspace = true } derive_more = { workspace = true } edgezero-adapter-axum = { workspace = true, features = ["axum"] } env_logger = { workspace = true } +futures = { workspace = true } +trusted-server-js = { workspace = true } error-stack = { workspace = true } http = { workspace = true } http-body-util = { workspace = true } @@ -41,6 +45,8 @@ log = { workspace = true } reqwest = { workspace = true, features = ["blocking", "cookies"] } scraper = { workspace = true } testcontainers = { workspace = true } +temp-env = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } tower = { workspace = true, features = ["util"] } trusted-server-adapter-axum = { path = "../trusted-server-adapter-axum" } diff --git a/crates/trusted-server-integration-tests/browser/global-setup.ts b/crates/trusted-server-integration-tests/browser/global-setup.ts index f54d92dbe..efded44d8 100644 --- a/crates/trusted-server-integration-tests/browser/global-setup.ts +++ b/crates/trusted-server-integration-tests/browser/global-setup.ts @@ -1,80 +1,98 @@ -import { writeFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import type { TestState } from './helpers/state.js' import { startContainer, startViceroy, stopContainer, stopViceroy, -} from "./helpers/infra.js"; +} from './helpers/infra.js' -const STATE_FILE = resolve(__dirname, ".browser-test-state.json"); +const STATE_FILE = resolve(__dirname, '.browser-test-state.json') const WASM_PATH = process.env.WASM_BINARY_PATH || resolve( __dirname, - "../../../target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm", - ); + '../../../target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm' + ) const VICEROY_CONFIG = process.env.VICEROY_CONFIG_PATH || resolve( __dirname, - "../../../target/integration-test-artifacts/configs/viceroy.toml", - ); + '../../../target/integration-test-artifacts/configs/viceroy.toml' + ) /** Persist current state so global-teardown can always clean up. */ -function writeState(state: { - baseUrl?: string; - containerId?: string; - viceroyPid?: number; - framework: string; -}): void { - writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +function writeState(state: Partial & { framework: string }): void { + writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)) } async function globalSetup(): Promise { - const framework = process.env.TEST_FRAMEWORK || "nextjs"; - let containerId: string | undefined; - let viceroyPid: number | undefined; + const framework = process.env.TEST_FRAMEWORK || 'nextjs' + let containerId: string | undefined + let viceroyPid: number | undefined + const extraViceroyPids: number[] = [] try { - console.log(`[global-setup] Starting ${framework} container...`); - containerId = await startContainer(framework); + console.log(`[global-setup] Starting ${framework} container...`) + containerId = await startContainer(framework) // Write partial state immediately so teardown can stop the container // even if Viceroy startup fails below. - writeState({ containerId, framework }); + writeState({ containerId, framework }) - console.log(`[global-setup] Starting Viceroy (WASM: ${WASM_PATH})...`); - const viceroy = await startViceroy(WASM_PATH, VICEROY_CONFIG); - viceroyPid = viceroy.process.pid; + console.log(`[global-setup] Starting Viceroy (WASM: ${WASM_PATH})...`) + const viceroy = await startViceroy(WASM_PATH, VICEROY_CONFIG) + viceroyPid = viceroy.process.pid - console.log(`[global-setup] Viceroy ready at ${viceroy.baseUrl}`); + console.log(`[global-setup] Viceroy ready at ${viceroy.baseUrl}`) // Write complete state for tests and teardown - writeState({ + const state: Partial & { framework: string } = { baseUrl: viceroy.baseUrl, containerId, viceroyPid, framework, - }); + extraViceroyPids, + } + writeState(state) + const traceConfigs = [ + [process.env.TRACE_VICEROY_CONFIG_PATH, 'traceBaseUrl'], + [process.env.TRACE_AUTH_VICEROY_CONFIG_PATH, 'traceAuthBaseUrl'], + ] as const + for (const [config, key] of traceConfigs) { + if (!config) continue + const fixture = await startViceroy(WASM_PATH, config) + if (fixture.process.pid !== undefined) + extraViceroyPids.push(fixture.process.pid) + state[key] = fixture.baseUrl + writeState(state) + } } catch (err) { // Clean up any resources that were started before re-throwing - console.error("[global-setup] Setup failed, cleaning up..."); - if (viceroyPid) await stopViceroy(viceroyPid); - if (containerId) stopContainer(containerId); + console.error('[global-setup] Setup failed, cleaning up...') + for (const pid of [viceroyPid, ...extraViceroyPids]) { + if (pid === undefined) continue + try { + await stopViceroy(pid) + } catch { + console.warn(`[global-setup] Could not stop owned runtime ${pid}`) + } + } + if (containerId) stopContainer(containerId) // Remove partial state file since we cleaned up manually try { - const { unlinkSync } = await import("node:fs"); - unlinkSync(STATE_FILE); + const { unlinkSync } = await import('node:fs') + unlinkSync(STATE_FILE) } catch { // State file may not exist } - throw err; + throw err } } -export default globalSetup; +export default globalSetup diff --git a/crates/trusted-server-integration-tests/browser/global-teardown.ts b/crates/trusted-server-integration-tests/browser/global-teardown.ts index 97bdf2828..dbc895199 100644 --- a/crates/trusted-server-integration-tests/browser/global-teardown.ts +++ b/crates/trusted-server-integration-tests/browser/global-teardown.ts @@ -1,33 +1,47 @@ -import { readFileSync, unlinkSync } from "node:fs"; -import { resolve } from "node:path"; -import { stopContainer, stopViceroy } from "./helpers/infra.js"; +import { readFileSync, unlinkSync } from 'node:fs' +import { resolve } from 'node:path' +import { stopContainer, stopViceroy } from './helpers/infra.js' -const STATE_FILE = resolve(__dirname, ".browser-test-state.json"); +const STATE_FILE = resolve(__dirname, '.browser-test-state.json') async function globalTeardown(): Promise { - let state: { containerId?: string; viceroyPid?: number }; + let state: { + containerId?: string + viceroyPid?: number + extraViceroyPids?: number[] + } try { - state = JSON.parse(readFileSync(STATE_FILE, "utf-8")); + state = JSON.parse(readFileSync(STATE_FILE, 'utf-8')) } catch { - console.warn("[global-teardown] No state file found, nothing to clean up"); - return; + console.warn('[global-teardown] No state file found, nothing to clean up') + return } - if (state.viceroyPid) { - console.log(`[global-teardown] Stopping Viceroy (pid: ${state.viceroyPid})`); - await stopViceroy(state.viceroyPid); + let cleanupError: unknown + const pids = new Set([state.viceroyPid, ...(state.extraViceroyPids ?? [])]) + for (const pid of pids) { + if (pid === undefined) continue + console.log(`[global-teardown] Stopping Viceroy (pid: ${pid})`) + try { + await stopViceroy(pid) + } catch (error) { + cleanupError ??= error + } } if (state.containerId) { - console.log(`[global-teardown] Stopping container ${state.containerId.slice(0, 12)}...`); - stopContainer(state.containerId); + console.log( + `[global-teardown] Stopping container ${state.containerId.slice(0, 12)}...` + ) + stopContainer(state.containerId) } try { - unlinkSync(STATE_FILE); + unlinkSync(STATE_FILE) } catch { // Already removed } + if (cleanupError) throw cleanupError } -export default globalTeardown; +export default globalTeardown diff --git a/crates/trusted-server-integration-tests/browser/helpers/state.ts b/crates/trusted-server-integration-tests/browser/helpers/state.ts index b8f5d4b66..1ca34367c 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/state.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/state.ts @@ -1,35 +1,42 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' export interface TestState { - baseUrl: string; - containerId: string; - viceroyPid: number; - framework: string; + baseUrl: string + containerId: string + viceroyPid: number + framework: string + traceBaseUrl?: string + traceAuthBaseUrl?: string + extraViceroyPids?: number[] } -const KNOWN_FRAMEWORKS = ["nextjs", "wordpress"] as const; +const KNOWN_FRAMEWORKS = ['nextjs', 'wordpress'] as const -const STATE_FILE = resolve(__dirname, "../.browser-test-state.json"); -let cachedState: TestState | undefined; +const STATE_FILE = resolve(__dirname, '../.browser-test-state.json') +let cachedState: TestState | undefined /** Read the state written by global-setup.ts. */ export function readState(): TestState { - const state: TestState = JSON.parse(readFileSync(STATE_FILE, "utf-8")); - if (!KNOWN_FRAMEWORKS.includes(state.framework as (typeof KNOWN_FRAMEWORKS)[number])) { + const state: TestState = JSON.parse(readFileSync(STATE_FILE, 'utf-8')) + if ( + !KNOWN_FRAMEWORKS.includes( + state.framework as (typeof KNOWN_FRAMEWORKS)[number] + ) + ) { throw new Error( - `Unknown framework "${state.framework}" in state file. Expected one of: ${KNOWN_FRAMEWORKS.join(", ")}`, - ); + `Unknown framework "${state.framework}" in state file. Expected one of: ${KNOWN_FRAMEWORKS.join(', ')}` + ) } - return state; + return state } /** Read the state once and reuse it for the rest of the test process. */ function getCachedState(): TestState { - return (cachedState ??= readState()); + return (cachedState ??= readState()) } /** Resolve an absolute runtime URL from the current browser test state. */ export function runtimeUrl(path: string): string { - return new URL(path, getCachedState().baseUrl).toString(); + return new URL(path, getCachedState().baseUrl).toString() } diff --git a/crates/trusted-server-integration-tests/browser/helpers/trace-fixture.ts b/crates/trusted-server-integration-tests/browser/helpers/trace-fixture.ts new file mode 100644 index 000000000..eb02de88c --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/helpers/trace-fixture.ts @@ -0,0 +1,59 @@ +import { expect, type Page } from '@playwright/test' +import { readState } from './state.js' + +/** Clicks the real handoff button inside the existing closed shadow root. */ +export async function clickTraceHandoff(page: Page): Promise { + const session = await page.context().newCDPSession(page) + try { + let backendNodeId: number | undefined + await expect + .poll(async () => { + const tree = await session.send('Accessibility.getFullAXTree') + const nodes = tree.nodes as Array<{ + ignored: boolean + backendDOMNodeId?: number + role?: { value?: string } + name?: { value?: string } + }> + const buttons = nodes.filter( + (node) => + !node.ignored && + node.role?.value === 'button' && + node.name?.value === 'View trace results' + ) + backendNodeId = + buttons.length === 1 ? buttons[0].backendDOMNodeId : undefined + return backendNodeId + }) + .toBeDefined() + const { model } = await session.send('DOM.getBoxModel', { backendNodeId }) + const points = model.border as number[] + // A real pointer click keeps browser user activation and the actual + // production button handler. No private callback or report is injected. + await page.mouse.click( + (points[0] + points[2] + points[4] + points[6]) / 4, + (points[1] + points[3] + points[5] + points[7]) / 4 + ) + } finally { + await session.detach() + } +} + +/** Resolves a browser-suitable localhost URL for the dedicated trace fixture. */ +export function traceRuntimeUrl(path: string, authenticated = false): string { + const state = readState() + const baseUrl = authenticated ? state.traceAuthBaseUrl : state.traceBaseUrl + if (!baseUrl) + throw new Error( + 'Run scripts/integration-tests-browser.sh to create the dedicated trace fixtures' + ) + const url = new URL(path, baseUrl) + url.hostname = 'localhost' + return url.toString() +} + +/** Fictional credentials already present in the integration secret-store fixture. */ +export const TRACE_FIXTURE_CREDENTIALS = { + username: 'admin', + password: 'integration-admin-password-32-bytes-ok', +} diff --git a/crates/trusted-server-integration-tests/browser/helpers/trace-gpt-fixture.js b/crates/trusted-server-integration-tests/browser/helpers/trace-gpt-fixture.js new file mode 100644 index 000000000..37644e6b7 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/helpers/trace-gpt-fixture.js @@ -0,0 +1,146 @@ +;(() => { + const slots = new Map() + const listeners = new Map() + const requests = [] + let initialLoadDisabled = false + + function emit(name, slot, facts = {}) { + for (const listener of listeners.get(name) || []) + listener({ slot, ...facts }) + } + + const service = { + getSlots: () => [...slots.values()], + getTargeting: () => [], + getTargetingKeys: () => [], + setTargeting() { + return service + }, + clearTargeting() { + return service + }, + enableSingleRequest() {}, + disableInitialLoad() { + initialLoadDisabled = true + }, + isInitialLoadDisabled: () => initialLoadDisabled, + addEventListener(name, callback) { + const callbacks = listeners.get(name) || [] + callbacks.push(callback) + listeners.set(name, callbacks) + return service + }, + removeEventListener(name, callback) { + listeners.set( + name, + (listeners.get(name) || []).filter((entry) => entry !== callback) + ) + return service + }, + refresh(selected = [...slots.values()]) { + for (const slot of selected) { + if (slots.get(slot.getSlotElementId()) !== slot) continue + requests.push({ + slot, + targeting: Object.fromEntries( + slot.getTargetingKeys().map((key) => [key, slot.getTargeting(key)]) + ), + }) + emit('slotRequested', slot) + } + }, + } + + window.googletag = { + apiReady: true, + pubadsReady: true, + cmd: { + push(...callbacks) { + callbacks.forEach((callback) => callback()) + return callbacks.length + }, + }, + pubads: () => service, + defineSlot(path, sizes, id) { + const targeting = new Map() + const dimensions = typeof sizes[0] === 'number' ? [sizes] : sizes + const slot = { + getSlotElementId: () => id, + getAdUnitPath: () => path, + getSizes: () => + dimensions.map(([width, height]) => ({ + getWidth: () => width, + getHeight: () => height, + })), + getTargeting: (key) => targeting.get(key) || [], + getTargetingKeys: () => [...targeting.keys()], + setTargeting(key, value) { + targeting.set( + key, + (Array.isArray(value) ? value : [value]).map(String) + ) + return slot + }, + clearTargeting(key) { + if (key === undefined) targeting.clear() + else targeting.delete(key) + return slot + }, + updateTargetingFromMap(values) { + for (const [key, value] of Object.entries(values)) { + if (value === null) targeting.delete(key) + else slot.setTargeting(key, value) + } + return slot + }, + addService: () => slot, + setConfig: () => slot, + } + slots.set(id, slot) + return slot + }, + destroySlots(selected = [...slots.values()]) { + for (const slot of selected) slots.delete(slot.getSlotElementId()) + return true + }, + enableServices() {}, + display(target) { + const id = + typeof target === 'string' + ? target + : target.getSlotElementId?.() || target.id + const slot = slots.get(id) + if (slot && !initialLoadDisabled) service.refresh([slot]) + }, + getConfig: () => ({ disableInitialLoad: initialLoadDisabled }), + setConfig(config) { + if (typeof config.disableInitialLoad === 'boolean') + initialLoadDisabled = config.disableInitialLoad + }, + } + + // This fixture supplies GPT's documented callbacks. Activation and auction + // evidence come exclusively from the real publisher response and built TSJS. + window.__traceGptFixture = { + requests, + complete(index) { + const request = requests[index] + if (!request) throw new Error('should complete an observed GPT request') + const adId = request.targeting.hb_adid?.[0] + if (adId) { + const frame = document.createElement('iframe') + frame.width = '300' + frame.height = '250' + frame.src = `https://creative.example.com/fixture-puc?adId=${encodeURIComponent(adId)}` + document + .getElementById(request.slot.getSlotElementId()) + .replaceChildren(frame) + } + emit('slotResponseReceived', request.slot) + emit('slotRenderEnded', request.slot, { + isEmpty: !adId, + ...(adId ? { size: [300, 250] } : {}), + }) + }, + } +})() diff --git a/crates/trusted-server-integration-tests/browser/helpers/trace-report-fixture.ts b/crates/trusted-server-integration-tests/browser/helpers/trace-report-fixture.ts new file mode 100644 index 000000000..0452e5a71 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/helpers/trace-report-fixture.ts @@ -0,0 +1,63 @@ +/** An explicitly browser-carried fixture; this does not prove live auction capture. */ +export function storedTraceReportFixture(origin: string, now = Date.now()) { + const capturedAt = new Date(now).toISOString() + const absent = { source: 'request', state: 'absent' } + const counts = { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 } + return { + stored_at_ms: now, + report: { + schema_version: 1, + captured_at: capturedAt, + request_context: { + schema_version: 1, + captured_at: capturedAt, + network: { + tls_cipher: '', + edge_hostname: `${'x'.repeat(116)}.example.com`, + }, + cookies: { + ts_ec: absent, + ts_eids: absent, + ts_tester: absent, + diagnostics_session: { + source: 'request', + state: 'unavailable', + detail: 'runtime_header_ambiguous', + }, + }, + }, + server_auctions: [], + slot_correlations: [], + gpt_diagnostics: { + schema_version: 1, + source_schema_version: 1, + capturedAt, + page: { origin, pathname: '/[redacted]' }, + slots: [], + callbackIssues: [], + coverage: { + slotRequested: counts, + slotResponseReceived: counts, + slotRenderEnded: counts, + slotOnload: counts, + impressionViewable: counts, + slotVisibilityChanged: counts, + }, + metadata: { + droppedCallbacks: 0, + evictedSlots: 0, + evictedRequestCycles: 0, + }, + }, + auction_coverage: { capture_status: 'not_observed', issues: [] }, + truncation: { + omitted_server_auctions: 0, + omitted_slot_correlations: 0, + omitted_request_cycles: 0, + omitted_callback_issues: 0, + omitted_attribution_issues: 0, + omitted_nested_values: 0, + }, + }, + } +} diff --git a/crates/trusted-server-integration-tests/browser/playwright.trace-runtime.config.ts b/crates/trusted-server-integration-tests/browser/playwright.trace-runtime.config.ts new file mode 100644 index 000000000..0f7a139c1 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/playwright.trace-runtime.config.ts @@ -0,0 +1,47 @@ +import { defineConfig } from '@playwright/test' + +const origin = process.env.TRACE_BROWSER_ORIGIN +const runtime = process.env.TRACE_BROWSER_RUNTIME +if ( + !origin || + !runtime || + !['fastly', 'axum', 'cloudflare', 'spin'].includes(runtime) +) + throw new Error( + 'The Rust trace browser harness must supply its runtime and origin' + ) +const url = new URL(origin) +if ( + url.protocol !== 'http:' || + url.hostname !== 'localhost' || + !url.port || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash +) + throw new Error( + 'Trace browser tests require an explicit localhost HTTP origin' + ) + +// Rust owns the runtime and origin container. This config never starts or stops +// another fixture, and Playwright creates a fresh browser context for each test. +export default defineConfig({ + testDir: './tests', + testMatch: 'shared/mobile-trace-runtime.spec.ts', + timeout: 60_000, + retries: 0, + workers: 1, + use: { + baseURL: url.origin, + headless: true, + viewport: { width: 390, height: 844 }, + acceptDownloads: true, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + projects: [{ name: runtime, use: { browserName: 'chromium' } }], + reporter: [['list']], + outputDir: `./test-results/trace-runtime-${runtime}`, +}) diff --git a/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts b/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts index 55996306a..93399fd95 100644 --- a/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts @@ -24,6 +24,32 @@ async function waitForApi(page: Page): Promise { ); } +async function captureClosedShadowRoots(page: Page): Promise { + await page.addInitScript(() => { + const roots = new WeakMap(); + const attachShadow = Element.prototype.attachShadow; + Object.defineProperty(Element.prototype, "attachShadow", { + configurable: true, + value(this: Element, init: ShadowRootInit): ShadowRoot { + const root = attachShadow.call(this, init); + roots.set(this, root); + return root; + }, + }); + (window as any).__gptDiagnosticsClosedRoots = roots; + }); +} + +async function diagnosticsText(page: Page): Promise { + return page.evaluate((hostId) => { + const host = document.getElementById(hostId); + const roots = (window as any).__gptDiagnosticsClosedRoots as + | WeakMap + | undefined; + return host && roots ? (roots.get(host)?.textContent ?? "") : ""; + }, HOST_ID); +} + async function emit( page: Page, name: string, @@ -188,6 +214,20 @@ test.describe("GPT runtime diagnostics", () => { }, testInfo) => { const pageErrors: string[] = []; const diagnosticNetworkRequests: string[] = []; + await captureClosedShadowRoots(page); + await page.addInitScript(() => { + const originalAttachShadow = Element.prototype.attachShadow; + Element.prototype.attachShadow = function (init: ShadowRootInit) { + const root = originalAttachShadow.call(this, init); + if ( + (this as HTMLElement).id === + "trusted-server-gpt-diagnostics" + ) { + (window as any).__gptDiagnosticsTestRoot = root; + } + return root; + }; + }); page.on("pageerror", (error) => pageErrors.push(error.message)); page.on("request", (request) => { if ( @@ -224,6 +264,9 @@ test.describe("GPT runtime diagnostics", () => { isBackfill: true, slotContentChanged: true, }); + await expect + .poll(() => diagnosticsText(page)) + .toContain("Fill 300×250"); await emit(page, "slotOnload", "gpt-diagnostics-slot-primary"); await emit(page, "impressionViewable", "gpt-diagnostics-slot-primary"); await emit( @@ -271,6 +314,9 @@ test.describe("GPT runtime diagnostics", () => { slot.slotElementId === "gpt-diagnostics-slot-secondary", ); expect(primary.binding).toEqual({ status: "bound" }); + expect(snapshot.slots.map((slot: any) => slot.runtimeSlotNumber)).toEqual([ + 1, 2, + ]); expect( primary.requests.map((cycle: any) => cycle.requestNumber), ).toEqual([1, 2, 3]); @@ -331,17 +377,109 @@ test.describe("GPT runtime diagnostics", () => { await page.evaluate(() => (window as any).tsjs.gptDiagnostics.hide()); await expect(page.locator(`#${HOST_ID}`)).toHaveCount(0); await emit(page, "slotRequested", "gpt-diagnostics-slot-secondary"); + await emit( + page, + "slotResponseReceived", + "gpt-diagnostics-slot-secondary", + ); + await emit(page, "slotRenderEnded", "gpt-diagnostics-slot-secondary", { + isEmpty: false, + size: [1, 1], + }); await page.evaluate(() => (window as any).tsjs.gptDiagnostics.show()); await expect(page.locator(`#${HOST_ID}`)).toHaveCount(1); const hiddenPeriodSnapshot = await page.evaluate(() => (window as any).tsjs.gptDiagnostics.snapshot(), ); - expect( - hiddenPeriodSnapshot.slots.find( - (slot: any) => - slot.slotElementId === "gpt-diagnostics-slot-secondary", - ).requests, - ).toHaveLength(2); + await page.waitForFunction(() => + Boolean( + (window as any).__gptDiagnosticsTestRoot?.querySelector( + ".tsgd-badge", + ), + ), + ); + const badgeIdentity = await page.evaluate(() => { + const badge = ( + window as any + ).__gptDiagnosticsTestRoot.querySelector( + ".tsgd-badge", + ) as HTMLButtonElement; + badge.focus(); + return { + tagName: badge.tagName, + text: badge.textContent, + ariaLabel: badge.getAttribute("aria-label"), + runtimeSlotNumber: badge.dataset.runtimeSlot, + requestNumber: badge.dataset.requestNumber, + }; + }); + expect(badgeIdentity).toMatchObject({ + tagName: "BUTTON", + text: expect.stringMatching(/Ad #\d+ · Request #\d+/), + ariaLabel: expect.stringMatching(/Ad #\d+, Request #\d+/), + }); + await page.keyboard.press("Enter"); + await page.waitForFunction( + ({ runtimeSlotNumber, requestNumber }) => { + const root = (window as any) + .__gptDiagnosticsTestRoot as ShadowRoot; + const selected = root?.querySelector( + `[aria-current="true"][data-runtime-slot="${runtimeSlotNumber}"][data-request-number="${requestNumber}"]`, + ); + return selected !== null && root.activeElement === selected; + }, + { + runtimeSlotNumber: badgeIdentity.runtimeSlotNumber, + requestNumber: badgeIdentity.requestNumber, + }, + ); + await page.evaluate( + ({ runtimeSlotNumber, requestNumber }) => { + const root = (window as any) + .__gptDiagnosticsTestRoot as ShadowRoot; + const selected = root.querySelector( + `[aria-current="true"][data-runtime-slot="${runtimeSlotNumber}"][data-request-number="${requestNumber}"]`, + ); + const locate = Array.from( + selected + ?.closest(".tsgd-slot") + ?.querySelectorAll("button") ?? [], + ).find( + (candidate) => + candidate.textContent === "Locate on page", + ); + locate?.click(); + }, + { + runtimeSlotNumber: badgeIdentity.runtimeSlotNumber, + requestNumber: badgeIdentity.requestNumber, + }, + ); + await page.waitForFunction(() => + Boolean( + (window as any).__gptDiagnosticsTestRoot?.querySelector( + ".tsgd-highlight", + ), + ), + ); + const secondaryRequests = hiddenPeriodSnapshot.slots.find( + (slot: any) => + slot.slotElementId === "gpt-diagnostics-slot-secondary", + ).requests; + expect(secondaryRequests).toHaveLength(2); + expect(secondaryRequests[1]).toMatchObject({ + isEmpty: false, + size: [1, 1], + }); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(() => resolve()), + ), + ), + ); + expect(await diagnosticsText(page)).not.toContain("1×1"); const downloadPromise = page.waitForEvent("download"); await page.evaluate(() => (window as any).tsjs.gptDiagnostics.export()); @@ -355,6 +493,16 @@ test.describe("GPT runtime diagnostics", () => { const exported = JSON.parse(Buffer.concat(chunks).toString("utf8")); expect(exported.version).toBe(1); expect(exported.slots).toHaveLength(hiddenPeriodSnapshot.slots.length); + const exportedPrimary = exported.slots.find( + (slot: any) => + slot.slotElementId === "gpt-diagnostics-slot-primary", + ); + const exportedSecondary = exported.slots.find( + (slot: any) => + slot.slotElementId === "gpt-diagnostics-slot-secondary", + ); + expect(exportedPrimary.requests[0].size).toEqual([300, 250]); + expect(exportedSecondary.requests[1].size).toEqual([1, 1]); expect(exported.callbackIssues).toHaveLength( hiddenPeriodSnapshot.callbackIssues.length, ); diff --git a/crates/trusted-server-integration-tests/browser/tests/nextjs/mobile-trace-live.spec.ts b/crates/trusted-server-integration-tests/browser/tests/nextjs/mobile-trace-live.spec.ts new file mode 100644 index 000000000..c6ef1bb6a --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/nextjs/mobile-trace-live.spec.ts @@ -0,0 +1,752 @@ +import { expect, test, type Page } from '@playwright/test' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { readState } from '../../helpers/state.js' +import { + clickTraceHandoff, + traceRuntimeUrl, +} from '../../helpers/trace-fixture.js' + +function bidderEndpoint(): string { + return `http://127.0.0.1:${process.env.INTEGRATION_ORIGIN_PORT || '8888'}/api/trace-bidder` +} + +const pucBanner = readFileSync( + resolve( + __dirname, + '../../node_modules/prebid-universal-creative/dist/banner.js' + ), + 'utf8' +) + +async function activatePublisher(page: Page, path: string): Promise { + await page.addInitScript({ + path: resolve(__dirname, '../../helpers/trace-gpt-fixture.js'), + }) + const publisher = traceRuntimeUrl(path) + await page.goto(publisher) + expect( + await page.evaluate(() => Reflect.get(window, '__tsjs_trace_active')) + ).toBeUndefined() + await page.goto(traceRuntimeUrl('/_ts/trace')) + await page + .getByRole('button', { name: 'Enable tracing', exact: true }) + .click() + await expect(page.locator('#trace-session-state')).toContainText( + 'Tracing is on' + ) + await page + .getByRole('button', { name: 'Return to previous page', exact: true }) + .click() + await page.waitForURL(publisher) + const response = await page.reload() + expect(response?.headers()['cache-control']).toContain('no-store') + await page.waitForFunction(() => { + const fixture = Reflect.get(window, '__traceGptFixture') + return ( + fixture?.requests.length > 0 && + Boolean(Reflect.get(window, 'tsjs')?.gptDiagnostics) + ) + }) + return publisher +} + +test.beforeEach(async ({ request }, testInfo) => { + if (readState().framework !== 'nextjs') testInfo.skip() + expect( + (await request.put(bidderEndpoint(), { data: { mode: 'empty' } })).status() + ).toBe(200) +}) + +test('captures a real zero-bid SSAT auction and carries its GPT cycle through the same-tab viewer', async ({ + page, + request, +}) => { + const observedRequests = (await (await request.get(bidderEndpoint())).json()) + .requests + const publisher = await activatePublisher( + page, + '/gpt-diagnostics?trace_fixture=empty' + ) + await page.evaluate(() => + Reflect.get(window, '__traceGptFixture').complete(0) + ) + await expect + .poll( + async () => (await (await request.get(bidderEndpoint())).json()).requests + ) + .toBeGreaterThanOrEqual(observedRequests + 2) + const evidence = await page.evaluate(() => + Reflect.get(window, 'tsjs').traceEvidence.snapshot() + ) + expect(evidence.ok).toBe(true) + expect(evidence.value.serverAuctions).toHaveLength(1) + expect(evidence.value.serverAuctions[0]).toMatchObject({ + source: 'initial_navigation_ssat', + terminal_status: 'completed', + provider_calls: [ + { + provider_number: 1, + role: 'bidder', + status: 'no_bid', + returned_bid_count: 0, + }, + ], + slots: [ + { slot_number: 1, candidate: 'no_candidate', returned_bid_count: 0 }, + ], + }) + expect(evidence.value.slotCorrelations).toHaveLength(1) + expect(evidence.value.slotCorrelations[0].diagnostic_auction_id).toBe( + evidence.value.serverAuctions[0].diagnostic_auction_id + ) + expect(evidence.value.slotCorrelations[0].slot_ref).toBe( + evidence.value.serverAuctions[0].slots[0].slot_ref + ) + await page.route('https://other.example/**', (route) => route.abort()) + await page.evaluate(() => { + const base = document.createElement('base') + base.href = 'https://other.example/' + document.head.prepend(base) + }) + expect(await page.evaluate(() => document.baseURI)).toBe('https://other.example/') + await clickTraceHandoff(page) + await page.waitForURL(traceRuntimeUrl('/_ts/trace')) + await expect( + page.getByRole('heading', { + name: 'Trusted Server trace results', + exact: true, + }) + ).toBeVisible() + await expect( + page.getByText('Browser-carried, unverified diagnostic data', { + exact: true, + }) + ).toBeVisible() + const report = await page.evaluate( + () => + JSON.parse(sessionStorage.getItem('trusted-server.trace.report.v1')!) + .report + ) + expect(report.server_auctions).toEqual(evidence.value.serverAuctions) + expect(report.slot_correlations).toEqual(evidence.value.slotCorrelations) + expect(report.gpt_diagnostics.page.origin).toBe(new URL(publisher).origin) + expect(report.gpt_diagnostics.page.pathname).toBe('/[redacted]') + expect(report.gpt_diagnostics.slots[0].requests[0].isEmpty).toBe(true) + await expect( + page.getByText('Auction 1: Initial-page server auction (SSAT)', { + exact: true, + }) + ).toBeVisible() + await expect( + page.getByText('Unavailable in v1', { + exact: true, + }) + ).toBeVisible() +}) + +test('retains the real failed-provider SSAT observation without claiming a delivered creative', async ({ + page, + request, +}) => { + expect( + (await request.put(bidderEndpoint(), { data: { mode: 'error' } })).status() + ).toBe(200) + const before = (await (await request.get(bidderEndpoint())).json()).requests + await activatePublisher(page, '/gpt-diagnostics') + await page.evaluate(() => + Reflect.get(window, '__traceGptFixture').complete(0) + ) + await expect + .poll( + async () => (await (await request.get(bidderEndpoint())).json()).requests + ) + .toBeGreaterThanOrEqual(before + 2) + const evidence = await page.evaluate(() => + Reflect.get(window, 'tsjs').traceEvidence.snapshot() + ) + expect(evidence.ok).toBe(true) + expect(evidence.value.serverAuctions).toHaveLength(1) + expect(evidence.value.serverAuctions[0]).toMatchObject({ + source: 'initial_navigation_ssat', + terminal_status: 'completed', + provider_calls: [{ status: 'error', returned_bid_count: 0 }], + slots: [{ candidate: 'no_candidate', returned_bid_count: 0 }], + }) + await expect(page.locator('iframe')).toHaveCount(0) + await clickTraceHandoff(page) + await page.waitForURL(traceRuntimeUrl('/_ts/trace')) + await expect(page.locator('#trace-report')).toBeVisible() + await expect( + page.getByText('Trusted Server creative rendered', { exact: true }) + ).toHaveCount(0) + const report = await page.evaluate( + () => + JSON.parse(sessionStorage.getItem('trusted-server.trace.report.v1')!) + .report + ) + expect(report.server_auctions).toEqual(evidence.value.serverAuctions) + expect(JSON.stringify(report)).not.toContain('controlled fixture failure') +}) + +test('proves a selected SSAT creative through the real PUC message bridge before reporting participation', async ({ + page, + request, +}) => { + expect( + ( + await request.put(bidderEndpoint(), { data: { mode: 'selected' } }) + ).status() + ).toBe(200) + const before = (await (await request.get(bidderEndpoint())).json()).requests + const publisherOrigin = new URL(traceRuntimeUrl('/')).origin + await page.route( + 'https://creative.example.com/fixture-puc*', + async (route) => { + const url = new URL(route.request().url()) + if (url.pathname === '/fixture-puc.js') { + await route.fulfill({ contentType: 'text/javascript', body: pucBanner }) + return + } + await route.fulfill({ + contentType: 'text/html', + body: ``, + }) + } + ) + await activatePublisher(page, '/gpt-diagnostics') + await page.evaluate(() => + Reflect.get(window, '__traceGptFixture').complete(0) + ) + await expect + .poll(async () => { + const texts = await Promise.all( + page.frames().map((frame) => frame.locator('.marker').allTextContents()) + ) + return texts.flat() + }) + .toContain('Trace fixture creative') + await expect + .poll( + async () => (await (await request.get(bidderEndpoint())).json()).requests + ) + .toBeGreaterThanOrEqual(before + 2) + await expect + .poll(() => + page.evaluate( + () => + Reflect.get(window, 'tsjs').gptDiagnostics.snapshot().slots[0] + ?.requests[0]?.delivery + ) + ) + .toBe('trusted_server_response_sent') + const evidence = await page.evaluate(() => + Reflect.get(window, 'tsjs').traceEvidence.snapshot() + ) + expect(evidence.ok).toBe(true) + expect(evidence.value.serverAuctions).toHaveLength(1) + expect(evidence.value.serverAuctions[0]).toMatchObject({ + source: 'initial_navigation_ssat', + terminal_status: 'completed', + slots: [{ candidate: 'selected', selected_creative_size: [300, 250] }], + }) + expect(evidence.value.slotCorrelations).toHaveLength(1) + expect(evidence.value.slotCorrelations[0]).toMatchObject({ + diagnostic_auction_id: + evidence.value.serverAuctions[0].diagnostic_auction_id, + slot_ref: evidence.value.serverAuctions[0].slots[0].slot_ref, + request_number: 1, + }) + await clickTraceHandoff(page) + await page.waitForURL(traceRuntimeUrl('/_ts/trace')) + await expect( + page + .locator('section') + .filter({ + has: page.getByRole('heading', { + name: 'Server auctions', + exact: true, + }), + }) + .getByText('Trusted Server creative rendered', { exact: true }) + ).toBeVisible() + const report = await page.evaluate( + () => + JSON.parse(sessionStorage.getItem('trusted-server.trace.report.v1')!) + .report + ) + expect(report.server_auctions).toEqual(evidence.value.serverAuctions) + expect(report.slot_correlations).toEqual(evidence.value.slotCorrelations) + expect(report.gpt_diagnostics.slots[0].requests[0]).toMatchObject({ + isEmpty: false, + delivery: 'trusted_server_response_sent', + trustedServerCreativeResponseAtMs: expect.any(Number), + renderAtMs: expect.any(Number), + }) + expect(JSON.stringify(report)).not.toContain('Trace fixture creative') + expect(JSON.stringify(report)).not.toContain('example-creative') +}) + +for (const mode of ['selected', 'empty'] as const) { + test(`captures the core /auction caller's real ${mode} response independently of GPT`, async ({ + page, + request, + }) => { + await activatePublisher(page, '/gpt-diagnostics') + expect( + (await request.put(bidderEndpoint(), { data: { mode } })).status() + ).toBe(200) + const before = (await (await request.get(bidderEndpoint())).json()).requests + const responsePromise = page.waitForResponse( + (response) => + new URL(response.url()).pathname === '/auction' && + response.request().method() === 'POST' + ) + await page.evaluate(() => { + const container = document.createElement('div') + container.id = 'example-core-api-slot' + document.body.append(container) + const api = Reflect.get(window, 'tsjs') + api.addAdUnits({ + code: container.id, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'example', params: {} }], + }) + api.requestAds() + }) + const response = await responsePromise + expect(response.status()).toBe(200) + expect(response.headers()['cache-control']).toContain('no-store') + const outgoing = response.request().postDataJSON() + const slotRef = outgoing.adUnits[0].ext.trusted_server.trace_slot_ref + expect(slotRef).toMatch( + /^ts-slot-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) + const body = await response.json() + const evidence = body.ext?.trusted_server?.trace_auction?.evidence + expect(evidence).toMatchObject({ + source: 'auction_api', + terminal_status: 'completed', + provider_calls: [ + { + provider_number: 1, + role: 'bidder', + status: mode === 'selected' ? 'success' : 'no_bid', + returned_bid_count: mode === 'selected' ? 1 : 0, + }, + ], + slots: [ + { + slot_number: 1, + slot_ref: slotRef, + candidate: mode === 'selected' ? 'selected' : 'no_candidate', + returned_bid_count: mode === 'selected' ? 1 : 0, + }, + ], + }) + await expect + .poll( + async () => + (await (await request.get(bidderEndpoint())).json()).requests + ) + .toBe(before + 1) + await expect + .poll(() => + page.evaluate(() => + Reflect.get(window, 'tsjs') + .traceEvidence.snapshot() + .value.serverAuctions.filter( + (auction: { source: string }) => auction.source === 'auction_api' + ) + ) + ) + .toEqual([evidence]) + const sidecars = await page.evaluate( + () => + Reflect.get(window, 'tsjs').traceEvidence.snapshot().value + .slotCorrelations + ) + expect( + sidecars.filter( + (sidecar: { diagnostic_auction_id: string }) => + sidecar.diagnostic_auction_id === evidence.diagnostic_auction_id + ) + ).toEqual([]) + const publicEvidence = JSON.stringify(evidence) + for (const forbidden of [ + 'example-core-api-slot', + 'example-bidder', + 'example-creative', + 'Trace fixture creative', + 'bidder.example.com', + ]) + expect(publicEvidence).not.toContain(forbidden) + if (mode === 'selected') { + await expect( + page.frameLocator('#example-core-api-slot iframe').locator('.marker') + ).toHaveText('Trace fixture creative') + } else { + await expect(page.locator('#example-core-api-slot iframe')).toHaveCount(0) + } + }) +} + +for (const transport of ['absent', 'malformed'] as const) { + test(`preserves the real core API creative when optional trace evidence is ${transport}`, async ({ + page, + request, + }) => { + await activatePublisher(page, '/gpt-diagnostics') + expect( + ( + await request.put(bidderEndpoint(), { data: { mode: 'selected' } }) + ).status() + ).toBe(200) + const before = (await (await request.get(bidderEndpoint())).json()).requests + await page.route('**/auction', async (route) => { + const response = await route.fetch() + const body = await response.json() + // Preserve the actual bidder response and ads. Alter only the optional + // diagnostic member to exercise missing and invalid transport behavior. + expect(body.ext?.trusted_server?.trace_auction?.evidence.source).toBe( + 'auction_api' + ) + if (transport === 'absent') delete body.ext.trusted_server.trace_auction + else body.ext.trusted_server.trace_auction = { schema_version: 999 } + await route.fulfill({ response, json: body }) + }) + await page.evaluate(() => { + const container = document.createElement('div') + container.id = 'example-core-api-slot' + document.body.append(container) + const api = Reflect.get(window, 'tsjs') + api.addAdUnits({ + code: container.id, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'example', params: {} }], + }) + api.requestAds() + }) + await expect( + page.frameLocator('#example-core-api-slot iframe').locator('.marker') + ).toHaveText('Trace fixture creative') + await expect + .poll( + async () => + (await (await request.get(bidderEndpoint())).json()).requests + ) + .toBe(before + 1) + const captured = await page.evaluate(() => + Reflect.get(window, 'tsjs').traceEvidence.snapshot() + ) + expect(captured.ok).toBe(true) + expect( + captured.value.serverAuctions.filter( + (auction: { source: string }) => auction.source === 'auction_api' + ) + ).toEqual([]) + expect(captured.value.issues).toEqual( + transport === 'malformed' ? ['evidence_validation_failed'] : [] + ) + }) +} + +test('captures the pinned real Prebid adapter /auction response without a GPT join', async ({ + page, + request, +}) => { + await activatePublisher(page, '/gpt-diagnostics') + const dist = resolve(__dirname, '../../../../trusted-server-js/dist') + const manifest = JSON.parse( + readFileSync(resolve(dist, 'prebid/manifest.json'), 'utf8') + ) + const external = readFileSync(resolve(dist, 'prebid', manifest.filename)) + expect(createHash('sha256').update(external).digest('hex')).toBe( + manifest.sha256 + ) + await page.addScriptTag({ content: external.toString('utf8') }) + await page.addScriptTag({ path: resolve(dist, 'tsjs-prebid.js') }) + expect( + await page.evaluate(() => typeof Reflect.get(window, 'pbjs').onEvent) + ).toBe('function') + const before = (await (await request.get(bidderEndpoint())).json()).requests + const responsePromise = page.waitForResponse( + (response) => + new URL(response.url()).pathname === '/auction' && + response.request().method() === 'POST' + ) + await page.evaluate( + () => + new Promise((done) => { + Reflect.get(window, 'pbjs').requestBids({ + adUnits: [ + { + code: 'example-prebid-api-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [], + }, + ], + timeout: 3000, + bidsBackHandler: done, + }) + }) + ) + const response = await responsePromise + expect(response.status()).toBe(200) + expect(response.headers()['cache-control']).toContain('no-store') + const outgoing = response.request().postDataJSON() + const slotRef = outgoing.adUnits[0].ext.trusted_server.trace_slot_ref + expect(slotRef).toMatch( + /^ts-slot-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) + const body = await response.json() + const auction = body.ext?.trusted_server?.trace_auction?.evidence + expect(auction).toMatchObject({ + source: 'auction_api', + terminal_status: 'completed', + provider_calls: [{ status: 'no_bid', returned_bid_count: 0 }], + slots: [ + { slot_ref: slotRef, candidate: 'no_candidate', returned_bid_count: 0 }, + ], + }) + await expect + .poll( + async () => (await (await request.get(bidderEndpoint())).json()).requests + ) + .toBe(before + 1) + await expect + .poll(() => + page.evaluate(() => + Reflect.get(window, 'tsjs') + .traceEvidence.snapshot() + .value.serverAuctions.filter( + (record: { source: string }) => record.source === 'auction_api' + ) + ) + ) + .toEqual([auction]) + expect( + await page.evaluate( + (id) => + Reflect.get(window, 'tsjs') + .traceEvidence.snapshot() + .value.slotCorrelations.filter( + (sidecar: { diagnostic_auction_id: string }) => + sidecar.diagnostic_auction_id === id + ), + auction.diagnostic_auction_id + ) + ).toEqual([]) +}) + +for (const legacy of [false, true]) { + test(`carries real ${legacy ? 'legacy fallback' : 'canonical'} SPA page-bids into its exact GPT cycle`, async ({ + page, + request, + }) => { + await activatePublisher(page, '/gpt-diagnostics') + await page.evaluate(() => + Reflect.get(window, '__traceGptFixture').complete(0) + ) + const before = (await (await request.get(bidderEndpoint())).json()).requests + if (legacy) { + // Simulate an unavailable canonical route only. The legacy response and + // every auction/evidence payload still come from the real Rust server. + await page.route('**/_ts/page-bids?*', (route) => + route.fulfill({ status: 404, body: '' }) + ) + } + const endpoint = legacy ? '/__ts/page-bids' : '/_ts/page-bids' + const homeResponse = page.waitForResponse((response) => { + const url = new URL(response.url()) + return url.pathname === endpoint && url.searchParams.get('path') === '/' + }) + await page + .getByRole('navigation', { name: 'Fixture navigation', exact: true }) + .getByRole('link', { name: 'Home', exact: true }) + .click() + await page.waitForURL(traceRuntimeUrl('/')) + const home = await homeResponse + expect(home.status()).toBe(200) + const homeAuction = (await home.json()).trace_auction?.evidence + expect(homeAuction).toMatchObject({ + source: 'spa_page_bids', + terminal_status: 'skipped', + terminal_reason: 'no_eligible_slots', + slots: [], + }) + await expect + .poll(() => + page.evaluate( + (id) => + Reflect.get(window, 'tsjs') + .traceEvidence.snapshot() + .value.serverAuctions.some( + (auction: { diagnostic_auction_id: string }) => + auction.diagnostic_auction_id === id + ), + homeAuction.diagnostic_auction_id + ) + ) + .toBe(true) + const pageBidsResponse = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === endpoint && + url.searchParams.get('path') === '/gpt-diagnostics' + ) + }) + await page.goBack() + await page.waitForURL(traceRuntimeUrl('/gpt-diagnostics')) + const response = await pageBidsResponse + expect(response.status()).toBe(200) + expect(response.headers()['cache-control']).toContain('no-store') + expect(response.request().headers()['x-tsjs-page-bids']).toBe( + legacy ? 'fallback' : '1' + ) + const body = await response.json() + const auction = body.trace_auction?.evidence + expect(auction).toMatchObject({ + source: 'spa_page_bids', + terminal_status: 'completed', + provider_calls: [{ status: 'no_bid', returned_bid_count: 0 }], + slots: [{ candidate: 'no_candidate', returned_bid_count: 0 }], + }) + expect(body.slots[0].ext.trusted_server.trace_slot_ref).toBe( + auction.slots[0].slot_ref + ) + await expect + .poll( + async () => + (await (await request.get(bidderEndpoint())).json()).requests + ) + .toBe(before + 1) + await page.waitForFunction( + () => Reflect.get(window, '__traceGptFixture').requests.length >= 2 + ) + await page.evaluate(() => { + const fixture = Reflect.get(window, '__traceGptFixture') + fixture.complete(fixture.requests.length - 1) + }) + await expect + .poll(() => + page.evaluate( + (id) => + Reflect.get(window, 'tsjs') + .traceEvidence.snapshot() + .value.slotCorrelations.filter( + (sidecar: { diagnostic_auction_id: string }) => + sidecar.diagnostic_auction_id === id + ), + auction.diagnostic_auction_id + ) + ) + .toEqual([ + { + schema_version: 1, + diagnostic_auction_id: auction.diagnostic_auction_id, + slot_ref: auction.slots[0].slot_ref, + runtime_slot_number: expect.any(Number), + request_number: expect.any(Number), + }, + ]) + await clickTraceHandoff(page) + await page.waitForURL(traceRuntimeUrl('/_ts/trace')) + const report = await page.evaluate( + () => + JSON.parse(sessionStorage.getItem('trusted-server.trace.report.v1')!) + .report + ) + expect(report.server_auctions).toContainEqual(auction) + expect( + report.server_auctions.some( + (record: { source: string }) => + record.source === 'initial_navigation_ssat' + ) + ).toBe(true) + await expect( + page + .locator('section') + .filter({ + has: page.getByRole('heading', { + name: 'Server auctions', + exact: true, + }), + }) + .locator(':scope > details > summary') + ).toHaveText([ + 'Auction 1: Initial-page server auction (SSAT)', + 'Auction 2: Trusted Server page-refresh auction', + 'Auction 3: Trusted Server page-refresh auction', + ]) + }) +} + +test('controlled bidder serves real OpenRTB selected, empty and error responses', async ({ + request, +}) => { + const endpoint = bidderEndpoint() + const before = await request.get(endpoint) + expect(before.status()).toBe(200) + const observedRequests = (await before.json()).requests + const auction = (mode: string) => ({ + id: 'example-request', + site: { + page: `https://publisher.example.com/gpt-diagnostics?trace_fixture=${mode}`, + }, + imp: [{ id: 'example-slot', banner: { format: [{ w: 300, h: 250 }] } }], + }) + const selected = await request.post(endpoint, { data: auction('selected') }) + expect(selected.status()).toBe(200) + expect(await selected.json()).toMatchObject({ + id: 'example-request', + cur: 'USD', + seatbid: [ + { + seat: 'example-bidder', + bid: [{ impid: 'example-slot', price: 1, w: 300, h: 250 }], + }, + ], + }) + const empty = await request.post(endpoint, { data: auction('empty') }) + expect(empty.status()).toBe(200) + expect(await empty.json()).toEqual({ + id: 'example-request', + cur: 'USD', + seatbid: [], + }) + const failed = await request.post(endpoint, { data: auction('error') }) + expect(failed.status()).toBe(503) + expect(await failed.json()).toEqual({ error: 'controlled fixture failure' }) + const after = await request.get(endpoint) + expect((await after.json()).requests).toBe(observedRequests + 3) + const control = await request.put(endpoint, { data: { mode: 'selected' } }) + expect(control.status()).toBe(200) + expect(await control.json()).toEqual({ mode: 'selected' }) + const controlled = await request.post(endpoint, { + data: { + ...auction('empty'), + site: { page: 'https://publisher.example.com/gpt-diagnostics' }, + }, + }) + expect((await controlled.json()).seatbid).toHaveLength(1) + const invalidControl = await request.put(endpoint, { + data: { mode: 'unexpected' }, + }) + expect(invalidControl.status()).toBe(400) + for (const invalidBody of ['null', '{']) { + const invalid = await request.put(endpoint, { + data: invalidBody, + headers: { 'Content-Type': 'application/json' }, + }) + expect(invalid.status()).toBe(400) + expect(await invalid.json()).toEqual({ error: 'invalid fixture mode' }) + } + await request.put(endpoint, { data: { mode: 'empty' } }) +}) diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts index e7ee7c1c7..22e11321e 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts @@ -18,6 +18,27 @@ const PUC_BANNER = readFileSync( "utf8", ); +/** + * Read the renderer version from its declaration rather than restating it. + * + * The constant is bumped whenever the Universal Creative source changes, so a + * literal here turns every such change into an unrelated-looking failure in + * this spec. + */ +function universalCreativeRendererVersion(): number { + const source = readFileSync( + resolve(TSJS_CRATE, "lib/src/integrations/aps/render.ts"), + "utf8", + ); + const match = /APS_UNIVERSAL_CREATIVE_RENDERER_VERSION = (\d+)/.exec(source); + if (!match) { + throw new Error( + "could not read APS_UNIVERSAL_CREATIVE_RENDERER_VERSION from render.ts", + ); + } + return Number(match[1]); +} + function clientAuctionBundlePaths() { const manifestPath = resolve(TSJS_CRATE, "dist/prebid/manifest.json"); const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { @@ -566,7 +587,7 @@ parent.postMessage(JSON.stringify({ expect.objectContaining({ message: "Prebid Response", adId: result.acceptedAdId, - rendererVersion: 4, + rendererVersion: universalCreativeRendererVersion(), apsRenderer, }), ); diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/mobile-trace-runtime.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/mobile-trace-runtime.spec.ts new file mode 100644 index 000000000..691224c5b --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/mobile-trace-runtime.spec.ts @@ -0,0 +1,225 @@ +import { expect, test } from '@playwright/test' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { clickTraceHandoff } from '../../helpers/trace-fixture.js' + +const SESSION = '__Host-ts-console' +const REPORT_KEY = 'trusted-server.trace.report.v1' +const PUBLISHER_PATH = '/gpt-diagnostics' + +test.skip( + !process.env.TRACE_BROWSER_ORIGIN, + 'Run the Rust-owned runtime workflow with playwright.trace-runtime.config.ts' +) + +test('stores a real browser session and captures, views, exports and clears its publisher report', async ({ + page, + baseURL, +}) => { + if (!baseURL) + throw new Error('The Rust harness must supply the runtime origin') + const publisher = new URL(PUBLISHER_PATH, baseURL).toString() + const setup = new URL('/_ts/trace', baseURL).toString() + const controls: string[] = [] + page.on('request', (request) => { + const path = new URL(request.url()).pathname + if ( + path.startsWith('/_ts/trace/') && + !path.startsWith('/_ts/trace/assets/') + ) + controls.push(`${request.method()} ${path}`) + }) + // Only GPT's callbacks are mocked. The adapter serves all activation, + // request-context, TSJS, capture, handoff and viewer bytes itself. + await page.addInitScript({ + path: resolve(__dirname, '../../helpers/trace-gpt-fixture.js'), + }) + await page.goto(publisher) + expect( + await page.evaluate(() => Reflect.get(window, '__tsjs_trace_active')) + ).toBeUndefined() + expect( + (await page.context().cookies()).find((cookie) => cookie.name === SESSION) + ).toBeUndefined() + + const shell = await page.goto(setup) + expect(shell?.status()).toBe(200) + expect(shell?.headers()['cache-control']).toBe('no-store, private') + expect(shell?.headers()['set-cookie']).toBeUndefined() + await expect( + page.getByRole('heading', { name: 'Setup request', exact: true }) + ).toBeVisible() + expect(controls).toEqual([]) + expect( + (await page.context().cookies()).find((cookie) => cookie.name === SESSION) + ).toBeUndefined() + const historyLength = await page.evaluate(() => history.length) + const observedState = page.waitForResponse( + (response) => + new URL(response.url()).pathname === '/_ts/trace/state' && + response.request().method() === 'GET' + ) + await page + .getByRole('button', { name: 'Enable tracing', exact: true }) + .click() + expect(await (await observedState).json()).toEqual({ observed_active: true }) + await expect(page.locator('#trace-session-state')).toHaveText( + 'Tracing is on — cookie observed by server' + ) + expect(controls).toEqual(['POST /_ts/trace/enable', 'GET /_ts/trace/state']) + expect(await page.evaluate(() => history.length)).toBe(historyLength) + const cookie = (await page.context().cookies()).find( + (item) => item.name === SESSION + ) + expect(cookie).toMatchObject({ + value: '1', + domain: 'localhost', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'Lax', + }) + expect( + Math.abs((cookie?.expires ?? 0) - (Date.now() / 1000 + 1800)) + ).toBeLessThan(10) + expect(await page.evaluate(() => document.cookie)).not.toContain(SESSION) + + await page + .getByRole('button', { name: 'Return to previous page', exact: true }) + .click() + await page.waitForURL(publisher) + const documentResponse = await page.reload() + expect(documentResponse?.status()).toBe(200) + expect(documentResponse?.headers()['cache-control']).toContain('no-store') + expect(documentResponse?.headers()['cache-control']).toContain('private') + await page.waitForFunction(() => { + const api = Reflect.get(window, 'tsjs') + return ( + Reflect.get(window, '__tsjs_trace_active') === true && + Boolean(api?.gptDiagnostics && api?.traceEvidence) + ) + }) + const context = await page.evaluate(() => { + const value = Reflect.get(window, '__tsjs_trace_request_context') + if ( + ![ + value, + value.network, + value.cookies, + ...Object.values(value.cookies), + ].every(Object.isFrozen) + ) + throw new Error('The publisher must emit frozen request context') + return value + }) + expect(context.cookies.diagnostics_session).toMatchObject({ + source: 'request', + state: 'present_valid', + detail: 'valid_diagnostics_value', + }) + + // No server opportunity or correlation tokens are invented for this disabled + // auction fixture. A client GPT cycle exercises the real observer and export. + await page.evaluate(() => { + const gpt = Reflect.get(window, 'googletag') + gpt + .defineSlot( + '/123456789/example-runtime', + [300, 250], + 'gpt-diagnostics-slot-primary' + ) + .addService(gpt.pubads()) + gpt.display('gpt-diagnostics-slot-primary') + Reflect.get(window, '__traceGptFixture').complete(0) + }) + await page.waitForFunction( + () => + Reflect.get(window, 'tsjs').gptDiagnostics.snapshot().slots[0] + ?.requests[0]?.isEmpty === true + ) + const evidence = await page.evaluate(() => + Reflect.get(window, 'tsjs').traceEvidence.snapshot() + ) + expect(evidence.ok).toBe(true) + expect(evidence.value.slotCorrelations).toEqual([]) + await page.evaluate(() => + sessionStorage.setItem('unrelated-fixture-key', 'retained') + ) + await clickTraceHandoff(page) + await page.waitForURL(setup) + const article = page.locator('#trace-report') + await expect(article).toContainText( + 'Browser-carried, unverified diagnostic data' + ) + const stored = await page.evaluate( + (key) => JSON.parse(sessionStorage.getItem(key)!), + REPORT_KEY + ) + expect(stored.report.request_context).toEqual(context) + expect(stored.report.server_auctions).toEqual(evidence.value.serverAuctions) + expect(stored.report.slot_correlations).toEqual([]) + expect(stored.report.auction_coverage.capture_status).toBe( + evidence.value.serverAuctions.length ? 'complete' : 'not_observed' + ) + expect(stored.report.gpt_diagnostics.page).toEqual({ + origin: baseURL, + pathname: '/[redacted]', + }) + expect(stored.report.gpt_diagnostics.slots[0].requests[0].isEmpty).toBe(true) + expect( + stored.report.gpt_diagnostics.slots[0].requests[0].trustedServerAuctionId + ).toBeUndefined() + + await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { + origin: baseURL, + }) + await page.getByRole('button', { name: 'Copy', exact: true }).focus() + await page.keyboard.press('Enter') + await expect(page.locator('#trace-export-status')).toHaveText('Copied JSON.') + const copied = await page.evaluate(() => navigator.clipboard.readText()) + const downloading = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download', exact: true }).click() + const downloaded = await downloading + expect(downloaded.suggestedFilename()).toBe('trusted-server-trace-v1.json') + const destination = await downloaded.path() + expect(destination).not.toBeNull() + const bytes = readFileSync(destination!, 'utf8') + expect(bytes).toBe(copied) + expect(JSON.parse(bytes)).toEqual(stored.report) + expect(bytes).not.toContain('stored_at_ms') + expect(bytes).not.toContain('gpt-diagnostics-slot-primary') + expect(bytes).not.toContain('/123456789/example-runtime') + + controls.length = 0 + const endedState = page.waitForResponse( + (response) => + new URL(response.url()).pathname === '/_ts/trace/state' && + response.request().method() === 'GET' + ) + page.once('dialog', (dialog) => dialog.accept()) + await page + .getByRole('button', { name: 'Clear report and end tracing', exact: true }) + .click() + expect(await (await endedState).json()).toEqual({ observed_active: false }) + await expect(article).toHaveCount(0) + await expect(page.locator('#trace-cleanup-local-status')).toHaveText( + 'Local report deleted from this tab.' + ) + await expect(page.locator('#trace-cleanup-server-status')).toHaveText( + 'Tracing is off — no valid diagnostics session observed.' + ) + expect(controls).toEqual(['POST /_ts/trace/end', 'GET /_ts/trace/state']) + expect( + await page.evaluate((key) => sessionStorage.getItem(key), REPORT_KEY) + ).toBeNull() + expect( + await page.evaluate(() => sessionStorage.getItem('unrelated-fixture-key')) + ).toBe('retained') + expect( + (await page.context().cookies()).find((item) => item.name === SESSION) + ).toBeUndefined() + await page.goto(publisher) + expect( + await page.evaluate(() => Reflect.get(window, '__tsjs_trace_active')) + ).toBeUndefined() +}) diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/mobile-trace.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/mobile-trace.spec.ts new file mode 100644 index 000000000..61d11cae6 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/mobile-trace.spec.ts @@ -0,0 +1,668 @@ +import { expect, test } from '@playwright/test' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { runtimeUrl, readState } from '../../helpers/state.js' +import { + traceRuntimeUrl, + TRACE_FIXTURE_CREDENTIALS, +} from '../../helpers/trace-fixture.js' +import { storedTraceReportFixture } from '../../helpers/trace-report-fixture.js' + +const SESSION = '__Host-ts-console' +const CSP = + "default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; connect-src 'self'; img-src data:" + +test.describe('mobile trace foundation', () => { + test('serves a read-only hardened shell and HEAD without a session mutation', async ({ + page, + request, + }) => { + const paths: string[] = [] + page.on('request', (request) => paths.push(new URL(request.url()).pathname)) + const response = await page.goto(traceRuntimeUrl('/_ts/trace')) + expect(response?.status()).toBe(200) + const headers = response?.headers() ?? {} + expect(headers['content-security-policy']).toBe(CSP) + expect(headers['cache-control']).toBe('no-store, private') + expect(headers['x-content-type-options']).toBe('nosniff') + expect(headers['referrer-policy']).toBe('no-referrer') + expect(headers['permissions-policy']).toBe( + 'camera=(), microphone=(), geolocation=(), payment=(), usb=()' + ) + expect(headers['set-cookie']).toBeUndefined() + await expect( + page.getByRole('heading', { name: 'Setup request', exact: true }) + ).toBeVisible() + expect(await page.context().cookies()).toEqual([]) + expect(await page.locator('link[rel="icon"]').getAttribute('href')).toMatch( + /^data:/ + ) + expect( + await page.locator('script:not([src]), style, [style]').count() + ).toBe(0) + expect(paths.sort()).toEqual([ + '/_ts/trace', + '/_ts/trace/assets/v1.css', + '/_ts/trace/assets/v1.js', + ]) + const head = await request.head(traceRuntimeUrl('/_ts/trace')) + expect(head.status()).toBe(200) + expect(await head.body()).toHaveLength(0) + expect(head.headers()['set-cookie']).toBeUndefined() + const manifest = JSON.parse( + readFileSync( + resolve( + __dirname, + '../../../../trusted-server-js/lib/trace-assets-manifest.json' + ), + 'utf8' + ) + ) as { assets: { path: string; sha256: string }[] } + for (const asset of manifest.assets) { + const delivered = await request.get(traceRuntimeUrl(asset.path)) + expect(delivered.status()).toBe(200) + expect( + createHash('sha256') + .update(await delivered.body()) + .digest('hex') + ).toBe(asset.sha256) + expect(delivered.headers()['etag']).toBe(`"${asset.sha256}"`) + expect(delivered.headers()['cache-control']).toBe( + 'public, max-age=31536000, immutable' + ) + expect(delivered.headers()['set-cookie']).toBeUndefined() + const assetHead = await request.head(traceRuntimeUrl(asset.path)) + expect(assetHead.status()).toBe(200) + expect(await assetHead.body()).toHaveLength(0) + expect(assetHead.headers()['etag']).toBe(delivered.headers()['etag']) + } + }) + + test('accepts the unchanged browser cookie only after an explicit tap and confirms through a separate request', async ({ + page, + }) => { + const requests: string[] = [] + page.on('request', (request) => { + if (new URL(request.url()).pathname.startsWith('/_ts/trace/')) + requests.push(`${request.method()} ${new URL(request.url()).pathname}`) + }) + await page.goto(traceRuntimeUrl('/_ts/trace')) + const history = await page.evaluate(() => window.history.length) + await page + .getByRole('button', { name: 'Enable tracing', exact: true }) + .click() + await expect(page.locator('#trace-session-state')).toHaveText( + 'Tracing is on — cookie observed by server' + ) + expect(requests.filter((entry) => !entry.includes('/assets/'))).toEqual([ + 'POST /_ts/trace/enable', + 'GET /_ts/trace/state', + ]) + expect(await page.evaluate(() => window.history.length)).toBe(history) + const cookie = (await page.context().cookies()).find( + (item) => item.name === SESSION + ) + expect(cookie).toMatchObject({ + value: '1', + domain: 'localhost', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'Lax', + }) + expect( + Math.abs((cookie?.expires ?? 0) - (Date.now() / 1000 + 1800)) + ).toBeLessThan(10) + expect(await page.evaluate(() => document.cookie)).not.toContain(SESSION) + await expect(page.locator('#trace-status')).toContainText('reload once') + await page.getByRole('button', { name: 'End tracing', exact: true }).click() + await expect(page.locator('#trace-session-state')).toHaveText( + 'Tracing is off — no valid diagnostics session observed' + ) + expect( + (await page.context().cookies()).find((item) => item.name === SESSION) + ).toBeUndefined() + }) + + test('GET control routes cannot activate or end a browser session', async ({ + request, + }) => { + for (const path of ['enable', 'end']) { + const response = await request.get(traceRuntimeUrl(`/_ts/trace/${path}`)) + expect(response.status()).toBe(405) + expect(response.headers()['set-cookie']).toBeUndefined() + expect(response.headers()['cache-control']).toBe('no-store, private') + } + const state = await request.get(traceRuntimeUrl('/_ts/trace/state')) + expect(await state.json()).toEqual({ observed_active: false }) + }) + + test('returns through history and activates only a freshly reloaded eligible publisher document', async ({ + page, + }) => { + await page.goto(traceRuntimeUrl('/')) + expect( + await page.evaluate(() => Reflect.get(window, '__tsjs_trace_active')) + ).toBeUndefined() + await page.goto(traceRuntimeUrl('/_ts/trace')) + await page + .getByRole('button', { name: 'Enable tracing', exact: true }) + .click() + await expect(page.locator('#trace-session-state')).toContainText( + 'Tracing is on' + ) + await page + .getByRole('button', { name: 'Return to previous page', exact: true }) + .click() + await page.waitForURL(traceRuntimeUrl('/')) + await page.reload() + await page.waitForFunction( + () => Reflect.get(window, '__tsjs_trace_active') === true + ) + expect( + await page.evaluate(() => { + const context = Reflect.get(window, '__tsjs_trace_request_context') + return [ + context, + context.network, + context.cookies, + ...Object.values(context.cookies), + ].every(Object.isFrozen) + }) + ).toBe(true) + await page.goto(traceRuntimeUrl('/?ts_console=0')) + expect( + await page.evaluate(() => Reflect.get(window, '__tsjs_trace_active')) + ).toBeUndefined() + expect( + (await page.context().cookies()).find((item) => item.name === SESSION) + ).toBeUndefined() + }) + + test('protects shell and assets before feature or method handling when configured', async ({ + request, + }) => { + for (const path of [ + '/_ts/trace', + '/_ts/trace/assets/v1.js', + '/_ts/trace/not-a-route', + ]) { + const response = await request.get(traceRuntimeUrl(path, true)) + expect(response.status()).toBe(401) + expect(response.headers()['www-authenticate']).toBeDefined() + expect(response.headers()['cache-control']).toBe('no-store, private') + expect(response.headers()['set-cookie']).toBeUndefined() + } + const credentials = Buffer.from( + `${TRACE_FIXTURE_CREDENTIALS.username}:${TRACE_FIXTURE_CREDENTIALS.password}` + ).toString('base64') + const shell = await request.get(traceRuntimeUrl('/_ts/trace', true), { + headers: { Authorization: `Basic ${credentials}` }, + }) + expect(shell.status()).toBe(200) + const asset = await request.get( + traceRuntimeUrl('/_ts/trace/assets/v1.js', true), + { headers: { Authorization: `Basic ${credentials}` } } + ) + expect(asset.status()).toBe(200) + expect(asset.headers()['cache-control']).toBe('no-store, private') + }) + + test('leaves the disabled runtime inert even with a browser session cookie', async ({ + page, + }) => { + await page.goto(traceRuntimeUrl('/_ts/trace')) + await page + .getByRole('button', { name: 'Enable tracing', exact: true }) + .click() + await expect(page.locator('#trace-session-state')).toContainText( + 'Tracing is on' + ) + const disabled = new URL(runtimeUrl('/')) + disabled.hostname = 'localhost' + await page.goto(disabled.toString()) + expect( + await page.evaluate(() => ({ + active: Reflect.get(window, '__tsjs_trace_active'), + context: Reflect.get(window, '__tsjs_trace_request_context'), + evidence: Reflect.get(window, 'tsjs')?.traceEvidence, + })) + ).toEqual({ active: undefined, context: undefined, evidence: undefined }) + const response = await page + .context() + .request.get(new URL('/_ts/trace', disabled).toString()) + expect(response.status()).toBe(404) + }) + + test('rejects genuine cross-site form and fetch mutations without storing a cookie', async ({ + page, + }) => { + const origin = `http://127.0.0.1:${process.env.INTEGRATION_ORIGIN_PORT ?? '8888'}` + await page.goto(origin) + const destination = traceRuntimeUrl('/_ts/trace/enable') + const result = page.waitForResponse( + (response) => response.url() === destination + ) + await Promise.all([ + page.waitForURL(destination, { waitUntil: 'domcontentloaded' }), + page.evaluate((url) => { + const form = document.createElement('form') + form.method = 'POST' + form.action = url + document.body.append(form) + form.submit() + }, destination), + ]) + expect((await result).status()).toBe(403) + expect( + (await page.context().cookies()).find((item) => item.name === SESSION) + ).toBeUndefined() + await page.goto(origin) + const fetchResponse = page.waitForResponse( + (response) => + response.url() === destination && response.request().method() === 'POST' + ) + await page.evaluate(async (url) => { + try { + await fetch(url, { + method: 'POST', + mode: 'no-cors', + credentials: 'include', + }) + } catch { + /* The browser may also block access to the response. */ + } + }, destination) + expect((await fetchResponse).status()).toBe(403) + expect( + (await page.context().cookies()).find((item) => item.name === SESSION) + ).toBeUndefined() + expect(readState().framework).toMatch(/^(nextjs|wordpress)$/) + }) +}) + +test.describe('browser-carried trace report viewer', () => { + test('blocks injected scripts, styles, frames and off-origin connections under the delivered CSP', async ({ + page, + }) => { + await page.goto(traceRuntimeUrl('/_ts/trace')) + const requests: string[] = [] + page.on('request', (request) => requests.push(request.url())) + await page.evaluate(() => { + const violations: string[] = [] + Reflect.set(window, '__traceFixtureCspViolations', violations) + document.addEventListener('securitypolicyviolation', (event) => + violations.push(event.effectiveDirective) + ) + const script = document.createElement('script') + script.textContent = 'window.__traceFixtureInjectedScript = true' + document.body.append(script) + const style = document.createElement('style') + style.textContent = 'body { display: none }' + document.body.append(style) + const frame = document.createElement('iframe') + frame.src = 'https://blocked.example.com/trace-fixture' + document.body.append(frame) + void fetch('https://blocked.example.com/trace-fixture').catch(() => {}) + }) + await expect + .poll(() => + page.evaluate(() => Reflect.get(window, '__traceFixtureCspViolations')) + ) + .toEqual( + expect.arrayContaining([ + 'script-src-elem', + 'style-src-elem', + 'frame-src', + 'connect-src', + ]) + ) + expect( + await page.evaluate(() => + Reflect.get(window, '__traceFixtureInjectedScript') + ) + ).toBeUndefined() + await expect(page.locator('body')).toBeVisible() + expect(requests).toEqual([]) + }) + + test('treats an opener-cloned report as separate unverified tab data and expires it on a later load', async ({ + page, + }) => { + const url = traceRuntimeUrl('/_ts/trace') + await page.goto(url) + const captured = Date.now() + const stored = storedTraceReportFixture(new URL(url).origin, captured) + await page.evaluate((value) => { + sessionStorage.setItem( + 'trusted-server.trace.report.v1', + JSON.stringify(value) + ) + }, stored) + await page.reload() + const opening = page.waitForEvent('popup') + await page.evaluate((destination) => { + window.open(destination, '_blank') + }, url) + const clone = await opening + try { + await expect(clone.locator('#trace-report')).toContainText( + 'Browser-carried, unverified diagnostic data' + ) + await page + .getByRole('button', { name: 'Delete local report', exact: true }) + .click() + await expect(page.locator('#trace-report')).toHaveCount(0) + await expect(clone.locator('#trace-report')).toBeVisible() + expect( + await clone.evaluate( + () => + JSON.parse( + sessionStorage.getItem('trusted-server.trace.report.v1')! + ).report + ) + ).toEqual(stored.report) + // This is a browser clock-restoration simulation, not a browser restart. + await clone.clock.install({ time: captured + 16 * 60 * 1000 }) + await clone.reload() + await expect(clone.locator('#trace-report')).toHaveCount(0) + await expect(clone.locator('#trace-report-notice')).toContainText( + 'unavailable, expired, or unsupported' + ) + expect( + await clone.evaluate(() => + sessionStorage.getItem('trusted-server.trace.report.v1') + ) + ).toBeNull() + } finally { + await clone.close() + } + }) + + for (const rejection of [ + 'expired', + 'future clock', + 'different origin', + 'unsupported version', + 'forbidden member', + ] as const) { + test(`rejects and removes saved data for ${rejection} without rendering or exporting it`, async ({ + page, + }) => { + const url = traceRuntimeUrl('/_ts/trace') + await page.goto(url) + const now = Date.now() + const stored = storedTraceReportFixture( + new URL(url).origin, + rejection === 'expired' + ? now - 15 * 60 * 1000 - 1000 + : rejection === 'future clock' + ? now + 2 * 60 * 1000 + : now + ) + if (rejection === 'different origin') + stored.report.gpt_diagnostics.page.origin = 'https://other.example.com' + if (rejection === 'unsupported version') stored.report.schema_version = 2 + if (rejection === 'forbidden member') + Reflect.set(stored.report, 'raw_cookie', 'forbidden-fixture-value') + await page.evaluate((value) => { + sessionStorage.setItem( + 'trusted-server.trace.report.v1', + JSON.stringify(value) + ) + sessionStorage.setItem('unrelated-fixture-key', 'retained') + }, stored) + await page.reload() + await expect(page.locator('#trace-report')).toHaveCount(0) + await expect(page.locator('#trace-report-notice')).toContainText( + 'unavailable, expired, or unsupported' + ) + await expect( + page.getByRole('button', { name: 'Download', exact: true }) + ).toHaveCount(0) + expect( + await page.evaluate(() => ({ + report: sessionStorage.getItem('trusted-server.trace.report.v1'), + unrelated: sessionStorage.getItem('unrelated-fixture-key'), + })) + ).toEqual({ report: null, unrelated: 'retained' }) + await expect(page.locator('body')).not.toContainText( + 'forbidden-fixture-value' + ) + }) + } + + test('keeps a report exportable when local deletion fails while independently ending the real server session', async ({ + page, + }) => { + const url = traceRuntimeUrl('/_ts/trace') + await page.goto(url) + await page + .getByRole('button', { name: 'Enable tracing', exact: true }) + .click() + await expect(page.locator('#trace-session-state')).toContainText( + 'Tracing is on' + ) + const stored = storedTraceReportFixture(new URL(url).origin) + await page.evaluate((value) => { + sessionStorage.setItem( + 'trusted-server.trace.report.v1', + JSON.stringify(value) + ) + }, stored) + await page.addInitScript(() => { + const remove = Storage.prototype.removeItem + Reflect.set(window, '__traceFixtureDeletionBlocked', true) + Storage.prototype.removeItem = function (key: string) { + if ( + key === 'trusted-server.trace.report.v1' && + Reflect.get(window, '__traceFixtureDeletionBlocked') + ) + throw new DOMException('Fixture deletion blocked', 'SecurityError') + return remove.call(this, key) + } + }) + await page.reload() + page.once('dialog', (dialog) => dialog.accept()) + await page + .getByRole('button', { + name: 'Clear report and end tracing', + exact: true, + }) + .click() + await expect(page.locator('#trace-cleanup-local-status')).toContainText( + 'Local report deletion failed' + ) + await expect(page.locator('#trace-cleanup-server-status')).toContainText( + 'Tracing is off' + ) + await expect(page.locator('#trace-report')).toBeVisible() + expect( + (await page.context().cookies()).find((item) => item.name === SESSION) + ).toBeUndefined() + const download = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download', exact: true }).click() + const file = await (await download).path() + expect(JSON.parse(readFileSync(file!, 'utf8'))).toEqual(stored.report) + await page.evaluate(() => + Reflect.set(window, '__traceFixtureDeletionBlocked', false) + ) + await page + .getByRole('button', { name: 'Delete local report', exact: true }) + .click() + await expect(page.locator('#trace-report')).toHaveCount(0) + await expect(page.locator('#trace-cleanup-local-status')).toHaveText( + 'Local report deleted from this tab.' + ) + await expect(page.locator('#trace-cleanup-local-status')).toBeFocused() + }) + + test('deletes the report offline and retries end with a separate server observation after reconnecting', async ({ + page, + }) => { + const url = traceRuntimeUrl('/_ts/trace') + await page.goto(url) + await page + .getByRole('button', { name: 'Enable tracing', exact: true }) + .click() + await expect(page.locator('#trace-session-state')).toContainText( + 'Tracing is on' + ) + await page.evaluate( + (value) => { + sessionStorage.setItem( + 'trusted-server.trace.report.v1', + JSON.stringify(value) + ) + }, + storedTraceReportFixture(new URL(url).origin) + ) + await page.reload() + await page.context().setOffline(true) + page.once('dialog', (dialog) => dialog.accept()) + await page + .getByRole('button', { + name: 'Clear report and end tracing', + exact: true, + }) + .click() + await expect(page.locator('#trace-report')).toHaveCount(0) + await expect(page.locator('#trace-cleanup-local-status')).toHaveText( + 'Local report deleted from this tab.' + ) + await expect(page.locator('#trace-cleanup-server-status')).toContainText( + 'End tracing unconfirmed' + ) + expect( + (await page.context().cookies()).find((item) => item.name === SESSION) + ).toBeDefined() + const requests: string[] = [] + page.on('request', (request) => { + const path = new URL(request.url()).pathname + if (path === '/_ts/trace/end' || path === '/_ts/trace/state') + requests.push(`${request.method()} ${path}`) + }) + await page.context().setOffline(false) + await page + .getByRole('button', { name: 'Retry end tracing', exact: true }) + .click() + await expect(page.locator('#trace-cleanup-server-status')).toContainText( + 'Tracing is off' + ) + await expect(page.locator('#trace-cleanup-server-status')).toBeFocused() + expect(requests).toEqual(['POST /_ts/trace/end', 'GET /_ts/trace/state']) + expect( + (await page.context().cookies()).find((item) => item.name === SESSION) + ).toBeUndefined() + }) + + test('renders safely at 320 px and exports the same report through real clipboard and Blob download', async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 640 }) + const origin = new URL(traceRuntimeUrl('/_ts/trace')).origin + await page + .context() + .grantPermissions(['clipboard-read', 'clipboard-write'], { origin }) + await page.goto(traceRuntimeUrl('/_ts/trace')) + const stored = storedTraceReportFixture(origin) + await page.evaluate((value) => { + sessionStorage.setItem( + 'trusted-server.trace.report.v1', + JSON.stringify(value) + ) + sessionStorage.setItem('unrelated-fixture-key', 'retained') + }, stored) + const paths: string[] = [] + page.on('request', (request) => paths.push(new URL(request.url()).pathname)) + await page.reload() + const article = page.locator('#trace-report') + await expect(article).toContainText( + 'Browser-carried, unverified diagnostic data' + ) + await expect(article).toContainText( + stored.report.request_context.network.tls_cipher + ) + expect( + await article.locator('img, script, iframe, style, [style]').count() + ).toBe(0) + expect( + await page.locator('#trace-viewer-setup').getAttribute('open') + ).toBeNull() + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + ) + ).toBe(true) + for (const label of [ + 'Copy', + 'Download', + 'Share', + 'Clear report and end tracing', + 'Delete local report', + ]) { + const button = page.getByRole('button', { name: label, exact: true }) + const box = await button.boundingBox() + expect(box?.width).toBeGreaterThanOrEqual(44) + expect(box?.height).toBeGreaterThanOrEqual(44) + } + expect(paths.sort()).toEqual([ + '/_ts/trace', + '/_ts/trace/assets/v1.css', + '/_ts/trace/assets/v1.js', + ]) + const copy = page.getByRole('button', { name: 'Copy', exact: true }) + await copy.focus() + await page.keyboard.press('Enter') + await expect(page.locator('#trace-export-status')).toHaveText( + 'Copied JSON.' + ) + const copied = await page.evaluate(() => navigator.clipboard.readText()) + const downloading = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download', exact: true }).click() + const downloaded = await downloading + expect(downloaded.suggestedFilename()).toBe('trusted-server-trace-v1.json') + const destination = await downloaded.path() + expect(destination).not.toBeNull() + const file = readFileSync(destination!, 'utf8') + expect(file).toBe(copied) + expect(JSON.parse(file)).toEqual(stored.report) + expect(file).not.toContain('stored_at_ms') + await expect(page.locator('#trace-export-status')).toHaveAttribute( + 'aria-live', + 'polite' + ) + const mutations: string[] = [] + page.on('request', (request) => { + if ( + new URL(request.url()).pathname.startsWith('/_ts/trace/') && + !request.url().includes('/assets/') + ) + mutations.push(`${request.method()} ${new URL(request.url()).pathname}`) + }) + page.once('dialog', (dialog) => dialog.accept()) + await page + .getByRole('button', { + name: 'Clear report and end tracing', + exact: true, + }) + .click() + await expect(article).toHaveCount(0) + await expect(page.locator('#trace-cleanup-local-status')).toHaveText( + 'Local report deleted from this tab.' + ) + await expect(page.locator('#trace-cleanup-server-status')).toHaveText( + 'Tracing is off — no valid diagnostics session observed.' + ) + expect( + await page.evaluate(() => + sessionStorage.getItem('trusted-server.trace.report.v1') + ) + ).toBeNull() + expect( + await page.evaluate(() => sessionStorage.getItem('unrelated-fixture-key')) + ).toBe('retained') + expect(mutations).toEqual(['POST /_ts/trace/end', 'GET /_ts/trace/state']) + }) +}) diff --git a/crates/trusted-server-integration-tests/browser/trace-fixture.test.cjs b/crates/trusted-server-integration-tests/browser/trace-fixture.test.cjs new file mode 100644 index 000000000..2e7f9d90a --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/trace-fixture.test.cjs @@ -0,0 +1,94 @@ +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const path = require('node:path') +const { test } = require('node:test') +const vm = require('node:vm') +const typescript = require('../../trusted-server-js/lib/node_modules/typescript') + +function load(filename, infra, state = {}) { + const saved = [] + const removed = [] + const fs = { + writeFileSync: (_filename, value) => saved.push(JSON.parse(value)), + readFileSync: () => JSON.stringify(state), + unlinkSync: (filename) => removed.push(filename), + } + const source = typescript.transpileModule( + readFileSync(path.join(__dirname, filename), 'utf8'), + { + compilerOptions: { + module: typescript.ModuleKind.CommonJS, + target: typescript.ScriptTarget.ES2022, + }, + } + ).outputText + const module = { exports: {} } + vm.runInNewContext(source, { + module, + exports: module.exports, + __dirname, + process: { + env: { + TEST_FRAMEWORK: 'nextjs', + TRACE_VICEROY_CONFIG_PATH: 'trace-public-fixture.toml', + TRACE_AUTH_VICEROY_CONFIG_PATH: 'trace-auth-fixture.toml', + }, + }, + console: { log() {}, warn() {}, error() {} }, + require(name) { + if (name === 'node:fs') return fs + if (name === 'node:path') return path + if (name === './helpers/infra.js') return infra + throw new Error(`should explicitly mock dependency ${name}`) + }, + }) + return { run: module.exports.default, saved, removed } +} + +test('failed trace runtime setup stops every already allocated runtime and the container even if one stop fails', async () => { + const stopped = [] + let allocations = 0 + const fixture = load('global-setup.ts', { + startContainer: async () => 'owned-container', + startViceroy: async () => { + allocations += 1 + if (allocations === 3) throw new Error('fixture-auth-runtime-failed') + return { + process: { pid: allocations * 101 }, + baseUrl: `http://127.0.0.1:${8000 + allocations}`, + } + }, + stopViceroy: async (pid) => { + stopped.push(pid) + if (pid === 101) throw new Error('fixture-first-stop-failed') + }, + stopContainer: (id) => stopped.push(id), + }) + await assert.rejects(fixture.run(), /fixture-auth-runtime-failed/) + assert.deepEqual(stopped, [101, 202, 'owned-container']) + assert.equal(fixture.removed.length, 1) + assert.equal(fixture.saved.at(-1).traceBaseUrl, 'http://127.0.0.1:8002') + assert.deepEqual(fixture.saved.at(-1).extraViceroyPids, [202]) +}) + +test('normal teardown attempts all owned resources and removes state after a runtime stop failure', async () => { + const stopped = [] + const fixture = load( + 'global-teardown.ts', + { + stopViceroy: async (pid) => { + stopped.push(pid) + if (pid === 101) throw new Error('fixture-first-stop-failed') + }, + stopContainer: (id) => stopped.push(id), + }, + { + viceroyPid: 101, + extraViceroyPids: [202, 303], + containerId: 'owned-container', + } + ) + await assert.rejects(fixture.run(), /fixture-first-stop-failed/) + assert.deepEqual(stopped, [101, 202, 303, 'owned-container']) + assert.equal(fixture.removed.length, 1) +}) diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.trace-auth.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.trace-auth.toml new file mode 100644 index 000000000..a94f7c515 --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.trace-auth.toml @@ -0,0 +1,40 @@ +[[handlers]] +path = "^/_ts/trace" +username = "admin" +password = "integration_admin_password" + +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "integration_admin_password" + +[publisher] +domain = "localhost" +cookie_domain = "localhost" +origin_url = "http://127.0.0.1:8888" +proxy_secret = "integration_proxy_secret" + +[ec] +passphrase = "integration_ec_passphrase" +ec_store = "ec_identity_store" + +[[ec.partners]] +name = "Example partner" +source_domain = "partner.example.com" +bidstream_enabled = true +api_token = "integration_partner_token_alpha" + +[request_signing] +enabled = false +config_store_id = "app_config" +secret_store_id = "secrets" + +[integrations.gpt_diagnostics] +enabled = true +trace_page_enabled = true + +[proxy] +certificate_check = false + +[auction] +enabled = false diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.trace.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.trace.toml new file mode 100644 index 000000000..dcc6b9b1c --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.trace.toml @@ -0,0 +1,61 @@ +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "integration_admin_password" + +[publisher] +domain = "localhost" +cookie_domain = "localhost" +origin_url = "http://127.0.0.1:8888" +proxy_secret = "integration_proxy_secret" + +[ec] +passphrase = "integration_ec_passphrase" +ec_store = "ec_identity_store" + +[[ec.partners]] +name = "Example partner" +source_domain = "partner.example.com" +bidstream_enabled = true +api_token = "integration_partner_token_alpha" + +[request_signing] +enabled = false +config_store_id = "app_config" +secret_store_id = "secrets" + +[integrations.gpt_diagnostics] +enabled = true +trace_page_enabled = true + +[integrations.gpt] +enabled = true +gam_attribution_enabled = false +script_url = "https://ads.example.com/gpt.js" +rewrite_script = false + +[proxy] +certificate_check = false + +[auction] +enabled = true +timeout_ms = 1000 +rewrite_creatives = false + +[auction.providers.example] +protocol = "openrtb-2.6" +profile = "standard" +endpoint = "https://bidder.example.com/api/trace-bidder" +routing = "all_eligible" + +[creative_opportunities] +enabled = true +gam_network_id = "123456789" +auction_timeout_ms = 1000 + +[[creative_opportunities.slot]] +id = "example-primary" +div_id = "gpt-diagnostics-slot-primary" +gam_unit_path = "/123456789/example-primary" +page_patterns = ["/gpt-diagnostics"] +formats = [{ width = 300, height = 250 }] diff --git a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml index eae7220a8..551d275d9 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml @@ -66,28 +66,23 @@ key = "api_key" data = "test-api-key" - [[local_server.secret_stores.ts_secrets]] + [[local_server.secret_stores.trusted_server_secrets]] key = "integration_admin_password" data = "integration-admin-password-32-bytes-ok" - [[local_server.secret_stores.ts_secrets]] + [[local_server.secret_stores.trusted_server_secrets]] key = "integration_proxy_secret" data = "integration-test-proxy-secret-32-bytes-ok" - [[local_server.secret_stores.ts_secrets]] + [[local_server.secret_stores.trusted_server_secrets]] key = "integration_ec_passphrase" data = "integration-test-ec-secret-padded-32" - [[local_server.secret_stores.ts_secrets]] + [[local_server.secret_stores.trusted_server_secrets]] key = "integration_partner_token_alpha" data = "integration-test-token-alpha-32-bytes-ok" - [[local_server.secret_stores.ts_secrets]] + [[local_server.secret_stores.trusted_server_secrets]] key = "integration_partner_token_bravo" data = "integration-test-token-bravo-32-bytes-ok" [local_server.config_stores] - [local_server.config_stores.edgezero_runtime_env] - format = "inline-toml" - [local_server.config_stores.edgezero_runtime_env.contents] - EDGEZERO__SERVICES__0000000000000000000000__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME = "ts_secrets" - # Generated integration configs inject the manifest-default app-config blob # into the store required by the Fastly entry point. # GENERATED_TRUSTED_SERVER_CONFIG_STORES diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/Dockerfile b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/Dockerfile index 6a6dc7d71..1e3e52cdc 100644 --- a/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/Dockerfile +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/Dockerfile @@ -14,7 +14,8 @@ # --- Build stage --- ARG NODE_VERSION -FROM node:${NODE_VERSION}-alpine AS builder +ARG DOCKER_LIBRARY=docker.io/library +FROM ${DOCKER_LIBRARY}/node:${NODE_VERSION}-alpine AS builder WORKDIR /app @@ -27,7 +28,7 @@ COPY app/ ./app/ RUN npm run build # --- Runtime stage --- -FROM node:${NODE_VERSION}-alpine AS runner +FROM ${DOCKER_LIBRARY}/node:${NODE_VERSION}-alpine AS runner WORKDIR /app diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/api/trace-bidder/route.ts b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/api/trace-bidder/route.ts new file mode 100644 index 000000000..f10cc315e --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/api/trace-bidder/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from 'next/server' + +export const dynamic = 'force-dynamic' + +let requestCount = 0 +let controlledMode = 'empty' + +/** Counts actual HTTP invocations without retaining bidder request bodies. */ +export async function GET() { + return NextResponse.json({ requests: requestCount }) +} + +/** Selects one bounded scenario for the next real server-side auctions. */ +export async function PUT(request: Request) { + let control: unknown + try { + control = await request.json() + } catch { + return NextResponse.json({ error: 'invalid fixture mode' }, { status: 400 }) + } + if ( + typeof control !== 'object' || + control === null || + !('mode' in control) || + typeof control.mode !== 'string' || + !['selected', 'empty', 'error'].includes(control.mode) + ) { + return NextResponse.json({ error: 'invalid fixture mode' }, { status: 400 }) + } + controlledMode = control.mode + return NextResponse.json({ mode: controlledMode }) +} + +/** Controlled fictional OpenRTB bidder reached by the real Rust HTTP client. */ +export async function POST(request: Request) { + const auction = await request.json() + requestCount += 1 + const mode = + new URL( + auction.site?.page || 'https://publisher.example.com/' + ).searchParams.get('trace_fixture') || controlledMode + if (mode === 'error') { + return NextResponse.json( + { error: 'controlled fixture failure' }, + { status: 503 } + ) + } + const bids = + mode === 'selected' && Array.isArray(auction.imp) + ? auction.imp.map( + ( + impression: { + id: string + banner?: { format?: { w: number; h: number }[] } + }, + index: number + ) => ({ + id: `example-bid-${index}`, + impid: impression.id, + price: 1, + w: impression.banner?.format?.[0]?.w || 300, + h: impression.banner?.format?.[0]?.h || 250, + crid: 'example-creative', + adm: '
Trace fixture creative
', + }) + ) + : [] + return NextResponse.json({ + id: auction.id, + cur: 'USD', + seatbid: bids.length ? [{ seat: 'example-bidder', bid: bids }] : [], + }) +} diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/wordpress/Dockerfile b/crates/trusted-server-integration-tests/fixtures/frameworks/wordpress/Dockerfile index 513b7a8e5..71d6c0012 100644 --- a/crates/trusted-server-integration-tests/fixtures/frameworks/wordpress/Dockerfile +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/wordpress/Dockerfile @@ -9,7 +9,8 @@ # Run manually: # docker run -p 8080:80 test-wordpress:latest -FROM php:8.3-cli-alpine +ARG DOCKER_LIBRARY=docker.io/library +FROM ${DOCKER_LIBRARY}/php:8.3-cli-alpine WORKDIR /var/www/html diff --git a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs index aada5dcf5..741c4237c 100644 --- a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs +++ b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs @@ -1,11 +1,15 @@ +use std::collections::BTreeSet; use std::env; use std::error::Error; use std::fs; use std::path::PathBuf; +use std::time::Duration; use edgezero_core::blob_envelope::BlobEnvelope; use trusted_server_core::config::TrustedServerAppConfig; use trusted_server_core::config_payload::{CONFIG_BLOB_KEY, DEFAULT_CONFIG_STORE_ID}; +use trusted_server_core::platform::{BackendNamingPolicy, PlatformBackendSpec}; +use url::{Host, Url}; const GENERATED_AT: &str = "2026-06-23T00:00:00Z"; const GENERATED_STORES_MARKER: &str = " # GENERATED_TRUSTED_SERVER_CONFIG_STORES"; @@ -18,6 +22,7 @@ struct Args { app_config: PathBuf, output: PathBuf, origin_url: Option, + bidder_origin_url: Option, } fn main() -> Result<(), DynError> { @@ -39,7 +44,11 @@ fn run(args: &Args) -> Result<(), DynError> { })?; let envelope_json = build_app_config_envelope(&app_config, args.origin_url.as_deref())?; - let generated_config = inject_generated_config_stores(&template, &envelope_json)?; + let mut generated_config = inject_generated_config_stores(&template, &envelope_json)?; + if let Some(origin) = &args.bidder_origin_url { + generated_config = + inject_controlled_bidder_backends(&generated_config, &app_config, origin)?; + } if let Some(parent) = args.output.parent() { fs::create_dir_all(parent).map_err(|error| { @@ -64,6 +73,7 @@ fn parse_args(args: impl IntoIterator) -> Result let mut app_config = None; let mut output = None; let mut origin_url = None; + let mut bidder_origin_url = None; let mut iter = args.into_iter(); while let Some(arg) = iter.next() { @@ -72,6 +82,9 @@ fn parse_args(args: impl IntoIterator) -> Result "--app-config" => app_config = Some(next_path_arg(&mut iter, "--app-config")?), "--output" => output = Some(next_path_arg(&mut iter, "--output")?), "--origin-url" => origin_url = Some(next_string_arg(&mut iter, "--origin-url")?), + "--bidder-origin-url" => { + bidder_origin_url = Some(next_string_arg(&mut iter, "--bidder-origin-url")?); + } "--help" | "-h" => return Err(error_box(usage())), other => { return Err(error_box(format!( @@ -89,9 +102,99 @@ fn parse_args(args: impl IntoIterator) -> Result .ok_or_else(|| error_box(format!("missing --app-config\n\n{}", usage())))?, output: output.ok_or_else(|| error_box(format!("missing --output\n\n{}", usage())))?, origin_url, + bidder_origin_url, }) } +fn inject_controlled_bidder_backends( + runtime_config: &str, + app_config: &str, + origin: &str, +) -> Result { + let origin = Url::parse(origin)?; + let local = match origin.host() { + Some(Host::Domain("localhost")) => true, + Some(Host::Ipv4(address)) => address.is_loopback(), + Some(Host::Ipv6(address)) => address.is_loopback(), + _ => false, + }; + if !local + || origin.scheme() != "http" + || !origin.username().is_empty() + || origin.password().is_some() + || origin.path() != "/" + || origin.query().is_some() + || origin.fragment().is_some() + { + return Err(error_box( + "bidder fixture requires a plain loopback HTTP origin", + )); + } + let settings = toml::from_str::(app_config)?.into_settings(); + let logical_budget = settings.auction.timeout_ms.max( + settings + .creative_opportunities + .as_ref() + .and_then(|config| config.auction_timeout_ms) + .unwrap_or(settings.auction.timeout_ms), + ); + if logical_budget > 60_000 { + return Err(error_box( + "controlled bidder fixture budget exceeds 60 seconds", + )); + } + let plan = trusted_server_core::auction::compile_auction_plan(&settings) + .map_err(|_| error_box("invalid controlled bidder auction plan"))?; + let mut runtime: toml::Value = toml::from_str(runtime_config)?; + let backends = runtime + .get_mut("local_server") + .and_then(|server| server.get_mut("backends")) + .and_then(toml::Value::as_table_mut) + .ok_or_else(|| error_box("runtime template requires a backend table"))?; + for provider in plan.providers() { + let endpoint = Url::parse(provider.endpoint.as_str())?; + // Reuse production policies for every reachable timer/name, rather + // than duplicating Fastly's quantization or backend-name algorithm. + let timers = (1..=provider.timeout_ms.min(logical_budget)) + .map(|remaining| { + BackendNamingPolicy::Fastly + .canonicalize_transport_timeout_ms(remaining, provider.timeout_ms) + }) + .filter(|timer| *timer != 0) + .collect::>(); + for timer in timers { + let duration = Duration::from_millis(u64::from(timer)); + let spec = PlatformBackendSpec { + scheme: endpoint.scheme().to_owned(), + host: endpoint + .host_str() + .ok_or_else(|| error_box("missing bidder host"))? + .to_owned(), + port: endpoint.port(), + host_header_override: None, + certificate_check: true, + first_byte_timeout: duration, + between_bytes_timeout: duration, + discriminator: Some(provider.id.as_str().to_owned()), + }; + let name = BackendNamingPolicy::Fastly + .predict(&spec) + .map_err(|_| error_box("invalid controlled bidder backend name"))? + .name; + let alias = toml::Value::Table(toml::Table::from_iter([( + "url".to_owned(), + toml::Value::String(origin.as_str().to_owned()), + )])); + if backends.insert(name, alias).is_some() { + return Err(error_box( + "controlled bidder alias collides with template backend", + )); + } + } + } + Ok(toml::to_string(&runtime)?) +} + fn next_path_arg( iter: &mut impl Iterator, flag: &'static str, @@ -108,7 +211,7 @@ fn next_string_arg( } fn usage() -> String { - "usage: generate-viceroy-config --template --app-config --output [--origin-url ]".to_string() + "usage: generate-viceroy-config --template --app-config --output [--origin-url ] [--bidder-origin-url ]".to_string() } fn build_app_config_envelope( @@ -165,6 +268,7 @@ mod tests { use super::*; use error_stack::Report; use std::collections::HashMap; + use tempfile::tempdir; use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::platform::{PlatformError, PlatformSecretStore, StoreId, StoreName}; @@ -245,6 +349,124 @@ mod tests { ); } + #[test] + fn parse_args_accepts_explicit_controlled_bidder_origin() { + assert!( + parse_args([ + "--template".to_string(), + "template.toml".to_string(), + "--app-config".to_string(), + "trusted-server.toml".to_string(), + "--output".to_string(), + "generated.toml".to_string(), + "--bidder-origin-url".to_string(), + "http://127.0.0.1:8888".to_string(), + ]) + .is_ok(), + "should accept an explicit local bidder fixture without changing defaults" + ); + } + + fn run_bidder_fixture(origin: &str) -> Result { + let directory = tempdir().expect("should create isolated generator fixture"); + let template = directory.path().join("template.toml"); + let app_config = directory.path().join("app.toml"); + let output = directory.path().join("output.toml"); + fs::write(&template, TEMPLATE).expect("should write runtime template"); + fs::write( + &app_config, + include_str!("../../fixtures/configs/trusted-server.trace.toml"), + ) + .expect("should write controlled trace app config"); + run(&Args { + template, + app_config, + output: output.clone(), + origin_url: None, + bidder_origin_url: Some(origin.to_string()), + })?; + Ok(toml::from_str(&fs::read_to_string(output)?)?) + } + + #[test] + fn controlled_bidder_uses_backend_aliases_and_preserves_https_app_config() { + let generated = run_bidder_fixture("http://127.0.0.1:8888") + .expect("should generate explicit local bidder aliases"); + let aliases = generated["local_server"]["backends"] + .as_table() + .expect("should retain backend table"); + assert!( + !aliases.is_empty(), + "should route the real Rust bidder HTTP client to the controlled local origin" + ); + for alias in aliases.values() { + assert_eq!( + alias["url"].as_str(), + Some("http://127.0.0.1:8888/"), + "should keep every runtime-only alias local" + ); + } + let envelope: serde_json::Value = serde_json::from_str( + generated["local_server"]["config_stores"][DEFAULT_CONFIG_STORE_ID]["contents"] + [CONFIG_BLOB_KEY] + .as_str() + .expect("should retain validated app envelope"), + ) + .expect("should read unchanged app config envelope"); + assert_eq!( + envelope["data"]["auction"]["providers"]["example"]["endpoint"], + "https://bidder.example.com/api/trace-bidder", + "should preserve production HTTPS and certificate admission" + ); + } + + #[test] + fn controlled_bidder_rejects_non_loopback_or_ambiguous_origins() { + for origin in [ + "http://bidder.example.com", + "http://127.0.0.1:8888/path", + "http://127.0.0.1:8888/?query=value", + "http://user:password@127.0.0.1:8888", + ] { + assert!( + run_bidder_fixture(origin).is_err(), + "should reject a non-local or non-origin bidder fixture URL" + ); + } + } + + #[test] + fn controlled_bidder_omits_unreachable_large_provider_timer_aliases() { + let app_config = include_str!("../../fixtures/configs/trusted-server.trace.toml").replace( + "[auction.providers.example]", + "[auction.providers.example]\ntimeout_ms = 100000", + ); + let generated = + inject_controlled_bidder_backends(TEMPLATE, &app_config, "http://127.0.0.1:8888") + .expect("should bound the configured provider by reachable logical budgets"); + let generated: toml::Value = + toml::from_str(&generated).expect("should generate valid bounded runtime aliases"); + assert_eq!( + generated["local_server"]["backends"] + .as_table() + .expect("should retain aliases") + .len(), + 8, + "should generate only eight reachable timers for the 1000 ms fixture budget" + ); + } + + #[test] + fn controlled_bidder_rejects_excessive_fixture_logical_budget() { + let app_config = include_str!("../../fixtures/configs/trusted-server.trace.toml") + .replace("auction_timeout_ms = 1000", "auction_timeout_ms = 100000"); + assert!( + inject_controlled_bidder_backends(TEMPLATE, &app_config, "http://127.0.0.1:8888") + .is_err(), + "should reject fixture budgets beyond the bounded 60 second timer enumeration" + ); + } + #[test] fn parse_args_accepts_required_flags_and_origin_override() { let args = parse_args([ @@ -265,7 +487,8 @@ mod tests { template: PathBuf::from("template.toml"), app_config: PathBuf::from("trusted-server.toml"), output: PathBuf::from("generated.toml"), - origin_url: Some("http://127.0.0.1:9999".to_string()) + origin_url: Some("http://127.0.0.1:9999".to_string()), + bidder_origin_url: None, }, "should parse expected args" ); diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index 3bcbfe53e..08ad81aa0 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -9,14 +9,55 @@ const GENERATED_AT: &str = "2026-06-23T00:00:00Z"; const APP_CONFIG: &str = include_str!("../../fixtures/configs/trusted-server.integration.toml"); pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { + integration_app_config_envelope_with_trace(origin_port, false) +} + +/// Generate an isolated runtime fixture with an explicit trace feature setting. +/// +/// # Errors +/// +/// Returns a bounded fixture-generation error when configuration validation fails. +pub fn integration_app_config_envelope_with_trace( + origin_port: u16, + trace: bool, +) -> TestResult { + app_config_envelope(origin_port, trace, |_| Ok(())) +} + +/// Builds the integration app-config envelope after `customize` edits the +/// parsed fixture. +fn app_config_envelope( + origin_port: u16, + trace: bool, + customize: impl FnOnce(&mut toml::Table) -> TestResult<()>, +) -> TestResult { let origin_url = format!("http://127.0.0.1:{origin_port}"); - let app_config: TrustedServerAppConfig = toml::from_str(APP_CONFIG).map_err(|error| { + let mut table: toml::Table = toml::from_str(APP_CONFIG).map_err(|error| { Report::new(TestError::ConfigGeneration).attach(format!( "invalid Trusted Server integration config: {error}" )) })?; + customize(&mut table)?; + let app_config: TrustedServerAppConfig = + toml::Value::Table(table).try_into().map_err(|error| { + Report::new(TestError::ConfigGeneration).attach(format!( + "invalid customized Trusted Server integration config: {error}" + )) + })?; let mut settings = app_config.into_settings(); settings.publisher.origin_url = origin_url; + if trace { + settings + .integrations + .insert_config( + "gpt_diagnostics", + &serde_json::json!({"enabled":true,"trace_page_enabled":true}), + ) + .map_err(|report| { + Report::new(TestError::ConfigGeneration) + .attach(format!("invalid trace fixture config: {report:?}")) + })?; + } let app_config = TrustedServerAppConfig::new(settings).map_err(|report| { Report::new(TestError::ConfigGeneration) .attach(format!("invalid generated integration config: {report:?}")) @@ -36,7 +77,37 @@ pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { pub fn cloudflare_config_json(origin_port: u16) -> TestResult { let envelope = integration_app_config_envelope(origin_port)?; - serde_json::to_string(&serde_json::json!({ CONFIG_BLOB_KEY: envelope })).map_err(|error| { + cloudflare_binding_json(&serde_json::json!({ CONFIG_BLOB_KEY: envelope })) +} + +/// Builds a Cloudflare config binding with server-side auctions enabled. +/// +/// The fixture's providers are removed so the enabled auction completes +/// through the orchestrator and telemetry without any outbound provider call. +pub fn cloudflare_auction_config_json(origin_port: u16) -> TestResult { + let envelope = app_config_envelope(origin_port, false, |table| { + let auction = table_mut(table, "auction")?; + auction.insert("enabled".to_string(), toml::Value::Boolean(true)); + auction.remove("providers"); + auction.remove("bidders"); + Ok(()) + })?; + cloudflare_binding_json(&serde_json::json!({ CONFIG_BLOB_KEY: envelope })) +} + +fn table_mut<'a>(table: &'a mut toml::Table, key: &str) -> TestResult<&'a mut toml::Table> { + table + .get_mut(key) + .and_then(toml::Value::as_table_mut) + .ok_or_else(|| { + Report::new(TestError::ConfigGeneration).attach(format!( + "integration config should contain a `[{key}]` table" + )) + }) +} + +fn cloudflare_binding_json(binding: &serde_json::Value) -> TestResult { + serde_json::to_string(binding).map_err(|error| { Report::new(TestError::ConfigGeneration).attach(format!( "failed to serialize Cloudflare config binding: {error}" )) @@ -45,10 +116,54 @@ pub fn cloudflare_config_json(origin_port: u16) -> TestResult { #[cfg(test)] mod tests { + use super::*; + + #[test] + fn trace_browser_fixture_keeps_auctions_and_shared_assembly_disabled() { + let envelope: serde_json::Value = serde_json::from_str( + &integration_app_config_envelope_with_trace(8888, true) + .expect("should generate normal-browser trace fixture"), + ) + .expect("should parse trace browser envelope"); + assert_eq!( + envelope["data"]["auction"]["enabled"], false, + "should keep the normal browser workflow independent of bidder transport" + ); + assert!( + envelope["data"] + .get("creative_opportunities") + .is_none_or(serde_json::Value::is_null), + "should keep shared assembly and configured server slots out of this fixture" + ); + assert_eq!( + envelope["data"]["integrations"]["gpt_diagnostics"]["trace_page_enabled"], true, + "should activate trace only through the explicit isolated configuration" + ); + } + + #[test] + fn trace_runtime_boundary_config_enables_only_explicit_fixture() { + let baseline: serde_json::Value = serde_json::from_str( + &integration_app_config_envelope(8888).expect("should generate baseline envelope"), + ) + .expect("should parse baseline envelope"); + assert_ne!( + baseline["data"]["integrations"]["gpt_diagnostics"]["trace_page_enabled"], true, + "should keep the trace feature disabled in the ordinary baseline" + ); + let enabled: serde_json::Value = serde_json::from_str( + &integration_app_config_envelope_with_trace(8888, true) + .expect("should generate trace fixture envelope"), + ) + .expect("should parse trace envelope"); + assert_eq!( + enabled["data"]["integrations"]["gpt_diagnostics"]["trace_page_enabled"], true, + "should explicitly enable isolated trace fixture" + ); + } const FASTLY_CONFIG: &str = include_str!("../../../../fastly.toml"); const VICEROY_TEMPLATE: &str = include_str!("../../fixtures/configs/viceroy-template.toml"); - const VICEROY_SECRET_STORE_MAPPING_KEY: &str = - "EDGEZERO__SERVICES__0000000000000000000000__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME"; + const LOGICAL_SECRET_STORE_ID: &str = "trusted_server_secrets"; #[test] fn local_fastly_config_defines_runtime_kv_stores() { @@ -75,26 +190,28 @@ mod tests { } #[test] - fn local_fastly_secret_store_mapping_is_service_scoped() { + fn local_fastly_secret_store_is_exposed_under_its_logical_id() { for (name, config) in [ ("fastly.toml", FASTLY_CONFIG), ("Viceroy integration template", VICEROY_TEMPLATE), ] { let parsed: toml::Value = toml::from_str(config).expect("should parse Fastly configuration"); - let runtime_env = - &parsed["local_server"]["config_stores"]["edgezero_runtime_env"]["contents"]; + let local_server = &parsed["local_server"]; - assert_eq!( - runtime_env[VICEROY_SECRET_STORE_MAPPING_KEY].as_str(), - Some("ts_secrets"), - "{name} should scope the secret-store mapping to Viceroy's service ID" + assert!( + local_server["secret_stores"][LOGICAL_SECRET_STORE_ID].is_array(), + "{name} should expose the secret store under its logical ID" + ); + assert!( + local_server["secret_stores"].get("ts_secrets").is_none(), + "{name} should not define the legacy physical secret store" ); assert!( - runtime_env - .get("EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME") + local_server["config_stores"] + .get("edgezero_runtime_env") .is_none(), - "{name} should not define the ignored unscoped secret-store mapping" + "{name} should not define the legacy runtime selector store" ); } } @@ -103,9 +220,9 @@ mod tests { fn local_fastly_config_defines_starter_secret_references() { let parsed: toml::Value = toml::from_str(FASTLY_CONFIG).expect("should parse root fastly.toml"); - let entries = parsed["local_server"]["secret_stores"]["ts_secrets"] + let entries = parsed["local_server"]["secret_stores"][LOGICAL_SECRET_STORE_ID] .as_array() - .expect("fastly.toml should define ts_secrets"); + .expect("fastly.toml should define trusted_server_secrets"); for key in [ "publisher_proxy_secret", diff --git a/crates/trusted-server-integration-tests/tests/common/mod.rs b/crates/trusted-server-integration-tests/tests/common/mod.rs index f5a4b578e..76a8c5f73 100644 --- a/crates/trusted-server-integration-tests/tests/common/mod.rs +++ b/crates/trusted-server-integration-tests/tests/common/mod.rs @@ -2,3 +2,4 @@ pub mod assertions; pub mod config; pub mod ec; pub mod runtime; +pub(crate) mod trace_boundary; diff --git a/crates/trusted-server-integration-tests/tests/common/trace_boundary.rs b/crates/trusted-server-integration-tests/tests/common/trace_boundary.rs new file mode 100644 index 000000000..8891015ed --- /dev/null +++ b/crates/trusted-server-integration-tests/tests/common/trace_boundary.rs @@ -0,0 +1,1020 @@ +//! Isolated actual-runtime probes at the SDK-visible trace boundary. + +use std::fs; +use std::io::{Read as _, Write as _}; +use std::net::TcpStream; +#[cfg(unix)] +use std::os::unix::process::CommandExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use edgezero_core::blob_envelope::BlobEnvelope; +use error_stack::Report; +use http::{HeaderMap, HeaderValue}; +use testcontainers::runners::SyncRunner as _; +use trusted_server_core::config_payload::{CONFIG_BLOB_KEY, DEFAULT_CONFIG_STORE_ID}; + +use crate::common::config::integration_app_config_envelope_with_trace; +use crate::common::runtime::TestError; +use crate::common::runtime::{ + RuntimeEnvironment as _, RuntimeProcess, RuntimeProcessHandle, origin_port, wasm_binary_path, +}; +use crate::environments::{ + axum::AxumDevServer, cloudflare::CloudflareWorkers, fastly::FastlyViceroy, +}; +use crate::frameworks::{FrontendFramework as _, nextjs::NextJs}; + +/// Actual runtime selected by an ignored boundary test. +#[derive(Clone, Copy)] +pub(crate) enum TraceRuntime { + /// Local Cloudflare Workers via Wrangler/workerd. + Cloudflare, + /// Local Fastly Compute via Viceroy. + Fastly, + /// Local Spin HTTP component. + Spin, +} + +/// Real runtime selected by the normal browser workflow. +#[derive(Clone, Copy)] +pub(crate) enum TraceBrowserRuntime { + /// Native Axum dev-server binary. + Axum, + /// Local Cloudflare Workers via Wrangler/workerd. + Cloudflare, + /// Local Fastly Compute via Viceroy. + Fastly, + /// Local Spin HTTP component. + Spin, +} + +impl TraceBrowserRuntime { + fn id(self) -> &'static str { + match self { + Self::Axum => "axum", + Self::Cloudflare => "cloudflare", + Self::Fastly => "fastly", + Self::Spin => "spin", + } + } +} + +struct IsolatedFixture(PathBuf); + +impl IsolatedFixture { + fn new() -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("should read fixture clock") + .as_nanos(); + let path = + std::env::temp_dir().join(format!("ts-trace-boundary-{}-{stamp}", std::process::id())); + fs::create_dir_all(&path).expect("should create isolated runtime fixture"); + Self(path) + } +} + +impl Drop for IsolatedFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +struct ChildHandle { + child: Child, + #[cfg(unix)] + process_group: bool, +} +impl RuntimeProcessHandle for ChildHandle {} +impl Drop for ChildHandle { + fn drop(&mut self) { + #[cfg(unix)] + if self.process_group { + // Give Playwright's signal handlers time to close its browsers + // before escalating the owned Node and worker process group. + unsafe { + libc::killpg(self.child.id() as libc::pid_t, libc::SIGTERM); + } + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + let group_exists = unsafe { libc::killpg(self.child.id() as libc::pid_t, 0) == 0 }; + if !group_exists { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + unsafe { + libc::killpg(self.child.id() as libc::pid_t, libc::SIGKILL); + } + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Run raw and normal requests against a separately configured runtime instance. +/// +/// These probes distinguish successful SDK conversion from runtime rejection. +/// +/// # Examples +/// +/// ```ignore +/// exercise(TraceRuntime::Fastly); +/// ``` +pub(crate) fn exercise(runtime: TraceRuntime) { + exercise_case(runtime, true, None, false); + for enabled in [false, true] { + for pattern in [None, Some("^/"), Some("^/_ts")] { + exercise_case(runtime, enabled, pattern, true); + } + } +} + +fn exercise_case(runtime: TraceRuntime, enabled: bool, auth_pattern: Option<&str>, matrix: bool) { + let envelope = integration_app_config_envelope_with_trace(8888, enabled) + .expect("should generate isolated trace config"); + let envelope = if let Some(pattern) = auth_pattern { + let mut envelope: BlobEnvelope = + serde_json::from_str(&envelope).expect("should parse fixture envelope"); + envelope.data["handlers"].as_array_mut().expect("should retain handler array").insert(0,serde_json::json!({"path":pattern,"username":"example-user","password":"integration_admin_password"})); + let envelope = BlobEnvelope::new(envelope.data, envelope.generated_at); + envelope + .verify() + .expect("should rebuild valid fixture envelope integrity"); + serde_json::to_string(&envelope).expect("should serialize trace auth fixture") + } else { + envelope + }; + let check = |base_url: &str| { + if matrix { + check_policy_matrix(base_url, &runtime, enabled, auth_pattern); + } else { + check_boundary(base_url, &runtime); + } + }; + with_trace_runtime(runtime, &envelope, None, check); +} + +fn with_trace_runtime( + runtime: TraceRuntime, + envelope: &str, + publisher_origin: Option<&str>, + check: impl FnOnce(&str), +) { + let fixture = IsolatedFixture::new(); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + match runtime { + TraceRuntime::Cloudflare => { + let original = root.join("crates/trusted-server-adapter-cloudflare"); + copy_directory(&original.join("build"), &fixture.0.join("build")); + let template = fs::read_to_string(original.join("wrangler.ci.toml")) + .expect("should read baseline Wrangler template"); + let binding = serde_json::to_string(&serde_json::json!({CONFIG_BLOB_KEY:envelope})) + .expect("should serialize trace binding"); + let config = template.replace( + "TRUSTED_SERVER_CONFIG = \"{}\"", + &format!("TRUSTED_SERVER_CONFIG = '''{binding}'''"), + ); + fs::write(fixture.0.join("wrangler.toml"), config) + .expect("should write isolated Wrangler config"); + temp_env::with_vars( + [ + ("CLOUDFLARE_WRANGLER_DIR", Some(fixture.0.as_os_str())), + ("CI", None), + ], + || { + let process = CloudflareWorkers + .spawn_with_readiness(wait_for_trace_fixture_ready) + .expect("should spawn isolated Cloudflare trace runtime"); + check(&process.base_url); + }, + ); + } + TraceRuntime::Fastly => { + let template = include_str!("../../fixtures/configs/viceroy-template.toml"); + let config=template.replace(" # GENERATED_TRUSTED_SERVER_CONFIG_STORES",&format!(" [local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}]\n format = \"inline-toml\"\n [local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}.contents]\n {CONFIG_BLOB_KEY} = '''{envelope}'''")); + let path = fixture.0.join("viceroy.toml"); + fs::write(&path, config).expect("should write isolated Viceroy config"); + temp_env::with_var("VICEROY_CONFIG_PATH", Some(path.as_os_str()), || { + let process = FastlyViceroy + .spawn(&wasm_binary_path()) + .expect("should spawn isolated Fastly trace runtime"); + check(&process.base_url); + }); + } + TraceRuntime::Spin => { + let artifact = + root.join("target/wasm32-wasip1/release/trusted_server_adapter_spin.wasm"); + assert!( + artifact.is_file(), + "should build production Spin component before boundary probe" + ); + let mut config = format!( + "spin_manifest_version = 2\n[application]\nname = \"trace-boundary-example\"\nversion = \"0.1.0\"\n[[trigger.http]]\nroute = \"/...\"\ncomponent = \"trace\"\n[component.trace]\nsource = {:?}\nkey_value_stores = [\"default\"]\n", + artifact.to_str().expect("should represent component path") + ); + if let Some(origin) = publisher_origin { + config.push_str(&format!("allowed_outbound_hosts = [{origin:?}]\n")); + } + let secrets = [ + ( + "integration_admin_password", + "integration-admin-password-32-bytes-ok", + ), + ( + "integration_proxy_secret", + "integration-test-proxy-secret-32-bytes-ok", + ), + ( + "integration_ec_passphrase", + "integration-test-ec-secret-padded-32", + ), + ( + "integration_partner_token_alpha", + "integration-test-token-alpha-32-bytes-ok", + ), + ( + "integration_partner_token_bravo", + "integration-test-token-bravo-32-bytes-ok", + ), + ]; + config.push_str("[variables]\n"); + for (name, value) in secrets { + config.push_str(&format!( + "{} = {{ default = {value:?} }}\n", + spin_secret_variable(name) + )); + } + config.push_str("[component.trace.variables]\n"); + for (name, _) in secrets { + let variable = spin_secret_variable(name); + config.push_str(&format!("{variable} = \"{{{{ {variable} }}}}\"\n")); + } + let manifest = fixture.0.join("spin.toml"); + fs::write(&manifest, config).expect("should write isolated Spin manifest"); + let port = crate::environments::find_available_port() + .expect("should reserve Spin runtime port"); + let child = Command::new("spin") + .arg("up") + .arg("--from") + .arg(&manifest) + .arg("--listen") + .arg(format!("127.0.0.1:{port}")) + .arg("--state-dir") + .arg(fixture.0.join("state")) + .arg("--key-value") + .arg(format!("{CONFIG_BLOB_KEY}={envelope}")) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("should spawn isolated Spin runtime"); + let process = RuntimeProcess { + inner: Box::new(ChildHandle { + child, + #[cfg(unix)] + process_group: false, + }), + base_url: format!("http://127.0.0.1:{port}"), + }; + wait_for_trace_fixture_ready(&process.base_url) + .expect("should load Spin trace configuration"); + check(&process.base_url); + } + } +} + +/// Exercise a real host-only cookie and publisher-to-viewer browser workflow. +/// +/// Auction and shared assembly stay disabled. Only GPT callbacks are mocked; +/// activation, context, built bundles, handoff, exports and cleanup are real. +/// Run ignored browser tests sequentially because they share the origin port. +/// +/// # Panics +/// +/// Panics when a required artifact, runtime, container or browser check fails. +pub(crate) fn exercise_browser(runtime: TraceBrowserRuntime) { + let port = origin_port(); + let _container = NextJs + .build_container(port) + .expect("should configure the existing Next.js origin fixture") + .start() + .expect("should start the existing Next.js origin fixture"); + crate::environments::wait_for_ready(&format!("http://127.0.0.1:{port}"), "/", false) + .expect("should make the ordinary publisher origin available"); + let envelope = integration_app_config_envelope_with_trace(port, true) + .expect("should generate isolated normal-browser trace configuration"); + let publisher_origin = format!("http://127.0.0.1:{port}"); + let check = |base_url: &str| run_browser_workflow(runtime, base_url); + match runtime { + TraceBrowserRuntime::Axum => { + let process = AxumDevServer + .spawn_with_app_config(&envelope) + .expect("should spawn the real native trace runtime"); + wait_for_trace_fixture_ready(&process.base_url) + .expect("should load native trace configuration"); + check(&process.base_url); + } + TraceBrowserRuntime::Cloudflare => { + with_trace_runtime(TraceRuntime::Cloudflare, &envelope, None, check); + } + TraceBrowserRuntime::Fastly => { + with_trace_runtime(TraceRuntime::Fastly, &envelope, None, check); + } + TraceBrowserRuntime::Spin => { + with_trace_runtime( + TraceRuntime::Spin, + &envelope, + Some(&publisher_origin), + check, + ); + } + } +} + +fn run_browser_workflow(runtime: TraceBrowserRuntime, base_url: &str) { + let browser_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("browser"); + let mut origin = url::Url::parse(base_url).expect("should parse the owned runtime origin"); + origin + .set_host(Some("localhost")) + .expect("should use browser-suitable localhost without changing the runtime port"); + let mut command = Command::new("node"); + command + .arg(browser_root.join("node_modules/@playwright/test/cli.js")) + .args(["test", "--config", "playwright.trace-runtime.config.ts"]) + .env( + "TRACE_BROWSER_ORIGIN", + origin.origin().ascii_serialization(), + ) + .env("TRACE_BROWSER_RUNTIME", runtime.id()) + .current_dir(&browser_root) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + #[cfg(unix)] + command.process_group(0); + let mut browser = ChildHandle { + child: command.spawn().expect( + "should launch installed Playwright without installing or building dependencies", + ), + #[cfg(unix)] + process_group: true, + }; + let started = Instant::now(); + loop { + if let Some(status) = browser + .child + .try_wait() + .expect("should poll the owned browser workflow") + { + assert!( + status.success(), + "should pass the real {} browser workflow: {status}", + runtime.id() + ); + break; + } + assert!( + started.elapsed() < Duration::from_secs(120), + "should finish the {} browser workflow within its bounded deadline", + runtime.id() + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn wait_for_trace_fixture_ready(base_url: &str) -> crate::common::runtime::TestResult<()> { + let client = reqwest::blocking::Client::new(); + for _ in 0..60 { + if client + .get(format!("{base_url}/_ts/trace/state")) + .timeout(Duration::from_millis(500)) + .send() + .is_ok_and(|response| matches!(response.status().as_u16(), 200 | 401 | 404)) + { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(250)); + } + Err(Report::new(TestError::RuntimeNotReady)) +} + +fn check_policy_matrix( + base_url: &str, + runtime: &TraceRuntime, + enabled: bool, + pattern: Option<&str>, +) { + let url = reqwest::Url::parse(base_url).expect("should parse matrix runtime origin"); + let authority = format!( + "{}:{}", + url.host_str().expect("should expose runtime host"), + url.port().expect("should expose runtime port") + ); + let authenticated = format!( + "Authorization: Basic {}\r\n", + STANDARD.encode("example-user:integration-admin-password-32-bytes-ok") + ); + for (method, path, route_status) in [ + (b"GET".as_slice(), "/_ts/trace/state", 200), + (b"HEAD".as_slice(), "/_ts/trace", 200), + (b"GET".as_slice(), "/_ts/trace/assets/v1.js", 200), + (b"GET".as_slice(), "/_ts/trace/assets/v1.css", 200), + (b"POST".as_slice(), "/_ts/trace/enable", 403), + (b"POST".as_slice(), "/_ts/trace/end", 403), + (b"PATCH".as_slice(), "/_ts/trace/state", 405), + (b"GET".as_slice(), "/_ts/trace/extra", 404), + ( + b"GET".as_slice(), + "/_ts/trace/../trace/state", + if matches!(runtime, TraceRuntime::Spin) { + 400 + } else { + 200 + }, + ), + (b"GET".as_slice(), "/%5Fts/trace", 400), + (b"GET".as_slice(), "/_ts//trace/state", 404), + ] { + for (credentials, headers) in [ + (false, b"".as_slice()), + ( + false, + b"Authorization: Basic ZXhhbXBsZS11c2VyOndyb25n\r\n".as_slice(), + ), + (true, authenticated.as_bytes()), + ] { + let protected = + pattern.is_some_and(|pattern| pattern == "^/" || path.starts_with("/_ts")); + let expected = if protected && !credentials { + 401 + } else if !enabled { + 404 + } else { + route_status + }; + let response = raw_request(&authority, method, path, headers); + assert_eq!( + response.status, expected, + "should apply actual-runtime auth before flag, reserved-path and method policy for {path}" + ); + if !path.contains("/assets/") || expected != 200 { + assert_private(&response); + } else { + assert_eq!( + response.headers["cache-control"], + if protected { + "no-store, private" + } else { + "public, max-age=31536000, immutable" + }, + "should preserve auth-dependent asset privacy" + ); + assert!( + response.headers.contains_key("etag"), + "should preserve protected strong ETag through actual runtime" + ); + } + if expected == 401 { + assert!( + response.headers.contains_key("www-authenticate"), + "should retain local auth challenge" + ); + } + if expected == 405 { + assert_eq!( + response.headers["allow"], "GET, HEAD", + "should retain local method Allow" + ); + } + if method == b"HEAD" { + assert!( + response.body.is_empty(), + "should strip every trace HEAD success/challenge/error body" + ); + } + } + } + if matches!(runtime, TraceRuntime::Fastly) { + let response = raw_request(&authority, b"GET", "/_ts/trace/../../_ts/debug/ja4", b""); + assert_eq!( + response.status, 404, + "should preserve normalized-out disabled native JA4 behavior before ordinary auth" + ); + assert!( + !response.headers.contains_key("content-security-policy"), + "should not relabel normalized-out native JA4 as a trace response" + ); + assert!( + !response.headers.contains_key("set-cookie"), + "should not create diagnostics cookies for ordinary native shortcut" + ); + } +} + +fn spin_secret_variable(key: &str) -> String { + let mut output = String::from("v_trusted_x5fserver_x5fsecrets_v_"); + for byte in key.bytes() { + if byte.is_ascii_lowercase() || byte.is_ascii_digit() { + output.push(char::from(byte)); + } else { + output.push_str(&format!("_x{byte:02x}")); + } + } + output +} + +fn copy_directory(source: &Path, destination: &Path) { + fs::create_dir_all(destination).expect("should create copied runtime build directory"); + for entry in fs::read_dir(source).expect("should locate prebuilt Cloudflare bundle") { + let entry = entry.expect("should read build entry"); + if entry + .file_type() + .expect("should inspect build entry") + .is_dir() + { + copy_directory(&entry.path(), &destination.join(entry.file_name())); + } else { + fs::copy(entry.path(), destination.join(entry.file_name())) + .expect("should copy isolated runtime build asset"); + } + } +} + +fn check_boundary(base_url: &str, runtime: &TraceRuntime) { + let url = reqwest::Url::parse(base_url).expect("should parse actual runtime origin"); + let authority = format!( + "{}:{}", + url.host_str().expect("should expose local runtime host"), + url.port().expect("should expose ephemeral runtime port") + ); + let client = reqwest::blocking::Client::new(); + let state = client + .get(format!("{base_url}/_ts/trace/state")) + .header("cookie", "__Host-ts-console=1") + .send() + .expect("should request ordinary visible session"); + assert_eq!( + state.status(), + 200, + "should activate marker-free Unknown-fidelity runtime cookies" + ); + assert_eq!( + state.text().expect("should read state"), + r#"{"observed_active":true}"#, + "should observe valid incoming session" + ); + for action in ["enable", "end"] { + let response = client + .post(format!("{base_url}/_ts/trace/{action}")) + .header("origin", base_url) + .header("sec-fetch-site", "same-origin") + .header("x-ts-trace-action", action) + .header("cookie", "__Host-ts-console=1, unrelated=value") + .send() + .expect("should perform actual-origin empty action"); + assert_eq!( + response.status(), + 200, + "should accept actual runtime origin" + ); + let expected = if action == "enable" { + "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800" + } else { + "__Host-ts-console=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0" + }; + let cookies: Vec<_> = response.headers().get_all("set-cookie").iter().collect(); + assert_eq!( + cookies.len(), + 1, + "should retain exactly one explicit action cookie through native finalization" + ); + assert_eq!( + cookies[0], expected, + "should preserve shared cookie attributes exactly" + ); + assert_eq!( + response.text().expect("should read requested mutation"), + r#"{"mutation_requested":true}"#, + "should report mutation without claiming browser acceptance" + ); + let state = client + .get(format!("{base_url}/_ts/trace/state")) + .header("cookie", "__Host-ts-console=1, unrelated=value") + .send() + .expect("should observe a separate ambiguous-cookie state"); + assert_eq!( + state.text().expect("should read independent state"), + r#"{"observed_active":false}"#, + "should not infer browser cookie acceptance from a requested mutation" + ); + } + let head = raw_request(&authority, b"HEAD", "/_ts/trace", b""); + assert_eq!(head.status, 200, "should serve bodyless setup HEAD"); + assert!(head.body.is_empty(), "should strip setup HEAD body"); + assert_private(&head); + let literal = raw_request( + &authority, + b"GET", + "/_ts/trace", + b"Cookie: __Host-ts-console=1; unrelated=\xef\xbf\xbd\r\n", + ); + assert_eq!( + literal.status, 200, + "should project runtime-visible literal U+FFFD safely" + ); + assert!( + String::from_utf8_lossy(&literal.body).contains("runtime_header_ambiguous"), + "should conservatively reject U+FFFD with unknown original octets" + ); + assert_private(&literal); + let repeated = raw_request( + &authority, + b"GET", + "/_ts/trace", + b"Cookie: __Host-ts-console=1\r\nCookie: __Host-ts-console=1\r\n", + ); + assert_eq!( + repeated.status, 200, + "should retain local cookie-health observation for repeated fields" + ); + let expected = if matches!(runtime, TraceRuntime::Cloudflare) { + "runtime_header_ambiguous" + } else { + "duplicate" + }; + assert!( + String::from_utf8_lossy(&repeated.body).contains(expected), + "should pin the runtime's visible repeated-cookie boundary" + ); + assert_private(&repeated); + let invalid = raw_request( + &authority, + b"GET", + "/_ts/trace", + b"Cookie: __Host-ts-console=1; unrelated=\xff\r\n", + ); + match runtime { + TraceRuntime::Cloudflare | TraceRuntime::Fastly => { + assert_eq!( + invalid.status, 200, + "should inspect bytes accepted at the application boundary" + ); + let reason = if matches!(runtime, TraceRuntime::Cloudflare) { + "runtime_header_ambiguous" + } else { + "header_not_utf8" + }; + assert!( + String::from_utf8_lossy(&invalid.body).contains(reason), + "should distinguish replacement ambiguity from actual visible non-UTF8" + ); + assert_private(&invalid); + } + TraceRuntime::Spin => { + assert_eq!( + invalid.status, 500, + "should pin the pre-component invalid-byte conversion failure separately" + ); + assert!( + !invalid.headers.contains_key("content-security-policy"), + "should not claim local trace hardening before successful SDK conversion" + ); + assert!( + !invalid.headers.contains_key("set-cookie"), + "should not mutate cookies on pre-component conversion failure" + ); + assert!( + !String::from_utf8_lossy(&invalid.body).contains("trace-request-context"), + "should not capture trace context on conversion failure" + ); + } + } + let extension = raw_request(&authority, b"EXAMPLE-METHOD", "/_ts/trace/state", b""); + if matches!(runtime, TraceRuntime::Cloudflare) { + assert_eq!( + extension.status, 501, + "should pin workerd's pre-application method rejection" + ); + assert!( + !extension.headers.contains_key("content-security-policy"), + "should keep wire rejection separate from application hardened 405" + ); + } else { + assert_eq!( + extension.status, 405, + "should reject converted extension method locally" + ); + assert_eq!( + extension.headers["allow"], "GET, HEAD", + "should retain application Allow policy" + ); + assert_private(&extension); + } + let asset = client + .get(format!("{base_url}/_ts/trace/assets/v1.js")) + .send() + .expect("should request fixed asset"); + assert_eq!(asset.status(), 200, "should serve verified fixed bytes"); + assert_eq!( + asset.headers()["cache-control"], + "public, max-age=31536000, immutable", + "should retain immutable asset cache policy" + ); + assert!( + asset.headers().contains_key("etag"), + "should retain strong asset validator through native conversion" + ); + assert!( + !asset.headers().contains_key("set-cookie"), + "should never add identity cookies to fixed assets" + ); + let normalized_in = raw_request(&authority, b"GET", "/_ts/trace/../trace/state", b""); + let expected = if matches!(runtime, TraceRuntime::Spin) { + 400 + } else { + 200 + }; + assert_eq!( + normalized_in.status, expected, + "should classify the actual runtime-visible path, not recover a discarded wire target" + ); + assert_private(&normalized_in); + let normalized_out = raw_request(&authority, b"GET", "/_ts/trace/../../health", b""); + if matches!(runtime, TraceRuntime::Spin) { + assert_eq!( + normalized_out.status, 400, + "should reject dot ambiguity still visible to Spin" + ); + assert_private(&normalized_out); + } else if matches!(runtime, TraceRuntime::Fastly) { + assert_eq!( + normalized_out.status, 200, + "should preserve ordinary health when runtime normalization removes the namespace" + ); + assert_eq!( + normalized_out.body, b"ok", + "should keep normalized-out requests on the ordinary health surface" + ); + } else { + let ordinary = raw_request(&authority, b"GET", "/health", b""); + assert_eq!( + normalized_out.status, ordinary.status, + "should preserve Cloudflare's ordinary publisher fallback for normalized-out health" + ); + assert!( + !String::from_utf8_lossy(&normalized_out.body).contains("trace-request-context"), + "should not capture context after the runtime removes the namespace" + ); + } +} + +struct RawResponse { + status: u16, + headers: HeaderMap, + body: Vec, +} + +fn assert_private(response: &RawResponse) { + assert_eq!( + response.headers["cache-control"], "no-store, private", + "should preserve dynamic/error cache policy through actual runtime" + ); + assert!( + response.headers.contains_key("content-security-policy"), + "should retain fixed trace CSP" + ); + assert!( + !response.headers.contains_key("set-cookie"), + "should not mutate read cookies" + ); +} + +fn raw_request(authority: &str, method: &[u8], path: &str, headers: &[u8]) -> RawResponse { + let mut connection = TcpStream::connect(authority).expect("should connect raw runtime client"); + connection + .set_read_timeout(Some(Duration::from_secs(10))) + .expect("should bound raw response wait"); + connection + .write_all(method) + .expect("should write exact method"); + connection + .write_all( + format!(" {path} HTTP/1.1\r\nHost: {authority}\r\nConnection: close\r\n").as_bytes(), + ) + .expect("should write runtime authority"); + connection + .write_all(headers) + .expect("should preserve original header bytes and repeated fields"); + connection + .write_all(b"\r\n") + .expect("should finish raw request"); + let mut bytes = Vec::new(); + let mut chunk = [0; 8192]; + while bytes.len() < 2 * 1024 * 1024 { + let available = chunk.len().min(2 * 1024 * 1024 - bytes.len()); + let read = connection + .read(&mut chunk[..available]) + .expect("should read bounded raw response"); + if read == 0 { + break; + } + bytes.extend_from_slice(&chunk[..read]); + if response_complete(&bytes, method == b"HEAD") { + break; + } + } + let end = bytes + .windows(4) + .position(|part| part == b"\r\n\r\n") + .expect("should receive HTTP response headers"); + let text = std::str::from_utf8(&bytes[..end]).expect("should receive UTF8 response headers"); + let mut lines = text.split("\r\n"); + let status = lines + .next() + .expect("should receive status line") + .split_ascii_whitespace() + .nth(1) + .expect("should receive numeric status") + .parse() + .expect("should parse status"); + let mut response_headers = HeaderMap::new(); + for line in lines { + let (name, value) = line.split_once(':').expect("should parse header field"); + response_headers.append( + http::header::HeaderName::from_bytes(name.as_bytes()) + .expect("should parse header name"), + HeaderValue::from_str(value.trim()).expect("should parse response header"), + ); + } + let body = if method != b"HEAD" + && response_headers + .get("transfer-encoding") + .is_some_and(|value| value == "chunked") + { + decode_chunks(&bytes[end + 4..]) + } else { + bytes[end + 4..].to_vec() + }; + RawResponse { + status, + headers: response_headers, + body, + } +} + +fn decode_chunks(mut bytes: &[u8]) -> Vec { + let mut body = Vec::new(); + loop { + let end = bytes + .windows(2) + .position(|part| part == b"\r\n") + .expect("should read chunk length"); + let size = usize::from_str_radix( + std::str::from_utf8(&bytes[..end]) + .expect("should read ASCII chunk length") + .split(';') + .next() + .expect("should read chunk size"), + 16, + ) + .expect("should parse chunk length"); + if size == 0 { + return body; + } + bytes = &bytes[end + 2..]; + body.extend_from_slice( + bytes + .get(..size) + .expect("should receive complete bounded chunk"), + ); + bytes = bytes + .get(size + 2..) + .expect("should receive chunk terminator"); + } +} + +fn response_complete(bytes: &[u8], head: bool) -> bool { + let Some(end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") else { + return false; + }; + if head { + return true; + } + let Ok(headers) = std::str::from_utf8(&bytes[..end]) else { + return false; + }; + let body = &bytes[end + 4..]; + let mut length = None; + let mut chunked = false; + for line in headers.split("\r\n").skip(1) { + let Some((name, value)) = line.split_once(':') else { + return false; + }; + if name.eq_ignore_ascii_case("content-length") { + let Ok(parsed) = value.trim().parse::() else { + return false; + }; + if length.replace(parsed).is_some() { + return false; + } + } + if name.eq_ignore_ascii_case("transfer-encoding") + && value.trim().eq_ignore_ascii_case("chunked") + { + chunked = true; + } + } + if chunked { + return chunks_complete(body); + } + length.is_some_and(|length| body.len() >= length) +} + +fn chunks_complete(mut bytes: &[u8]) -> bool { + loop { + let Some(end) = bytes.windows(2).position(|part| part == b"\r\n") else { + return false; + }; + let Ok(line) = std::str::from_utf8(&bytes[..end]) else { + return false; + }; + let Some(size) = line + .split(';') + .next() + .and_then(|size| usize::from_str_radix(size, 16).ok()) + else { + return false; + }; + bytes = &bytes[end + 2..]; + if size == 0 { + loop { + let Some(end) = bytes.windows(2).position(|part| part == b"\r\n") else { + return false; + }; + if end == 0 { + return true; + } + bytes = &bytes[end + 2..]; + } + } + let Some(next) = size.checked_add(2) else { + return false; + }; + if bytes.get(size..next) != Some(b"\r\n".as_slice()) { + return false; + } + bytes = &bytes[next..]; + } +} + +#[test] +fn trace_raw_response_completion_respects_framing() { + assert!( + response_complete(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", false), + "should finish an empty framed response without waiting for connection closure" + ); + assert!( + !response_complete(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nab", false), + "should wait for the full declared body" + ); + assert!( + response_complete(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc", false), + "should finish at the full declared body" + ); + assert!( + response_complete(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n", true), + "should finish HEAD at the header boundary" + ); + assert!( + !response_complete(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n", true), + "should require the complete header terminator" + ); + assert!( + !response_complete( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nabcde\r\n0\r\n", + false + ), + "should wait for the complete final chunk terminator" + ); + assert!( + response_complete( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nabcde\r\n0\r\n\r\n", + false + ), + "should finish a complete chunked response" + ); + assert!( + !response_complete( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n8\r\n\r\n0\r\n\r\n", + false + ), + "should not mistake a final-chunk marker inside chunk data for completion" + ); + assert!(response_complete(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n1;example=yes\r\na\r\n0\r\nExample: value\r\n\r\n", false), "should finish chunk extensions and bounded trailers"); + assert!( + !response_complete(b"HTTP/1.1 200 OK\r\n\r\nabc", false), + "should retain EOF framing when no length or transfer framing is present" + ); +} diff --git a/crates/trusted-server-integration-tests/tests/environments/axum.rs b/crates/trusted-server-integration-tests/tests/environments/axum.rs index 6e1388cea..fa9e389c4 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -55,10 +55,27 @@ impl RuntimeEnvironment for AxumDevServer { } fn spawn(&self, _wasm_path: &Path) -> TestResult { + let app_config = integration_app_config_envelope(origin_port())?; + self.spawn_with_app_config(&app_config) + } + + fn health_check_path(&self) -> &str { + "/health" + } +} + +impl AxumDevServer { + /// Spawn the real native binary with an isolated app-config envelope. + /// + /// Normal [`RuntimeEnvironment::spawn`] retains its disabled baseline config. + /// + /// # Errors + /// + /// Returns a spawn or readiness error if the native process is unavailable. + pub(crate) fn spawn_with_app_config(&self, app_config: &str) -> TestResult { let binary = self.binary_path(); let port = super::find_available_port().unwrap_or(AXUM_DEFAULT_PORT); - let app_config = integration_app_config_envelope(origin_port())?; let store_name = default_config_store_name(); let config_key = default_config_key(); let config_variable = config_env_var(store_name.as_ref(), &config_key); @@ -100,12 +117,6 @@ impl RuntimeEnvironment for AxumDevServer { }) } - fn health_check_path(&self) -> &str { - "/health" - } -} - -impl AxumDevServer { /// Resolve the path to the compiled `trusted-server-axum` binary. /// /// Respects the `AXUM_BINARY_PATH` environment variable for CI overrides. diff --git a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs index 10d16c0a7..02f4605ae 100644 --- a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs +++ b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs @@ -27,7 +27,7 @@ const CI_CONFIG_TEMPLATE: &str = "wrangler.ci.toml"; const GENERATED_CI_CONFIG: &str = "wrangler.integration.generated.toml"; const TRUSTED_SERVER_CONFIG_PLACEHOLDER: &str = "TRUSTED_SERVER_CONFIG = \"{}\""; -fn write_generated_ci_config(wrangler_dir: &Path) -> TestResult { +fn write_generated_ci_config(wrangler_dir: &Path, config_json: &str) -> TestResult { let template_path = wrangler_dir.join(CI_CONFIG_TEMPLATE); let template = std::fs::read_to_string(&template_path) .change_context(TestError::RuntimeSpawn) @@ -35,8 +35,7 @@ fn write_generated_ci_config(wrangler_dir: &Path) -> TestResult { "failed to read Cloudflare CI wrangler config at {}", template_path.display() ))?; - let config_json = cloudflare_config_json(origin_port())?; - let generated = inject_cloudflare_config(&template, &config_json)?; + let generated = inject_cloudflare_config(&template, config_json)?; let output_path = wrangler_dir.join(GENERATED_CI_CONFIG); std::fs::write(&output_path, generated) .change_context(TestError::RuntimeSpawn) @@ -67,13 +66,61 @@ impl RuntimeEnvironment for CloudflareWorkers { } fn spawn(&self, _wasm_path: &Path) -> TestResult { + self.spawn_with_readiness(|base_url| { + super::wait_for_ready(base_url, self.health_check_path(), true) + }) + } + + fn health_check_path(&self) -> &str { + "/.well-known/trusted-server.json" + } +} + +impl CloudflareWorkers { + /// Start an isolated fixture with an explicit authenticated readiness probe. + /// + /// The standard [`RuntimeEnvironment::spawn`] retains its original readiness check. + /// + /// # Errors + /// + /// Returns the existing spawn failure or the supplied readiness-probe error. + pub(crate) fn spawn_with_readiness( + &self, + ready: impl FnOnce(&str) -> TestResult<()>, + ) -> TestResult { let wrangler_dir = self.wrangler_dir(); let config = if std::env::var("CI").is_ok() { - write_generated_ci_config(&wrangler_dir)? + write_generated_ci_config(&wrangler_dir, &cloudflare_config_json(origin_port())?)? } else { "wrangler.toml".to_string() }; + Self::spawn_wrangler(&wrangler_dir, &config, ready) + } + /// Start `wrangler dev` with a test-specific `TRUSTED_SERVER_CONFIG` binding. + /// + /// Unlike [`RuntimeEnvironment::spawn`], this always renders the CI + /// wrangler template, so tests can enable features the shared fixture + /// leaves off without changing the other runtimes' configuration. + /// + /// # Errors + /// + /// Returns [`TestError::RuntimeSpawn`] if the wrangler config cannot be + /// written or `wrangler dev` cannot be started, and + /// [`TestError::RuntimeNotReady`] if the health check times out. + pub fn spawn_with_config_json(&self, config_json: &str) -> TestResult { + let wrangler_dir = self.wrangler_dir(); + let config = write_generated_ci_config(&wrangler_dir, config_json)?; + Self::spawn_wrangler(&wrangler_dir, &config, |base_url| { + super::wait_for_ready(base_url, self.health_check_path(), true) + }) + } + + fn spawn_wrangler( + wrangler_dir: &Path, + config: &str, + ready: impl FnOnce(&str) -> TestResult<()>, + ) -> TestResult { let port = super::find_available_port().unwrap_or(CLOUDFLARE_DEFAULT_PORT); #[cfg(unix)] @@ -83,13 +130,13 @@ impl RuntimeEnvironment for CloudflareWorkers { .args([ "dev", "--config", - config.as_str(), + config, "--port", &port.to_string(), "--ip", "127.0.0.1", ]) - .current_dir(&wrangler_dir) + .current_dir(wrangler_dir) .stdout(Stdio::null()) .stderr(Stdio::piped()) .process_group(0) @@ -108,13 +155,13 @@ impl RuntimeEnvironment for CloudflareWorkers { .args([ "dev", "--config", - config.as_str(), + config, "--port", &port.to_string(), "--ip", "127.0.0.1", ]) - .current_dir(&wrangler_dir) + .current_dir(wrangler_dir) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() @@ -141,7 +188,7 @@ impl RuntimeEnvironment for CloudflareWorkers { let handle = CloudflareHandle { child }; let base_url = format!("http://127.0.0.1:{port}"); - super::wait_for_ready(&base_url, self.health_check_path(), true)?; + ready(&base_url)?; Ok(RuntimeProcess { inner: Box::new(handle), @@ -149,12 +196,6 @@ impl RuntimeEnvironment for CloudflareWorkers { }) } - fn health_check_path(&self) -> &str { - "/.well-known/trusted-server.json" - } -} - -impl CloudflareWorkers { /// Resolve the Cloudflare adapter crate root. /// /// Respects `CLOUDFLARE_WRANGLER_DIR` for CI overrides; falls back to diff --git a/crates/trusted-server-integration-tests/tests/integration.rs b/crates/trusted-server-integration-tests/tests/integration.rs index ebd026410..f99f8f6c0 100644 --- a/crates/trusted-server-integration-tests/tests/integration.rs +++ b/crates/trusted-server-integration-tests/tests/integration.rs @@ -214,6 +214,60 @@ fn test_cloudflare_dynamic_publisher_response_does_not_cross_cookie_boundaries() ); } +/// Exercises an enabled `/auction` request through auction telemetry in workerd. +/// +/// `std::time::Instant::now()` traps on Cloudflare's `wasm32-unknown-unknown` +/// target, so this guards the `web_time::Instant` timing in +/// `AuctionObservationContext`. The auction config has no providers, so the +/// enabled auction completes as a no-bid without outbound calls. +#[test] +#[ignore = "requires the `wrangler` CLI in $PATH and a prebuilt Cloudflare Workers bundle (run build.sh first); the test starts wrangler dev automatically"] +fn test_cloudflare_enabled_auction_reaches_telemetry() { + init_logger(); + // The readiness probe falls back to the proxied root page. + let _origin = common::ec::MinimalOrigin::start(origin_port()); + let config_json = common::config::cloudflare_auction_config_json(origin_port()) + .expect("should build auction-enabled Cloudflare config"); + let process = environments::cloudflare::CloudflareWorkers + .spawn_with_config_json(&config_json) + .expect("should start Cloudflare Worker"); + let request_body = serde_json::json!({ + "adUnits": [{ + "code": "integration-slot", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + }] + }); + + // A non-regulated geo passes the server-side auction consent gate, so the + // request runs the enabled orchestration path instead of a consent skip. + let response = reqwest::blocking::Client::new() + .post(format!("{}/auction", process.base_url)) + .header("cf-ipcountry", "US") + .json(&request_body) + .send() + .expect("should send auction request"); + + let status = response.status().as_u16(); + let body = response.text().expect("should read auction response"); + assert_eq!( + status, 200, + "enabled auction should complete without a worker exception: {body}" + ); + let auction_response: serde_json::Value = + serde_json::from_str(&body).expect("should return an OpenRTB JSON response"); + assert!( + auction_response["id"] + .as_str() + .is_some_and(|id| !id.is_empty()), + "auction response should carry the auction id: {body}" + ); + assert_eq!( + auction_response["ext"]["orchestrator"]["providers"], + serde_json::json!(0), + "enabled auction without providers should complete as a no-bid: {body}" + ); +} + #[test] #[ignore = "requires Docker and pre-built trusted-server-axum binary"] fn test_wordpress_axum() { @@ -276,3 +330,53 @@ fn test_ec_lifecycle_fastly() { ); } } +#[test] +#[ignore = "requires Wrangler in PATH and the current Cloudflare build.sh output"] +fn trace_runtime_boundary_cloudflare() { + init_logger(); + common::trace_boundary::exercise(common::trace_boundary::TraceRuntime::Cloudflare); +} + +#[test] +#[ignore = "requires Viceroy and the current production Fastly WASM artifact"] +fn trace_runtime_boundary_fastly() { + init_logger(); + common::trace_boundary::exercise(common::trace_boundary::TraceRuntime::Fastly); +} + +#[test] +#[ignore = "requires Spin and the current production Spin WASM artifact"] +fn trace_runtime_boundary_spin() { + init_logger(); + common::trace_boundary::exercise(common::trace_boundary::TraceRuntime::Spin); +} + +#[test] +#[ignore = "requires Docker, Chromium and the current native Axum binary"] +fn trace_browser_workflow_axum() { + init_logger(); + common::trace_boundary::exercise_browser(common::trace_boundary::TraceBrowserRuntime::Axum); +} + +#[test] +#[ignore = "requires Docker, Chromium, Viceroy and the current release Fastly WASM"] +fn trace_browser_workflow_fastly() { + init_logger(); + common::trace_boundary::exercise_browser(common::trace_boundary::TraceBrowserRuntime::Fastly); +} + +#[test] +#[ignore = "requires Docker, Chromium, Wrangler and the current Cloudflare build output"] +fn trace_browser_workflow_cloudflare() { + init_logger(); + common::trace_boundary::exercise_browser( + common::trace_boundary::TraceBrowserRuntime::Cloudflare, + ); +} + +#[test] +#[ignore = "requires Docker, Chromium, Spin and the current release Spin component"] +fn trace_browser_workflow_spin() { + init_logger(); + common::trace_boundary::exercise_browser(common::trace_boundary::TraceBrowserRuntime::Spin); +} diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index 2428d7799..9da6d08ad 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -1119,3 +1119,1096 @@ async fn adapter_buffers_nextjs_auction_output() { } } } +#[allow(clippy::panic)] +#[cfg(test)] +mod trace_parity { + use std::net::IpAddr; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + use async_trait::async_trait; + use bytes::Bytes; + use edgezero_core::key_value_store::{KvError, KvPage}; + use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetUnavailable, + }; + use error_stack::Report; + use http::{Method, StatusCode, header}; + use trusted_server_core::auction::telemetry::{AuctionEventBatch, AuctionTelemetrySink}; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::platform::{ + BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, + PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, + PlatformKvStore, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, + PlatformSelectResult, RuntimeServices, StoreId, StoreName, UnavailableKvStore, + }; + use trusted_server_core::redacted::Redacted; + use trusted_server_core::settings::TrustedForwarderConfig; + use trusted_server_core::trace::TraceTerminalResponse; + + use super::*; + + struct ForbiddenLifecycle; + + impl PlatformConfigStore for ForbiddenLifecycle { + fn get(&self, _store: &StoreName, _key: &str) -> Result> { + panic!("should not read trace config per request"); + } + fn put( + &self, + _store: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + panic!("should not write trace config"); + } + fn delete(&self, _store: &StoreId, _key: &str) -> Result<(), Report> { + panic!("should not delete trace config"); + } + } + impl PlatformSecretStore for ForbiddenLifecycle { + fn get_bytes( + &self, + _store: &StoreName, + _key: &str, + ) -> Result, Report> { + panic!("should not read trace secrets"); + } + fn create( + &self, + _store: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + panic!("should not write trace secrets"); + } + fn delete(&self, _store: &StoreId, _key: &str) -> Result<(), Report> { + panic!("should not delete trace secrets"); + } + } + impl PlatformBackend for ForbiddenLifecycle { + fn naming_policy(&self) -> BackendNamingPolicy { + panic!("should not resolve trace backends"); + } + fn predict_name( + &self, + _spec: &PlatformBackendSpec, + ) -> Result> { + panic!("should not predict trace backends"); + } + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + panic!("should not register trace backends"); + } + } + #[async_trait(?Send)] + impl PlatformKvStore for ForbiddenLifecycle { + async fn get_bytes(&self, _key: &str) -> Result, KvError> { + panic!("should not read trace identity"); + } + async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { + panic!("should not write trace identity"); + } + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + panic!("should not refresh trace identity"); + } + async fn delete(&self, _key: &str) -> Result<(), KvError> { + panic!("should not delete trace identity"); + } + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + panic!("should not enumerate trace identity"); + } + } + #[async_trait(?Send)] + impl PlatformHttpClient for ForbiddenLifecycle { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + panic!("should not contact trace origin"); + } + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + panic!("should not launch trace auctions"); + } + async fn select( + &self, + _requests: Vec, + ) -> Result> { + panic!("should not collect trace auctions"); + } + } + #[async_trait(?Send)] + impl AuctionTelemetrySink for ForbiddenLifecycle { + fn is_enabled(&self) -> bool { + panic!("should not inspect trace auction telemetry"); + } + async fn emit_auction_events( + &self, + _services: &RuntimeServices, + _batch: AuctionEventBatch, + ) -> Result<(), Report> { + panic!("should not emit trace auction telemetry"); + } + } + struct CountingGeo(Arc); + impl PlatformGeo for CountingGeo { + fn lookup(&self, ip: Option) -> Result, Report> { + assert_eq!( + ip, + Some( + "192.0.2.99" + .parse() + .expect("should parse injected example IP") + ), + "should use injected trusted client metadata" + ); + self.0.fetch_add(1, Ordering::SeqCst); + Ok(Some(GeoInfo { + city: String::new(), + country: "GB".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("EX".to_owned()), + asn: Some(64512), + })) + } + } + + fn routers( + enabled: bool, + auth_pattern: Option<&str>, + calls: Arc, + ) -> Vec<(&'static str, RouterService)> { + routers_with_client( + enabled, + auth_pattern, + calls, + Some( + "192.0.2.99" + .parse() + .expect("should parse injected client IP"), + ), + ) + } + + fn routers_with_client( + enabled: bool, + auth_pattern: Option<&str>, + calls: Arc, + client_ip: Option, + ) -> Vec<(&'static str, RouterService)> { + let mut settings = test_settings(); + settings + .integrations + .insert_config( + "gpt_diagnostics", + &serde_json::json!({"enabled":true,"trace_page_enabled":enabled}), + ) + .expect("should insert trace settings"); + if let Some(pattern) = auth_pattern { + settings.handlers.insert(0,serde_json::from_value(serde_json::json!({"path":pattern,"username":"example-user","password":"example-password"})).expect("should insert trace auth rule")); + } + let forbidden = Arc::new(ForbiddenLifecycle); + let services = RuntimeServices::builder() + .config_store(forbidden.clone()) + .secret_store(forbidden.clone()) + .kv_store(forbidden.clone()) + .backend(forbidden.clone()) + .http_client(forbidden.clone()) + .auction_telemetry_sink(forbidden) + .geo(Arc::new(CountingGeo(calls))) + .client_info(ClientInfo { + client_ip, + ..ClientInfo::default() + }) + .build(); + routers_with_services(settings, services) + } + + fn routers_with_services( + settings: Settings, + services: RuntimeServices, + ) -> Vec<(&'static str, RouterService)> { + vec![ + ( + "Axum", + AxumApp::routes_with_settings_and_services(settings.clone(), services.clone()) + .expect("should build Axum trace routes"), + ), + ( + "Cloudflare", + CloudflareApp::routes_with_settings_and_services( + settings.clone(), + services.clone(), + ) + .expect("should build Cloudflare trace routes"), + ), + ( + "Spin", + SpinApp::routes_with_settings_and_services(settings, services) + .expect("should build Spin trace routes"), + ), + ] + } + + #[tokio::test] + async fn trace_dispatch_parity_missing_client_ip_skips_geo() { + let calls = Arc::new(AtomicUsize::new(0)); + for (adapter, router) in routers_with_client(true, None, Arc::clone(&calls), None) { + let response = RouterService::oneshot( + &router, + request_builder() + .uri("/_ts/trace") + .body(edgezero_core::body::Body::empty()) + .expect("should build missing-IP setup"), + ) + .await + .expect("should render setup without optional facts"); + assert_eq!( + response.status(), + StatusCode::OK, + "{adapter} should render missing optional facts" + ); + } + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "should not synthesize a geo lookup without trusted client IP" + ); + } + + #[tokio::test] + async fn trace_dispatch_parity_auth_precedes_flags_aliases_and_methods_without_services() { + let calls = Arc::new(AtomicUsize::new(0)); + for enabled in [false, true] { + for (pattern, path, expected) in [ + (Some("^/"), "/%5Fts/trace", StatusCode::UNAUTHORIZED), + ( + Some("^/_ts"), + "/%5Fts/trace", + if enabled { + StatusCode::BAD_REQUEST + } else { + StatusCode::NOT_FOUND + }, + ), + (Some("^/_ts"), "/_ts/trace/extra", StatusCode::UNAUTHORIZED), + ( + None, + "/_ts/trace", + if enabled { + StatusCode::METHOD_NOT_ALLOWED + } else { + StatusCode::NOT_FOUND + }, + ), + ] { + for (adapter, router) in routers(enabled, pattern, Arc::clone(&calls)) { + let request = request_builder() + .method("EXAMPLE-METHOD") + .uri(path) + .body(edgezero_core::body::Body::empty()) + .expect("should build extension method"); + let response = router + .oneshot(request) + .await + .expect("should reject locally"); + assert_eq!( + response.status(), + expected, + "{adapter} should apply auth then flag/path/method" + ); + assert!( + response + .extensions() + .get::() + .is_some(), + "{adapter} should retain terminal marker" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "no-store, private", + "{adapter} should keep trace errors private" + ); + assert!( + !response.headers().contains_key(header::SET_COOKIE), + "{adapter} should not write rejected cookies" + ); + } + } + } + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "should never lookup denied metadata" + ); + } + + #[tokio::test] + async fn trace_dispatch_parity_read_only_setup_and_verified_assets() { + let calls = Arc::new(AtomicUsize::new(0)); + for (adapter, router) in routers(true, None, Arc::clone(&calls)) { + for (method, path, expected_calls) in [ + (Method::HEAD, "/_ts/trace", 0), + (Method::GET, "/_ts/trace/state", 0), + (Method::GET, "/_ts/trace/assets/v1.js", 0), + (Method::GET, "/_ts/trace/assets/v1.css", 0), + (Method::GET, "/_ts/trace", 1), + ] { + calls.store(0, Ordering::SeqCst); + let head = method == Method::HEAD; + let request = request_builder() + .method(method) + .uri(path) + .header("cookie", "__Host-ts-console=1") + .header("cf-connecting-ip", "203.0.113.22") + .header("spin-client-addr", "203.0.113.33:1234") + .body(edgezero_core::body::Body::empty()) + .expect("should build local read"); + let response = RouterService::oneshot(&router, request) + .await + .expect("should serve local read"); + assert_eq!( + response.status(), + StatusCode::OK, + "{adapter} should serve local route" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + expected_calls, + "{adapter} should fetch metadata only for GET setup" + ); + assert!( + !response.headers().contains_key(header::SET_COOKIE), + "{adapter} should not refresh cookies" + ); + let headers = response.headers().clone(); + let bytes = response + .into_body() + .into_bytes() + .expect("should buffer trace response"); + if head { + assert!(bytes.is_empty(), "{adapter} should remove HEAD body"); + } else if path == "/_ts/trace" { + let html = String::from_utf8(bytes.to_vec()).expect("should render UTF8 setup"); + assert!( + html.contains("192.0.2.0/24"), + "{adapter} should prefer injected client metadata" + ); + assert!( + !html.contains("192.0.2.99"), + "{adapter} should redact full client IP" + ); + assert!( + html.contains("64512"), + "{adapter} should project populated ASN" + ); + } else if path.contains("/assets/") { + assert_eq!( + headers[header::CACHE_CONTROL], + "public, max-age=31536000, immutable", + "{adapter} should cache unprotected fixed bytes" + ); + assert_eq!( + bytes.as_ref(), + trusted_server_js_asset(path), + "{adapter} should serve actual verified build bytes" + ); + } else { + assert_eq!( + bytes.as_ref(), + br#"{"observed_active":true}"#, + "{adapter} should return boolean state only" + ); + } + } + } + } + + fn trusted_server_js_asset(path: &str) -> &'static [u8] { + trusted_server_js::trace_assets::trace_asset(path) + .expect("should locate verified fixed asset") + .bytes + } + + #[tokio::test] + async fn trace_dispatch_parity_stream_actions_preserve_cookie_policy_and_separate_state() { + let calls = Arc::new(AtomicUsize::new(0)); + for (adapter, router) in routers(true, None, Arc::clone(&calls)) { + for action in ["enable", "end"] { + let mut request = request_builder() + .method("POST") + .uri(format!("https://publisher.example.com/_ts/trace/{action}")) + .header("host", "publisher.example.com") + .header("origin", "https://publisher.example.com") + .header("sec-fetch-site", "same-origin") + .header("x-ts-trace-action", action) + .header("cookie", "__Host-ts-console=1, unrelated=value") + .body(edgezero_core::body::Body::from_stream( + futures_trace_empty_stream(), + )) + .expect("should build no-content-type streamed action"); + request.extensions_mut().insert( + RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::NotExposed), + Some( + InboundOrigin::parse( + "https", + "publisher.example.com", + OriginSource::RuntimeUri, + ) + .expect("should freeze trusted origin"), + ), + HeaderFidelity::default(), + Vec::new(), + ) + .expect("should create ingress snapshot"), + ); + let response = RouterService::oneshot(&router, request) + .await + .expect("should request local mutation"); + assert_eq!( + response.status(), + StatusCode::OK, + "{adapter} should accept explicit empty streamed action independently of cookie ambiguity" + ); + let cookies: Vec<_> = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .collect(); + assert_eq!( + cookies.len(), + 1, + "{adapter} should emit exactly one action cookie" + ); + let expected = if action == "enable" { + "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800" + } else { + "__Host-ts-console=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0" + }; + assert_eq!( + cookies[0], expected, + "{adapter} should reuse the shared cookie policy" + ); + assert_eq!( + response + .into_body() + .into_bytes() + .expect("should buffer mutation response") + .as_ref(), + br#"{"mutation_requested":true}"#, + "{adapter} should report requested mutation only" + ); + let state = RouterService::oneshot( + &router, + request_builder() + .uri("/_ts/trace/state") + .header("cookie", "__Host-ts-console=1, unrelated=value") + .body(edgezero_core::body::Body::empty()) + .expect("should build separate state observation"), + ) + .await + .expect("should observe separate state"); + assert_eq!( + state + .into_body() + .into_bytes() + .expect("should buffer state") + .as_ref(), + br#"{"observed_active":false}"#, + "{adapter} should not infer accepted browser cookie mutation" + ); + } + } + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "should not lookup metadata for actions or state" + ); + } + + const FORWARDER_HEADER: &str = "x-example-forwarder-key"; + const FORWARDER_SECRET: &str = "fictional-forwarder-secret-0123456789"; + const PUBLIC_ORIGIN: &str = "https://test-publisher.example.com:8443"; + const TRANSPORT_AUTHORITY: &str = "upstream.example.com:8080"; + + fn forwarder_settings() -> Settings { + let mut settings = test_settings(); + settings.trusted_forwarder = Some(TrustedForwarderConfig { + auth_header: FORWARDER_HEADER.to_owned(), + shared_secret: Redacted::new(FORWARDER_SECRET.to_owned()), + }); + settings + .integrations + .insert_config( + "gpt_diagnostics", + &serde_json::json!({"enabled":true,"trace_page_enabled":true}), + ) + .expect("should enable forwarded trace routes"); + settings + } + + fn forwarded_request(path: &str) -> http::Request { + let mut request = request_builder() + .method(Method::POST) + .uri(format!("http://{TRANSPORT_AUTHORITY}{path}")) + .header(header::HOST, TRANSPORT_AUTHORITY) + .header(header::ORIGIN, PUBLIC_ORIGIN) + .header(FORWARDER_HEADER, FORWARDER_SECRET) + .header("x-ts-forwarder-auth", "fictional-default-header-canary") + .header("x-forwarded-host", "test-publisher.example.com:8443") + .header("x-forwarded-proto", "https") + .header("sec-fetch-site", "same-origin") + .header("x-ts-trace-action", "enable") + .body(edgezero_core::body::Body::empty()) + .expect("should build an authenticated forwarded request"); + request.extensions_mut().insert( + RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::NotExposed), + Some( + InboundOrigin::parse("http", TRANSPORT_AUTHORITY, OriginSource::RuntimeUri) + .expect("should freeze internal transport origin"), + ), + HeaderFidelity::default(), + Vec::new(), + ) + .expect("should retain transport ingress"), + ); + request + } + + fn forwarder_trace_routers(settings: Settings) -> Vec<(&'static str, RouterService)> { + let forbidden = Arc::new(ForbiddenLifecycle); + let services = RuntimeServices::builder() + .config_store(forbidden.clone()) + .secret_store(forbidden.clone()) + .kv_store(forbidden.clone()) + .backend(forbidden.clone()) + .http_client(forbidden.clone()) + .auction_telemetry_sink(forbidden) + .geo(Arc::new(CountingGeo(Arc::new(AtomicUsize::new(0))))) + .client_info(ClientInfo::default()) + .build(); + routers_with_services(settings, services) + } + + #[tokio::test] + async fn trusted_forwarder_parity_trace_actions_use_public_origin() { + for (adapter, router) in forwarder_trace_routers(forwarder_settings()) { + for action in ["enable", "end"] { + let mut request = forwarded_request(&format!("/_ts/trace/{action}")); + request.headers_mut().insert( + "x-ts-trace-action", + http::HeaderValue::from_str(action).expect("should set action control"), + ); + let response = RouterService::oneshot(&router, request) + .await + .expect("should dispatch public-origin action"); + assert_eq!( + response.status(), + StatusCode::OK, + "{adapter} should accept {action} over internal HTTP transport" + ); + let cookies: Vec<_> = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .collect(); + let expected = if action == "enable" { + "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800" + } else { + "__Host-ts-console=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0" + }; + assert_eq!(cookies.len(), 1, "{adapter} should issue one action cookie"); + assert_eq!( + cookies[0], expected, + "{adapter} should preserve the secure host-only policy" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "no-store, private", + "{adapter} should keep mutations private" + ); + assert_eq!( + response + .into_body() + .into_bytes() + .expect("should collect action response") + .as_ref(), + br#"{"mutation_requested":true}"#, + "{adapter} should confirm requested mutation" + ); + } + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn trusted_forwarder_axum_boundary_requires_transport_binding() { + let router = AxumApp::routes_with_settings(forwarder_settings()) + .expect("should build native Axum forwarded routes"); + let mut service = EdgeZeroAxumService::new(router); + for duplicate in [false, true] { + let request = forwarded_request("/_ts/trace/enable"); + let (mut parts, _) = request.into_parts(); + // Without the private binding from a real HTTP listener, the SDK + // must leave transport origin unavailable even for an absolute URI. + parts.extensions.remove::(); + if duplicate { + parts.headers.append( + FORWARDER_HEADER, + http::HeaderValue::from_static(FORWARDER_SECRET), + ); + } + let response = service + .ready() + .await + .expect("should ready native Axum service") + .call(AxumRequest::from_parts(parts, AxumBody::empty())) + .await + .expect("should convert and dispatch forwarded action"); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "should not infer trusted transport from forwarding metadata or URI" + ); + assert!( + !response.headers().contains_key(header::SET_COOKIE), + "should not mutate cookies without transport binding" + ); + } + } + + #[tokio::test] + async fn trusted_forwarder_parity_rejects_untrusted_and_ambiguous_actions() { + for (adapter, router) in forwarder_trace_routers(forwarder_settings()) { + for case in [ + "missing-auth", + "wrong-auth", + "duplicate-auth", + "foreign-host", + "duplicate-host", + "foreign-origin", + "missing-origin", + "duplicate-origin", + "query", + "transport-host-mismatch", + ] { + let mut request = forwarded_request(if case == "query" { + "/_ts/trace/enable?example=1" + } else { + "/_ts/trace/enable" + }); + let headers = request.headers_mut(); + match case { + "missing-auth" => { + headers.remove(FORWARDER_HEADER); + } + "wrong-auth" => { + headers.insert( + FORWARDER_HEADER, + http::HeaderValue::from_static("fictional-wrong-forwarder-secret"), + ); + } + "duplicate-auth" => { + headers.append( + FORWARDER_HEADER, + http::HeaderValue::from_static(FORWARDER_SECRET), + ); + } + "foreign-host" => { + headers.insert( + "x-forwarded-host", + http::HeaderValue::from_static("foreign.example.com:8443"), + ); + } + "duplicate-host" => { + headers.append( + "x-forwarded-host", + http::HeaderValue::from_static("test-publisher.example.com:8443"), + ); + } + "foreign-origin" => { + headers.insert( + header::ORIGIN, + http::HeaderValue::from_static("https://foreign.example.com:8443"), + ); + } + "missing-origin" => { + headers.remove(header::ORIGIN); + } + "duplicate-origin" => { + headers.append( + header::ORIGIN, + http::HeaderValue::from_static(PUBLIC_ORIGIN), + ); + } + "transport-host-mismatch" => { + headers.insert( + header::HOST, + http::HeaderValue::from_static("other-upstream.example.com:8080"), + ); + } + _ => {} + } + let response = RouterService::oneshot(&router, request) + .await + .expect("should reject action locally"); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "{adapter} should reject {case}" + ); + assert!( + !response.headers().contains_key(header::SET_COOKIE), + "{adapter} should not mutate rejected action cookies for {case}" + ); + assert!( + response + .extensions() + .get::() + .is_some(), + "{adapter} should terminate {case} before ordinary services" + ); + } + } + } + + #[tokio::test] + async fn trusted_forwarder_parity_default_config_keeps_transport_authority() { + let mut settings = forwarder_settings(); + settings.trusted_forwarder = None; + for (adapter, router) in forwarder_trace_routers(settings) { + for origin in [PUBLIC_ORIGIN, "http://upstream.example.com:8080"] { + let mut request = forwarded_request("/_ts/trace/enable"); + request.headers_mut().insert( + header::ORIGIN, + http::HeaderValue::from_str(origin).expect("should supply browser origin"), + ); + let response = RouterService::oneshot(&router, request) + .await + .expect("should dispatch with default trust policy"); + assert_eq!( + response.status(), + if origin == PUBLIC_ORIGIN { + StatusCode::FORBIDDEN + } else { + StatusCode::OK + }, + "{adapter} should authorize only immutable transport without configured trust" + ); + } + } + } + + #[tokio::test] + async fn trusted_forwarder_parity_preserves_cookie_fidelity() { + for (adapter, router) in forwarder_trace_routers(forwarder_settings()) { + for (preservation, expected) in [ + (Preservation::Unknown, false), + (Preservation::Preserved, true), + ] { + let mut request = forwarded_request("/_ts/trace/state"); + *request.method_mut() = Method::GET; + request.headers_mut().insert( + header::COOKIE, + http::HeaderValue::from_str("__Host-ts-console=1; unrelated=\u{fffd}") + .expect("should supply runtime-visible cookie marker"), + ); + request.extensions_mut().insert( + RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::NotExposed), + Some( + InboundOrigin::parse( + "http", + TRANSPORT_AUTHORITY, + OriginSource::RuntimeUri, + ) + .expect("should retain transport origin"), + ), + HeaderFidelity::new( + preservation, + preservation, + Preservation::Unknown, + Preservation::Unavailable, + ), + Vec::new(), + ) + .expect("should supply original cookie fidelity"), + ); + let response = RouterService::oneshot(&router, request) + .await + .expect("should observe forwarded cookie state"); + assert_eq!( + response.status(), + StatusCode::OK, + "{adapter} should serve read-only state" + ); + assert!( + !response.headers().contains_key(header::SET_COOKIE), + "{adapter} should not refresh observed cookies" + ); + let state: serde_json::Value = serde_json::from_slice( + &response + .into_body() + .into_bytes() + .expect("should buffer state"), + ) + .expect("should parse state"); + assert_eq!( + state, + serde_json::json!({"observed_active":expected}), + "{adapter} should preserve {preservation:?} cookie octets evidence" + ); + } + } + } + + struct ForwarderOrigin { + base: RuntimeServices, + requests: Mutex>, + } + + impl PlatformBackend for ForwarderOrigin { + fn naming_policy(&self) -> BackendNamingPolicy { + self.base.backend().naming_policy() + } + + fn predict_name( + &self, + spec: &PlatformBackendSpec, + ) -> Result> { + self.base.backend().predict_name(spec) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.base.backend().ensure(spec) + } + } + + impl PlatformGeo for ForwarderOrigin { + fn lookup(&self, ip: Option) -> Result, Report> { + self.base.geo().lookup(ip) + } + } + + #[async_trait(?Send)] + impl PlatformHttpClient for ForwarderOrigin { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.requests + .lock() + .expect("should lock recorded origin requests") + .push(( + request.request.uri().to_string(), + request.request.headers().clone(), + )); + if request.request.uri().host() == Some("vendor.example.com") { + return Ok(PlatformResponse::new( + http::Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .body(edgezero_core::body::Body::from( + serde_json::json!({"example":true}).to_string(), + )) + .expect("should answer fictional vendor request"), + )); + } + self.base.http_client().send(request).await + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + panic!("should not launch auctions from forwarder fixture"); + } + + async fn select( + &self, + _requests: Vec, + ) -> Result> { + panic!("should not collect auctions from forwarder fixture"); + } + } + + #[tokio::test] + async fn trusted_forwarder_parity_proxy_strips_credentials_and_retains_backend_targets() { + let origin = Arc::new(ForwarderOrigin { + base: nextjs_auction::services( + Arc::new(nextjs_auction::NextJsAuctionOrigin::default()), + ), + requests: Mutex::new(Vec::new()), + }); + let forbidden = Arc::new(ForbiddenLifecycle); + let services = RuntimeServices::builder() + .config_store(forbidden.clone()) + .secret_store(forbidden) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(origin.clone()) + .http_client(origin.clone()) + .geo(origin.clone()) + .client_info(ClientInfo::default()) + .build(); + let mut settings = forwarder_settings(); + settings + .integrations + .insert_config( + "nextjs", + &serde_json::json!({"enabled":true,"rewrite_attributes":["href","link","url"]}), + ) + .expect("should enable public URL rewriting"); + settings.integrations.insert_config("lockr", &serde_json::json!({"enabled":true,"app_id":"fictional-app","api_endpoint":"https://vendor.example.com","sdk_url":"https://vendor.example.com/sdk.js"})).expect("should configure fictional integration upstream"); + for (adapter, router) in routers_with_services(settings, services) { + origin + .requests + .lock() + .expect("should reset recorded origin requests") + .clear(); + for (method, path, target) in [ + ( + Method::GET, + "/article", + "https://origin.test-publisher.example.com/article", + ), + ( + Method::PATCH, + "/article", + "https://origin.test-publisher.example.com/article", + ), + ( + Method::GET, + "/integrations/lockr/api/example", + "https://vendor.example.com/example", + ), + ] { + let mut request = forwarded_request(path); + *request.method_mut() = method.clone(); + request + .headers_mut() + .insert(header::ACCEPT, http::HeaderValue::from_static("text/html")); + let response = RouterService::oneshot(&router, request) + .await + .expect("should serve forwarded proxy request"); + assert_eq!( + response.status(), + StatusCode::OK, + "{adapter} should serve {method} {path}" + ); + let body = response + .into_body() + .into_bytes() + .expect("should buffer proxied response"); + if path == "/article" && method == Method::GET { + let html = String::from_utf8(body.to_vec()).expect("should serve HTML"); + let document = scraper::Html::parse_document(&html); + let scripts = + scraper::Selector::parse("script").expect("should select Flight scripts"); + let payload: String = document + .select(&scripts) + .filter_map(|script| { + let text: String = script.text().collect(); + let array = text + .strip_prefix("self.__next_f.push(")? + .strip_suffix(')')?; + let value: serde_json::Value = + serde_json::from_str(array).expect("should preserve Flight JSON"); + Some( + value[1] + .as_str() + .expect("should retain Flight data") + .to_owned(), + ) + }) + .collect(); + assert!( + payload.contains("https://test-publisher.example.com:8443/app"), + "{adapter} should generate public HTTPS URLs retaining :8443: {payload}" + ); + assert!( + !payload.contains(TRANSPORT_AUTHORITY), + "{adapter} should not generate internal transport URLs" + ); + } + let requests = origin + .requests + .lock() + .expect("should inspect actual outbound request"); + let (actual_target, headers) = + requests.last().expect("should reach configured upstream"); + assert_eq!( + actual_target, target, + "{adapter} should keep configured backend target for {method} {path}" + ); + for name in [FORWARDER_HEADER, "x-ts-forwarder-auth"] { + assert!( + !headers.contains_key(name), + "{adapter} should strip {name} on {method} {path}" + ); + } + assert!( + !headers.values().any(|value| value + .as_bytes() + .windows(FORWARDER_SECRET.len()) + .any(|part| part == FORWARDER_SECRET.as_bytes())), + "{adapter} should never forward the credential under another header" + ); + } + assert_eq!( + origin + .requests + .lock() + .expect("should count outbound requests") + .len(), + 3, + "{adapter} should dispatch exactly the three intended upstream requests" + ); + for method in [Method::GET, Method::PUT, Method::DELETE, Method::OPTIONS] { + let mut request = forwarded_request("/_ts/trace/enable"); + *request.method_mut() = method.clone(); + let response = RouterService::oneshot(&router, request) + .await + .expect("should reject unsupported action verb"); + assert_eq!( + response.status(), + StatusCode::METHOD_NOT_ALLOWED, + "{adapter} should terminate {method} trace action locally" + ); + assert!( + !response.headers().contains_key(header::SET_COOKIE), + "{adapter} should not mutate rejected cookies" + ); + } + assert_eq!( + origin + .requests + .lock() + .expect("should count requests after early returns") + .len(), + 3, + "{adapter} should keep forwarding credentials away from all upstreams on trace early returns" + ); + } + } + + fn futures_trace_empty_stream() -> impl futures::Stream> { + futures::stream::iter([Ok(Bytes::new()), Ok(Bytes::new())]) + } +} diff --git a/crates/trusted-server-js/Cargo.toml b/crates/trusted-server-js/Cargo.toml index 67a4ac698..5a382a53a 100644 --- a/crates/trusted-server-js/Cargo.toml +++ b/crates/trusted-server-js/Cargo.toml @@ -20,6 +20,7 @@ path = "src/lib.rs" build-print = { workspace = true } hex = { workspace = true } sha2 = { workspace = true } +serde_json = { workspace = true } which = { workspace = true } [dependencies] diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index 0d4306f15..d15e43bdb 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -24,12 +24,19 @@ const TEST_VAR: &str = "TSJS_TEST"; fn main() { // Cargo scans directories recursively, so these cover every TS source. + // Every path must exist: a missing entry makes Cargo rerun this script on + // every build, the opposite of the intent. for path in [ "lib/src", "lib/build-all.mjs", + "lib/build-prebid-external.mjs", "lib/package.json", "lib/package-lock.json", + "lib/trace-asset-sources.mjs", + "lib/trace-assets", + "lib/trace-assets-manifest.json", "lib/tsconfig.json", + "lib/vite.config.ts", ] { println!("cargo:rerun-if-changed={path}"); } @@ -48,7 +55,7 @@ fn main() { let expected = expected_module_ids(&ts_dir.join("src")) .unwrap_or_else(|err| panic!("tsjs: failed to discover modules: {err}")); - if let Some(prebuilt_dir) = env::var_os(PREBUILT_DIR_VAR).map(PathBuf::from) { + let rebuilt = if let Some(prebuilt_dir) = env::var_os(PREBUILT_DIR_VAR).map(PathBuf::from) { assert!( prebuilt_dir.is_absolute(), "tsjs: {PREBUILT_DIR_VAR} must be an absolute path because Cargo runs build \ @@ -62,9 +69,11 @@ fn main() { ); validate_bundle_dir(&expected, &prebuilt_dir); copy_prebuilt_bundles(&expected, &prebuilt_dir, &bundle_dir); + false } else { build_bundles(&ts_dir, &bundle_dir); - } + true + }; validate_bundle_dir(&expected, &bundle_dir); info!( @@ -74,6 +83,7 @@ fn main() { ); write_module_table(&expected, &bundle_dir, &out_dir); + embed_trace_assets(&ts_dir, &bundle_dir, &out_dir, rebuilt); } fn build_bundles(ts_dir: &Path, bundle_dir: &Path) { @@ -270,6 +280,133 @@ fn write_module_table(expected: &[String], bundle_dir: &Path, out_dir: &Path) { }); } +fn embed_trace_assets(ts_dir: &Path, dist_dir: &Path, out_dir: &Path, rebuilt: bool) { + let manifest: serde_json::Value = serde_json::from_slice( + &fs::read(ts_dir.join("trace-assets-manifest.json")) + .expect("should read the committed trace asset manifest"), + ) + .expect("should parse the trace asset manifest"); + let object = manifest + .as_object() + .expect("should use a trace manifest object"); + assert!( + object.len() == 3 + && object.contains_key("schema_version") + && object.contains_key("assets") + && object.contains_key("source_sha256"), + "tsjs: trace manifest must contain exactly the documented fields" + ); + assert_eq!( + manifest["schema_version"].as_u64(), + Some(1), + "tsjs: trace manifest schema version must be one" + ); + assert_eq!( + manifest["source_sha256"].as_str(), + Some(trace_source_digest(ts_dir).as_str()), + "tsjs: trace build inputs changed; rebuild and review the unpublished versioned assets" + ); + let assets = manifest["assets"] + .as_array() + .expect("should list the versioned trace assets"); + assert_eq!( + assets.len(), + 2, + "tsjs: version one requires its JS and CSS assets" + ); + let mut generated = String::from("const TRACE_ASSETS: [TraceAsset; 2] = [\n"); + for (asset, file) in assets.iter().zip(["v1.js", "v1.css"]) { + let fields = asset + .as_object() + .expect("should represent trace asset metadata as an object"); + assert!( + fields.len() == 3 + && fields.contains_key("path") + && fields.contains_key("file") + && fields.contains_key("sha256"), + "tsjs: trace asset metadata must contain exactly path, file and sha256" + ); + let path = format!("/_ts/trace/assets/{file}"); + assert_eq!( + asset["path"].as_str(), + Some(path.as_str()), + "tsjs: trace asset path must be exact" + ); + assert_eq!( + asset["file"].as_str(), + Some(file), + "tsjs: trace asset filename must be exact" + ); + let content = fs::read(ts_dir.join("trace-assets").join(file)) + .expect("should read the committed versioned trace asset bytes"); + let digest = hex::encode(Sha256::digest(&content)); + assert_eq!( + asset["sha256"].as_str(), + Some(digest.as_str()), + "tsjs: committed trace asset bytes do not match their manifest digest" + ); + if rebuilt { + assert_eq!( + fs::read(dist_dir.join("trace").join(file)) + .expect("should rebuild both versioned trace assets"), + content, + "tsjs: rebuilt trace bytes differ from committed assets; review the unpublished draft or add a version" + ); + } + fs::write(out_dir.join(format!("trace-{file}")), &content) + .expect("should copy verified trace bytes into the build output"); + writeln!(generated, + "TraceAsset {{ path: \"{path}\", bytes: include_bytes!(concat!(env!(\"OUT_DIR\"), \"/trace-{file}\")), sha256: \"{digest}\" }},") + .expect("should generate the verified trace asset entry"); + } + generated.push_str("];\n"); + fs::write(out_dir.join("trace_assets.rs"), generated) + .expect("should generate the trace asset lookup table"); +} + +fn trace_source_digest(ts_dir: &Path) -> String { + let mut sources = vec![ + "build-all.mjs".to_owned(), + "trace-asset-sources.mjs".to_owned(), + "package-lock.json".to_owned(), + ]; + let mut directories = vec![PathBuf::from("src/trace")]; + while let Some(directory) = directories.pop() { + for entry in + fs::read_dir(ts_dir.join(&directory)).expect("should enumerate trace build inputs") + { + let entry = entry.expect("should read a trace build input entry"); + let relative = directory.join(entry.file_name()); + let kind = entry + .file_type() + .expect("should inspect the trace build input type"); + if kind.is_dir() { + directories.push(relative); + } else if kind.is_file() + && relative + .extension() + .is_some_and(|extension| extension == "ts" || extension == "css") + { + sources.push( + relative + .to_str() + .expect("should use UTF-8 trace build input filenames") + .replace('\\', "/"), + ); + } + } + } + sources.sort(); + let mut hash = Sha256::new(); + for source in sources { + hash.update(source.as_bytes()); + hash.update([0]); + hash.update(fs::read(ts_dir.join(&source)).expect("should read a trace build input")); + hash.update([0]); + } + hex::encode(hash.finalize()) +} + fn bundle_sha256(path: &Path) -> String { let content = fs::read(path).unwrap_or_else(|err| { panic!( diff --git a/crates/trusted-server-js/lib/.prettierignore b/crates/trusted-server-js/lib/.prettierignore index 72274829b..88957b43a 100644 --- a/crates/trusted-server-js/lib/.prettierignore +++ b/crates/trusted-server-js/lib/.prettierignore @@ -1,4 +1,4 @@ node_modules dist coverage - +trace-assets/** diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 24f842e76..fc76d14eb 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -84,6 +84,30 @@ await Promise.all( integrationModules.map((name) => buildModule(name, path.join(integrationsDir, name, 'index.ts'))) ); +// The full-document trace viewer is independent of publisher integration bundles. +await build({ + configFile: false, + root: __dirname, + build: { + emptyOutDir: false, + outDir: distDir, + sourcemap: false, + minify: 'esbuild', + cssCodeSplit: false, + rollupOptions: { + input: path.join(srcDir, 'trace', 'viewer.ts'), + output: { + format: 'iife', + inlineDynamicImports: true, + entryFileNames: 'trace/v1.js', + assetFileNames: 'trace/v1.css', + name: 'tsTraceViewer', + }, + }, + }, + logLevel: 'warn', +}); + // List all built files const builtFiles = fs .readdirSync(distDir) diff --git a/crates/trusted-server-js/lib/eslint.config.js b/crates/trusted-server-js/lib/eslint.config.js index 2720ba3a0..2d6b83f03 100644 --- a/crates/trusted-server-js/lib/eslint.config.js +++ b/crates/trusted-server-js/lib/eslint.config.js @@ -9,7 +9,7 @@ import unicorn from 'eslint-plugin-unicorn'; export default [ // Files/folders to ignore { - ignores: ['node_modules', 'dist', 'coverage'], + ignores: ['node_modules', 'dist', 'coverage', 'trace-assets/**'], }, // Base JS recommended js.configs.recommended, diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index a02684362..60bbc4663 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -3,6 +3,11 @@ // and the Prebid.js trustedServer adapter. import { parseApsRendererDescriptor } from '../integrations/aps/render'; +import { + prepareTraceAuctionRequest, + observeTraceApiResponse, + observeTraceApiFailure, +} from '../trace/runtime'; import { log } from './log'; import type { ApsRendererV1 } from './types'; @@ -13,6 +18,8 @@ import type { ApsRendererV1 } from './types'; /** A single ad unit in the AdRequest payload sent to POST /auction. */ export interface AdRequestUnit { + /** Optional namespaced request-scoped diagnostic slot reference. */ + ext?: Record; code: string; mediaTypes: { banner?: { sizes: number[][] }; @@ -30,6 +37,9 @@ export interface AuctionUid { /** An auction-level EID entry forwarded to the server. */ export interface AuctionEid { source: string; + inserter?: string; + matcher?: string; + mm?: number; uids: AuctionUid[]; } @@ -173,7 +183,10 @@ export function parseAuctionResponse(body: any): AuctionBid[] { * Returns an empty array on network or parse errors (non-throwing). */ export async function sendAuction(endpoint: string, request: AdRequest): Promise { + const trace = prepareTraceAuctionRequest(request); + const outgoing = trace?.request ?? request; if (typeof fetch !== 'function') { + observeTraceApiFailure(trace); log.warn('auction: fetch not available'); return []; } @@ -185,18 +198,20 @@ export async function sendAuction(endpoint: string, request: AdRequest): Promise method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', - body: JSON.stringify(request), + body: JSON.stringify(outgoing), keepalive: true, }); const contentType = response.headers.get('content-type') || ''; if (response.ok && contentType.includes('application/json')) { const data: unknown = await response.json(); + observeTraceApiResponse(trace, data); const bids = parseAuctionResponse(data); log.info('auction: received bids', { count: bids.length }); return bids; } + observeTraceApiFailure(trace); log.warn('auction: unexpected response', { ok: response.ok, status: response.status, @@ -204,6 +219,7 @@ export async function sendAuction(endpoint: string, request: AdRequest): Promise }); return []; } catch (error) { + observeTraceApiFailure(trace); log.warn('auction: request failed', error); return []; } diff --git a/crates/trusted-server-js/lib/src/core/global.d.ts b/crates/trusted-server-js/lib/src/core/global.d.ts index c7c8b08fb..bc5d2f247 100644 --- a/crates/trusted-server-js/lib/src/core/global.d.ts +++ b/crates/trusted-server-js/lib/src/core/global.d.ts @@ -2,6 +2,8 @@ import type { TsjsApi } from './types'; declare global { interface Window { + __tsjs_trace_active?: unknown; + __tsjs_trace_request_context?: unknown; tsjs?: TsjsApi; pbjs?: TsjsApi; } diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index b2c4e41e1..eccfb9d1b 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -6,6 +6,8 @@ export type { GptDiagnosticsRequestCycle, TsjsApi, } from './types'; +import { installTraceRuntime } from '../trace/runtime'; + import type { TsjsApi } from './types'; import { addAdUnits } from './registry'; import { renderAdUnit, renderAllAdUnits } from './render'; @@ -44,6 +46,7 @@ api.adSlots ??= []; api.bids ??= {}; // Point global tsjs w.tsjs = api; +installTraceRuntime(api, w); // Single shared queue installQueue(api, w); diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 3407d487a..39f75f309 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -1,3 +1,7 @@ +import type { TraceCollector } from '../trace/collector'; +import type { TraceGptIdentity } from '../trace/types'; +import type { TraceGptBridge } from '../trace/gpt'; + // Shared TypeScript types for the tsjs core API and extensions. export type Size = readonly [number, number]; @@ -27,6 +31,10 @@ export interface AuctionSlot { div_id: string; formats: Array<[number, number]>; targeting?: Record; + ext?: { + trusted_server?: { trace_slot_ref?: unknown; [key: string]: unknown }; + [key: string]: unknown; + }; } /** Debug-only copy of server-side bid fields exposed for pipeline inspection. */ @@ -87,10 +95,6 @@ export interface AuctionBidData { hb_cache_path?: string; /** Opaque server-auction correlation ID used only by GPT diagnostics. */ hb_auction_id?: string; - /** Winning creative width; the bridge sizes the inline render from this. */ - w?: number; - /** Winning creative height; the bridge sizes the inline render from this. */ - h?: number; nurl?: string; burl?: string; /** Typed winning-bid renderer capability. */ @@ -108,6 +112,43 @@ export interface AuctionBidData { debug_bid?: AuctionDebugBidData; } +/** Server-measured auction timings relative to their documented server-side origin. */ +export interface AuctionDiagnosticsData { + auctionDispatchedMs?: number; + auctionResolvedMs?: number; + auctionCommittedMs?: number; + auctionWaitMs?: number; + auctionWaitPlacement?: 'pre_header' | 'in_stream'; +} + +/** Auction path presented by GPT diagnostics. */ +export type GptDiagnosticsAuctionType = 'ssat' | 'trusted_server' | 'client_side' | 'competing'; + +/** Clock origin for server auction timings, independent of aggregate auction classification. */ +export type GptDiagnosticsServerAuctionTimingOrigin = 'navigation' | 'spa_auction'; + +/** Sanitized bid facts already exposed as bucketed ad-server targeting. */ +export interface GptDiagnosticsAuctionWinner { + bidder: string; + priceBucket: string; + /** ISO currency supplied by the evidence source; absent means not supplied. */ + currency?: string; +} + +/** A completed, exactly correlated client-side Prebid auction. */ +export interface GptDiagnosticsPrebidAuctionEvidence { + auctionId: string; + targetingCandidate?: GptDiagnosticsAuctionWinner; + win?: GptDiagnosticsAuctionWinner; +} + +/** Internal Trusted Server auction evidence attached to the next GPT request. */ +export interface GptDiagnosticsAuctionFacts { + auctionType?: Extract; + winner?: GptDiagnosticsAuctionWinner; + serverTimings?: AuctionDiagnosticsData; +} + export type GptDiagnosticsCallbackKind = | 'slotRequested' | 'slotResponseReceived' @@ -181,12 +222,39 @@ export type GptDiagnosticsTrustedServerOpportunity = | 'unrenderable_candidate' | 'no_candidate'; +/** + * Every safe failure category observed while obtaining or posting creative markup. + * + * This array is the single source of truth: `GptDiagnosticsCreativeFailure` is + * derived from it, and the diagnostics store validates against it at runtime. + * Adding a member here is sufficient; a compile-time union and a hand-written + * runtime allowlist would otherwise drift, and a dropped reason is silent. + * + * The `aps_` members cover the APS render path, where a blank slot is otherwise + * indistinguishable from a filled one: Ad Manager reports a non-empty 1x1 + * render whether or not the creative ever drew. + */ +export const GPT_DIAGNOSTICS_CREATIVE_FAILURES = [ + 'missing_render_source', + 'cache_fetch_failed', + 'invalid_cache_payload', + 'response_post_failed', + // Reported by the sandboxed renderer document and relayed by the creative. + 'aps_bad_hash', + 'aps_nonce_mismatch', + 'aps_descriptor_keys', + 'aps_descriptor_fields', + 'aps_descriptor_envelope', + 'aps_runner_script_error', + // Observed by the Universal Creative source around its renderer frame. + 'aps_frame_timeout', + 'aps_frame_load_error', + 'aps_frame_reported_failure', + 'aps_unknown', +] as const; + /** A safe failure category observed while obtaining or posting creative markup. */ -export type GptDiagnosticsCreativeFailure = - | 'missing_render_source' - | 'cache_fetch_failed' - | 'invalid_cache_payload' - | 'response_post_failed'; +export type GptDiagnosticsCreativeFailure = (typeof GPT_DIAGNOSTICS_CREATIVE_FAILURES)[number]; /** Delivery evidence derived for a GPT request cycle. */ export type GptDiagnosticsDelivery = @@ -224,6 +292,14 @@ export interface GptDiagnosticsRequestCycle { requestPath?: GptDiagnosticsRequestPath; requestIntentId?: number; trustedServerAuctionId?: string; + auctionType?: GptDiagnosticsAuctionType; + /** Compatibility field: winner of the observed server auction, not necessarily the served creative. */ + auctionWinner?: GptDiagnosticsAuctionWinner; + /** Completed Prebid facts, correlated to this exact slot, request, and auction attempt. */ + prebidAuction?: GptDiagnosticsPrebidAuctionEvidence; + serverAuctionTimings?: AuctionDiagnosticsData; + /** Retained separately because `auctionType` can become `competing`. */ + serverAuctionTimingOrigin?: GptDiagnosticsServerAuctionTimingOrigin; opportunityToRequestMs?: number; replacedRequestNumber?: number; previousRenderToRequestMs?: number; @@ -332,10 +408,24 @@ export interface GptDiagnosticsRecorder { auctionSlotId: string, opportunity: GptDiagnosticsTrustedServerOpportunity, trustedServerAuctionId?: string, - requestedSlotSizes?: ReadonlyArray + requestedSlotSizes?: ReadonlyArray, + auctionFacts?: GptDiagnosticsAuctionFacts, + traceIdentity?: TraceGptIdentity ): void; /** Mark slots whose next observed GPT request follows the Prebid refresh path. */ recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void; + /** Record a completed Prebid attempt at its targeting boundary for one exact GPT slot. */ + recordPrebidAuction( + slot: GptDiagnosticsSlotHandle, + auctionId: string, + targetingCandidate?: GptDiagnosticsAuctionWinner + ): void; + /** Record Prebid's documented `bidWon` observation for that exact attempt. */ + recordPrebidWin( + slot: GptDiagnosticsSlotHandle, + auctionId: string, + winner: GptDiagnosticsAuctionWinner + ): void; /** Record a creative markup request and return its opaque attempt ID. */ recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined; /** Record that a creative attempt successfully posted markup. */ @@ -404,6 +494,10 @@ export interface FirstImpressionState { } export interface TsjsApi { + /** Internal trace observations, installed only for a literal activated document. */ + traceEvidence?: TraceCollector; + /** Internal active-only bridge shared with the early GPT bootstrap. */ + traceGpt?: TraceGptBridge; version: string; que: Array<() => void>; addAdUnits(units: AdUnit | AdUnit[]): void; @@ -430,6 +524,8 @@ export interface TsjsApi { adSlots?: AuctionSlot[]; /** Winning bid targeting data injected before . */ bids?: Record; + /** Server-measured timing evidence for the auction that populated `bids`. */ + auctionDiagnostics?: AuctionDiagnosticsData; /** * Bounded client-side Prebid APS renderer capabilities keyed by Prebid's generated * `hb_adid`. The Universal Creative bridge consumes each entry at most once. @@ -515,7 +611,9 @@ export interface TsjsApi { */ scheduleInitialAdInit?: ( initialBids?: Record, - initialSlots?: AuctionSlot[] + initialSlots?: AuctionSlot[], + initialAuctionDiagnostics?: AuctionDiagnosticsData, + traceAuctionTransport?: unknown ) => void; /** Read-only GPT lifecycle diagnostics API, present only in an activated tab. */ gptDiagnostics?: GptDiagnosticsApi; diff --git a/crates/trusted-server-js/lib/src/integrations/aps/render.ts b/crates/trusted-server-js/lib/src/integrations/aps/render.ts index d794b86f3..61a27c2d0 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -1,6 +1,11 @@ import { log } from '../../core/log'; import { findSlot } from '../../core/render'; -import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; +import type { + ApsPrebidRendererEntry, + ApsRendererV1, + GptDiagnosticsCreativeFailure, + TsjsApi, +} from '../../core/types'; export const APS_RENDERER_PATH = '/integrations/aps/renderer'; export const APS_RENDERING_MODE_ATTRIBUTE_NAME = 'data-ts-aps-rendering-mode'; @@ -9,7 +14,7 @@ export const APS_PREBID_CREATIVE_RUNNER_URL = export const APS_NATIVE_RENDERER_TIMEOUT_MS = 10_000; export const APS_RENDERER_SANDBOX = 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; -export const APS_UNIVERSAL_CREATIVE_RENDERER_VERSION = 4; +export const APS_UNIVERSAL_CREATIVE_RENDERER_VERSION = 5; const MAX_ACCOUNT_ID_BYTES = 1024; const MAX_CREATIVE_ID_BYTES = 1024; @@ -33,6 +38,69 @@ const pendingFrameCancels = new WeakMap void>(); const RENDERER_READY_MESSAGE = 'trusted-server/aps/renderer-ready'; const RENDERER_FAILED_MESSAGE = 'trusted-server/aps/renderer-failed'; const RENDERER_READY_TIMEOUT_MS = 10_000; + +/** + * Message the Universal Creative frame relays to the top window when an APS + * render never completes. + * + * The creative frame is cross-origin, so the top-window listener treats every + * field as untrusted and resolves the reason through + * [`apsRenderFailureReason`] before recording it. The relay is + * diagnostics-only and never influences creative delivery. + */ +export const APS_RENDER_FAILED_MESSAGE = 'trusted-server/aps/render-failed'; + +/** + * Wire reasons the render path can emit, mapped onto safe diagnostic categories. + * + * Built on a null prototype so a hostile `__proto__`, `constructor`, or + * `toString` relayed by the cross-origin creative frame resolves to `undefined` + * rather than an inherited member. + */ +let apsRenderFailureReasons: Readonly> | undefined; + +/** + * Build the reason allowlist on first use. + * + * Deferred so the table is not a module-level side effect: the Prebid shim + * imports two unrelated helpers from this module, and `Object.freeze` at module + * scope would defeat tree-shaking and carry this table into that bundle. + */ +function renderFailureReasons(): Readonly> { + apsRenderFailureReasons ??= Object.freeze( + Object.assign( + Object.create(null) as Record, + { + bad_hash: 'aps_bad_hash', + nonce_mismatch: 'aps_nonce_mismatch', + descriptor_keys: 'aps_descriptor_keys', + descriptor_fields: 'aps_descriptor_fields', + descriptor_envelope: 'aps_descriptor_envelope', + amazon_script_error: 'aps_runner_script_error', + frame_timeout: 'aps_frame_timeout', + frame_load_error: 'aps_frame_load_error', + frame_reported_failure: 'aps_frame_reported_failure', + unknown: 'aps_unknown', + } as const + ) + ); + return apsRenderFailureReasons; +} + +/** + * Resolve a relayed render failure reason to a safe diagnostic category. + * + * Returns `undefined` for anything not on the allowlist, so an unrecognized or + * hostile value from the cross-origin creative frame is dropped instead of + * being recorded. + * + * @example + * apsRenderFailureReason('frame_timeout'); // 'aps_frame_timeout' + * apsRenderFailureReason('__proto__'); // undefined + */ +export function apsRenderFailureReason(value: unknown): GptDiagnosticsCreativeFailure | undefined { + return typeof value === 'string' ? renderFailureReasons()[value] : undefined; +} const MAX_PREBID_RENDERER_ENTRIES = 256; const DEFAULT_PREBID_RENDERER_TTL_SECONDS = 300; const MAX_PREBID_RENDERER_TTL_SECONDS = 3600; @@ -688,12 +756,15 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative iframe.style.display = ''; }; function receive(event: MessageEvent): void { - if (event.source !== iframe.contentWindow || !hasExactKeys(event.data, ['message', 'nonce'])) { + if (event.source !== iframe.contentWindow || !isRecord(event.data)) return; + const { message, nonce: sent } = event.data; + if (message === RENDERER_READY_MESSAGE) { + if (hasExactKeys(event.data, ['message', 'nonce']) && sent === nonce) commit(); return; } - if (event.data.nonce !== nonce) return; - if (event.data.message === RENDERER_READY_MESSAGE) commit(); - else if (event.data.message === RENDERER_FAILED_MESSAGE) fail(); + // A failure message also carries a `reason`, and the frame reports + // `bad_hash` before it has learned a nonce to echo back. + if (message === RENDERER_FAILED_MESSAGE && (sent === nonce || sent === undefined)) fail(); } window.addEventListener('message', receive); @@ -734,12 +805,13 @@ var b=new Uint8Array(16);c.getRandomValues(b);var s="";for(var i=0;i 0; + const winner = + isNonEmptyString(bid.hb_bidder) && isNonEmptyString(bid.hb_pb) + ? { bidder: bid.hb_bidder, priceBucket: bid.hb_pb } + : undefined; + if (!winner && auctionDiagnostics === undefined) return undefined; + + return { + auctionType: isSpaAuction ? 'trusted_server' : 'ssat', + ...(winner ? { winner } : {}), + ...(auctionDiagnostics ? { serverTimings: auctionDiagnostics } : {}), + }; +} + // ------------------------------------------------------------------ // googletag type stubs (minimal surface needed by the shim) // ------------------------------------------------------------------ @@ -799,17 +824,39 @@ function installInitialLoadDetector(ts: TsjsApi): void { * riding rAF keeps a single code path whose post-hydration-commit guarantee * holds whenever the request is actually issued. */ +function traceOpportunity(slot: AuctionSlot, auctionId: string | undefined): TraceGptOpportunity { + try { + return getActiveTraceGptBridge()?.opportunity(slot, auctionId) ?? { auctionId }; + } catch { + return { auctionId }; + } +} + function installScheduleInitialAdInit(ts: TsjsApi): void { ts.scheduleInitialAdInit = function ( initialBids?: Record, - initialSlots?: AuctionSlot[] + initialSlots?: AuctionSlot[], + initialAuctionDiagnostics?: AuctionDiagnosticsData, + traceAuctionTransport?: unknown ) { if ((ts.navGeneration ?? 0) !== 0 || ts.initialAdInitScheduled) return; ts.initialAdInitScheduled = true; if (initialSlots !== undefined) ts.adSlots = initialSlots; if (initialBids !== undefined) ts.bids = initialBids; + if (initialAuctionDiagnostics !== undefined) { + ts.auctionDiagnostics = initialAuctionDiagnostics; + } const runUnlessNavigated = (): void => { if ((ts.navGeneration ?? 0) !== 0) return; + try { + getActiveTraceGptBridge()?.observeTransport( + initialSlots ?? ts.adSlots, + traceAuctionTransport, + 'initial_navigation_ssat' + ); + } catch { + // A diagnostic bridge cannot block the ordinary initial ad pass. + } ts.adInit?.(); }; const afterHydrationFrames = (): void => { @@ -1187,13 +1234,27 @@ function schedulePublisherFirstImpressionFallback( if (tsOwned) (ts.prevGptSlots ??= []).push(gptSlot); try { - ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( - gptSlot, - slot.id, - trustedServerOpportunity(bid), - bid.hb_auction_id, - slot.formats - ); + const trace = traceOpportunity(slot, bid.hb_auction_id); + const recorder = ts.gptDiagnosticsRecorder; + if (trace.identity) { + recorder?.recordTrustedServerOpportunity( + gptSlot, + slot.id, + trustedServerOpportunity(bid), + trace.auctionId, + slot.formats, + undefined, + trace.identity + ); + } else { + recorder?.recordTrustedServerOpportunity( + gptSlot, + slot.id, + trustedServerOpportunity(bid), + trace.auctionId, + slot.formats + ); + } } catch { // Diagnostics must not alter fallback delivery. } @@ -1240,6 +1301,7 @@ export function installTsAdInit(): void { // first act and stands down rather than applying this invocation's // slots/bids to the newer route's DOM and double-requesting it. const generation = ts.navGeneration ?? 0; + const auctionDiagnostics = ts.auctionDiagnostics ? { ...ts.auctionDiagnostics } : undefined; const g = (window as GptWindow).googletag; if (!g) return; installFirstImpressionLifecycleObservers(ts, g); @@ -1382,13 +1444,28 @@ export function installTsAdInit(): void { try { const requestedSlotSizes = ts.gptSlotHandoffs?.[slotDivId2]?.formats; const opportunity = trustedServerOpportunity(bid); - ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( - gptSlot, - slot.id, - opportunity, - bid.hb_auction_id, - requestedSlotSizes - ); + const auctionFacts = diagnosticsAuctionFacts(generation, auctionDiagnostics, bid); + const trace = traceOpportunity(slot, bid.hb_auction_id); + const recorder = ts.gptDiagnosticsRecorder; + if (auctionFacts || trace.identity) { + recorder?.recordTrustedServerOpportunity( + gptSlot, + slot.id, + opportunity, + trace.auctionId, + requestedSlotSizes, + auctionFacts, + ...(trace.identity ? ([trace.identity] as const) : []) + ); + } else { + recorder?.recordTrustedServerOpportunity( + gptSlot, + slot.id, + opportunity, + trace.auctionId, + requestedSlotSizes + ); + } } catch { // Diagnostics must not alter ad delivery. } @@ -1490,6 +1567,8 @@ export function installTsAdInit(): void { interface PageBidsResponse { slots: AuctionSlot[]; bids: Record; + auctionDiagnostics?: AuctionDiagnosticsData; + trace_auction?: unknown; } /** Canonical SPA re-auction endpoint. Mirrors `PAGE_BIDS_PATH` in Rust. */ @@ -1698,6 +1777,9 @@ export function installSpaAuctionHook(): void { if (g) clearPreviousNavigationTargeting(ts, g); ts.navGeneration = (ts.navGeneration ?? 0) + 1; delete ts.firstImpression; + // Server timings belong to the route that produced them. Clear them before + // page-bids starts so failure or supersession cannot relabel stale offsets. + ts.auctionDiagnostics = undefined; // A route change invalidates hydration aliases before the new route's // publisher can define a same-prefix slot while page-bids is in flight. for (const [elementId, handoff] of Object.entries(ts.gptSlotHandoffs ?? {})) { @@ -1724,6 +1806,12 @@ export function installSpaAuctionHook(): void { if (inflight !== controller) return; ts.adSlots = data.slots; ts.bids = data.bids; + ts.auctionDiagnostics = data.auctionDiagnostics; + try { + getActiveTraceGptBridge()?.observePageBids(data, data.slots); + } catch { + // A diagnostic bridge cannot block the accepted navigation. + } // This route is now the committed, loaded state — a later failed // navigation rolls back here, and a return trip no-ops correctly. lastAppliedPath = path; @@ -1901,11 +1989,23 @@ function safelyRecordCreativeFailure( } } +/** + * A consumed APS ad ID, retained as a security tombstone. + * + * `adUnitCode` is kept so a failure relayed later by the creative frame can be + * source-checked against the ad unit the capability was served to, rather than + * trusted because it names a live ad ID. + */ +interface ApsConsumedTombstone { + expiresAt: number; + adUnitCode: string; +} + /** Maximum number of consumed APS Prebid IDs retained as security tombstones. */ const MAX_CONSUMED_PREBID_APS_IDS = 256; function pruneConsumedPrebidApsIds( - consumedIds: Map, + consumedIds: Map, now: number ): void { for (const [adId, consumed] of consumedIds) { @@ -1914,7 +2014,7 @@ function pruneConsumedPrebidApsIds( } function hasConsumedPrebidApsIdCapacity( - consumedIds: Map, + consumedIds: Map, adId: string ): boolean { if (consumedIds.has(adId) || consumedIds.size < MAX_CONSUMED_PREBID_APS_IDS) return true; @@ -1924,11 +2024,12 @@ function hasConsumedPrebidApsIdCapacity( } function recordConsumedPrebidApsId( - consumedIds: Map, + consumedIds: Map, adId: string, - expiresAt: number + expiresAt: number, + adUnitCode: string ): void { - consumedIds.set(adId, { expiresAt }); + consumedIds.set(adId, { expiresAt, adUnitCode }); } /** @@ -1962,7 +2063,7 @@ export function installTsRenderBridge(): void { // is scoped to the slot, not the bare adId: hb_adid is not unique per bid, so // keying on it alone would let one slot block a distinct slot's render. const renderingKeys = new Set(); - const consumedPrebidApsIds = new Map(); + const consumedPrebidApsIds = new Map(); // One consumed APS ad ID per slot is sufficient: a newer bid replaces the // slot's old ad ID in `window.tsjs.bids`, so the ownership guard rejects it. const consumedServerApsBySlot = new Map(); @@ -1978,6 +2079,23 @@ export function installTsRenderBridge(): void { return; } + // Diagnostics relayed by the APS Universal Creative frame. The creative is + // cross-origin, so every field is untrusted: the reason resolves through the + // allowlist, and the sender must be a frame inside the ad unit the + // capability was served to. Recording only; it never answers the sender. + if (data['message'] === APS_RENDER_FAILED_MESSAGE) { + const failedAdId = data['adId']; + const reason = apsRenderFailureReason(data['reason']); + if (typeof failedAdId === 'string' && reason !== undefined) { + pruneConsumedPrebidApsIds(consumedPrebidApsIds, Date.now()); + const tombstone = consumedPrebidApsIds.get(failedAdId); + if (tombstone && sourceFrameForAdUnit(e.source, tombstone.adUnitCode)) { + safelyRecordCreativeFailure(safelyRecordCreativeRequest(tombstone.adUnitCode), reason); + } + } + return; + } + if (data['message'] !== 'Prebid Request') return; const adId = data['adId'] as string | undefined; if (!adId) return; @@ -2008,7 +2126,12 @@ export function installTsRenderBridge(): void { const renderer = validateApsRenderer(prebidRendererEntry.renderer); if (!renderer || !hasConsumedPrebidApsIdCapacity(consumedPrebidApsIds, adId)) return; if (!consumeApsPrebidRenderer(adId, prebidRendererEntry)) return; - recordConsumedPrebidApsId(consumedPrebidApsIds, adId, prebidRendererEntry.expiresAt); + recordConsumedPrebidApsId( + consumedPrebidApsIds, + adId, + prebidRendererEntry.expiresAt, + prebidRendererEntry.adUnitCode + ); const markUsed = (): void => { try { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts index 475bc7f93..8076a7e7e 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts @@ -1,13 +1,17 @@ import type { GptDiagnosticsApi, + GptDiagnosticsAuctionFacts, + GptDiagnosticsAuctionWinner, GptDiagnosticsCreativeFailure, GptDiagnosticsExportV1, GptDiagnosticsRecorder, GptDiagnosticsSlotHandle, GptDiagnosticsTrustedServerOpportunity, } from '../../core/types'; +import type { TraceGptIdentity } from '../../trace/types'; import type { GptDiagnosticsBindingManager } from './binding'; +import { clonePrebidAuctionEvidence } from './evidence'; import type { GptDiagnosticsStoreSnapshot } from './store'; interface ApiStore { @@ -18,9 +22,21 @@ interface ApiStore { auctionSlotId: string, opportunity: GptDiagnosticsTrustedServerOpportunity, trustedServerAuctionId?: string, - requestedSlotSizes?: ReadonlyArray + requestedSlotSizes?: ReadonlyArray, + auctionFacts?: GptDiagnosticsAuctionFacts, + traceIdentity?: TraceGptIdentity ): void; recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void; + recordPrebidAuction( + slot: GptDiagnosticsSlotHandle, + auctionId: string, + targetingCandidate?: GptDiagnosticsAuctionWinner + ): void; + recordPrebidWin( + slot: GptDiagnosticsSlotHandle, + auctionId: string, + winner: GptDiagnosticsAuctionWinner + ): void; recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined; recordTrustedServerCreativeResponse(attemptId: number): void; recordTrustedServerCreativeFailure( @@ -67,6 +83,13 @@ function cloneExportSnapshot(snapshot: GptDiagnosticsExportV1): GptDiagnosticsEx requestedSlotSizes: cycle.requestedSlotSizes?.map((size) => [...size]), size: cycle.size ? [...cycle.size] : undefined, observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined, + ...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}), + ...(cycle.prebidAuction + ? { prebidAuction: clonePrebidAuctionEvidence(cycle.prebidAuction) } + : {}), + ...(cycle.serverAuctionTimings + ? { serverAuctionTimings: { ...cycle.serverAuctionTimings } } + : {}), adManager: cycle.adManager ? { ...cycle.adManager, @@ -157,7 +180,9 @@ export class GptDiagnosticsApiController { auctionSlotId, opportunity, trustedServerAuctionId, - requestedSlotSizes + requestedSlotSizes, + auctionFacts, + traceIdentity ) => safelyRecord(() => { this.store.recordTrustedServerOpportunity( @@ -165,10 +190,16 @@ export class GptDiagnosticsApiController { auctionSlotId, opportunity, trustedServerAuctionId, - requestedSlotSizes + requestedSlotSizes, + auctionFacts, + ...(traceIdentity ? ([traceIdentity] as const) : []) ); }), recordPrebidRefresh: (slots) => safelyRecord(() => this.store.recordPrebidRefresh(slots)), + recordPrebidAuction: (slot, auctionId, targetingCandidate) => + safelyRecord(() => this.store.recordPrebidAuction(slot, auctionId, targetingCandidate)), + recordPrebidWin: (slot, auctionId, winner) => + safelyRecord(() => this.store.recordPrebidWin(slot, auctionId, winner)), recordTrustedServerCreativeRequest: (auctionSlotId) => safelyCreateAttempt(() => this.store.recordTrustedServerCreativeRequest(auctionSlotId)), recordTrustedServerCreativeResponse: (attemptId) => @@ -200,6 +231,13 @@ export class GptDiagnosticsApiController { requestedSlotSizes: cycle.requestedSlotSizes?.map((size) => [...size]), size: cycle.size ? [...cycle.size] : undefined, observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined, + ...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}), + ...(cycle.prebidAuction + ? { prebidAuction: clonePrebidAuctionEvidence(cycle.prebidAuction) } + : {}), + ...(cycle.serverAuctionTimings + ? { serverAuctionTimings: { ...cycle.serverAuctionTimings } } + : {}), adManager: cycle.adManager ? { ...cycle.adManager, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts index 643dc355a..a93fac8a7 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts @@ -2,7 +2,12 @@ import type { GptDiagnosticsRequestCycle } from '../../core/types'; import type { GptDiagnosticsBindingManager } from './binding'; import { unhandledCase } from './exhaustive'; -import { formatSizes, scheduleFrame } from './presentation_helpers'; +import { + auctionTypeBadgeLabel, + displayableGptFillSize, + formatSizes, + scheduleFrame, +} from './presentation_helpers'; import type { GptDiagnosticsBindingInput, GptDiagnosticsStoreSlotSnapshot, @@ -33,6 +38,7 @@ interface BadgeOptions { window?: BadgeWindow; document?: Document; scheduleFrame?: (callback: () => void) => void; + onActivate?: (runtimeSlotNumber: number, requestNumber: number) => void; } function intersectsViewport(rectangle: DOMRect, window: Window): boolean { @@ -106,9 +112,12 @@ function badgeText(cycle: GptDiagnosticsRequestCycle): string { else if (cycle.isEmpty === false) firstLine.push('Filled'); else if (cycle.renderAtMs !== undefined) firstLine.push('Rendered (fill unknown)'); else firstLine.push('Pending'); + if (cycle.auctionType) firstLine.push(auctionTypeBadgeLabel(cycle.auctionType)); const delivery = deliveryLabel(cycle); if (delivery) firstLine.push(delivery); - if (cycle.requestPath === 'competing') firstLine.push('Competing paths'); + if (cycle.requestPath === 'competing' && cycle.auctionType !== 'competing') { + firstLine.push('Competing paths'); + } if (cycle.requestedSlotSizes) { const displayedSizes = cycle.requestedSlotSizes.slice(0, MAX_BADGE_REQUESTED_SLOT_SIZES); const remainingSizeCount = cycle.requestedSlotSizes.length - displayedSizes.length; @@ -116,16 +125,17 @@ function badgeText(cycle: GptDiagnosticsRequestCycle): string { `Req ${formatSizes(displayedSizes)}${remainingSizeCount > 0 ? ` +${remainingSizeCount}` : ''}` ); } - if (cycle.size) firstLine.push(`Fill ${cycle.size[0]}×${cycle.size[1]}`); + const fillSize = displayableGptFillSize(cycle.size); + if (fillSize) firstLine.push(`Fill ${fillSize[0]}×${fillSize[1]}`); if (cycle.observedSlotSize) { - firstLine.push(`Box ${cycle.observedSlotSize[0]}×${cycle.observedSlotSize[1]}`); + firstLine.push(`Size filled ${cycle.observedSlotSize[0]}×${cycle.observedSlotSize[1]}`); } const timingLine: string[] = []; const response = formatMilliseconds(cycle.durations.requestToResponseMs); const render = formatMilliseconds(cycle.durations.responseToRenderMs); - if (response) timingLine.push(`Response ${response}`); - if (render) timingLine.push(`Render ${render}`); + if (response) timingLine.push(`GAM request → response ${response}`); + if (render) timingLine.push(`GAM response → render ${render}`); const lines = [firstLine.join(' · ')]; if (timingLine.length > 0) lines.push(timingLine.join(' · ')); @@ -148,6 +158,7 @@ export class GptDiagnosticsBadgeManager { private readonly window: BadgeWindow; private readonly document: Document; private readonly scheduleFrame: (callback: () => void) => void; + private readonly onActivate: (runtimeSlotNumber: number, requestNumber: number) => void; private readonly unsubscribeStore: () => void; private readonly unsubscribeBindings: () => void; private readonly slotElementIds = new Set(); @@ -165,6 +176,7 @@ export class GptDiagnosticsBadgeManager { this.document = options.document ?? document; this.scheduleFrame = options.scheduleFrame ?? ((callback) => scheduleFrame(this.window, callback)); + this.onActivate = options.onActivate ?? (() => undefined); this.refreshSlotElementIds(); this.unsubscribeStore = this.store.subscribe(() => { this.refreshSlotElementIds(); @@ -188,7 +200,13 @@ export class GptDiagnosticsBadgeManager { update(): void { if (this.destroyed || !this.layer?.isConnected) return; const observedElements: HTMLElement[] = []; - const badges: HTMLElement[] = []; + const existingBadges = new Map( + Array.from(this.layer.querySelectorAll('.tsgd-badge')).map((badge) => [ + badge.dataset.runtimeSlot, + badge, + ]) + ); + const badges = new Set(); for (const slot of this.slots) { const cycle = latestCycle(slot); @@ -201,10 +219,30 @@ export class GptDiagnosticsBadgeManager { if (!intersectsViewport(rectangle, this.window)) continue; observedElements.push(element); - const badge = this.document.createElement('div'); - badge.className = 'tsgd-badge'; - badge.dataset.runtimeSlot = String(slot.runtimeSlotNumber); - badge.textContent = badgeText(cycle); + const runtimeSlot = String(slot.runtimeSlotNumber); + let badge = existingBadges.get(runtimeSlot); + if (!badge) { + const createdBadge = this.document.createElement('button'); + createdBadge.type = 'button'; + createdBadge.className = 'tsgd-badge'; + createdBadge.addEventListener('click', () => { + const runtimeSlotNumber = Number(createdBadge.dataset.runtimeSlot); + const requestNumber = Number(createdBadge.dataset.requestNumber); + if (!Number.isSafeInteger(runtimeSlotNumber) || !Number.isSafeInteger(requestNumber)) { + return; + } + this.onActivate(runtimeSlotNumber, requestNumber); + }); + badge = createdBadge; + } + const text = badgeText(cycle); + badge.dataset.runtimeSlot = runtimeSlot; + badge.dataset.requestNumber = String(cycle.requestNumber); + badge.textContent = `Ad #${slot.runtimeSlotNumber} · Request #${cycle.requestNumber} · ${text}`; + badge.setAttribute( + 'aria-label', + `Open diagnostics for Ad #${slot.runtimeSlotNumber}, Request #${cycle.requestNumber}: ${text}` + ); badge.style.maxWidth = `${BADGE_MAX_WIDTH_PX}px`; badge.style.left = `${Math.max( BADGE_EDGE_GUTTER_PX, @@ -218,11 +256,17 @@ export class GptDiagnosticsBadgeManager { BADGE_EDGE_GUTTER_PX, rectangle.top + BADGE_EDGE_GUTTER_PX )}px`; + badge.style.transform = ''; } - badges.push(badge); + badges.add(badge); } - this.layer.replaceChildren(...badges); + for (const badge of Array.from(this.layer.querySelectorAll('.tsgd-badge'))) { + if (!badges.has(badge)) badge.remove(); + } + for (const badge of badges) { + if (!badge.isConnected) this.layer.append(badge); + } this.resizeObserver?.disconnect(); for (const element of observedElements) this.resizeObserver?.observe(element); } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/evidence.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/evidence.ts new file mode 100644 index 000000000..af9779597 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/evidence.ts @@ -0,0 +1,14 @@ +import type { GptDiagnosticsPrebidAuctionEvidence } from '../../core/types'; + +/** Return a mutable copy of retained Prebid auction evidence. */ +export function clonePrebidAuctionEvidence( + evidence: GptDiagnosticsPrebidAuctionEvidence +): GptDiagnosticsPrebidAuctionEvidence { + return { + ...evidence, + ...(evidence.targetingCandidate + ? { targetingCandidate: { ...evidence.targetingCandidate } } + : {}), + ...(evidence.win ? { win: { ...evidence.win } } : {}), + }; +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts index d7271710c..856893935 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts @@ -1,5 +1,8 @@ import { log } from '../../core/log'; import type { GptDiagnosticsApi, TsjsApi } from '../../core/types'; +import { createTraceHandoff } from '../../trace/handoff'; +import type { TraceHandoff } from '../../trace/handoff'; +import { getActiveTraceCollector } from '../../trace/runtime'; import { GptDiagnosticsApiController } from './api'; import { GptDiagnosticsBadgeManager } from './badges'; @@ -47,11 +50,19 @@ export function installGptDiagnosticsRuntime( let overlay: GptDiagnosticsOverlay | undefined; let slotSizeObserver: GptDiagnosticsSlotSizeObserver | undefined; let apiController: GptDiagnosticsApiController | undefined; + let traceHandoff: TraceHandoff | undefined; try { if (!target.tsjs) throw new Error('TSJS core API unavailable'); - const store = new GptDiagnosticsStore(); + const store = new GptDiagnosticsStore( + target.__tsjs_trace_active === true + ? { + onTraceCorrelation: (value) => + getActiveTraceCollector(target)?.recordCorrelation(value), + } + : {} + ); const observer = new GptDiagnosticsObserver(store, { window: target }); bindings = new GptDiagnosticsBindingManager(store, { window: target, @@ -60,12 +71,20 @@ export function installGptDiagnosticsRuntime( badges = new GptDiagnosticsBadgeManager(store, bindings, { window: target, document: target.document, + onActivate: (runtimeSlotNumber, requestNumber) => + overlay?.selectRequest(runtimeSlotNumber, requestNumber), }); slotSizeObserver = new GptDiagnosticsSlotSizeObserver(store, bindings, { window: target }); overlay = new GptDiagnosticsOverlay(store, bindings, { window: target, document: target.document, onExport: () => apiController?.api.export(), + ...(target.__tsjs_trace_active === true + ? { + onViewTrace: () => traceHandoff?.view(), + onDownloadTrace: () => traceHandoff?.download(), + } + : {}), onBadgeLayerChange: (layer) => badges?.setLayer(layer), }); apiController = new GptDiagnosticsApiController(store, bindings, overlay, { @@ -79,6 +98,8 @@ export function installGptDiagnosticsRuntime( const runtime: GptDiagnosticsRuntime = { api, destroy: () => { + traceHandoff?.destroy(); + traceHandoff = undefined; if (target.tsjs?.gptDiagnostics === api) delete target.tsjs.gptDiagnostics; if (target.tsjs?.gptDiagnosticsRecorder === recorder) { delete target.tsjs.gptDiagnosticsRecorder; @@ -93,9 +114,17 @@ export function installGptDiagnosticsRuntime( }; target.tsjs.gptDiagnostics = api; target.tsjs.gptDiagnosticsRecorder = recorder; + if (target.__tsjs_trace_active === true) { + traceHandoff = createTraceHandoff({ + target, + onChange: (state) => overlay?.setTraceState(state), + }); + } target.__tsjs_gpt_diagnostics_runtime = runtime; return api; } catch (error) { + traceHandoff?.destroy(); + traceHandoff = undefined; apiController?.destroy(); overlay?.destroy(); badges?.destroy(); diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts index 63c0b6fb3..9c288e72f 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts @@ -1,9 +1,15 @@ import { log } from '../../core/log'; import type { GptDiagnosticsRequestCycle } from '../../core/types'; +import type { TraceHandoffState } from '../../trace/handoff'; import type { GptDiagnosticsBindingManager } from './binding'; import { unhandledCase } from './exhaustive'; -import { formatSizes, scheduleFrame } from './presentation_helpers'; +import { + auctionTypeLabel, + displayableGptFillSize, + formatSizes, + scheduleFrame, +} from './presentation_helpers'; import type { GptDiagnosticsStoreSlotSnapshot, GptDiagnosticsStoreSnapshot } from './store'; export const GPT_DIAGNOSTICS_HOST_ID = 'trusted-server-gpt-diagnostics'; @@ -29,6 +35,8 @@ interface OverlayOptions { document?: Document; scheduleFrame?: (callback: () => void) => void; onExport?: () => void; + onViewTrace?: () => void; + onDownloadTrace?: () => void; onShadowRoot?: (root: ShadowRoot) => void; onBadgeLayerChange?: (layer: HTMLElement | undefined) => void; } @@ -69,8 +77,11 @@ const PANEL_STYLES = ` font: inherit; } button { cursor: pointer; } - button:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; } + button:focus-visible, select:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; } .tsgd-toolbar { display: flex; gap: 8px; align-items: center; border-bottom: 1px solid #334155; } + .tsgd-toolbar { flex-wrap: wrap; } + .tsgd-trace-action { min-height: 44px; background: #1d4ed8; font-weight: 700; } + .tsgd-trace-status { margin: 0; padding: 0 12px 10px; color: #cbd5e1; } .tsgd-toolbar label { color: #cbd5e1; } .tsgd-summary { color: #cbd5e1; border-bottom: 1px solid #334155; } .tsgd-coverage { margin: 6px 0 0; padding: 0; list-style: none; font-size: 12px; } @@ -78,6 +89,13 @@ const PANEL_STYLES = ` .tsgd-empty { padding: 18px 12px; color: #94a3b8; } .tsgd-slot { border-bottom: 1px solid #334155; } .tsgd-slot:last-child { border-bottom: 0; } + .tsgd-slot[aria-current="true"], .tsgd-cycle[aria-current="true"] { outline: 2px solid #60a5fa; outline-offset: -2px; } + .tsgd-group { margin-top: 8px; } + .tsgd-group h3 { margin: 0; color: #e2e8f0; font-size: 12px; } + .tsgd-help { padding: 0 12px 8px; color: #cbd5e1; } + .tsgd-help p { margin: 6px 0 0; } + .tsgd-locate { margin-top: 8px; } + .tsgd-selection-note { padding: 8px 12px; color: #fde68a; border-bottom: 1px solid #334155; } .tsgd-slot-title { display: flex; gap: 8px; align-items: baseline; } .tsgd-slot-title strong { overflow-wrap: anywhere; } .tsgd-state { margin-left: auto; color: #fde68a; white-space: nowrap; } @@ -89,6 +107,7 @@ const PANEL_STYLES = ` .tsgd-badge-layer { position: fixed; z-index: 2147483646; inset: 0; pointer-events: none; } .tsgd-badge { position: fixed; + pointer-events: auto; padding: 5px 7px; color: #fff; background: rgb(15 23 42 / 94%); @@ -97,6 +116,15 @@ const PANEL_STYLES = ` box-shadow: 0 2px 8px rgb(0 0 0 / 35%); font: 11px/1.35 ui-sans-serif, system-ui, sans-serif; white-space: pre-line; + text-align: left; + cursor: pointer; + } + .tsgd-highlight { + position: fixed; + border: 3px solid #fbbf24; + background: rgb(251 191 36 / 18%); + box-shadow: 0 0 0 3px rgb(15 23 42 / 75%); + pointer-events: none; } `; @@ -120,28 +148,33 @@ function formatMilliseconds(value: number | undefined): string | undefined { return `${Math.round(value * 10) / 10} ms`; } -function deliveryFact(cycle: GptDiagnosticsRequestCycle): string | undefined { +function deliveryFact(cycle: GptDiagnosticsRequestCycle): string { switch (cycle.delivery) { case 'trusted_server_response_sent': - return 'Trusted Server selected; markup response sent to PUC'; + return 'Creative markup sent; execution not confirmed'; case 'trusted_server_selected': - return 'Trusted Server selected; no markup response confirmed'; + return 'Server bid selected by the creative bridge; response not confirmed'; case 'candidate_unconfirmed': - return 'Trusted Server candidate unconfirmed — another GAM result or a creative/bridge failure is possible'; + return 'Server bid available; selection not confirmed'; case 'no_candidate': - return 'adInit observed no direct Trusted Server candidate for this request'; + return 'No direct Trusted Server candidate'; case 'unknown': - return 'Delivery status unknown — required GPT or direct-candidate evidence was not observed'; + return 'Delivery status unknown — required evidence was not observed'; case 'pending': return 'Waiting for Trusted Server creative evidence'; case 'not_applicable': + return 'Delivery evidence: Not applicable'; case undefined: - return undefined; + return 'Delivery evidence: Not observed'; default: - return unhandledCase(cycle.delivery); + return unhandledCase(cycle.delivery) ?? 'Delivery evidence: Not observed'; } } +function servedBidderFact(cycle: GptDiagnosticsRequestCycle): string | undefined { + return cycle.isEmpty === false ? 'Served bidder not confirmed' : undefined; +} + function requestPathFact(cycle: GptDiagnosticsRequestCycle): string { switch (cycle.requestPath) { case 'trusted_server_direct': @@ -151,24 +184,24 @@ function requestPathFact(cycle: GptDiagnosticsRequestCycle): string { case 'publisher_refresh': return 'Request path: Publisher refresh'; case 'competing': - return 'Request path: Competing paths'; + return 'Request path: Multiple paths observed'; case 'unattributed': - return 'Request path: Unattributed'; + return 'Request path: Not observed'; case undefined: - return 'Request path: Unknown (not observed)'; + return 'Request path: Not observed'; } } function trustedServerOpportunityFact(cycle: GptDiagnosticsRequestCycle): string { switch (cycle.trustedServerOpportunity) { case 'renderable_candidate': - return 'Direct opportunity: Renderable candidate'; + return 'Server bid available; creative source present'; case 'unrenderable_candidate': - return 'Direct opportunity: Unrenderable candidate'; + return 'Server bid available; creative source incomplete'; case 'no_candidate': return 'Direct opportunity: No candidate'; case undefined: - return 'Direct opportunity: Unknown (not observed)'; + return 'Direct opportunity: Not observed'; } } @@ -184,6 +217,26 @@ function creativeFailureFact( return 'Creative bridge failure: invalid cache payload'; case 'response_post_failed': return 'Creative bridge failure: response post failed'; + case 'aps_bad_hash': + return 'APS render failure: renderer frame received no nonce'; + case 'aps_nonce_mismatch': + return 'APS render failure: renderer nonce mismatch'; + case 'aps_descriptor_keys': + return 'APS render failure: descriptor key set rejected'; + case 'aps_descriptor_fields': + return 'APS render failure: descriptor fields rejected'; + case 'aps_descriptor_envelope': + return 'APS render failure: descriptor envelope rejected'; + case 'aps_runner_script_error': + return 'APS render failure: Amazon runner script failed to load'; + case 'aps_frame_timeout': + return 'APS render failure: renderer frame never acknowledged'; + case 'aps_frame_load_error': + return 'APS render failure: renderer frame failed to load'; + case 'aps_frame_reported_failure': + return 'APS render failure: renderer frame reported an unlabelled failure'; + case 'aps_unknown': + return 'APS render failure: unknown'; } } @@ -221,16 +274,124 @@ function responseClassFact(cycle: GptDiagnosticsRequestCycle): string | undefine } } -function cycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { - const facts: string[] = [requestPathFact(cycle), trustedServerOpportunityFact(cycle)]; +function priceBucket(value: { priceBucket: string; currency?: string }): string { + return `${value.priceBucket}${value.currency ? ` ${value.currency}` : ''}`; +} + +function auctionFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const facts = [ + requestPathFact(cycle), + `Auction evidence: ${cycle.auctionType ? auctionTypeLabel(cycle.auctionType) : 'Auction not observed'}`, + ]; + if (cycle.auctionWinner) { + facts.push(`Server auction winner: ${cycle.auctionWinner.bidder}`); + facts.push(`Server bid price bucket: ${priceBucket(cycle.auctionWinner)}`); + } + if (cycle.prebidAuction?.targetingCandidate) { + const candidate = cycle.prebidAuction.targetingCandidate; + facts.push(`Prebid targeting candidate: ${candidate.bidder}`); + facts.push(`Prebid candidate price bucket: ${priceBucket(candidate)}`); + } + if (cycle.prebidAuction?.win) { + const win = cycle.prebidAuction.win; + facts.push(`Prebid bidWon observation: ${win.bidder}`); + facts.push(`Prebid win price bucket: ${priceBucket(win)}`); + } + const servedBidder = servedBidderFact(cycle); + if (servedBidder) facts.push(servedBidder); + return facts; +} + +function timingFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const serverTimingApplies = + cycle.auctionType === 'ssat' || + cycle.auctionType === 'trusted_server' || + cycle.auctionType === 'competing'; + const missingServerTiming = serverTimingApplies ? 'Unavailable' : 'Not applicable'; + const timingAnchor = + cycle.serverAuctionTimingOrigin === 'spa_auction' + ? 'SPA page-bids request T0' + : 'Initial document request T0'; + const serverTimings = [ + [`${timingAnchor} → auction dispatched`, cycle.serverAuctionTimings?.auctionDispatchedMs], + [`${timingAnchor} → auction collected`, cycle.serverAuctionTimings?.auctionResolvedMs], + [`${timingAnchor} → bids ready`, cycle.serverAuctionTimings?.auctionCommittedMs], + ] as const; + const facts = serverTimings.map( + ([label, timing]) => `${label} ${formatMilliseconds(timing) ?? missingServerTiming}` + ); + const wait = formatMilliseconds(cycle.serverAuctionTimings?.auctionWaitMs); + if (wait) { + const placement = + cycle.serverAuctionTimings?.auctionWaitPlacement === 'pre_header' + ? 'pre-header' + : cycle.serverAuctionTimings?.auctionWaitPlacement === 'in_stream' + ? 'in stream' + : 'placement unknown'; + facts.push(`Auction collection wait (${placement}) ${wait}`); + } else { + facts.push(`Auction collection wait ${missingServerTiming}`); + } + facts.push( + `Opportunity → request ${formatMilliseconds(cycle.opportunityToRequestMs) ?? 'Unavailable'}` + ); + const durations = [ + ['GAM request → response', cycle.durations.requestToResponseMs], + ['GAM response → render', cycle.durations.responseToRenderMs], + ['GAM request → render', cycle.durations.requestToRenderMs], + ['Render → load', cycle.durations.renderToLoadMs], + ['Render → viewable', cycle.durations.renderToViewableMs], + ] as const; + for (const [label, duration] of durations) { + facts.push(`${label} ${formatMilliseconds(duration) ?? 'Unavailable'}`); + } + return facts; +} + +function deliveryFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const facts = [ + deliveryFact(cycle), + responseClassFact(cycle) ?? 'Ad Manager response class: Not observed', + adManagerFact(cycle) ?? 'Ad Manager fields: Not observed', + ]; + if (cycle.loadAtMs !== undefined) facts.push('GPT slot onload observed'); + if (cycle.viewableAtMs !== undefined) facts.push('GPT impressionViewable observed'); + if (cycle.incompleteSequence) facts.push('Incomplete sequence'); + if (cycle.isBackfill !== undefined) facts.push(`Backfill ${cycle.isBackfill ? 'yes' : 'no'}`); + if (cycle.slotContentChanged !== undefined) { + facts.push(`Slot content changed ${cycle.slotContentChanged ? 'yes' : 'no'}`); + } + return facts; +} + +function sizeFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const fillSize = displayableGptFillSize(cycle.size); + return [ + cycle.requestedSlotSizes + ? `Requested sizes ${formatSizes(cycle.requestedSlotSizes)}` + : 'Requested sizes: Not observed', + fillSize + ? `GPT-reported size ${formatSizes([fillSize])}` + : cycle.size?.[0] === 1 && cycle.size[1] === 1 + ? 'GPT-reported size: placeholder hidden' + : 'GPT-reported size: Not observed', + cycle.observedSlotSize + ? `Size filled ${formatSizes([cycle.observedSlotSize])} · Measured outer slot size` + : 'Size filled: Not observed · Measured outer slot size', + ]; +} + +function technicalCycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const facts = [trustedServerOpportunityFact(cycle)]; if (Number.isSafeInteger(cycle.requestIntentId) && cycle.requestIntentId! > 0) { facts.push(`Request intent: ${cycle.requestIntentId}`); } if (typeof cycle.trustedServerAuctionId === 'string' && cycle.trustedServerAuctionId.length > 0) { facts.push(`Trusted Server auction: ${cycle.trustedServerAuctionId}`); } - const opportunityToRequest = formatMilliseconds(cycle.opportunityToRequestMs); - if (opportunityToRequest) facts.push(`Opportunity → request ${opportunityToRequest}`); + if (cycle.prebidAuction?.auctionId) { + facts.push(`Prebid auction: ${cycle.prebidAuction.auctionId}`); + } const previousRenderToRequest = formatMilliseconds(cycle.previousRenderToRequestMs); if (cycle.replacedRequestNumber !== undefined && previousRenderToRequest) { facts.push( @@ -261,41 +422,19 @@ function cycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { for (const failure of new Set(cycle.trustedServerCreativeFailures ?? [])) { facts.push(creativeFailureFact(failure)); } - const deliveryLine = deliveryFact(cycle); - if (deliveryLine) facts.push(deliveryLine); - const responseClassLine = responseClassFact(cycle); - if (responseClassLine) facts.push(responseClassLine); - const adManagerLine = adManagerFact(cycle); - if (adManagerLine) facts.push(adManagerLine); - if (cycle.loadAtMs !== undefined) facts.push('GPT slot onload observed'); - if (cycle.viewableAtMs !== undefined) facts.push('GPT impressionViewable observed'); - if (cycle.incompleteSequence) facts.push('Incomplete sequence'); - if (cycle.requestedSlotSizes) { - facts.push(`Requested slot sizes ${formatSizes(cycle.requestedSlotSizes)}`); - } - if (cycle.size) facts.push(`GPT-reported fill size ${cycle.size[0]}×${cycle.size[1]}`); - if (cycle.observedSlotSize) { - facts.push(`Observed outer slot box ${cycle.observedSlotSize[0]}×${cycle.observedSlotSize[1]}`); - } - if (cycle.isBackfill !== undefined) facts.push(`Backfill ${cycle.isBackfill ? 'yes' : 'no'}`); - if (cycle.slotContentChanged !== undefined) { - facts.push(`Slot content changed ${cycle.slotContentChanged ? 'yes' : 'no'}`); - } - - const durations = [ - ['Request → response', cycle.durations.requestToResponseMs], - ['Response → render', cycle.durations.responseToRenderMs], - ['Request → render', cycle.durations.requestToRenderMs], - ['Render → load', cycle.durations.renderToLoadMs], - ['Render → viewable', cycle.durations.renderToViewableMs], - ] as const; - for (const [label, duration] of durations) { - const formatted = formatMilliseconds(duration); - if (formatted) facts.push(`${label} ${formatted}`); - } return facts; } +function cycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { + return [ + ...auctionFacts(cycle), + ...deliveryFacts(cycle), + ...timingFacts(cycle), + ...sizeFacts(cycle), + ...technicalCycleFacts(cycle), + ]; +} + function cycleLabel(cycle: GptDiagnosticsRequestCycle): string { return cycle.requestNumber === 1 ? 'Initial request' : `Refresh ${cycle.requestNumber - 1}`; } @@ -327,6 +466,21 @@ function appendFacts(document: Document, parent: HTMLElement, facts: string[]): parent.append(list); } +function appendGroup( + document: Document, + parent: HTMLElement, + heading: string, + facts: string[] +): void { + const section = document.createElement('section'); + section.className = 'tsgd-group'; + const title = document.createElement('h3'); + title.textContent = heading; + section.append(title); + appendFacts(document, section, facts); + parent.append(section); +} + /** Owns hydration-safe mounting and the closed-shadow diagnostics panel. */ export class GptDiagnosticsOverlay { private readonly store: OverlayStore; @@ -335,12 +489,16 @@ export class GptDiagnosticsOverlay { private readonly document: Document; private readonly scheduleFrame: (callback: () => void) => void; private readonly onExport: () => void; + private readonly onViewTrace?: () => void; + private readonly onDownloadTrace?: () => void; + private traceState: TraceHandoffState = { kind: 'ready', downloadAvailable: false }; private readonly onShadowRoot?: (root: ShadowRoot) => void; private readonly onBadgeLayerChange?: (layer: HTMLElement | undefined) => void; private readonly unsubscribeStore: () => void; private readonly unsubscribeBindings: () => void; private host?: HTMLElement; private panel?: HTMLElement; + private badgeLayer?: HTMLElement; private lifecycleObserver?: MutationObserver; private visualReady = false; private mountWaitStarted = false; @@ -351,6 +509,9 @@ export class GptDiagnosticsOverlay { private dismissed = false; private destroyed = false; private filter: GptDiagnosticsFilter = 'all'; + private selectedRequest?: { runtimeSlotNumber: number; requestNumber: number }; + private historySlotToOpenOnce?: number; + private selectedRequestHasFocus = false; constructor(store: OverlayStore, bindings: OverlayBindings, options: OverlayOptions = {}) { this.store = store; @@ -360,6 +521,8 @@ export class GptDiagnosticsOverlay { this.scheduleFrame = options.scheduleFrame ?? ((callback) => scheduleFrame(this.window, callback)); this.onExport = options.onExport ?? (() => undefined); + this.onViewTrace = options.onViewTrace; + this.onDownloadTrace = options.onDownloadTrace; this.onShadowRoot = options.onShadowRoot; this.onBadgeLayerChange = options.onBadgeLayerChange; this.unsubscribeStore = this.store.subscribe(() => this.scheduleRender()); @@ -381,6 +544,31 @@ export class GptDiagnosticsOverlay { this.removeHost(); } + /** Open and focus the exact retained request selected from an on-page badge. */ + selectRequest(runtimeSlotNumber: number, requestNumber: number): void { + if (this.destroyed) return; + this.selectedRequest = { runtimeSlotNumber, requestNumber }; + this.historySlotToOpenOnce = runtimeSlotNumber; + this.filter = 'all'; + this.collapsed = false; + this.show(); + this.render(); + this.scheduleFrame(() => { + const selected = this.panel?.querySelector( + `[data-runtime-slot="${runtimeSlotNumber}"][data-request-number="${requestNumber}"]` + ); + selected?.focus(); + selected?.scrollIntoView?.({ block: 'nearest' }); + }); + } + + /** Updates the optional trace action without changing the GPT-only snapshot. */ + setTraceState(state: TraceHandoffState): void { + if (this.destroyed || !this.onViewTrace) return; + this.traceState = state; + this.scheduleRender(); + } + destroy(): void { if (this.destroyed) return; this.destroyed = true; @@ -439,11 +627,11 @@ export class GptDiagnosticsOverlay { panel.setAttribute('aria-label', 'GPT runtime diagnostics'); const badgeLayer = this.document.createElement('div'); badgeLayer.className = 'tsgd-badge-layer'; - badgeLayer.setAttribute('aria-hidden', 'true'); root.append(style, badgeLayer, panel); this.host = host; this.panel = panel; + this.badgeLayer = badgeLayer; (this.document.body ?? this.document.documentElement).append(host); this.onShadowRoot?.(root); this.onBadgeLayerChange?.(badgeLayer); @@ -455,6 +643,7 @@ export class GptDiagnosticsOverlay { const host = this.host; this.host = undefined; this.panel = undefined; + this.badgeLayer = undefined; host?.remove(); } @@ -502,12 +691,30 @@ export class GptDiagnosticsOverlay { const panel = this.panel; const previousContent = panel.querySelector('.tsgd-content'); const previousScrollTop = previousContent?.scrollTop ?? 0; + const selectedRequestWasFocused = this.selectedRequestHasFocus; + const helpOpen = panel.querySelector('.tsgd-help')?.open ?? false; const openHistorySlots = new Set( - Array.from(panel.querySelectorAll('.tsgd-slot details[open]')) + Array.from(panel.querySelectorAll('.tsgd-history[open]')) + .map((details) => details.closest('.tsgd-slot')?.dataset.runtimeSlot) + .filter((runtimeSlot): runtimeSlot is string => runtimeSlot !== undefined) + ); + const openTechnicalSlots = new Set( + Array.from(panel.querySelectorAll('.tsgd-technical[open]')) .map((details) => details.closest('.tsgd-slot')?.dataset.runtimeSlot) .filter((runtimeSlot): runtimeSlot is string => runtimeSlot !== undefined) ); - panel.replaceChildren(); + const historySlotToOpenOnce = this.historySlotToOpenOnce; + this.historySlotToOpenOnce = undefined; + const retainedToolbar = + this.onViewTrace && !this.collapsed + ? panel.querySelector('.tsgd-toolbar') + : null; + const retainedTraceStatus = retainedToolbar + ? panel.querySelector('.tsgd-trace-status') + : null; + for (const child of Array.from(panel.children)) { + if (child !== retainedToolbar && child !== retainedTraceStatus) child.remove(); + } const header = this.document.createElement('header'); header.className = 'tsgd-header'; @@ -524,39 +731,107 @@ export class GptDiagnosticsOverlay { collapse.setAttribute('aria-expanded', String(!this.collapsed)); const close = this.button('Close', () => this.hide()); header.append(title, status, collapse, close); - panel.append(header); + panel.prepend(header); if (this.collapsed) return; - const toolbar = this.document.createElement('div'); + const toolbar = retainedToolbar ?? this.document.createElement('div'); toolbar.className = 'tsgd-toolbar'; - const filterLabel = this.document.createElement('label'); - filterLabel.textContent = 'Filter'; - const select = this.document.createElement('select'); - select.setAttribute('aria-label', 'Filter diagnostic slots'); - const filters: Array<[GptDiagnosticsFilter, string]> = [ - ['all', 'All'], - ['visible', 'Visible'], - ['filled', 'Filled'], - ['empty', 'Empty'], - ['pending', 'Pending/Incomplete'], - ['unbound', 'Unbound/Ambiguous'], - ]; - for (const [value, label] of filters) { - const option = this.document.createElement('option'); - option.value = value; - option.textContent = label; - option.selected = this.filter === value; - select.append(option); + if (!retainedToolbar) { + const filterLabel = this.document.createElement('label'); + filterLabel.textContent = 'Filter'; + const select = this.document.createElement('select'); + select.setAttribute('aria-label', 'Filter diagnostic slots'); + const filters: Array<[GptDiagnosticsFilter, string]> = [ + ['all', 'All'], + ['visible', 'Visible'], + ['filled', 'Filled'], + ['empty', 'Empty'], + ['pending', 'Pending/Incomplete'], + ['unbound', 'Unbound/Ambiguous'], + ]; + for (const [value, label] of filters) { + const option = this.document.createElement('option'); + option.value = value; + option.textContent = label; + option.selected = this.filter === value; + select.append(option); + } + select.addEventListener('change', () => { + this.filter = select.value as GptDiagnosticsFilter; + this.render(); + }); + const exportButton = this.button('Export JSON', () => this.onExport()); + const dictionaryLink = this.document.createElement('a'); + dictionaryLink.href = + 'https://iabtechlab.github.io/trusted-server/guide/integrations/gpt-diagnostics-dictionary'; + dictionaryLink.target = '_blank'; + dictionaryLink.rel = 'noopener'; + dictionaryLink.textContent = 'Label dictionary'; + dictionaryLink.setAttribute('aria-label', 'Open GPT diagnostics label dictionary'); + filterLabel.append(select); + toolbar.append(filterLabel, exportButton, dictionaryLink); + } else { + // A retained toolbar keeps its select, so programmatic filter changes + // such as `selectRequest` must be reflected here. + const select = toolbar.querySelector('select'); + if (select) select.value = this.filter; } - select.addEventListener('change', () => { - this.filter = select.value as GptDiagnosticsFilter; - this.render(); - }); - const exportButton = this.button('Export JSON', () => this.onExport()); - filterLabel.append(select); - toolbar.append(filterLabel, exportButton); - panel.append(toolbar); + if (this.onViewTrace) { + const viewTrace = + toolbar.querySelector('[data-trace-action="view"]') ?? + this.button('View trace results', this.onViewTrace); + viewTrace.className = 'tsgd-trace-action'; + viewTrace.dataset.traceAction = 'view'; + viewTrace.disabled = this.traceState.kind === 'capturing'; + if (!viewTrace.parentNode) toolbar.prepend(viewTrace); + const existingDownload = toolbar.querySelector( + '[data-trace-action="download"]' + ); + if (this.traceState.downloadAvailable && this.onDownloadTrace) { + const download = + existingDownload ?? this.button('Download trace report', this.onDownloadTrace); + download.className = 'tsgd-trace-action'; + download.dataset.traceAction = 'download'; + if (!download.parentNode) toolbar.append(download); + } else { + existingDownload?.remove(); + } + } + if (!retainedToolbar) panel.append(toolbar); + if (this.onViewTrace) { + const messages: Record = { + ready: 'Capture the current page, then view its trace in this tab.', + capturing: 'Capturing trace results…', + capture_failed: 'Trace results could not be captured. Stay on this page and retry.', + storage_unavailable: + 'Trace results could not be saved in this tab. Download this report or retry.', + navigation_unavailable: + 'The report was saved, but the viewer could not be opened. Download it or retry.', + navigating: 'Opening trace results in this tab…', + download_failed: + 'The download could not be started. Your report remains available for retry.', + downloaded: 'The download was started. Your report remains available.', + }; + const traceStatus = retainedTraceStatus ?? this.document.createElement('p'); + traceStatus.className = 'tsgd-trace-status'; + traceStatus.setAttribute('role', 'status'); + traceStatus.setAttribute('aria-live', 'polite'); + const message = messages[this.traceState.kind]; + if (traceStatus.textContent !== message) traceStatus.textContent = message; + if (!retainedTraceStatus) panel.append(traceStatus); + } + + const help = this.document.createElement('details'); + help.className = 'tsgd-help'; + help.open = helpOpen; + const helpSummary = this.document.createElement('summary'); + helpSummary.textContent = 'How to read this evidence'; + const helpText = this.document.createElement('p'); + helpText.textContent = + 'Auction winners, Prebid candidates, and GPT render results are separate observations. “Filled” does not identify the served bidder. Browser and server timings use separate clocks.'; + help.append(helpSummary, helpText); + panel.append(help); const summary = this.document.createElement('div'); summary.className = 'tsgd-summary'; @@ -572,6 +847,23 @@ export class GptDiagnosticsOverlay { summary.append(coverage); panel.append(summary); + if ( + this.selectedRequest && + !snapshot.slots.some( + (slot) => + slot.runtimeSlotNumber === this.selectedRequest?.runtimeSlotNumber && + slot.requests.some((cycle) => cycle.requestNumber === this.selectedRequest?.requestNumber) + ) + ) { + const note = this.document.createElement('div'); + note.className = 'tsgd-selection-note'; + note.setAttribute('role', 'status'); + note.textContent = `Ad #${this.selectedRequest.runtimeSlotNumber}, Request #${this.selectedRequest.requestNumber} is no longer retained.`; + panel.append(note); + this.selectedRequest = undefined; + this.selectedRequestHasFocus = false; + } + const content = this.document.createElement('div'); content.className = 'tsgd-content'; const filteredSlots = snapshot.slots.filter((slot) => @@ -585,22 +877,47 @@ export class GptDiagnosticsOverlay { content.append(empty); } else { for (const slot of filteredSlots) { - content.append(this.renderSlot(slot, openHistorySlots.has(String(slot.runtimeSlotNumber)))); + const runtimeSlot = String(slot.runtimeSlotNumber); + content.append( + this.renderSlot( + slot, + openHistorySlots.has(runtimeSlot) || historySlotToOpenOnce === slot.runtimeSlotNumber, + openTechnicalSlots.has(runtimeSlot) + ) + ); } } panel.append(content); content.scrollTop = previousScrollTop; + if (selectedRequestWasFocused) { + panel.querySelector('[aria-current="true"]')?.focus({ preventScroll: true }); + } } - private renderSlot(slot: GptDiagnosticsStoreSlotSnapshot, historyOpen: boolean): HTMLElement { + private renderSlot( + slot: GptDiagnosticsStoreSlotSnapshot, + historyOpen: boolean, + technicalOpen: boolean + ): HTMLElement { const container = this.document.createElement('article'); container.className = 'tsgd-slot'; + const latest = latestCycle(slot); container.dataset.runtimeSlot = String(slot.runtimeSlotNumber); + if (latest) container.dataset.requestNumber = String(latest.requestNumber); + const latestSelected = + latest !== undefined && + this.selectedRequest?.runtimeSlotNumber === slot.runtimeSlotNumber && + this.selectedRequest.requestNumber === latest.requestNumber; + if (latestSelected) { + container.tabIndex = -1; + container.setAttribute('aria-current', 'true'); + this.trackSelectedRequestFocus(container); + } + const title = this.document.createElement('div'); title.className = 'tsgd-slot-title'; const name = this.document.createElement('strong'); - name.textContent = slot.slotElementId ?? `Unbound GPT slot ${slot.runtimeSlotNumber}`; - const latest = latestCycle(slot); + name.textContent = `Ad #${slot.runtimeSlotNumber}${latest ? ` · Request #${latest.requestNumber}` : ''} · ${slot.slotElementId ?? 'Unbound GPT slot'}`; const state = this.document.createElement('span'); state.className = 'tsgd-state'; state.textContent = primaryState(latest); @@ -608,41 +925,117 @@ export class GptDiagnosticsOverlay { container.append(title); const binding = this.bindings.get(slot.runtimeSlotNumber); - const facts = [ - slot.adUnitPath ? `Ad unit ${slot.adUnitPath}` : undefined, - binding.binding.status === 'bound' - ? `Bound · ${binding.visible ? 'Visible' : 'Outside viewport'}` - : binding.binding.status === 'ambiguous' - ? `Ambiguous binding · ${binding.binding.reason ?? 'unknown'}` - : `Unbound · ${binding.binding.reason ?? 'unknown'}`, - slot.currentVisibilityPercentage !== undefined - ? `GPT visibility ${slot.currentVisibilityPercentage}% (maximum ${slot.maximumVisibilityPercentage ?? slot.currentVisibilityPercentage}%)` - : undefined, - latest ? cycleLabel(latest) : undefined, - ...(latest ? cycleFacts(latest) : []), - ].filter((fact): fact is string => fact !== undefined); - appendFacts(this.document, container, facts); + if (binding.binding.status === 'bound' && binding.element?.isConnected) { + const locate = this.button('Locate on page', () => this.locateOnPage(slot.runtimeSlotNumber)); + locate.className = 'tsgd-locate'; + container.append(locate); + } + + if (latest) { + const summaryFacts = [ + `Ad #${slot.runtimeSlotNumber} · Request #${latest.requestNumber}`, + `GPT result: ${primaryState(latest)}`, + `Observed auction path: ${latest.auctionType ? auctionTypeLabel(latest.auctionType) : 'Auction not observed'}`, + deliveryFact(latest), + ]; + const servedBidder = servedBidderFact(latest); + if (servedBidder) summaryFacts.push(servedBidder); + appendGroup(this.document, container, 'Summary', summaryFacts); + appendGroup(this.document, container, 'Auction evidence', auctionFacts(latest)); + appendGroup(this.document, container, 'Delivery evidence', deliveryFacts(latest)); + appendGroup(this.document, container, 'Timing', timingFacts(latest)); + appendGroup(this.document, container, 'Size and visibility', [ + ...sizeFacts(latest), + binding.binding.status === 'bound' + ? `Binding: Bound · ${binding.visible ? 'Visible' : 'Outside viewport'}` + : `Binding: ${binding.binding.status} · ${binding.binding.reason ?? 'reason unavailable'}`, + slot.currentVisibilityPercentage !== undefined + ? `GPT visibility ${slot.currentVisibilityPercentage}% (maximum ${slot.maximumVisibilityPercentage ?? slot.currentVisibilityPercentage}%)` + : 'GPT visibility: Not observed', + ]); + } if (slot.requests.length > 1) { const history = this.document.createElement('details'); + history.className = 'tsgd-history'; history.open = historyOpen; const summary = this.document.createElement('summary'); - summary.textContent = `Previous requests (${slot.requests.length - 1})`; + summary.textContent = `Request history (${slot.requests.length - 1} previous)`; history.append(summary); for (const cycle of slot.requests.slice(0, -1).reverse()) { const previous = this.document.createElement('div'); previous.className = 'tsgd-cycle'; + previous.dataset.runtimeSlot = String(slot.runtimeSlotNumber); + previous.dataset.requestNumber = String(cycle.requestNumber); + const selected = + this.selectedRequest?.runtimeSlotNumber === slot.runtimeSlotNumber && + this.selectedRequest.requestNumber === cycle.requestNumber; + if (selected) { + previous.tabIndex = -1; + previous.setAttribute('aria-current', 'true'); + this.trackSelectedRequestFocus(previous); + } const heading = this.document.createElement('strong'); - heading.textContent = `${cycleLabel(cycle)} · ${primaryState(cycle)}`; + heading.textContent = `Request #${cycle.requestNumber} · ${cycleLabel(cycle)} · ${primaryState(cycle)}`; previous.append(heading); appendFacts(this.document, previous, cycleFacts(cycle)); history.append(previous); } container.append(history); } + + const technical = this.document.createElement('details'); + technical.className = 'tsgd-technical'; + technical.open = technicalOpen; + const technicalSummary = this.document.createElement('summary'); + technicalSummary.textContent = 'Technical details'; + technical.append(technicalSummary); + appendFacts(this.document, technical, [ + slot.adUnitPath ? `Ad unit ${slot.adUnitPath}` : 'Ad unit: Unavailable', + ...(latest + ? [] + : [ + binding.binding.status === 'bound' + ? `Bound · ${binding.visible ? 'Visible' : 'Outside viewport'}` + : binding.binding.status === 'ambiguous' + ? `Ambiguous binding · ${binding.binding.reason ?? 'reason unavailable'}` + : `Unbound · ${binding.binding.reason ?? 'reason unavailable'}`, + ]), + ...(latest ? technicalCycleFacts(latest) : []), + ]); + container.append(technical); return container; } + private trackSelectedRequestFocus(element: HTMLElement): void { + element.addEventListener('focus', () => { + this.selectedRequestHasFocus = true; + }); + element.addEventListener('blur', () => { + this.selectedRequestHasFocus = false; + }); + } + + private locateOnPage(runtimeSlotNumber: number): void { + const binding = this.bindings.get(runtimeSlotNumber); + const element = binding.element; + if (binding.binding.status !== 'bound' || !element?.isConnected) return; + element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' }); + this.scheduleFrame(() => { + if (!this.badgeLayer?.isConnected || !element.isConnected) return; + const rectangle = element.getBoundingClientRect(); + const highlight = this.document.createElement('div'); + highlight.className = 'tsgd-highlight'; + highlight.setAttribute('aria-hidden', 'true'); + highlight.style.left = `${rectangle.left}px`; + highlight.style.top = `${rectangle.top}px`; + highlight.style.width = `${rectangle.width}px`; + highlight.style.height = `${rectangle.height}px`; + this.badgeLayer.append(highlight); + this.window.setTimeout(() => highlight.remove(), 1500); + }); + } + private button(label: string, action: () => void): HTMLButtonElement { const button = this.document.createElement('button'); button.type = 'button'; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts index b601833b8..42ce496c6 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts @@ -1,10 +1,43 @@ -import type { Size } from '../../core/types'; +import type { GptDiagnosticsAuctionType, Size } from '../../core/types'; /** Formats CSS sizes consistently across diagnostics presentation surfaces. */ export function formatSizes(sizes: ReadonlyArray): string { return sizes.map((size) => `${size[0]}×${size[1]}`).join(', '); } +/** Hide GPT's ubiquitous 1×1 placeholder from presentation while retaining it in exports. */ +export function displayableGptFillSize(size: Size | undefined): Size | undefined { + return size?.[0] === 1 && size[1] === 1 ? undefined : size; +} + +/** Human-readable auction classification shared by the badge and side panel. */ +export function auctionTypeLabel(type: GptDiagnosticsAuctionType): string { + switch (type) { + case 'ssat': + return 'SSAT: initial-page server auction'; + case 'trusted_server': + return 'TS auction: SPA server auction'; + case 'client_side': + return 'Client-side Prebid auction'; + case 'competing': + return 'Multiple auction paths observed'; + } +} + +/** Compact auction classification shared by on-page badges. */ +export function auctionTypeBadgeLabel(type: GptDiagnosticsAuctionType): string { + switch (type) { + case 'ssat': + return 'SSAT'; + case 'trusted_server': + return 'TS auction'; + case 'client_side': + return 'Prebid auction'; + case 'competing': + return 'Multiple paths'; + } +} + /** Schedules presentation work in the target window's next animation frame. */ export function scheduleFrame( window: Pick, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts index ca3348ae9..733674444 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -1,4 +1,5 @@ import type { + AuctionDiagnosticsData, GptDiagnosticsAdManagerIdentity, GptDiagnosticsAttributionIssue, GptDiagnosticsAttributionIssueReason, @@ -8,14 +9,46 @@ import type { GptDiagnosticsCoverageCounters, GptDiagnosticsCreativeFailure, GptDiagnosticsDelivery, + GptDiagnosticsAuctionFacts, + GptDiagnosticsAuctionType, + GptDiagnosticsAuctionWinner, + GptDiagnosticsPrebidAuctionEvidence, GptDiagnosticsDurations, GptDiagnosticsRequestCycle, GptDiagnosticsRequestPath, GptDiagnosticsResponseClass, + GptDiagnosticsServerAuctionTimingOrigin, GptDiagnosticsSlotHandle, GptDiagnosticsTrustedServerOpportunity, Size, } from '../../core/types'; +import { GPT_DIAGNOSTICS_CREATIVE_FAILURES } from '../../core/types'; +import type { TraceGptIdentity, TraceSlotCorrelationV1 } from '../../trace/types'; +import { validDiagnosticAuctionId, validTraceSlotRef } from '../../trace/validation'; + +import { clonePrebidAuctionEvidence } from './evidence'; + +function normalizedTraceIdentity( + value: TraceGptIdentity | undefined +): TraceGptIdentity | undefined { + try { + if (!value || Reflect.ownKeys(value).length !== 2) return undefined; + const auction = Object.getOwnPropertyDescriptor(value, 'diagnostic_auction_id'); + const slot = Object.getOwnPropertyDescriptor(value, 'slot_ref'); + if ( + !auction || + !slot || + !Object.prototype.hasOwnProperty.call(auction, 'value') || + !Object.prototype.hasOwnProperty.call(slot, 'value') || + !validDiagnosticAuctionId(auction.value) || + !validTraceSlotRef(slot.value) + ) + return undefined; + return Object.freeze({ diagnostic_auction_id: auction.value, slot_ref: slot.value }); + } catch { + return undefined; + } +} export const MAX_DIAGNOSTIC_SLOTS = 64; export const MAX_REQUEST_CYCLES_PER_SLOT = 10; @@ -99,6 +132,7 @@ interface StoreOptions { schedule?: (callback: () => void) => void; /** Deferred marker cleanup and diagnostic-window re-notification. */ defer?: (callback: () => void, delayMs: number) => void; + onTraceCorrelation?: (value: TraceSlotCorrelationV1) => void; } type RequestIntentSource = 'trusted_server_direct' | 'prebid_refresh' | 'publisher_refresh'; @@ -108,6 +142,11 @@ interface PendingSourceEvidence { trustedServerOpportunity?: GptDiagnosticsTrustedServerOpportunity; trustedServerAuctionId?: string; requestedSlotSizes?: ReadonlyArray; + auctionType?: Extract; + auctionWinner?: GptDiagnosticsAuctionWinner; + prebidAuction?: GptDiagnosticsPrebidAuctionEvidence; + serverAuctionTimings?: AuctionDiagnosticsData; + traceIdentity?: TraceGptIdentity; } interface PendingRequestIntent { @@ -165,13 +204,10 @@ function isTrustedServerOpportunity( ); } +const CREATIVE_FAILURES: ReadonlySet = new Set(GPT_DIAGNOSTICS_CREATIVE_FAILURES); + function isCreativeFailure(reason: unknown): reason is GptDiagnosticsCreativeFailure { - return ( - reason === 'missing_render_source' || - reason === 'cache_fetch_failed' || - reason === 'invalid_cache_payload' || - reason === 'response_post_failed' - ); + return typeof reason === 'string' && CREATIVE_FAILURES.has(reason); } function validDuration(start: number | undefined, end: number | undefined): number | undefined { @@ -230,6 +266,78 @@ function normalizedRequestedSlotSizes(value: unknown): ReadonlyArray | und return requestedSlotSizes.length > 0 ? Object.freeze(requestedSlotSizes) : undefined; } +function normalizedBoundedString(value: unknown, maxBytes: number): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (trimmed.length === 0 || new TextEncoder().encode(trimmed).length > maxBytes) return undefined; + return trimmed; +} + +function normalizedAuctionWinner(value: unknown): GptDiagnosticsAuctionWinner | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const candidate = value as Partial; + const bidder = normalizedBoundedString(candidate.bidder, 128); + const priceBucket = normalizedBoundedString(candidate.priceBucket, 64); + if (!bidder || !priceBucket || !/^\d+(?:\.\d+)?$/.test(priceBucket)) return undefined; + const currency = normalizedBoundedString(candidate.currency, 3)?.toUpperCase(); + return Object.freeze({ + bidder, + priceBucket, + ...(currency && /^[A-Z]{3}$/.test(currency) ? { currency } : {}), + }); +} + +const MAX_SERVER_AUCTION_TIMING_MS = 0xffffffff; + +function normalizedTiming(value: unknown): number | undefined { + return typeof value === 'number' && + Number.isFinite(value) && + value >= 0 && + value <= MAX_SERVER_AUCTION_TIMING_MS + ? value + : undefined; +} + +function normalizedServerAuctionTimings(value: unknown): AuctionDiagnosticsData | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const candidate = value as AuctionDiagnosticsData; + const timings: AuctionDiagnosticsData = { + auctionDispatchedMs: normalizedTiming(candidate.auctionDispatchedMs), + auctionResolvedMs: normalizedTiming(candidate.auctionResolvedMs), + auctionCommittedMs: normalizedTiming(candidate.auctionCommittedMs), + auctionWaitMs: normalizedTiming(candidate.auctionWaitMs), + auctionWaitPlacement: + candidate.auctionWaitPlacement === 'pre_header' || + candidate.auctionWaitPlacement === 'in_stream' + ? candidate.auctionWaitPlacement + : undefined, + }; + return Object.values(timings).some((entry) => entry !== undefined) + ? Object.freeze(timings) + : undefined; +} + +function normalizedAuctionFacts( + value: unknown +): Omit< + PendingSourceEvidence, + 'observedAtMs' | 'trustedServerOpportunity' | 'trustedServerAuctionId' | 'requestedSlotSizes' +> { + if (typeof value !== 'object' || value === null) return {}; + const candidate = value as GptDiagnosticsAuctionFacts; + const auctionType = + candidate.auctionType === 'ssat' || candidate.auctionType === 'trusted_server' + ? candidate.auctionType + : undefined; + const auctionWinner = normalizedAuctionWinner(candidate.winner); + const serverAuctionTimings = normalizedServerAuctionTimings(candidate.serverTimings); + return { + ...(auctionType ? { auctionType } : {}), + ...(auctionWinner ? { auctionWinner } : {}), + ...(serverAuctionTimings ? { serverAuctionTimings } : {}), + }; +} + function responseClass(cycle: MutableRequestCycle): GptDiagnosticsResponseClass | undefined { if (cycle.renderAtMs === undefined) return undefined; if (cycle.isEmpty === true) return 'empty'; @@ -285,6 +393,13 @@ function copyCycle(cycle: MutableRequestCycle, nowMs: number): GptDiagnosticsReq companyIds: cycle.adManager.companyIds ? [...cycle.adManager.companyIds] : undefined, } : undefined, + ...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}), + ...(cycle.prebidAuction + ? { prebidAuction: clonePrebidAuctionEvidence(cycle.prebidAuction) } + : {}), + ...(cycle.serverAuctionTimings + ? { serverAuctionTimings: { ...cycle.serverAuctionTimings } } + : {}), trustedServerCreativeFailures: cycle.trustedServerCreativeFailures ? [...cycle.trustedServerCreativeFailures] : undefined, @@ -298,6 +413,7 @@ export class GptDiagnosticsStore { private readonly now: () => number; private readonly schedule: (callback: () => void) => void; private readonly defer: (callback: () => void, delayMs: number) => void; + private readonly onTraceCorrelation?: (value: TraceSlotCorrelationV1) => void; /** Auction slot ID → GPT slot, established by the Trusted Server integration. */ private readonly trustedServerSlots = new Map(); private readonly pendingRequestIntents = new WeakMap(); @@ -329,6 +445,7 @@ export class GptDiagnosticsStore { this.now = options.now ?? (() => performance.now()); this.schedule = options.schedule ?? ((callback) => queueMicrotask(callback)); this.defer = options.defer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); + this.onTraceCorrelation = options.onTraceCorrelation; } /** Record Trusted Server's opportunity evidence for a GPT slot's next request. */ @@ -337,7 +454,9 @@ export class GptDiagnosticsStore { auctionSlotId: string, opportunity: GptDiagnosticsTrustedServerOpportunity, trustedServerAuctionId?: string, - requestedSlotSizes?: ReadonlyArray + requestedSlotSizes?: ReadonlyArray, + auctionFacts?: GptDiagnosticsAuctionFacts, + traceIdentity?: TraceGptIdentity ): void { if ( !isSlotObject(slot) || @@ -356,10 +475,20 @@ export class GptDiagnosticsStore { this.trustedServerSlots.delete(oldest); } + const auctionId = normalizedAuctionId(trustedServerAuctionId); + const identity = this.onTraceCorrelation ? normalizedTraceIdentity(traceIdentity) : undefined; + const correlatedIdentity = + identity && + validDiagnosticAuctionId(auctionId) && + auctionId === identity.diagnostic_auction_id + ? identity + : undefined; this.recordRequestIntentSource(slot, 'trusted_server_direct', { trustedServerOpportunity: opportunity, - trustedServerAuctionId: normalizedAuctionId(trustedServerAuctionId), + trustedServerAuctionId: auctionId, requestedSlotSizes: normalizedRequestedSlotSizes(requestedSlotSizes), + ...normalizedAuctionFacts(auctionFacts), + ...(correlatedIdentity ? { traceIdentity: correlatedIdentity } : {}), }); } @@ -373,6 +502,47 @@ export class GptDiagnosticsStore { } } + /** Record a completed Prebid attempt for one exact slot's next request. */ + recordPrebidAuction( + slot: GptDiagnosticsSlotLike, + auctionId: string, + targetingCandidate?: GptDiagnosticsAuctionWinner + ): void { + if (!isSlotObject(slot)) return; + const normalizedId = normalizedAuctionId(auctionId); + if (!normalizedId) return; + const candidate = normalizedAuctionWinner(targetingCandidate); + this.recordRequestIntentSource(slot, 'prebid_refresh', { + prebidAuction: Object.freeze({ + auctionId: normalizedId, + ...(candidate ? { targetingCandidate: candidate } : {}), + }), + }); + } + + /** Attach a documented Prebid win only to its retained exact slot and attempt. */ + recordPrebidWin( + slot: GptDiagnosticsSlotLike, + auctionId: string, + winner: GptDiagnosticsAuctionWinner + ): void { + if (!isSlotObject(slot)) return; + const normalizedId = normalizedAuctionId(auctionId); + const normalizedWinner = normalizedAuctionWinner(winner); + if (!normalizedId || !normalizedWinner) return; + const runtimeSlotNumber = this.slotNumbers.get(slot); + const record = runtimeSlotNumber === undefined ? undefined : this.slots.get(runtimeSlotNumber); + const matches = record?.requests.filter( + (cycle) => cycle.prebidAuction?.auctionId === normalizedId + ); + if (!matches || matches.length !== 1 || !matches[0]?.prebidAuction) return; + matches[0].prebidAuction = Object.freeze({ + ...matches[0].prebidAuction, + win: normalizedWinner, + }); + this.notify(); + } + /** Record publisher refresh observation from the private GPT diagnostics observer. */ recordPublisherRefresh(slots: GptDiagnosticsSlotLike[]): void { if (!Array.isArray(slots)) return; @@ -594,6 +764,13 @@ export class GptDiagnosticsStore { const intent = this.consumeRequestIntent(slot, timestampMs); const trustedServerEvidence = intent?.sources.get('trusted_server_direct'); const requestPath = this.requestPath(intent); + const auctionType = this.auctionType(intent, trustedServerEvidence); + const serverAuctionTimingOrigin: GptDiagnosticsServerAuctionTimingOrigin | undefined = + trustedServerEvidence?.auctionType === 'trusted_server' + ? 'spa_auction' + : trustedServerEvidence?.auctionType === 'ssat' + ? 'navigation' + : undefined; record.requests.push({ requestNumber, requestedAtMs: timestampMs, @@ -607,6 +784,17 @@ export class GptDiagnosticsStore { ...(trustedServerEvidence?.trustedServerAuctionId !== undefined ? { trustedServerAuctionId: trustedServerEvidence.trustedServerAuctionId } : {}), + ...(auctionType !== undefined ? { auctionType } : {}), + ...(trustedServerEvidence?.auctionWinner !== undefined + ? { auctionWinner: trustedServerEvidence.auctionWinner } + : {}), + ...(intent?.sources.get('prebid_refresh')?.prebidAuction !== undefined + ? { prebidAuction: intent.sources.get('prebid_refresh')?.prebidAuction } + : {}), + ...(trustedServerEvidence?.serverAuctionTimings !== undefined + ? { serverAuctionTimings: trustedServerEvidence.serverAuctionTimings } + : {}), + ...(serverAuctionTimingOrigin !== undefined ? { serverAuctionTimingOrigin } : {}), ...(trustedServerEvidence?.requestedSlotSizes !== undefined ? { requestedSlotSizes: trustedServerEvidence.requestedSlotSizes } : {}), @@ -616,6 +804,23 @@ export class GptDiagnosticsStore { } : {}), }); + if ( + trustedServerEvidence?.traceIdentity && + this.onTraceCorrelation && + Number.isSafeInteger(record.runtimeSlotNumber) && + Number.isSafeInteger(requestNumber) + ) { + try { + this.onTraceCorrelation({ + schema_version: 1, + ...trustedServerEvidence.traceIdentity, + runtime_slot_number: record.runtimeSlotNumber, + request_number: requestNumber, + }); + } catch { + // A failed trace callback must not interrupt the actual GPT cycle. + } + } this.incrementDisposition('slotRequested', 'matched'); this.notify(); } @@ -934,10 +1139,7 @@ export class GptDiagnosticsStore { private recordRequestIntentSource( slot: object, source: RequestIntentSource, - facts: Pick< - PendingSourceEvidence, - 'trustedServerOpportunity' | 'trustedServerAuctionId' | 'requestedSlotSizes' - > = {} + facts: Omit = {} ): void { const observedAtMs = this.now(); let intent = this.pendingRequestIntents.get(slot); @@ -952,6 +1154,8 @@ export class GptDiagnosticsStore { this.nextRequestIntentId += 1; this.pendingRequestIntents.set(slot, intent); } + // Replace rather than merge: a later bare refresh must not carry an + // earlier auction's evidence into the next GPT request. intent.sources.set(source, { observedAtMs, ...facts }); } @@ -1025,6 +1229,17 @@ export class GptDiagnosticsStore { return source ?? 'unattributed'; } + private auctionType( + intent: PendingRequestIntent | undefined, + trustedServerEvidence: PendingSourceEvidence | undefined + ): GptDiagnosticsAuctionType | undefined { + const trustedServerAuction = trustedServerEvidence?.auctionType; + const hasClientSideAuction = intent?.sources.get('prebid_refresh')?.prebidAuction !== undefined; + if (trustedServerAuction && hasClientSideAuction) return 'competing'; + if (hasClientSideAuction) return 'client_side'; + return trustedServerAuction; + } + private recordReplacement(record: MutableSlotRecord, cycle: MutableRequestCycle): void { const currentIndex = record.requests.indexOf(cycle); if (currentIndex <= 0) return; @@ -1093,13 +1308,15 @@ export class GptDiagnosticsStore { } } - const runtimeSlotNumber = this.nextRuntimeSlotNumber; - this.nextRuntimeSlotNumber += 1; + const runtimeSlotNumber = existingNumber ?? this.nextRuntimeSlotNumber; + if (existingNumber === undefined) { + this.nextRuntimeSlotNumber += 1; + this.slotNumbers.set(slot, runtimeSlotNumber); + } const record: MutableSlotRecord = { runtimeSlotNumber, requests: [], }; - this.slotNumbers.set(slot, runtimeSlotNumber); this.refreshSlotMetadata(record, slot); this.slots.set(runtimeSlotNumber, record); this.slotOrder.push(runtimeSlotNumber); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 82c0e8c6b..f624c98b8 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -26,7 +26,9 @@ import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import { registerApsPrebidRenderer, validateApsRenderer } from '../aps/render'; import type { AuctionBid, AuctionEid } from '../../core/auction'; -import type { AuctionSlot, TsjsApi } from '../../core/types'; +import type { AuctionSlot, GptDiagnosticsAuctionWinner, TsjsApi } from '../../core/types'; +import { getActiveTraceCollector, prepareTraceAuctionRequest } from '../../trace/runtime'; +import { createTracePending, type TracePendingHandle } from '../../trace/pending'; import { PREBID_USER_ID_MODULE_REGISTRY, @@ -999,7 +1001,11 @@ type TrustedServerBidRequest = { adUnitCode?: string; code?: string; bidId?: string; + bidderRequestId?: string; }; +function traceHookBindings(bids: TrustedServerBidRequest[]) { + return bids.map((bid) => ({ bidId: bid.bidId ?? '', bidderRequestId: bid.bidderRequestId })); +} type TrustedServerRequest = { method: 'POST'; url: string; @@ -1007,10 +1013,14 @@ type TrustedServerRequest = { options: { contentType: 'application/json' }; bidRequests: TrustedServerBidRequest[]; tsjsBidRequests: TrustedServerBidRequest[]; + tracePending?: TracePendingHandle; }; type PrebidUserIdEid = { source?: unknown; + inserter?: unknown; + matcher?: unknown; + mm?: unknown; uids?: Array<{ id?: unknown; atype?: unknown; ext?: unknown }>; }; @@ -1031,6 +1041,136 @@ function recordPrebidRefreshForDiagnostics(slots: RefreshGptSlot[]): void { } } +const MAX_PREBID_DIAGNOSTIC_ATTEMPTS = 128; +const PREBID_DIAGNOSTIC_WINDOW_MS = 30_000; + +interface PrebidDiagnosticAttempt { + slot: RefreshGptSlot; + generation: number; + expiresAtMs: number; +} + +const prebidDiagnosticAttempts = new Map(); + +function prebidDiagnosticKey(auctionId: string, adUnitCode: string): string { + return `${auctionId}\u0000${adUnitCode}`; +} + +function boundedTargetingValue( + slot: RefreshGptSlot, + key: string, + maxBytes: number +): string | undefined { + let values: string[] | undefined; + try { + values = slot.getTargeting?.(key); + } catch { + return undefined; + } + if (!Array.isArray(values) || values.length !== 1) return undefined; + const value = values[0]?.trim(); + if (!value || new TextEncoder().encode(value).length > maxBytes) return undefined; + return value; +} + +function targetingCandidate(slot: RefreshGptSlot): GptDiagnosticsAuctionWinner | undefined { + const bidder = boundedTargetingValue(slot, 'hb_bidder', 128); + const priceBucket = boundedTargetingValue(slot, 'hb_pb', 64); + if (!bidder || !priceBucket || !/^\d+(?:\.\d+)?$/.test(priceBucket)) return undefined; + return { bidder, priceBucket }; +} + +function recordCompletedPrebidAuction( + rawAuctionId: unknown, + auctionSlots: RefreshGptSlot[], + adUnitCodes: string[] +): void { + const recorder = window.tsjs?.gptDiagnosticsRecorder; + if (!recorder || typeof rawAuctionId !== 'string') return; + const auctionId = rawAuctionId; + if ( + !auctionId || + auctionId !== auctionId.trim() || + new TextEncoder().encode(auctionId).length > 256 + ) + return; + if (auctionSlots.length !== adUnitCodes.length) return; + installPrebidWinDiagnostics(); + const counts = new Map(); + for (const code of adUnitCodes) counts.set(code, (counts.get(code) ?? 0) + 1); + const nowMs = performance.now(); + for (const [key, attempt] of prebidDiagnosticAttempts) { + if (nowMs > attempt.expiresAtMs) prebidDiagnosticAttempts.delete(key); + } + const generation = window.tsjs?.navGeneration ?? 0; + for (let index = 0; index < auctionSlots.length; index += 1) { + const slot = auctionSlots[index]; + const code = adUnitCodes[index]; + if (!slot || !code || counts.get(code) !== 1) continue; + try { + recorder.recordPrebidAuction(slot, auctionId, targetingCandidate(slot)); + } catch { + // Diagnostics must not suppress the GAM request. + } + const key = prebidDiagnosticKey(auctionId, code); + prebidDiagnosticAttempts.delete(key); + prebidDiagnosticAttempts.set(key, { + slot, + generation, + expiresAtMs: nowMs + PREBID_DIAGNOSTIC_WINDOW_MS, + }); + while (prebidDiagnosticAttempts.size > MAX_PREBID_DIAGNOSTIC_ATTEMPTS) { + const oldest = prebidDiagnosticAttempts.keys().next().value; + if (oldest === undefined) break; + prebidDiagnosticAttempts.delete(oldest); + } + } +} + +function installPrebidWinDiagnostics(): void { + const diagnosticPbjs = pbjs as PbjsGlobal & { __tsDiagnosticsBidWonInstalled?: boolean }; + if (diagnosticPbjs.__tsDiagnosticsBidWonInstalled || typeof pbjs.onEvent !== 'function') return; + diagnosticPbjs.__tsDiagnosticsBidWonInstalled = true; + pbjs.onEvent('bidWon', (rawBid: unknown) => { + if (typeof rawBid !== 'object' || rawBid === null) return; + const bid = rawBid as Record; + const auctionId = typeof bid.auctionId === 'string' ? bid.auctionId : undefined; + const adUnitCode = typeof bid.adUnitCode === 'string' ? bid.adUnitCode : undefined; + if ( + !auctionId || + !adUnitCode || + (bid.latestTargetedAuctionId !== undefined && bid.latestTargetedAuctionId !== auctionId) + ) + return; + const key = prebidDiagnosticKey(auctionId, adUnitCode); + const attempt = prebidDiagnosticAttempts.get(key); + prebidDiagnosticAttempts.delete(key); + if ( + !attempt || + performance.now() > attempt.expiresAtMs || + (window.tsjs?.navGeneration ?? 0) !== attempt.generation + ) { + return; + } + const adserverTargeting = + typeof bid.adserverTargeting === 'object' && bid.adserverTargeting !== null + ? (bid.adserverTargeting as Record) + : {}; + const winner = targetingCandidate({ + getTargeting: (targetingKey) => { + const value = adserverTargeting[targetingKey]; + return typeof value === 'string' ? [value] : []; + }, + }); + if (!winner) return; + try { + window.tsjs?.gptDiagnosticsRecorder?.recordPrebidWin(attempt.slot, auctionId, winner); + } catch { + // Diagnostics must not alter delivery. + } + }); +} + function dispatchPrebidRefresh( refresh: (slots?: unknown[], opts?: unknown) => T, slots: unknown[] | undefined, @@ -1907,7 +2047,18 @@ function collectAuctionEids(): AuctionEid[] | undefined { continue; } - eids.push({ source: eid.source, uids }); + const sanitized: AuctionEid = { source: eid.source, uids }; + if (typeof eid.inserter === 'string') sanitized.inserter = eid.inserter; + if (typeof eid.matcher === 'string') sanitized.matcher = eid.matcher; + if ( + typeof eid.mm === 'number' && + Number.isInteger(eid.mm) && + eid.mm >= -2147483648 && + eid.mm <= 2147483647 + ) { + sanitized.mm = eid.mm; + } + eids.push(sanitized); } return eids.length > 0 ? eids : undefined; @@ -1987,6 +2138,7 @@ function installApsBidResponseRegistry(): void { } export function installPrebidNpm(config?: Partial): typeof pbjs { + expireRetiredPrebidEidsCookie(); // The prebid integration requires the external Prebid.js bundle // (integrations.prebid.external_bundle_url). When it failed to load (network // error, SRI mismatch) window.pbjs is still the head-injected stub with no @@ -2344,11 +2496,60 @@ export function installPrebidNpm(config?: Partial): typeof pbjs log.warn('[tsjs-prebid] Prebid bundle lacks markWinningBidAsUsed; APS renderer bids disabled'); } + const traceCollector = getActiveTraceCollector(); + let tracePending = traceCollector + ? createTracePending({ + timeout: () => pbjs.getConfig('bidderTimeout'), + }) + : undefined; + if (tracePending) { + const pending = tracePending; + const retireTracePending = (event: PageTransitionEvent): void => { + if (event.persisted) pending.clear(); + else { + pending.destroy(); + try { + window.removeEventListener('pagehide', retireTracePending); + } catch { + /* Already retired diagnostic state cannot affect page cleanup. */ + } + } + }; + try { + window.addEventListener('pagehide', retireTracePending); + } catch { + pending.destroy(); + tracePending = undefined; + } + } + + const activeTracePending = tracePending; + // Register the trustedServer adapter using pbjs.registerBidAdapter(null, code, spec) // eslint-disable-next-line @typescript-eslint/no-explicit-any (pbjs as any).registerBidAdapter(undefined, ADAPTER_CODE, { code: ADAPTER_CODE, supportedMediaTypes: ['banner'], + ...(activeTracePending + ? { + onTimeout(timedOutBids: TrustedServerBidRequest[]) { + try { + if (timedOutBids.length <= 2048) + activeTracePending.failure(traceHookBindings(timedOutBids)); + } catch { + /* Diagnostic hook input cannot interrupt Prebid callbacks. */ + } + }, + onBidderError(value: { bidderRequest?: { bids?: TrustedServerBidRequest[] } }) { + try { + const bids = value.bidderRequest?.bids ?? []; + if (bids.length <= 2048) activeTracePending.failure(traceHookBindings(bids)); + } catch { + /* Error objects and response bodies are never inspected. */ + } + }, + } + : {}), isBidRequestValid(): boolean { return true; // All requests are valid — orchestrator handles filtering @@ -2357,21 +2558,45 @@ export function installPrebidNpm(config?: Partial): typeof pbjs buildRequests(validBidRequests: TrustedServerBidRequest[]): TrustedServerRequest { log.debug('[tsjs-prebid] buildRequests', { count: validBidRequests.length }); const requestScopedBidRequests = [...validBidRequests]; - const hasUserIdApi = typeof pbjs.getUserIdsAsEids === 'function'; const auctionEids = collectAuctionEids(); - if (hasUserIdApi && !auctionEids) { - clearPrebidEidsCookie(); - } const payload = buildAdRequest(validBidRequests, { eids: auctionEids }); + const traceCarry = activeTracePending ? prepareTraceAuctionRequest(payload) : undefined; + let traceHandle: TracePendingHandle | undefined; + if (activeTracePending && traceCarry) { + try { + const refsByCode = new Map( + traceCarry.request.adUnits + .slice(0, 64) + .map((unit, index) => [unit.code, traceCarry.slotRefs[index]]) + ); + // Oversized ID collections keep exact readable-response capture, while + // declining unsupported hook association rather than guessing a prefix. + const bindings = + validBidRequests.length <= 2048 + ? validBidRequests.map((bid) => ({ + bidId: bid.bidId ?? '', + bidderRequestId: bid.bidderRequestId, + slotRef: refsByCode.get(bid.adUnitCode ?? bid.code ?? ''), + })) + : undefined; + traceHandle = activeTracePending.add(bindings, { + collector: traceCarry.collector, + slotRefs: traceCarry.slotRefs, + }); + } catch { + /* Retaining diagnostic state cannot change the ordinary request. */ + } + } return { method: 'POST', url: auctionEndpoint, - data: JSON.stringify(payload), + data: JSON.stringify(traceCarry?.request ?? payload), options: { contentType: 'application/json' }, // Keep bid requests on the request object so interpretResponse can // map bids without relying on shared mutable adapter state. bidRequests: requestScopedBidRequests, tsjsBidRequests: requestScopedBidRequests, + ...(traceHandle ? { tracePending: traceHandle } : {}), }; }, @@ -2381,6 +2606,11 @@ export function installPrebidNpm(config?: Partial): typeof pbjs request?: Partial ) { const body = serverResponse?.body; + try { + activeTracePending?.response(request?.tracePending, body); + } catch { + /* Optional diagnostic handles cannot interrupt ordinary bid parsing. */ + } log.debug('[tsjs-prebid] interpretResponse', { hasSeatbid: !!body?.seatbid }); const auctionBids = parseAuctionResponse(body); const bidRequests = request?.tsjsBidRequests ?? request?.bidRequests ?? []; @@ -2538,7 +2768,6 @@ export function installPrebidNpm(config?: Partial): typeof pbjs // and persist them as a cookie for backend sync. const originalBidsBack = opts.bidsBackHandler; opts.bidsBackHandler = function (...args: unknown[]) { - syncPrebidEidsCookie(); const registrationId = isSyntheticRefresh ? undefined : registerPendingPublisherBids(publisherAdUnitCodes, args[0], firstImpressionTokens); @@ -2793,11 +3022,10 @@ export function installRefreshHandler(timeoutMs = 1500): void { // slots, and a late callback cannot issue a second GAM request. let completed = false; let fallbackTimer: ReturnType | undefined; - function completeRefresh(applyTargeting: boolean): void { + function completeRefresh(applyTargeting: boolean, completedAuctionId?: string): void { if (completed) return; completed = true; if (fallbackTimer !== undefined) clearTimeout(fallbackTimer); - // The publisher refresh itself started before this asynchronous auction. // Reconcile its per-slot token only when the callback is ready to issue // GPT: TS may have won an already-overlapping first impression while the @@ -2832,15 +3060,27 @@ export function installRefreshHandler(timeoutMs = 1500): void { const completedAdUnitCodes = refreshAdUnitCodes.filter( (_code, index) => !callbackFilteredSlots.has(auctionSlots[index]) ); - if (applyTargeting) { + const completedAuctionSlots = auctionSlots.filter( + (slot) => !callbackFilteredSlots.has(slot) + ); + let targetingApplied = false; + if (applyTargeting && typeof pbjs.setTargetingForGPTAsync === 'function') { try { - pbjs.setTargetingForGPTAsync?.(completedAdUnitCodes); + pbjs.setTargetingForGPTAsync(completedAdUnitCodes); + targetingApplied = true; } catch (error) { log.error('[tsjs-prebid] refresh targeting failed', error); } } completedSlots.forEach(consumeGptPublisherRefreshSuppression); recordPrebidRefreshForDiagnostics(completedSlots); + if (targetingApplied) { + recordCompletedPrebidAuction( + completedAuctionId, + completedAuctionSlots, + completedAdUnitCodes + ); + } // Preserve the publisher's original refresh form unless one losing // first-impression slot was filtered. A delayed bare call must also // become explicit so slots added after the auction snapshot cannot join. @@ -2852,7 +3092,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { try { pbjs.requestBids({ adUnits, - bidsBackHandler: () => completeRefresh(true), + bidsBackHandler: (_bids, _timedOut, auctionId) => completeRefresh(true, auctionId), timeout: timeoutMs, }); // A one-shot watchdog completes the GAM request even if Prebid never @@ -2897,114 +3137,14 @@ export function installUserIdModules(): void { } } -// --------------------------------------------------------------------------- -// Prebid EID cookie sync -// --------------------------------------------------------------------------- - -/** Maximum cookie payload size in bytes (leave room for other cookies). */ -const MAX_EID_COOKIE_BYTES = 3072; - -/** Cookie name for persisted Prebid EIDs. */ -const EID_COOKIE_NAME = 'ts-eids'; - -/** Cookie max-age in seconds (1 day). */ -const EID_COOKIE_MAX_AGE = 86400; - -/** Clears any previously persisted Prebid EIDs cookie. */ -function clearPrebidEidsCookie(): void { - document.cookie = `${EID_COOKIE_NAME}=; Path=/; Secure; SameSite=Lax; Max-Age=0`; -} - -/** - * Trims an EID payload so its base64-encoded cookie fits `MAX_EID_COOKIE_BYTES`, - * dropping UIDs then whole sources from the tail. `/auction` requests still - * forward the untrimmed set in the request body (see `buildAdRequest`); this - * cap only bounds what the `ts-eids` cookie carries for routes without a body - * (e.g. `GET /_ts/page-bids`) and for backend ingestion at response finalize. - */ -function fitAuctionEidsToCookie(eids: AuctionEid[]): AuctionEid[] | undefined { - const payload = eids.map((eid) => ({ source: eid.source, uids: [...eid.uids] })); - const droppedSources = new Set(); - const trimmedSources = new Set(); - - while (payload.length > 0) { - const encoded = btoa(JSON.stringify(payload)); - if (encoded.length <= MAX_EID_COOKIE_BYTES) { - logTrimmedEidSources(droppedSources, trimmedSources); - return payload; - } - - const last = payload[payload.length - 1]; - if (last && last.uids.length > 1) { - last.uids = last.uids.slice(0, last.uids.length - 1); - trimmedSources.add(last.source); - continue; - } - - const dropped = payload.pop(); - if (dropped) { - droppedSources.add(dropped.source); - // A source trimmed and then dropped is reported once, as dropped. - trimmedSources.delete(dropped.source); - } - } - - logTrimmedEidSources(droppedSources, trimmedSources); - return undefined; -} - /** - * Logs which EID sources `fitAuctionEidsToCookie` had to drop or trim UIDs - * from, if any. Debug level: `/auction` sends the untrimmed set in the request - * body, so a trimmed cookie is expected steady state rather than a warning. + * Transition-only cleanup for the retired host-only Path=/ EID cookie. + * Remove once bundles that write it have aged past both their deployment cache + * lifetime and the old cookie's one-day max-age. New servers ignore those writes. */ -function logTrimmedEidSources(droppedSources: Set, trimmedSources: Set): void { - if (droppedSources.size === 0 && trimmedSources.size === 0) { - return; - } - const parts: string[] = []; - if (droppedSources.size > 0) { - parts.push(`dropped sources: ${[...droppedSources].join(', ')}`); - } - if (trimmedSources.size > 0) { - parts.push(`trimmed uids from sources: ${[...trimmedSources].join(', ')}`); - } - log.debug( - `[tsjs-prebid] ts-eids cookie exceeded ${MAX_EID_COOKIE_BYTES} bytes; ${parts.join('; ')}` - ); -} - -/** - * Collects EIDs from Prebid's User ID Module and writes them as a - * base64-encoded OpenRTB-style JSON cookie (`ts-eids`) for backend ingestion - * and auction fallback on later requests. - */ -function syncPrebidEidsCookie(): void { - try { - if (typeof pbjs.getUserIdsAsEids !== 'function') { - // Without Prebid EIDs to forward, stale auction fallback IDs must not persist. - clearPrebidEidsCookie(); - return; - } - - const eids = collectAuctionEids(); - if (!eids) { - clearPrebidEidsCookie(); - return; - } - - const payload = fitAuctionEidsToCookie(eids); - if (!payload) { - clearPrebidEidsCookie(); - return; - } - - const encoded = btoa(JSON.stringify(payload)); - document.cookie = `${EID_COOKIE_NAME}=${encoded}; Path=/; Secure; SameSite=Lax; Max-Age=${EID_COOKIE_MAX_AGE}`; - - log.debug(`[tsjs-prebid] synced ${payload.length} EID sources to cookie`); - } catch (err) { - log.warn('[tsjs-prebid] failed to sync EIDs cookie', err); +function expireRetiredPrebidEidsCookie(): void { + if (typeof document !== 'undefined') { + document.cookie = 'ts-eids=; Path=/; Secure; SameSite=Lax; Max-Age=0'; } } diff --git a/crates/trusted-server-js/lib/src/trace/collector.ts b/crates/trusted-server-js/lib/src/trace/collector.ts new file mode 100644 index 000000000..74dfaade1 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/collector.ts @@ -0,0 +1,147 @@ +import { addOmissions } from './omissions'; +import type { TraceCollectorSnapshot } from './report'; +import { TRACE_COVERAGE_ISSUES, type TraceCoverageIssue } from './report-types'; +import type { TraceAuctionEvidenceV1, TraceSlotCorrelationV1 } from './types'; +import { parseTraceAuctionTransport, parseTraceSlotCorrelation, traceEnum } from './validation'; + +export type TraceCollectorRead = + | { readonly ok: true; readonly value: TraceCollectorSnapshot; readonly reason?: never } + | { readonly ok: false; readonly reason: 'omission_counter_overflow'; readonly value?: never }; +/** One shared page-local collector; caller integration supplies the strict activation gate. */ +export interface TraceCollector { + recordTransport(value: unknown): void; + recordTransportFailure(): void; + recordCorrelation(value: unknown): void; + recordInterpretationIssue( + issue: 'correlation_unavailable' | 'external_client_side_unobservable' + ): void; + snapshot(): TraceCollectorRead; + captureStatus(): 'complete' | 'partial' | 'unavailable' | 'not_observed'; + destroy(): void; +} + +/** Retains bounded owned observations in memory without storage or network effects. */ +export function createTraceCollector(): TraceCollector { + let records: TraceAuctionEvidenceV1[] = []; + let sidecars: TraceSlotCorrelationV1[] = []; + const issues = new Set(); + let omittedAuctions = 0; + let omittedSidecars = 0; + let overflow = false; + let destroyed = false; + const active = () => !destroyed && !overflow; + const add = (current: number, added: number): number => { + try { + return addOmissions(current, added); + } catch { + overflow = true; + return current; + } + }; + const prune = (id: string): void => { + const retained = sidecars.filter((sidecar) => sidecar.diagnostic_auction_id !== id); + const removed = sidecars.length - retained.length; + if (!removed) return; + omittedSidecars = add(omittedSidecars, removed); + sidecars = retained; + issues.add('correlation_unavailable'); + }; + const api: TraceCollector = { + recordTransport(value) { + if (!active() || value === undefined) return; + const transport = parseTraceAuctionTransport(value); + if (!transport) { + issues.add('evidence_validation_failed'); + return; + } + if (!transport.evidence) { + issues.add('evidence_projection_failed'); + return; + } + const record = transport.evidence; + if (record.source === 'auction_api') { + issues.add('correlation_unavailable'); + prune(record.diagnostic_auction_id); + } + records.push(record); + if (records.length <= 16) return; + const evicted = records.shift()!; + omittedAuctions = add(omittedAuctions, 1); + issues.add('record_evicted'); + if ( + !records.some( + (retained) => retained.diagnostic_auction_id === evicted.diagnostic_auction_id + ) + ) + prune(evicted.diagnostic_auction_id); + }, + recordTransportFailure() { + if (active()) issues.add('evidence_transport_failed'); + }, + recordCorrelation(value) { + if (!active()) return; + const sidecar = parseTraceSlotCorrelation(value); + if ( + !sidecar || + records.some( + (record) => + record.source === 'auction_api' && + record.diagnostic_auction_id === sidecar.diagnostic_auction_id + ) + ) { + issues.add('correlation_unavailable'); + return; + } + sidecars.push(sidecar); + if (sidecars.length <= 128) return; + sidecars.shift(); + omittedSidecars = add(omittedSidecars, 1); + issues.add('correlation_unavailable'); + }, + recordInterpretationIssue(issue) { + if ( + active() && + traceEnum(issue, ['correlation_unavailable', 'external_client_side_unobservable']) + ) + issues.add(issue); + }, + snapshot() { + if (overflow) return Object.freeze({ ok: false, reason: 'omission_counter_overflow' }); + return Object.freeze({ + ok: true, + value: Object.freeze({ + serverAuctions: Object.freeze([...records]), + slotCorrelations: Object.freeze([...sidecars]), + issues: Object.freeze(TRACE_COVERAGE_ISSUES.filter((issue) => issues.has(issue))), + omittedServerAuctions: omittedAuctions, + omittedSlotCorrelations: omittedSidecars, + }), + }); + }, + captureStatus() { + const failed = [ + 'evidence_projection_failed', + 'evidence_transport_failed', + 'evidence_validation_failed', + 'record_evicted', + ].some((issue) => issues.has(issue as TraceCoverageIssue)); + return records.length + ? failed + ? 'partial' + : 'complete' + : failed + ? 'unavailable' + : 'not_observed'; + }, + destroy() { + destroyed = true; + records = []; + sidecars = []; + issues.clear(); + omittedAuctions = 0; + omittedSidecars = 0; + overflow = false; + }, + }; + return Object.freeze(api); +} diff --git a/crates/trusted-server-js/lib/src/trace/context.ts b/crates/trusted-server-js/lib/src/trace/context.ts new file mode 100644 index 000000000..3263fedb9 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/context.ts @@ -0,0 +1,164 @@ +import type { TraceRequestContextV1 } from './types'; + +/** Tests an own property without relying on newer browser object helpers. */ +export function traceOwn(value: object, key: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +/** Accepts only plain JSON-shaped objects with their own data properties. */ +export function traceObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + return Reflect.ownKeys(value).every((key) => { + if (typeof key !== 'string') return false; + const property = Object.getOwnPropertyDescriptor(value, key); + return property?.enumerable === true && traceOwn(property, 'value'); + }); +} + +/** Enforces an explicit object allowlist, including its required keys. */ +export function traceKeys( + value: Record, + required: readonly string[], + optional: readonly string[] = [] +): boolean { + return ( + required.every((key) => traceOwn(value, key)) && + Object.keys(value).every((key) => required.includes(key) || optional.includes(key)) + ); +} + +/** Validates Unicode and printable text before measuring its UTF-8 byte bound. */ +export function traceText(value: unknown, limit = 128): value is string { + if (typeof value !== 'string') return false; + for (const character of value) { + const code = character.codePointAt(0)!; + if ( + code <= 0x1f || + (code >= 0x7f && code <= 0x9f) || + (code >= 0xd800 && code <= 0xdfff) || + code === 0x061c || + code === 0x200e || + code === 0x200f || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069) + ) + return false; + } + return new TextEncoder().encode(value).length <= limit; +} + +/** Requires an actual calendar date and UTC RFC 3339 serialization. */ +export function traceTimestamp(value: unknown): value is string { + if (typeof value !== 'string') return false; + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?Z$/.exec(value); + if (!match) return false; + const [, yearText, monthText, dayText, hourText, minuteText, secondText] = match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return ( + month >= 1 && + month <= 12 && + day >= 1 && + day <= days[month - 1] && + Number(hourText) <= 23 && + Number(minuteText) <= 59 && + Number(secondText) <= 59 + ); +} + +/** Validates a finite nonnegative counter within its explicit upper bound. */ +export function traceInteger(value: unknown, maximum = Number.MAX_SAFE_INTEGER): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= maximum; +} + +function maskedAddress(value: unknown): boolean { + if (!traceText(value)) return false; + const ipv4 = /^((?:0|[1-9]\d{0,2}))\.((?:0|[1-9]\d{0,2}))\.((?:0|[1-9]\d{0,2}))\.0\/24$/.exec( + value + ); + if (ipv4) return ipv4.slice(1).every((octet) => Number(octet) <= 255); + if (!value.endsWith('::/48')) return false; + const address = value.slice(0, -3); + const prefix = address.slice(0, -2); + if (prefix !== '' && !/^[\da-f]{1,4}(?::[\da-f]{1,4}){0,2}$/.test(prefix)) return false; + try { + return new URL(`http://[${address}]/`).hostname === `[${address}]`; + } catch { + return false; + } +} + +function cookieHealth(value: unknown, validDetail: string): boolean { + if (!traceObject(value) || value.source !== 'request') return false; + if (value.state === 'absent') return traceKeys(value, ['source', 'state']); + if (!traceKeys(value, ['source', 'state', 'detail']) || typeof value.detail !== 'string') + return false; + switch (value.state) { + case 'present_valid': + return value.detail === validDetail; + case 'present_invalid': + return ['malformed', 'oversized', 'unsupported_value'].includes(value.detail); + case 'duplicate': + return value.detail === 'multiple_values'; + case 'unavailable': + return ['header_too_large', 'header_not_utf8', 'runtime_header_ambiguous'].includes( + value.detail + ); + default: + return false; + } +} + +/** Validates the exact request-context schema, including unavailable cookie facts. */ +export function validateTraceRequestContext(value: unknown): value is TraceRequestContextV1 { + try { + return requestContext(value); + } catch { + return false; + } +} + +function requestContext(value: unknown): value is TraceRequestContextV1 { + if ( + !traceObject(value) || + !traceKeys(value, ['schema_version', 'captured_at', 'network', 'cookies']) || + value.schema_version !== 1 || + !traceTimestamp(value.captured_at) || + !traceObject(value.network) || + !traceObject(value.cookies) + ) + return false; + const network = value.network; + const bounds = { + country: 2, + region: 32, + http_version: 32, + tls_protocol: 32, + tls_cipher: 32, + edge_hostname: 128, + edge_region: 128, + edge_pop: 32, + } as const; + if (!traceKeys(network, [], ['masked_client_ip', 'asn', ...Object.keys(bounds)])) return false; + if (traceOwn(network, 'masked_client_ip') && !maskedAddress(network.masked_client_ip)) + return false; + if (traceOwn(network, 'asn') && !traceInteger(network.asn, 0xffff_ffff)) return false; + for (const [key, bound] of Object.entries(bounds)) { + if (traceOwn(network, key) && !traceText(network[key], bound)) return false; + } + if (typeof network.country === 'string' && !/^[\x20-\x7e]{0,2}$/.test(network.country)) + return false; + const cookies = value.cookies; + return ( + traceKeys(cookies, ['ts_ec', 'ts_eids', 'ts_tester', 'diagnostics_session']) && + cookieHealth(cookies.ts_ec, 'valid_ec_format') && + cookieHealth(cookies.ts_eids, 'valid_eids_format') && + cookieHealth(cookies.ts_tester, 'valid_tester_value') && + cookieHealth(cookies.diagnostics_session, 'valid_diagnostics_value') + ); +} diff --git a/crates/trusted-server-js/lib/src/trace/correlation.ts b/crates/trusted-server-js/lib/src/trace/correlation.ts new file mode 100644 index 000000000..c8ecec206 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/correlation.ts @@ -0,0 +1,121 @@ +import type { TraceGptRequestCycle } from './report-types'; +import { parseTraceReport } from './report-validation'; +import type { TraceAuctionEvidenceV1, TraceAuctionSlot } from './types'; + +/** Server candidate facts remain independent when the exact browser join is unknown. */ +export interface TraceSlotEvidenceView { + readonly serverSlot: TraceAuctionSlot; + readonly correlation: 'matched' | 'unknown'; + readonly runtimeSlotNumber?: number; + readonly requestNumber?: number; + readonly cycle?: TraceGptRequestCycle; + readonly pathLabel?: string; + readonly creativeLabel?: string; +} +export interface TraceAuctionEvidenceView { + readonly evidence: TraceAuctionEvidenceV1; + readonly sourceLabel: string; + readonly relativeMilestonesLabel: 'Unavailable in v1'; + readonly providerScopeLabel: 'Auction-wide provider status; per-slot no-bid reason unavailable'; + readonly slots: readonly TraceSlotEvidenceView[]; +} +export interface TraceEvidenceView { + readonly auctions: readonly TraceAuctionEvidenceView[]; +} +function pathLabel(cycle: TraceGptRequestCycle): string { + switch (cycle.requestPath) { + case 'prebid_refresh': + case 'publisher_refresh': + return 'Browser refresh observed; winner not determined'; + case 'competing': + case 'unattributed': + return 'Multiple or unknown delivery paths'; + case 'trusted_server_direct': + return 'Trusted Server request path observed'; + default: + return 'Request path unavailable'; + } +} +const SOURCES = { + initial_navigation_ssat: 'Initial-page server auction (SSAT)', + spa_page_bids: 'Trusted Server page-refresh auction', + auction_api: 'Trusted Server auction API', +} as const; + +/** Joins only unique token pairs and exact exported GPT request-cycle identities. */ +export function joinTraceEvidence( + value: unknown, + origin: string, + capturedAtMs: number +): TraceEvidenceView | undefined { + const report = parseTraceReport(value, origin, capturedAtMs); + if (!report) return undefined; + const cycles = report.gpt_diagnostics.slots.flatMap((slot) => + slot.requests.map((cycle) => ({ runtimeSlotNumber: slot.runtimeSlotNumber, cycle })) + ); + const serverSlots = report.server_auctions.flatMap((auction) => + auction.slots.map((slot) => ({ id: auction.diagnostic_auction_id, slot })) + ); + const auctions = report.server_auctions.map((auction) => { + const id = auction.diagnostic_auction_id; + const uniqueAuction = + report.server_auctions.filter((record) => record.diagnostic_auction_id === id).length === 1; + const slots = auction.slots.map((slot): TraceSlotEvidenceView => { + const unknown: TraceSlotEvidenceView = Object.freeze({ + serverSlot: slot, + correlation: 'unknown', + }); + if (!uniqueAuction || auction.source === 'auction_api') return unknown; + if ( + serverSlots.filter((record) => record.id === id && record.slot.slot_ref === slot.slot_ref) + .length !== 1 + ) + return unknown; + const sidecars = report.slot_correlations.filter( + (sidecar) => sidecar.diagnostic_auction_id === id && sidecar.slot_ref === slot.slot_ref + ); + if (sidecars.length !== 1) return unknown; + const sidecar = sidecars[0]; + if ( + report.slot_correlations.filter( + (record) => + record.runtime_slot_number === sidecar.runtime_slot_number && + record.request_number === sidecar.request_number + ).length !== 1 + ) + return unknown; + const matches = cycles.filter( + (record) => + record.runtimeSlotNumber === sidecar.runtime_slot_number && + record.cycle.requestNumber === sidecar.request_number + ); + if (matches.length !== 1 || matches[0].cycle.trustedServerAuctionId !== id) return unknown; + const cycle = matches[0].cycle; + const participated = + cycle.isEmpty === false && + cycle.renderAtMs !== undefined && + cycle.trustedServerCreativeResponseAtMs !== undefined && + cycle.delivery === 'trusted_server_response_sent'; + return Object.freeze({ + serverSlot: slot, + correlation: 'matched', + runtimeSlotNumber: sidecar.runtime_slot_number, + requestNumber: sidecar.request_number, + cycle, + pathLabel: pathLabel(cycle), + creativeLabel: participated + ? 'Trusted Server creative rendered' + : 'Participation unconfirmed', + }); + }); + return Object.freeze({ + evidence: auction, + sourceLabel: SOURCES[auction.source], + relativeMilestonesLabel: 'Unavailable in v1' as const, + providerScopeLabel: + 'Auction-wide provider status; per-slot no-bid reason unavailable' as const, + slots: Object.freeze(slots), + }); + }); + return Object.freeze({ auctions: Object.freeze(auctions) }); +} diff --git a/crates/trusted-server-js/lib/src/trace/export.ts b/crates/trusted-server-js/lib/src/trace/export.ts new file mode 100644 index 000000000..d46972517 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/export.ts @@ -0,0 +1,87 @@ +import { parseTraceReport } from './report-validation'; + +/** One deterministic file name for every trace export surface. */ +export const TRACE_REPORT_FILENAME = 'trusted-server-trace-v1.json'; +type TraceExportFailure = { readonly status: 'failed' | 'unsupported' | 'invalid_report' }; + +/** Formats the validated public report identically for Copy, Share, and Download. */ +export function formatTraceReport( + value: unknown, + origin: string, + capturedAtMs: number +): string | undefined { + const report = parseTraceReport(value, origin, capturedAtMs); + return report ? JSON.stringify(report, null, 2) : undefined; +} + +/** Starts one explicit download and independently defers its object URL cleanup. */ +export function downloadTraceReport( + value: unknown, + origin: string, + capturedAtMs: number +): { readonly status: 'downloaded' } | TraceExportFailure { + const json = formatTraceReport(value, origin, capturedAtMs); + if (json === undefined) return { status: 'invalid_report' }; + let url: string; + try { + url = URL.createObjectURL(new Blob([json], { type: 'application/json' })); + } catch { + return { status: 'failed' }; + } + let anchor: HTMLAnchorElement | undefined; + try { + anchor = document.createElement('a'); + anchor.href = url; + anchor.download = TRACE_REPORT_FILENAME; + document.body.append(anchor); + anchor.click(); + return { status: 'downloaded' }; + } catch { + return { status: 'failed' }; + } finally { + try { + anchor?.remove(); + } catch { + /* URL cleanup still runs when DOM removal is unavailable. */ + } + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + } +} + +/** Copies validated report JSON only when the user invokes this action. */ +export async function copyTraceReport( + value: unknown, + origin: string, + capturedAtMs: number +): Promise<{ readonly status: 'copied' } | TraceExportFailure> { + const json = formatTraceReport(value, origin, capturedAtMs); + if (json === undefined) return { status: 'invalid_report' }; + try { + if (typeof navigator.clipboard?.writeText !== 'function') return { status: 'unsupported' }; + await navigator.clipboard.writeText(json); + return { status: 'copied' }; + } catch { + return { status: 'failed' }; + } +} + +/** Shares the same validated JSON file when file sharing is supported. */ +export async function shareTraceReport( + value: unknown, + origin: string, + capturedAtMs: number +): Promise<{ readonly status: 'shared' } | TraceExportFailure> { + const json = formatTraceReport(value, origin, capturedAtMs); + if (json === undefined) return { status: 'invalid_report' }; + try { + if (typeof navigator.canShare !== 'function' || typeof navigator.share !== 'function') + return { status: 'unsupported' }; + const file = new File([json], TRACE_REPORT_FILENAME, { type: 'application/json' }); + const data: ShareData = { files: [file] }; + if (!navigator.canShare(data)) return { status: 'unsupported' }; + await navigator.share(data); + return { status: 'shared' }; + } catch { + return { status: 'failed' }; + } +} diff --git a/crates/trusted-server-js/lib/src/trace/gpt.ts b/crates/trusted-server-js/lib/src/trace/gpt.ts new file mode 100644 index 000000000..a2e15be8b --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/gpt.ts @@ -0,0 +1,138 @@ +import type { AuctionSlot } from '../core/types'; + +import type { TraceCollector } from './collector'; +import type { TraceGptIdentity } from './types'; +import { traceItems } from './shape'; +import { + parseTraceAuctionTransport, + validDiagnosticAuctionId, + validTraceSlotRef, +} from './validation'; + +/** Ordinary marker plus the optional exact identity for one concrete GPT opportunity. */ +export interface TraceGptOpportunity { + readonly auctionId: string | undefined; + readonly identity?: TraceGptIdentity; +} + +/** Narrow internal facade for the shared validated SSAT/SPA slot bindings. */ +export interface TraceGptBridge { + observeTransport( + slots: readonly AuctionSlot[] | undefined, + value: unknown, + source: 'initial_navigation_ssat' | 'spa_page_bids' + ): void; + observePageBids(response: unknown, slots: readonly AuctionSlot[]): void; + identity(slot: AuctionSlot): TraceGptIdentity | undefined; + opportunity(slot: AuctionSlot, auctionId: string | undefined): TraceGptOpportunity; +} + +function ownData(value: unknown, key: string): unknown { + if (value === null || typeof value !== 'object') return undefined; + const property = Object.getOwnPropertyDescriptor(value, key); + return property && Object.prototype.hasOwnProperty.call(property, 'value') + ? property.value + : undefined; +} + +/** Creates the active core bridge shared by bootstrap and independent GPT bundles. */ +export function createTraceGptBridge(collector: TraceCollector): TraceGptBridge { + let identities: WeakMap | undefined; + function observeTraceGptTransport( + slots: readonly AuctionSlot[] | undefined, + value: unknown, + source: 'initial_navigation_ssat' | 'spa_page_bids' + ): void { + try { + // A new accepted document/navigation batch replaces slot bindings only. + // Previously consumed store intents and accumulated capture stay intact. + identities = undefined; + if (value === undefined) return; + const transport = parseTraceAuctionTransport(value); + if (!transport || (transport.evidence && transport.evidence.source !== source)) { + collector.recordTransport(null); + return; + } + collector.recordTransport(transport); + if (!transport.evidence) return; + // Correlation failures cannot discard already validated server evidence. + // Read only own array entries and the sole allowed extension member. + let delivered: unknown[] | undefined; + try { + delivered = slots === undefined ? [] : traceItems(slots, 64); + } catch { + delivered = undefined; + } + if (!delivered) { + collector.recordInterpretationIssue('correlation_unavailable'); + return; + } + const evidenceCounts = new Map(); + for (const slot of transport.evidence.slots) + evidenceCounts.set(slot.slot_ref, (evidenceCounts.get(slot.slot_ref) ?? 0) + 1); + const deliveredCounts = new Map(); + const refs = delivered.map((slot) => { + try { + const ref = ownData(ownData(ownData(slot, 'ext'), 'trusted_server'), 'trace_slot_ref'); + if (!validTraceSlotRef(ref)) return undefined; + deliveredCounts.set(ref, (deliveredCounts.get(ref) ?? 0) + 1); + return ref; + } catch { + return undefined; + } + }); + for (const [index, slot] of delivered.entries()) { + const ref = refs[index]; + if (!ref || deliveredCounts.get(ref) !== 1 || evidenceCounts.get(ref) !== 1) { + collector.recordInterpretationIssue('correlation_unavailable'); + continue; + } + identities ??= new WeakMap(); + identities.set( + slot as object, + Object.freeze({ + diagnostic_auction_id: transport.evidence.diagnostic_auction_id, + slot_ref: ref, + }) + ); + } + } catch { + // Diagnostic parsing never changes ordinary slot or bid delivery. + } + } + + /** Reads the sole optional SPA member after the navigation's existing guards. */ + function observeTraceGptPageBids(response: unknown, slots: readonly AuctionSlot[]): void { + if (response === null || typeof response !== 'object') return; + let value: unknown; + try { + const property = Object.getOwnPropertyDescriptor(response, 'trace_auction'); + value = + property && !Object.prototype.hasOwnProperty.call(property, 'value') + ? null + : property?.value; + } catch { + value = null; + } + observeTraceGptTransport(slots, value, 'spa_page_bids'); + } + + /** Returns only the validated identity bound to this exact delivered slot object. */ + return Object.freeze({ + observeTransport: observeTraceGptTransport, + observePageBids: observeTraceGptPageBids, + identity: (slot: AuctionSlot) => identities?.get(slot), + opportunity: (slot: AuctionSlot, auctionId: string | undefined): TraceGptOpportunity => { + const identity = identities?.get(slot); + if (!identity) return { auctionId }; + // Only an absent marker can inherit the validated pre-dispatch token. + // Provided markers keep the store's existing normalization and semantics. + if (auctionId === undefined) return { auctionId: identity.diagnostic_auction_id, identity }; + const normalized = typeof auctionId === 'string' ? auctionId.trim() : undefined; + if (validDiagnosticAuctionId(normalized) && normalized === identity.diagnostic_auction_id) + return { auctionId, identity }; + collector.recordInterpretationIssue('correlation_unavailable'); + return { auctionId }; + }, + }); +} diff --git a/crates/trusted-server-js/lib/src/trace/handoff.ts b/crates/trusted-server-js/lib/src/trace/handoff.ts new file mode 100644 index 000000000..fb516d4a5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/handoff.ts @@ -0,0 +1,144 @@ +import type { TsjsApi } from '../core/types'; + +import { downloadTraceReport } from './export'; +import { buildTraceReport } from './report'; +import type { TraceStoredReportV1 } from './report-types'; +import { storeTraceReport } from './storage'; + +export interface TraceHandoffState { + readonly kind: + | 'ready' + | 'capturing' + | 'capture_failed' + | 'storage_unavailable' + | 'navigation_unavailable' + | 'navigating' + | 'download_failed' + | 'downloaded'; + readonly downloadAvailable: boolean; +} +export interface TraceHandoffOptions { + readonly target: { + __tsjs_trace_active?: unknown; + __tsjs_trace_request_context?: unknown; + tsjs?: TsjsApi; + location: Pick; + sessionStorage: Pick; + }; + readonly now?: () => number; + readonly onChange?: (state: TraceHandoffState) => void; + readonly download?: typeof downloadTraceReport; +} +export interface TraceHandoff { + view(): void; + download(): void; + destroy(): void; +} + +/** Captures one explicit combined report and navigates only after its validated save. */ +export function createTraceHandoff(options: TraceHandoffOptions): TraceHandoff | undefined { + try { + if (options.target.__tsjs_trace_active !== true) return undefined; + } catch { + return undefined; + } + let destroyed = false; + let busy = false; + let recovery: TraceStoredReportV1 | undefined; + let recoveryOrigin: string | undefined; + const notify = (kind: TraceHandoffState['kind']): void => { + if (destroyed) return; + try { + options.onChange?.(Object.freeze({ kind, downloadAvailable: recovery !== undefined })); + } catch { + /* UI callbacks cannot interrupt the capture boundary. */ + } + }; + const action: TraceHandoff = { + view() { + if (destroyed || busy) return; + busy = true; + recovery = undefined; + recoveryOrigin = undefined; + notify('capturing'); + try { + if (destroyed) return; + if (options.target.__tsjs_trace_active !== true) { + notify('capture_failed'); + return; + } + const clock = (options.now ?? Date.now)(); + const origin = options.target.location.origin; + const api = options.target.tsjs; + if (typeof api?.gptDiagnostics?.snapshot !== 'function' || !api.traceEvidence) { + notify('capture_failed'); + return; + } + const collector = api.traceEvidence.snapshot(); + if (destroyed) return; + if (!collector.ok) { + notify('capture_failed'); + return; + } + const result = buildTraceReport({ + capturedAtMs: clock, + origin, + requestContext: options.target.__tsjs_trace_request_context, + gptSource: api.gptDiagnostics.snapshot(), + collector: collector.value, + }); + if (destroyed) return; + if (!result.ok) { + notify('capture_failed'); + return; + } + recovery = result.value; + recoveryOrigin = origin; + let stored = false; + try { + stored = + storeTraceReport(recovery, origin, clock, options.target.sessionStorage).status === + 'stored'; + } catch { + /* Unavailable storage preserves this valid combined recovery report. */ + } + if (destroyed) return; + if (!stored) { + notify('storage_unavailable'); + return; + } + try { + options.target.location.assign(`${origin}/_ts/trace`); + notify('navigating'); + } catch { + notify('navigation_unavailable'); + } + } catch { + recovery = undefined; + recoveryOrigin = undefined; + notify('capture_failed'); + } finally { + busy = false; + } + }, + download() { + if (destroyed || busy || !recovery || !recoveryOrigin) return; + try { + const result = (options.download ?? downloadTraceReport)( + recovery.report, + recoveryOrigin, + recovery.stored_at_ms + ); + notify(result.status === 'downloaded' ? 'downloaded' : 'download_failed'); + } catch { + notify('download_failed'); + } + }, + destroy() { + destroyed = true; + recovery = undefined; + recoveryOrigin = undefined; + }, + }; + return Object.freeze(action); +} diff --git a/crates/trusted-server-js/lib/src/trace/json.ts b/crates/trusted-server-js/lib/src/trace/json.ts new file mode 100644 index 000000000..6b876def2 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/json.ts @@ -0,0 +1,92 @@ +import { traceObject, traceOwn, traceText } from './context'; +import { traceItems } from './shape'; +const MAX_STORED_BYTES = 512 * 1024; + +/** Measures compact JSON using own data descriptors, never caller serialization. */ +export function boundedJsonShape( + value: unknown, + maximumDepth = 10, + maximumBytes = MAX_STORED_BYTES +): number | undefined { + return traceJsonSnapshot(value, maximumDepth, maximumBytes)?.bytes; +} + +/** Copies and measures the same bounded own JSON data, without caller get traps. */ +export function traceJsonSnapshot( + value: unknown, + maximumDepth = 10, + maximumBytes = MAX_STORED_BYTES +): { value: unknown; bytes: number } | undefined { + const encoder = new TextEncoder(); + let bytes = 0; + const ancestors = new Set(); + const add = (text: string): boolean => { + bytes += encoder.encode(text).length; + return bytes <= maximumBytes; + }; + const walk = (item: unknown, depth: number): { value: unknown } | undefined => { + if (item === null || typeof item === 'boolean' || typeof item === 'number') + return (typeof item !== 'number' || Number.isFinite(item)) && add(JSON.stringify(item)) + ? { value: item } + : undefined; + if (typeof item === 'string') + return traceText(item, maximumBytes) && add(JSON.stringify(item)) + ? { value: item } + : undefined; + if (depth > maximumDepth || typeof item !== 'object' || ancestors.has(item)) return undefined; + ancestors.add(item); + let okay = true; + let owned: unknown; + if (Array.isArray(item)) { + const array: unknown[] = []; + owned = array; + const items = traceItems(item, maximumBytes); + if (!items || !add('[')) okay = false; + else { + for (let index = 0; index < items.length && okay; index += 1) { + if (index !== 0 && !add(',')) { + okay = false; + break; + } + const child = walk(items[index], depth + 1); + if (!child) { + okay = false; + break; + } + array.push(child.value); + } + } + okay = okay && add(']'); + } else if (traceObject(item)) { + const object: Record = Object.create(null); + owned = object; + if (!add('{')) okay = false; + const keys = Object.keys(item); + for (let index = 0; index < keys.length && okay; index += 1) { + const key = keys[index]; + const property = Object.getOwnPropertyDescriptor(item, key); + okay = + traceText(key, 128) && + (index === 0 || add(',')) && + add(JSON.stringify(key)) && + add(':') && + property !== undefined && + traceOwn(property, 'value'); + if (okay && property) { + const child = walk(property.value, depth + 1); + if (!child) okay = false; + else object[key] = child.value; + } + } + okay = okay && add('}'); + } else okay = false; + ancestors.delete(item); + return okay ? { value: owned } : undefined; + }; + try { + const owned = walk(value, 1); + return owned ? { value: owned.value, bytes } : undefined; + } catch { + return undefined; + } +} diff --git a/crates/trusted-server-js/lib/src/trace/lifecycle.ts b/crates/trusted-server-js/lib/src/trace/lifecycle.ts new file mode 100644 index 000000000..e56c4bdac --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/lifecycle.ts @@ -0,0 +1,77 @@ +/** Deliberate server action requested by the trace setup and cleanup controls. */ +export type TraceSessionAction = 'enable' | 'end'; + +/** Separates requested mutation from what the follow-up request observed. */ +export type TraceSessionChangeResult = Readonly<{ + mutation: 'requested' | 'failed'; + observation: 'active' | 'inactive' | 'failed' | 'not_attempted'; + confirmed: boolean; +}>; + +/** Requests an explicit cookie change and verifies the resulting request state. */ +export async function changeTraceSession( + action: TraceSessionAction +): Promise { + return requestAndObserve(action, false); +} + +/** Attempts end and a separate state observation even when the end request fails. */ +export async function endTraceSessionAndObserve(): Promise { + return requestAndObserve('end', true); +} + +async function requestAndObserve( + action: TraceSessionAction, + observeFailedMutation: boolean +): Promise { + const failed: TraceSessionChangeResult = { + mutation: 'failed', + observation: 'not_attempted', + confirmed: false, + }; + if (action !== 'enable' && action !== 'end') return failed; + + let mutation: TraceSessionChangeResult['mutation'] = 'failed'; + try { + const response = await fetch(`/_ts/trace/${action}`, { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + headers: { 'X-TS-Trace-Action': action }, + }); + if (response.ok) mutation = 'requested'; + } catch { + /* The cleanup caller still attempts its independent state observation. */ + } + if (mutation === 'failed' && !observeFailedMutation) return failed; + + const unconfirmed: TraceSessionChangeResult = { + mutation, + observation: 'failed', + confirmed: false, + }; + try { + const response = await fetch('/_ts/trace/state', { + method: 'GET', + credentials: 'same-origin', + cache: 'no-store', + }); + if (!response.ok) return unconfirmed; + const state: unknown = await response.json(); + if (state === null || typeof state !== 'object' || Array.isArray(state)) { + return unconfirmed; + } + const keys = Object.keys(state); + if (keys.length !== 1 || keys[0] !== 'observed_active') return unconfirmed; + const observed = Object.getOwnPropertyDescriptor(state, 'observed_active'); + if (!observed || typeof observed.value !== 'boolean') return unconfirmed; + const active: boolean = observed.value; + return { + mutation, + observation: active ? 'active' : 'inactive', + confirmed: mutation === 'requested' && active === (action === 'enable'), + }; + } catch { + return unconfirmed; + } +} diff --git a/crates/trusted-server-js/lib/src/trace/omissions.ts b/crates/trusted-server-js/lib/src/trace/omissions.ts new file mode 100644 index 000000000..d42c297e1 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/omissions.ts @@ -0,0 +1,27 @@ +const omissionFailures = new WeakSet(); + +export function omissionOverflow(): never { + const error = new Error('omission_counter_overflow'); + omissionFailures.add(error); + throw error; +} + +/** Adds exact omissions without wrapping or saturating the public u16 counter. */ +export function addOmissions(current: number, added: number): number { + if ( + !Number.isInteger(current) || + !Number.isInteger(added) || + current < 0 || + added < 0 || + current > 65535 || + added > 65535 - current + ) { + omissionOverflow(); + } + return current + added; +} + +/** Recognizes only locally created overflow failures without inspecting caller errors. */ +export function isOmissionOverflow(value: unknown): boolean { + return typeof value === 'object' && value !== null && omissionFailures.has(value); +} diff --git a/crates/trusted-server-js/lib/src/trace/pending.ts b/crates/trusted-server-js/lib/src/trace/pending.ts new file mode 100644 index 000000000..a346cada5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/pending.ts @@ -0,0 +1,242 @@ +import type { TraceCollector } from './collector'; +import { observeTraceApiResponse, observeTraceApiFailure } from './runtime'; + +export interface TracePendingCarry { + readonly collector: TraceCollector; + readonly slotRefs: readonly string[]; +} +export interface TraceBidBinding { + readonly bidId: string; + readonly bidderRequestId?: string; + readonly slotRef?: string; +} +export interface TracePendingHandle { + readonly key: symbol; +} +export interface TracePendingOptions { + now?: () => number; + timeout?: () => unknown; + schedule?: (callback: () => void, delay: number) => ReturnType; + cancel?: (timer: ReturnType) => void; +} +export interface TracePending { + /** Undefined bindings retain exact responses while declining uninspectable ID hooks. */ + add( + bindings: readonly TraceBidBinding[] | undefined, + carry: TracePendingCarry + ): TracePendingHandle | undefined; + response(handle: TracePendingHandle | undefined, body: unknown): void; + failure(bids: readonly Pick[]): void; + clear(): void; + destroy(): void; +} +const MAX_TIMEOUT = 2 ** 31 - 1 - 5000; +const MAX_PENDING = 128; +const MAX_BINDINGS = 2048; + +function hookKey(bid: Pick): string | undefined { + const requestId = bid.bidderRequestId; + const id = bid.bidId; + if ( + typeof requestId !== 'string' || + !requestId.length || + requestId.length > 128 || + typeof id !== 'string' || + !id.length || + id.length > 128 + ) + return undefined; + // Length framing keeps the two SDK identities distinct without delimiter assumptions. + return `${requestId.length}:${requestId}${id}`; +} + +/** Checks the captured browser timeout and wall-clock expiry before retaining state. */ +export function pendingExpiry(createdAtMs: number, configuredTimeout: unknown): number | undefined { + if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0) return undefined; + const timeout = + typeof configuredTimeout === 'number' && + Number.isInteger(configuredTimeout) && + configuredTimeout >= 0 && + configuredTimeout <= MAX_TIMEOUT + ? configuredTimeout + : 3000; + const expiresAtMs = createdAtMs + timeout + 5000; + return Number.isSafeInteger(expiresAtMs) ? expiresAtMs : undefined; +} +interface PendingRecord { + readonly bindings: readonly TraceBidBinding[]; + readonly carry: TracePendingCarry; + readonly expiresAtMs: number; + readonly timer: ReturnType; + readonly unknownIds: boolean; + hookEligible: boolean; +} + +/** Owns bounded request-local tokens and retires markers before diagnostic callbacks. */ +export function createTracePending(options: TracePendingOptions = {}): TracePending { + const now = options.now ?? (() => Date.now()); + const schedule = options.schedule ?? ((callback, delay) => setTimeout(callback, delay)); + const cancel = options.cancel ?? ((timer) => clearTimeout(timer)); + const records = new Map(); + const originalIds = new Map>(); + let destroyed = false; + function remove(handle: TracePendingHandle | undefined): PendingRecord | undefined { + if (!handle) return undefined; + const record = records.get(handle); + if (!record) return undefined; + records.delete(handle); + for (const binding of record.bindings) { + const key = hookKey(binding)!; + const owners = originalIds.get(key); + owners?.delete(handle); + if (!owners?.size) originalIds.delete(key); + } + try { + cancel(record.timer); + } catch { + /* Marker retirement precedes timer cleanup. */ + } + return record; + } + function take(handle: TracePendingHandle | undefined): TracePendingCarry | undefined { + const record = remove(handle); + if (!record) return undefined; + try { + const current = now(); + return Number.isSafeInteger(current) && current >= 0 && current < record.expiresAtMs + ? record.carry + : undefined; + } catch { + return undefined; + } + } + function clear(): void { + for (const handle of records.keys()) remove(handle); + } + function noteUnavailable(record: PendingRecord): void { + try { + record.carry.collector.recordInterpretationIssue('correlation_unavailable'); + } catch { + /* A diagnostic callback cannot alter request delivery. */ + } + } + return Object.freeze({ + add(bindings: readonly TraceBidBinding[] | undefined, carry: TracePendingCarry) { + if (destroyed) return undefined; + try { + const createdAtMs = now(); + let configuredTimeout: unknown; + try { + configuredTimeout = options.timeout?.(); + } catch { + /* Use the pinned default. */ + } + const expiresAtMs = pendingExpiry(createdAtMs, configuredTimeout); + if (expiresAtMs === undefined) return undefined; + // The ID index is bounded independently of exact response ownership. + // Unknown IDs disable hook attribution while their response handle lives. + let unknownIds = bindings === undefined || bindings.length > MAX_BINDINGS; + let repeatedId = false; + const ids = new Set(); + const ownedBindings: TraceBidBinding[] = []; + const collisions = new Set(); + const refs: string[] = []; + for (let index = 0; index < Math.min(64, carry.slotRefs.length); index += 1) + refs.push(carry.slotRefs[index]!); + const acceptedRefs = new Set(refs); + if (!unknownIds && bindings) { + for (const binding of bindings) { + const id = hookKey(binding); + if (id === undefined) { + unknownIds = true; + continue; + } + for (const owner of originalIds.get(id) ?? []) collisions.add(owner); + if (ids.has(id)) { + repeatedId = true; + continue; + } + ids.add(id); + ownedBindings.push( + Object.freeze({ + bidId: binding.bidId, + bidderRequestId: binding.bidderRequestId, + ...(binding.slotRef !== undefined && acceptedRefs.has(binding.slotRef) + ? { slotRef: binding.slotRef } + : {}), + }) + ); + } + } + const affected = new Set(); + let liveUnknown = false; + for (const [handle, record] of records) { + liveUnknown ||= record.unknownIds; + if (unknownIds || collisions.has(handle)) { + record.hookEligible = false; + affected.add(record); + } + } + const handle = Object.freeze({ key: Symbol() }); + const ownedCarry = Object.freeze({ + collector: carry.collector, + slotRefs: Object.freeze(refs), + }); + const timer = schedule(() => { + remove(handle); + }, expiresAtMs - createdAtMs); + const record: PendingRecord = { + bindings: Object.freeze(ownedBindings), + carry: ownedCarry, + expiresAtMs, + timer, + unknownIds, + hookEligible: + !!ownedBindings.length && + !unknownIds && + !liveUnknown && + !repeatedId && + !collisions.size, + }; + records.set(handle, record); + for (const id of ids) { + const owners = originalIds.get(id) ?? new Set(); + owners.add(handle); + originalIds.set(id, owners); + } + if (records.size > MAX_PENDING) remove(records.keys().next().value); + if (!record.hookEligible) affected.add(record); + for (const unavailable of affected) noteUnavailable(unavailable); + return handle; + } catch { + return undefined; + } + }, + response(handle: TracePendingHandle | undefined, body: unknown) { + observeTraceApiResponse(take(handle), body); + }, + failure(bids: readonly Pick[]) { + try { + if (bids.length > MAX_BINDINGS) return; + const handles = new Set(); + for (const bid of bids) { + const id = hookKey(bid); + if (id === undefined) continue; + const owners = originalIds.get(id); + if (owners?.size === 1) { + const handle = owners.values().next().value; + if (handle && records.get(handle)?.hookEligible) handles.add(handle); + } + } + for (const handle of handles) observeTraceApiFailure(take(handle)); + } catch { + /* No raw callback input is retained or reported. */ + } + }, + clear, + destroy() { + destroyed = true; + clear(); + }, + }); +} diff --git a/crates/trusted-server-js/lib/src/trace/projection.ts b/crates/trusted-server-js/lib/src/trace/projection.ts new file mode 100644 index 000000000..15b311992 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/projection.ts @@ -0,0 +1,276 @@ +import { GPT_DIAGNOSTICS_CREATIVE_FAILURES } from '../core/types'; + +import { addOmissions, omissionOverflow, isOmissionOverflow } from './omissions'; +export { addOmissions } from './omissions'; +import { traceInteger, traceKeys, traceObject } from './context'; +import { + TRACE_CALLBACK_KINDS, + TRACE_CREATIVE_FAILURES, + type TraceGptDiagnosticsV1, +} from './report-types'; +import { TRACE_CYCLE_KEYS, traceOrigin, validateTraceGptDiagnostics } from './report-validation'; +import { traceEnum, traceItems, traceSize } from './validation'; + +export type TraceProjectionResult = + | { + readonly ok: true; + readonly value: TraceGptDiagnosticsV1; + readonly omittedNestedValues: number; + } + | { + readonly ok: false; + readonly reason: + | 'invalid_source' + | 'unsupported_source_version' + | 'omission_counter_overflow'; + }; + +/** + * Auction evidence the TS Console records on each request cycle. Trace reports + * have not been reviewed to carry it, so these members are accepted and dropped + * like `adManager`, never copied. + */ +const EXCLUDED_AUCTION_EVIDENCE_KEYS = [ + 'auctionType', + 'auctionWinner', + 'prebidAuction', + 'serverAuctionTimings', + 'serverAuctionTimingOrigin', +] as const; + +function sourceObject( + value: unknown, + required: readonly string[], + optional: readonly string[] = [] +): Record { + if (!traceObject(value) || !traceKeys(value, required, optional)) + throw new Error('invalid_source'); + return value; +} +function data(source: Record, key: string): unknown { + return Object.getOwnPropertyDescriptor(source, key)?.value; +} +function copyOptional( + source: Record, + output: Record, + key: string +): void { + // The public API includes own optional undefined values while cycles are pending. + // Omit only those values, matching their absence in its JSON export. + const value = data(source, key); + if (value !== undefined) output[key] = value; +} +function sourceItems(value: unknown, limit: number): unknown[] { + const items = traceItems(value, limit); + if (!items) throw new Error('invalid_source'); + return items; +} +function nestedItems(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new Error('invalid_source'); + const length = Object.getOwnPropertyDescriptor(value, 'length')?.value; + if (!traceInteger(length)) throw new Error('invalid_source'); + if (length > 65535 + 16) omissionOverflow(); + return sourceItems(value, 65535 + 16); +} +function copySize(value: unknown, zero = false): readonly [number, number] { + const items = sourceItems(value, 2); + if (!traceSize(items, zero)) throw new Error('invalid_source'); + return [items[0], items[1]]; +} +function durationProjection(value: unknown): Record { + const source = sourceObject( + value, + [], + [ + 'requestToResponseMs', + 'responseToRenderMs', + 'requestToRenderMs', + 'renderToLoadMs', + 'renderToViewableMs', + ] + ); + const output: Record = {}; + copyOptional(source, output, 'requestToResponseMs'); + copyOptional(source, output, 'responseToRenderMs'); + copyOptional(source, output, 'requestToRenderMs'); + copyOptional(source, output, 'renderToLoadMs'); + copyOptional(source, output, 'renderToViewableMs'); + return output; +} +function cycleProjection(value: unknown, omissions: { value: number }): Record { + const source = sourceObject( + value, + ['requestNumber', 'durations', 'incompleteSequence'], + [...TRACE_CYCLE_KEYS, 'adManager', 'previousCreativeId', ...EXCLUDED_AUCTION_EVIDENCE_KEYS] + ); + const output: Record = { + requestNumber: data(source, 'requestNumber'), + durations: durationProjection(data(source, 'durations')), + incompleteSequence: data(source, 'incompleteSequence'), + }; + copyOptional(source, output, 'requestedAtMs'); + copyOptional(source, output, 'responseAtMs'); + copyOptional(source, output, 'renderAtMs'); + copyOptional(source, output, 'loadAtMs'); + copyOptional(source, output, 'viewableAtMs'); + copyOptional(source, output, 'isEmpty'); + copyOptional(source, output, 'isBackfill'); + copyOptional(source, output, 'slotContentChanged'); + copyOptional(source, output, 'responseClass'); + copyOptional(source, output, 'requestPath'); + copyOptional(source, output, 'requestIntentId'); + copyOptional(source, output, 'trustedServerAuctionId'); + copyOptional(source, output, 'opportunityToRequestMs'); + copyOptional(source, output, 'replacedRequestNumber'); + copyOptional(source, output, 'previousRenderToRequestMs'); + copyOptional(source, output, 'creativeChanged'); + copyOptional(source, output, 'loadObservedBeforeRender'); + copyOptional(source, output, 'trustedServerOpportunity'); + copyOptional(source, output, 'trustedServerCreativeRequestAtMs'); + copyOptional(source, output, 'trustedServerCreativeResponseAtMs'); + copyOptional(source, output, 'delivery'); + if (data(source, 'size') !== undefined) output.size = copySize(data(source, 'size')); + if (data(source, 'observedSlotSize') !== undefined) + output.observedSlotSize = copySize(data(source, 'observedSlotSize'), true); + if (data(source, 'requestedSlotSizes') !== undefined) { + const values = nestedItems(data(source, 'requestedSlotSizes')); + if (!values.every((item) => traceSize(item))) throw new Error('invalid_source'); + output.requestedSlotSizes = values.slice(0, 16).map((item) => copySize(item)); + omissions.value = addOmissions(omissions.value, Math.max(0, values.length - 16)); + } + if (data(source, 'trustedServerCreativeFailures') !== undefined) { + const values = nestedItems(data(source, 'trustedServerCreativeFailures')); + if (!values.every((item) => traceEnum(item, GPT_DIAGNOSTICS_CREATIVE_FAILURES))) + throw new Error('invalid_source'); + // APS renderer reasons are valid TS Console failures that trace reports + // have not been reviewed to carry, so they are dropped rather than rejected. + const traced = values.filter((item) => traceEnum(item, TRACE_CREATIVE_FAILURES)); + output.trustedServerCreativeFailures = traced.slice(0, 16); + omissions.value = addOmissions(omissions.value, Math.max(0, traced.length - 16)); + } + return output; +} +function slotProjection(value: unknown, omissions: { value: number }): Record { + const source = sourceObject( + value, + ['runtimeSlotNumber', 'binding', 'requests'], + ['slotElementId', 'adUnitPath', 'currentVisibilityPercentage', 'maximumVisibilityPercentage'] + ); + const binding = sourceObject(data(source, 'binding'), ['status'], ['reason']); + const ownedBinding: Record = { status: data(binding, 'status') }; + copyOptional(binding, ownedBinding, 'reason'); + const output: Record = { + runtimeSlotNumber: data(source, 'runtimeSlotNumber'), + binding: ownedBinding, + requests: sourceItems(data(source, 'requests'), 10).map((item) => + cycleProjection(item, omissions) + ), + }; + copyOptional(source, output, 'currentVisibilityPercentage'); + copyOptional(source, output, 'maximumVisibilityPercentage'); + return output; +} +function callbackProjection(value: unknown): Record { + const source = sourceObject( + value, + ['kind', 'runtimeSlotNumber', 'timestampMs', 'disposition', 'reason'], + ['slotElementId'] + ); + return { + kind: data(source, 'kind'), + runtimeSlotNumber: data(source, 'runtimeSlotNumber'), + timestampMs: data(source, 'timestampMs'), + disposition: data(source, 'disposition'), + reason: data(source, 'reason'), + }; +} +function attributionProjection(value: unknown): Record { + const source = sourceObject( + value, + ['reason', 'timestampMs'], + ['runtimeSlotNumber', 'slotElementId'] + ); + const output: Record = { + reason: data(source, 'reason'), + timestampMs: data(source, 'timestampMs'), + }; + copyOptional(source, output, 'runtimeSlotNumber'); + return output; +} +function coverageProjection(value: unknown): Record { + const source = sourceObject(value, TRACE_CALLBACK_KINDS); + const output: Record = {}; + for (const kind of TRACE_CALLBACK_KINDS) { + const counters = sourceObject(data(source, kind), [ + 'observed', + 'matched', + 'unmatched', + 'ambiguous', + ]); + output[kind] = { + observed: data(counters, 'observed'), + matched: data(counters, 'matched'), + unmatched: data(counters, 'unmatched'), + ambiguous: data(counters, 'ambiguous'), + }; + } + return output; +} +function metadataProjection(value: unknown): Record { + const source = sourceObject( + value, + ['droppedCallbacks', 'evictedSlots', 'evictedRequestCycles'], + ['droppedAttributionIssues'] + ); + const output: Record = { + droppedCallbacks: data(source, 'droppedCallbacks'), + evictedSlots: data(source, 'evictedSlots'), + evictedRequestCycles: data(source, 'evictedRequestCycles'), + }; + copyOptional(source, output, 'droppedAttributionIssues'); + return output; +} +function freezeProjection(value: unknown): void { + if (typeof value !== 'object' || value === null) return; + Object.values(value).forEach(freezeProjection); + Object.freeze(value); +} +/** Copies only explicitly allowlisted TS Console facts into the public GPT model. */ +export function projectTraceGptDiagnostics(value: unknown, origin: string): TraceProjectionResult { + try { + const source = sourceObject( + value, + ['version', 'capturedAt', 'page', 'slots', 'callbackIssues', 'coverage', 'metadata'], + ['attributionIssues'] + ); + if (data(source, 'version') !== 1) return { ok: false, reason: 'unsupported_source_version' }; + const page = sourceObject(data(source, 'page'), ['origin', 'pathname']); + if (typeof data(page, 'pathname') !== 'string') throw new Error('invalid_source'); + const canonicalOrigin = traceOrigin(data(page, 'origin')); + if (canonicalOrigin !== origin || traceOrigin(origin) !== origin) + throw new Error('invalid_source'); + const omissions = { value: 0 }; + const projection: Record = { + schema_version: 1, + source_schema_version: 1, + capturedAt: data(source, 'capturedAt'), + page: { origin: canonicalOrigin, pathname: '/[redacted]' }, + slots: sourceItems(data(source, 'slots'), 64).map((item) => slotProjection(item, omissions)), + callbackIssues: sourceItems(data(source, 'callbackIssues'), 128).map(callbackProjection), + coverage: coverageProjection(data(source, 'coverage')), + metadata: metadataProjection(data(source, 'metadata')), + }; + if (data(source, 'attributionIssues') !== undefined) + projection.attributionIssues = sourceItems(data(source, 'attributionIssues'), 128).map( + attributionProjection + ); + if (!validateTraceGptDiagnostics(projection, origin)) throw new Error('invalid_source'); + freezeProjection(projection); + return { ok: true, value: projection, omittedNestedValues: omissions.value }; + } catch (error) { + return { + ok: false, + reason: isOmissionOverflow(error) ? 'omission_counter_overflow' : 'invalid_source', + }; + } +} diff --git a/crates/trusted-server-js/lib/src/trace/report-details.ts b/crates/trusted-server-js/lib/src/trace/report-details.ts new file mode 100644 index 000000000..d5b20c6c6 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/report-details.ts @@ -0,0 +1,309 @@ +import type { TraceEvidenceView } from './correlation'; +import { + capturedLabel, + collapsible, + element, + facts, + healthText, + label, + millis, + section, + sizes, +} from './report-dom'; +import type { TraceGptRequestCycle, TraceReportV1 } from './report-types'; +import type { TraceRequestContextV1 } from './types'; + +/** Collapsed detail sections that keep every retained trace field reachable. */ +const NETWORK_LABELS: Record = { + masked_client_ip: 'Approximate network identifier', + country: 'Country', + region: 'Region', + asn: 'ASN', + http_version: 'HTTP version', + tls_protocol: 'TLS protocol', + tls_cipher: 'TLS cipher', + edge_hostname: 'Edge hostname', + edge_region: 'Edge region', + edge_pop: 'Edge POP', +}; +const CYCLE_FACTS: Record< + Exclude< + keyof TraceGptRequestCycle, + | 'requestNumber' + | 'durations' + | 'requestedSlotSizes' + | 'size' + | 'observedSlotSize' + | 'trustedServerAuctionId' + | 'trustedServerCreativeFailures' + | 'isEmpty' + | 'requestPath' + >, + string +> = { + requestedAtMs: 'Requested (browser clock)', + responseAtMs: 'Response received (browser clock)', + renderAtMs: 'Rendered (browser clock)', + loadAtMs: 'Loaded (browser clock)', + viewableAtMs: 'Viewable (browser clock)', + isBackfill: 'Backfill observed', + slotContentChanged: 'Slot content changed', + incompleteSequence: 'Incomplete sequence', + responseClass: 'GPT response class', + requestIntentId: 'Browser request intent number', + opportunityToRequestMs: 'Opportunity to request', + replacedRequestNumber: 'Replaced request number', + previousRenderToRequestMs: 'Previous render to request', + creativeChanged: 'Creative changed', + loadObservedBeforeRender: 'Load observed before render', + trustedServerOpportunity: 'Trusted Server candidate opportunity', + trustedServerCreativeRequestAtMs: 'Creative bridge request (browser clock)', + trustedServerCreativeResponseAtMs: 'Creative bridge response (browser clock)', + delivery: 'Creative delivery observation', +}; + +export function renderRequestDetails( + root: Document, + parent: HTMLElement, + runtimeSlotNumber: number, + cycle: TraceGptRequestCycle, + joined: TraceEvidenceView | undefined +): void { + const request = element(root, 'details'); + request.className = 'trace-request'; + request.append(element(root, 'summary', `Request ${cycle.requestNumber}`)); + const matches = (joined?.auctions ?? []) + .flatMap((auction) => auction.slots) + .filter( + (entry) => + entry.correlation === 'matched' && + entry.runtimeSlotNumber === runtimeSlotNumber && + entry.requestNumber === cycle.requestNumber + ); + facts(root, request, [ + ['Correlation', matches.length === 1 ? 'Matched server slot' : 'Correlation unknown'], + [ + 'Creative participation', + matches.length === 1 ? matches[0].creativeLabel : 'Participation unconfirmed', + ], + [ + 'GPT fill observation', + cycle.isEmpty === undefined ? 'Unknown' : cycle.isEmpty ? 'Empty' : 'Filled', + ], + [ + 'Browser request path', + cycle.requestPath === undefined ? 'Unavailable' : label(cycle.requestPath), + ], + ['Requested sizes', sizes(cycle.requestedSlotSizes)], + ['Rendered size', cycle.size ? sizes([cycle.size]) : 'Unavailable'], + [ + 'Observed CSS box size', + cycle.observedSlotSize ? sizes([cycle.observedSlotSize]) : 'Unavailable', + ], + ]); + const rows = Object.entries(CYCLE_FACTS).map(([key, name]): readonly [string, unknown] => { + const value = cycle[key as keyof typeof CYCLE_FACTS]; + return [ + name, + typeof value === 'string' + ? label(value) + : key.endsWith('Ms') + ? millis(value as number | undefined) + : value, + ]; + }); + facts(root, request, rows); + facts(root, request, [ + ['Request to response', millis(cycle.durations.requestToResponseMs)], + ['Response to render', millis(cycle.durations.responseToRenderMs)], + ['Request to render', millis(cycle.durations.requestToRenderMs)], + ['Render to load', millis(cycle.durations.renderToLoadMs)], + ['Render to viewable', millis(cycle.durations.renderToViewableMs)], + [ + 'Creative bridge failures', + cycle.trustedServerCreativeFailures === undefined + ? 'Unavailable' + : cycle.trustedServerCreativeFailures.length === 0 + ? 'Not observed' + : cycle.trustedServerCreativeFailures.map(label).join(', '), + ], + ]); + parent.append(request); +} + +export function renderAuctions( + root: Document, + article: HTMLElement, + report: TraceReportV1, + joined: TraceEvidenceView | undefined +): void { + // Stays expanded: auction outcomes are primary evidence, not optional detail. + const auctions = section(root, article, 'Server auctions', 'trace-report-auctions'); + auctions.append( + element( + root, + 'p', + 'Returned bid counts may overlap between slots and must not be summed as unique bids.' + ) + ); + if (!report.server_auctions.length) + auctions.append(element(root, 'p', 'Not observed. This does not mean no server auction ran.')); + for (const [index, view] of (joined?.auctions ?? []).entries()) { + const entry = element(root, 'details'); + entry.className = 'trace-auction'; + entry.open = true; + entry.append(element(root, 'summary', `Auction ${index + 1}: ${view.sourceLabel}`)); + entry.append( + element(root, 'p', 'Produced by Trusted Server; copied through an untrusted browser snapshot') + ); + facts(root, entry, [ + ['Terminal outcome', label(view.evidence.terminal_status)], + [ + 'Terminal reason', + view.evidence.terminal_reason === undefined + ? 'Unavailable' + : label(view.evidence.terminal_reason), + ], + ['Server auction-local elapsed time', millis(view.evidence.total_time_ms)], + ['Request-relative milestones', view.relativeMilestonesLabel], + ]); + entry.append(element(root, 'p', view.providerScopeLabel)); + for (const provider of view.evidence.provider_calls) + facts(root, entry, [ + [`Provider call ${provider.provider_number}`, label(provider.role)], + ['Call outcome', label(provider.status)], + ['Provider-local elapsed time', millis(provider.response_time_ms)], + ['Returned bid count', provider.returned_bid_count], + ]); + if (!view.evidence.provider_calls.length) + entry.append(element(root, 'p', 'Provider calls: Not observed')); + for (const slot of view.slots) { + const box = element(root, 'div'); + box.className = 'trace-server-slot'; + box.append(element(root, 'h3', `Server slot ${slot.serverSlot.slot_number}`)); + facts(root, box, [ + ['Requested sizes', sizes(slot.serverSlot.requested_sizes)], + ['Returned bid count', slot.serverSlot.returned_bid_count], + ['Candidate', label(slot.serverSlot.candidate)], + [ + 'Selected creative size', + slot.serverSlot.selected_creative_size + ? sizes([slot.serverSlot.selected_creative_size]) + : 'Unavailable', + ], + [ + 'Correlation', + slot.correlation === 'matched' + ? `Matched GPT slot ${slot.runtimeSlotNumber}, request ${slot.requestNumber}` + : 'Correlation unknown', + ], + ['Browser request path', slot.pathLabel ?? 'Unknown'], + ['Creative participation', slot.creativeLabel ?? 'Participation unconfirmed'], + ]); + entry.append(box); + } + facts(root, entry, [ + ['Provider calls omitted', view.evidence.truncation.omitted_provider_calls], + ['Server slots omitted', view.evidence.truncation.omitted_slots], + ['Nested values omitted', view.evidence.truncation.omitted_nested_values], + ]); + auctions.append(entry); + } +} + +export function renderRequestContext( + root: Document, + article: HTMLElement, + report: TraceReportV1 +): void { + const network = collapsible(root, article, 'Publisher request', 'trace-report-request'); + network.append( + element(root, 'p', 'Produced by Trusted Server; copied through an untrusted browser snapshot'), + element( + root, + 'p', + 'These facts describe the traced publisher document. Masked identifiers are approximate and may still identify a network.' + ) + ); + facts(root, network, [ + ['Document request captured at', capturedLabel(report.request_context.captured_at)], + ...Object.entries(NETWORK_LABELS).map( + ([key, name]) => + [ + name, + report.request_context.network[key as keyof TraceRequestContextV1['network']], + ] as const + ), + ]); + const cookies = collapsible(root, article, 'Cookie health', 'trace-report-cookies'); + cookies.append( + element( + root, + 'p', + 'Produced by Trusted Server; copied through an untrusted browser snapshot. Only cookie shape visible in this request is inspected; values and browser attributes are excluded.' + ) + ); + facts(root, cookies, [ + ['Edge Cookie', healthText(report.request_context.cookies.ts_ec)], + ['External IDs', healthText(report.request_context.cookies.ts_eids)], + ['Tester', healthText(report.request_context.cookies.ts_tester)], + ['Diagnostics session', healthText(report.request_context.cookies.diagnostics_session)], + ]); +} + +export function renderCoverage(root: Document, article: HTMLElement, report: TraceReportV1): void { + const coverage = collapsible(root, article, 'Coverage and ambiguity', 'trace-report-coverage'); + facts(root, coverage, [ + ['Server capture', label(report.auction_coverage.capture_status)], + [ + 'Capture and interpretation limits', + report.auction_coverage.issues.length + ? report.auction_coverage.issues.map(label).join(', ') + : 'None recorded', + ], + ['Correlation sidecars retained', report.slot_correlations.length], + ]); + for (const [kind, counts] of Object.entries(report.gpt_diagnostics.coverage)) + facts(root, coverage, [ + [ + label(kind), + `${counts.observed} observed; ${counts.matched} matched; ${counts.unmatched} unmatched; ${counts.ambiguous} ambiguous`, + ], + ]); + for (const [key, count] of Object.entries(report.truncation)) + facts(root, coverage, [[label(key), count]]); + for (const [key, count] of Object.entries(report.gpt_diagnostics.metadata)) + facts(root, coverage, [[`GPT ${label(key)}`, count]]); + for (const issue of report.gpt_diagnostics.callbackIssues) + facts(root, coverage, [ + ['Callback issue', label(issue.kind)], + ['GPT slot', issue.runtimeSlotNumber], + ['Browser clock', millis(issue.timestampMs)], + ['Disposition', label(issue.disposition)], + ['Reason', label(issue.reason)], + ]); + for (const issue of report.gpt_diagnostics.attributionIssues ?? []) + facts(root, coverage, [ + ['Creative attribution issue', label(issue.reason)], + ['GPT slot', issue.runtimeSlotNumber], + ['Browser clock', millis(issue.timestampMs)], + ]); + for (const [index, record] of report.slot_correlations.entries()) { + const entry = element(root, 'details'); + entry.append(element(root, 'summary', `Correlation record ${index + 1}`)); + entry.append( + element( + root, + 'p', + 'Browser observed correlation. These opaque references permit a join only when unique and consistent; duplicate or conflicting records remain unknown.' + ) + ); + facts(root, entry, [ + ['Auction reference', record.diagnostic_auction_id], + ['Slot reference', record.slot_ref], + ['GPT slot number', record.runtime_slot_number], + ['GPT request number', record.request_number], + ]); + coverage.append(entry); + } +} diff --git a/crates/trusted-server-js/lib/src/trace/report-dom.ts b/crates/trusted-server-js/lib/src/trace/report-dom.ts new file mode 100644 index 000000000..b28451267 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/report-dom.ts @@ -0,0 +1,132 @@ +import type { CookieHealth } from './types'; + +/** Shared DOM and label primitives for the trace viewer; all report text uses textContent. */ +export function label(value: string): string { + const labels: Record = { + no_bid: 'No bid returned', + no_candidate: 'No candidate', + selected: 'Candidate selected', + selected_unrenderable: 'Selected candidate could not be rendered', + trusted_server_direct: 'Trusted Server request path observed', + prebid_refresh: 'Browser refresh observed; winner not determined', + publisher_refresh: 'Browser refresh observed; winner not determined', + competing: 'Multiple or unknown delivery paths', + unattributed: 'Multiple or unknown delivery paths', + trusted_server_response_sent: 'Trusted Server creative response sent', + trusted_server_selected: 'Trusted Server candidate selected; render unconfirmed', + candidate_unconfirmed: 'Candidate unconfirmed', + not_observed: 'Not observed', + unknown: 'Unknown', + unavailable: 'Unavailable', + }; + return ( + labels[value] ?? + value + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/_/g, ' ') + .replace(/^./, (first) => first.toUpperCase()) + ); +} +export function valueText(value: unknown): string { + if (value === undefined) return 'Unavailable'; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + if (typeof value === 'number') return String(value); + if (typeof value === 'string') return value; + return 'Unavailable'; +} +export function healthText(health: CookieHealth): string { + if (health.state === 'absent') return 'Not present in this request'; + if (health.state === 'present_valid') return 'Valid shape observed'; + if (health.state === 'duplicate') return 'Multiple values observed'; + if (health.state === 'present_invalid') + return health.detail === 'oversized' + ? 'Invalid shape — too long' + : health.detail === 'unsupported_value' + ? 'Invalid shape — unsupported value' + : 'Invalid shape observed'; + if (health.detail === 'runtime_header_ambiguous') + return 'Unavailable — runtime-visible cookies could not be reliably inspected'; + return health.detail === 'header_too_large' + ? 'Unavailable — the visible cookie header was too large' + : 'Unavailable — the visible cookie header was not valid text'; +} +export function element( + root: Document, + tag: K, + text?: string +): HTMLElementTagNameMap[K] { + const node = root.createElement(tag); + if (text !== undefined) node.textContent = text; + return node; +} +export function section(root: Document, parent: Element, title: string, id?: string): HTMLElement { + const node = element(root, 'section'); + if (id) node.id = id; + node.append(element(root, 'h2', title)); + parent.append(node); + return node; +} +export function facts( + root: Document, + parent: Element, + rows: readonly (readonly [string, unknown])[] +): void { + const list = element(root, 'dl'); + const unavailable = element(root, 'dl'); + let unavailableCount = 0; + for (const [name, value] of rows) { + const text = valueText(value); + const target = text === 'Unavailable' ? unavailable : list; + if (target === unavailable) unavailableCount += 1; + target.append(element(root, 'dt', name), element(root, 'dd', text)); + } + if (list.childElementCount) parent.append(list); + if (unavailableCount) { + // Keep sparse fields reachable without making every slot scroll through them. + const more = element(root, 'details'); + more.className = 'trace-unavailable'; + more.append( + element( + root, + 'summary', + unavailableCount === 1 ? '1 field unavailable' : `${unavailableCount} fields unavailable` + ), + unavailable + ); + parent.append(more); + } +} +export function sizes(value?: readonly (readonly [number, number])[]): string { + return value === undefined + ? 'Unavailable' + : value.length === 0 + ? 'Not observed' + : value.map(([width, height]) => `${width} × ${height}`).join(', '); +} +export function millis(value?: number): string { + // Browser clocks carry sub-millisecond float noise; one decimal stays readable. + return value === undefined ? 'Unavailable' : `${Number(value.toFixed(1))} ms`; +} + +/** A collapsed, titled detail section; titles stay visible to Find and screen readers. */ +export function collapsible( + root: Document, + parent: Element, + title: string, + id?: string +): HTMLElement { + const node = element(root, 'details'); + node.className = 'trace-section'; + if (id) node.id = id; + const summary = element(root, 'summary'); + summary.append(element(root, 'h2', title)); + node.append(summary); + parent.append(node); + return node; +} + +/** Formats an ISO capture instant as a readable UTC date and time; other text passes through. */ +export function capturedLabel(value: string): string { + const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(value); + return match ? `${match[1]} ${match[2]} UTC` : value; +} diff --git a/crates/trusted-server-js/lib/src/trace/report-types.ts b/crates/trusted-server-js/lib/src/trace/report-types.ts new file mode 100644 index 000000000..1032f2f51 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/report-types.ts @@ -0,0 +1,189 @@ +import type { + TraceAuctionEvidenceV1, + TraceRequestContextV1, + TraceSlotCorrelationV1, +} from './types'; + +export const TRACE_CALLBACK_KINDS = [ + 'slotRequested', + 'slotResponseReceived', + 'slotRenderEnded', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', +] as const; +export const TRACE_BINDING_REASONS = [ + 'missing_slot_element_id', + 'missing_element', + 'duplicate_dom_id', + 'dom_uniqueness_unverifiable', + 'duplicate_gpt_slot_id', +] as const; +export const TRACE_CALLBACK_REASONS = [ + 'invalid_event_order', + 'missing_response_before_render', + 'invalid_visibility_percentage', + 'evicted_slot', + 'no_compatible_request_cycle', + 'overlapping_request_cycles', +] as const; +export const TRACE_ATTRIBUTION_REASONS = [ + 'creative_request_without_slot', + 'creative_request_without_cycle', + 'creative_request_ambiguous_cycle', + 'creative_request_on_empty_cycle', + 'creative_attempt_capacity', + 'creative_attempt_unknown', + 'creative_attempt_expired', + 'creative_attempt_evicted', +] as const; +export const TRACE_RESPONSE_CLASSES = [ + 'empty', + 'backfill', + 'reservation', + 'unclassified_non_empty', +] as const; +export const TRACE_REQUEST_PATHS = [ + 'trusted_server_direct', + 'prebid_refresh', + 'publisher_refresh', + 'competing', + 'unattributed', +] as const; +export const TRACE_OPPORTUNITIES = [ + 'renderable_candidate', + 'unrenderable_candidate', + 'no_candidate', +] as const; +export const TRACE_CREATIVE_FAILURES = [ + 'missing_render_source', + 'cache_fetch_failed', + 'invalid_cache_payload', + 'response_post_failed', +] as const; +export const TRACE_DELIVERIES = [ + 'trusted_server_response_sent', + 'trusted_server_selected', + 'candidate_unconfirmed', + 'no_candidate', + 'unknown', + 'pending', + 'not_applicable', +] as const; +export const TRACE_COVERAGE_ISSUES = [ + 'evidence_projection_failed', + 'evidence_transport_failed', + 'evidence_validation_failed', + 'record_evicted', + 'correlation_unavailable', + 'external_client_side_unobservable', +] as const; +export type TraceCoverageIssue = (typeof TRACE_COVERAGE_ISSUES)[number]; + +/** Public request cycle facts, with delivery identifiers deliberately excluded. */ +export interface TraceGptRequestCycle { + readonly requestNumber: number; + readonly requestedAtMs?: number; + readonly responseAtMs?: number; + readonly renderAtMs?: number; + readonly loadAtMs?: number; + readonly viewableAtMs?: number; + readonly durations: { + readonly requestToResponseMs?: number; + readonly responseToRenderMs?: number; + readonly requestToRenderMs?: number; + readonly renderToLoadMs?: number; + readonly renderToViewableMs?: number; + }; + readonly isEmpty?: boolean; + readonly requestedSlotSizes?: readonly (readonly [number, number])[]; + readonly size?: readonly [number, number]; + readonly observedSlotSize?: readonly [number, number]; + readonly isBackfill?: boolean; + readonly slotContentChanged?: boolean; + readonly incompleteSequence: boolean; + readonly responseClass?: (typeof TRACE_RESPONSE_CLASSES)[number]; + readonly requestPath?: (typeof TRACE_REQUEST_PATHS)[number]; + readonly requestIntentId?: number; + readonly trustedServerAuctionId?: string; + readonly opportunityToRequestMs?: number; + readonly replacedRequestNumber?: number; + readonly previousRenderToRequestMs?: number; + readonly creativeChanged?: boolean; + readonly loadObservedBeforeRender?: boolean; + readonly trustedServerOpportunity?: (typeof TRACE_OPPORTUNITIES)[number]; + readonly trustedServerCreativeRequestAtMs?: number; + readonly trustedServerCreativeResponseAtMs?: number; + readonly trustedServerCreativeFailures?: readonly (typeof TRACE_CREATIVE_FAILURES)[number][]; + readonly delivery?: (typeof TRACE_DELIVERIES)[number]; +} + +/** Trace-owned GPT snapshot, sourced from the explicitly supported TS Console version. */ +export interface TraceGptDiagnosticsV1 { + readonly schema_version: 1; + readonly source_schema_version: 1; + readonly capturedAt: string; + readonly page: { readonly origin: string; readonly pathname: '/[redacted]' }; + readonly slots: readonly { + readonly runtimeSlotNumber: number; + readonly binding: { + readonly status: 'bound' | 'unbound' | 'ambiguous'; + readonly reason?: (typeof TRACE_BINDING_REASONS)[number]; + }; + readonly currentVisibilityPercentage?: number; + readonly maximumVisibilityPercentage?: number; + readonly requests: readonly TraceGptRequestCycle[]; + }[]; + readonly callbackIssues: readonly { + readonly kind: (typeof TRACE_CALLBACK_KINDS)[number]; + readonly runtimeSlotNumber: number; + readonly timestampMs: number; + readonly disposition: 'matched' | 'unmatched' | 'ambiguous'; + readonly reason: (typeof TRACE_CALLBACK_REASONS)[number]; + }[]; + readonly attributionIssues?: readonly { + readonly reason: (typeof TRACE_ATTRIBUTION_REASONS)[number]; + readonly timestampMs: number; + readonly runtimeSlotNumber?: number; + }[]; + readonly coverage: Readonly< + Record< + (typeof TRACE_CALLBACK_KINDS)[number], + Readonly<{ observed: number; matched: number; unmatched: number; ambiguous: number }> + > + >; + readonly metadata: { + readonly droppedCallbacks: number; + readonly droppedAttributionIssues?: number; + readonly evictedSlots: number; + readonly evictedRequestCycles: number; + }; +} + +/** Combined immutable public report used by the viewer and every export. */ +export interface TraceReportV1 { + readonly schema_version: 1; + readonly captured_at: string; + readonly request_context: TraceRequestContextV1; + readonly server_auctions: readonly TraceAuctionEvidenceV1[]; + readonly slot_correlations: readonly TraceSlotCorrelationV1[]; + readonly gpt_diagnostics: TraceGptDiagnosticsV1; + readonly auction_coverage: { + readonly capture_status: 'complete' | 'partial' | 'unavailable' | 'not_observed'; + readonly issues: readonly TraceCoverageIssue[]; + }; + readonly truncation: { + readonly omitted_server_auctions: number; + readonly omitted_slot_correlations: number; + readonly omitted_request_cycles: number; + readonly omitted_callback_issues: number; + readonly omitted_attribution_issues: number; + readonly omitted_nested_values: number; + }; +} + +/** Exact origin-local storage envelope; its timestamp is independent of request time. */ +export interface TraceStoredReportV1 { + readonly stored_at_ms: number; + readonly report: TraceReportV1; +} diff --git a/crates/trusted-server-js/lib/src/trace/report-validation.ts b/crates/trusted-server-js/lib/src/trace/report-validation.ts new file mode 100644 index 000000000..f93ea9481 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/report-validation.ts @@ -0,0 +1,504 @@ +import { boundedJsonShape, traceJsonSnapshot } from './json'; +export { boundedJsonShape, traceJsonSnapshot } from './json'; +import { + traceInteger, + traceKeys, + traceObject, + traceOwn, + traceText, + traceTimestamp, + validateTraceRequestContext, +} from './context'; +import { + TRACE_ATTRIBUTION_REASONS, + TRACE_BINDING_REASONS, + TRACE_CALLBACK_KINDS, + TRACE_CALLBACK_REASONS, + TRACE_COVERAGE_ISSUES, + TRACE_CREATIVE_FAILURES, + TRACE_DELIVERIES, + TRACE_OPPORTUNITIES, + TRACE_REQUEST_PATHS, + TRACE_RESPONSE_CLASSES, + type TraceGptDiagnosticsV1, + type TraceReportV1, + type TraceStoredReportV1, +} from './report-types'; +import { + traceEnum, + traceItems, + traceSize, + validDiagnosticAuctionId, + validateTraceAuctionEvidence, + validateTraceSlotCorrelation, +} from './validation'; + +export const TRACE_CYCLE_KEYS = [ + 'requestNumber', + 'requestedAtMs', + 'responseAtMs', + 'renderAtMs', + 'loadAtMs', + 'viewableAtMs', + 'durations', + 'isEmpty', + 'requestedSlotSizes', + 'size', + 'observedSlotSize', + 'isBackfill', + 'slotContentChanged', + 'incompleteSequence', + 'responseClass', + 'requestPath', + 'requestIntentId', + 'trustedServerAuctionId', + 'opportunityToRequestMs', + 'replacedRequestNumber', + 'previousRenderToRequestMs', + 'creativeChanged', + 'loadObservedBeforeRender', + 'trustedServerOpportunity', + 'trustedServerCreativeRequestAtMs', + 'trustedServerCreativeResponseAtMs', + 'trustedServerCreativeFailures', + 'delivery', +] as const; +const DURATION_KEYS = [ + 'requestToResponseMs', + 'responseToRenderMs', + 'requestToRenderMs', + 'renderToLoadMs', + 'renderToViewableMs', +] as const; +const TIME_KEYS = [ + 'requestedAtMs', + 'responseAtMs', + 'renderAtMs', + 'loadAtMs', + 'viewableAtMs', + 'opportunityToRequestMs', + 'previousRenderToRequestMs', + 'trustedServerCreativeRequestAtMs', + 'trustedServerCreativeResponseAtMs', +] as const; +const BOOLEAN_KEYS = [ + 'isEmpty', + 'isBackfill', + 'slotContentChanged', + 'creativeChanged', + 'loadObservedBeforeRender', +] as const; +const TRUNCATION_KEYS = [ + 'omitted_server_auctions', + 'omitted_slot_correlations', + 'omitted_request_cycles', + 'omitted_callback_issues', + 'omitted_attribution_issues', + 'omitted_nested_values', +] as const; + +/** Ingests a report into an independently owned immutable public model. */ +export function parseTraceReport( + value: unknown, + origin: string, + storedAtMs: number +): TraceReportV1 | undefined { + const snapshot = traceJsonSnapshot(value); + if ( + !snapshot || + traceOrigin(origin) !== origin || + !traceNumber(storedAtMs) || + !report(snapshot.value, origin, storedAtMs) + ) + return undefined; + freezeOwned(snapshot.value); + return snapshot.value; +} + +/** Ingests a supported, fresh storage wrapper without retaining caller objects. */ +export function parseTraceStoredReport( + value: unknown, + origin: string, + nowMs: number +): TraceStoredReportV1 | undefined { + const snapshot = traceJsonSnapshot(value, 11); + if (!snapshot || !validateTraceStoredReport(snapshot.value, origin, nowMs)) return undefined; + freezeOwned(snapshot.value); + return snapshot.value; +} + +function freezeOwned(value: unknown): void { + if (typeof value !== 'object' || value === null) return; + Object.values(value).forEach(freezeOwned); + Object.freeze(value); +} + +/** Safe categories for incompatible source/public schema versions. */ +export type TraceReportRejection = + | 'unsupported_report_version' + | 'unsupported_auction_version' + | 'unsupported_correlation_version' + | 'unsupported_gpt_version' + | 'unsupported_gpt_source_version' + | 'invalid_report'; + +/** Classifies invalid reports without returning input values or parser errors. */ +export function traceReportRejection( + value: unknown, + origin: string, + storedAtMs: number +): TraceReportRejection | undefined { + try { + const snapshot = traceJsonSnapshot(value); + if (!snapshot) return 'invalid_report'; + value = snapshot.value; + if (validateTraceReport(value, origin, storedAtMs)) return undefined; + if (!traceObject(value)) return 'invalid_report'; + if (traceOwn(value, 'schema_version') && value.schema_version !== 1) + return 'unsupported_report_version'; + const gpt = value.gpt_diagnostics; + if (traceObject(gpt)) { + if (traceOwn(gpt, 'schema_version') && gpt.schema_version !== 1) + return 'unsupported_gpt_version'; + if (traceOwn(gpt, 'source_schema_version') && gpt.source_schema_version !== 1) + return 'unsupported_gpt_source_version'; + } + const auctions = traceItems(value.server_auctions, 16); + if ( + auctions?.some( + (item) => traceObject(item) && traceOwn(item, 'schema_version') && item.schema_version !== 1 + ) + ) + return 'unsupported_auction_version'; + const correlations = traceItems(value.slot_correlations, 128); + if ( + correlations?.some( + (item) => traceObject(item) && traceOwn(item, 'schema_version') && item.schema_version !== 1 + ) + ) + return 'unsupported_correlation_version'; + return 'invalid_report'; + } catch { + return 'invalid_report'; + } +} + +/** Validates a browser-relative numeric clock without truncating fractional observations. */ +export function traceNumber(value: unknown, maximum = Number.MAX_SAFE_INTEGER): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= maximum; +} + +/** Canonicalizes only a strict bare HTTP(S) origin, with no URL repair. */ +export function traceOrigin(value: unknown): string | undefined { + if (!traceText(value, 255) || !/^https?:\/\//i.test(value)) return undefined; + const authority = value.slice(value.indexOf('://') + 3); + if (!authority || /[\s/@?#,\\]/.test(authority)) return undefined; + const port = authority.startsWith('[') + ? authority.slice(authority.indexOf(']') + 1) + : authority.includes(':') + ? authority.slice(authority.lastIndexOf(':')) + : ''; + if (port !== '' && (!/^:\d+$/.test(port) || Number(port.slice(1)) > 65535)) return undefined; + try { + const parsed = new URL(value); + if ( + !['http:', 'https:'].includes(parsed.protocol) || + !parsed.hostname || + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + parsed.pathname !== '/' + ) + return undefined; + return parsed.origin; + } catch { + return undefined; + } +} + +function members(value: unknown, limit: number, predicate: (item: unknown) => boolean): boolean { + const items = traceItems(value, limit); + return items !== undefined && items.every(predicate); +} +function optional( + value: Record, + key: string, + valid: (item: unknown) => boolean +): boolean { + return !traceOwn(value, key) || valid(value[key]); +} +function cycle(value: unknown): boolean { + if ( + !traceObject(value) || + !traceKeys(value, ['requestNumber', 'durations', 'incompleteSequence'], TRACE_CYCLE_KEYS) || + !traceInteger(value.requestNumber) || + typeof value.incompleteSequence !== 'boolean' + ) + return false; + if ( + !traceObject(value.durations) || + !traceKeys(value.durations, [], DURATION_KEYS) || + !Object.values(value.durations).every((item) => traceNumber(item)) + ) + return false; + if ( + !TIME_KEYS.every((key) => optional(value, key, traceNumber)) || + !BOOLEAN_KEYS.every((key) => optional(value, key, (item) => typeof item === 'boolean')) + ) + return false; + return ( + ['requestIntentId', 'replacedRequestNumber'].every((key) => + optional(value, key, traceInteger) + ) && + optional(value, 'trustedServerAuctionId', validDiagnosticAuctionId) && + optional(value, 'requestedSlotSizes', (item) => members(item, 16, (size) => traceSize(size))) && + optional(value, 'size', (item) => traceSize(item)) && + optional(value, 'observedSlotSize', (item) => traceSize(item, true)) && + optional(value, 'responseClass', (item) => traceEnum(item, TRACE_RESPONSE_CLASSES)) && + optional(value, 'requestPath', (item) => traceEnum(item, TRACE_REQUEST_PATHS)) && + optional(value, 'trustedServerOpportunity', (item) => traceEnum(item, TRACE_OPPORTUNITIES)) && + optional(value, 'delivery', (item) => traceEnum(item, TRACE_DELIVERIES)) && + optional(value, 'trustedServerCreativeFailures', (item) => + members(item, 16, (failure) => traceEnum(failure, TRACE_CREATIVE_FAILURES)) + ) + ); +} +function slot(value: unknown): boolean { + if ( + !traceObject(value) || + !traceKeys( + value, + ['runtimeSlotNumber', 'binding', 'requests'], + ['currentVisibilityPercentage', 'maximumVisibilityPercentage'] + ) || + !traceInteger(value.runtimeSlotNumber) + ) + return false; + if ( + !traceObject(value.binding) || + !traceKeys(value.binding, ['status'], ['reason']) || + !traceEnum(value.binding.status, ['bound', 'unbound', 'ambiguous']) || + !optional(value.binding, 'reason', (item) => traceEnum(item, TRACE_BINDING_REASONS)) + ) + return false; + return ( + optional(value, 'currentVisibilityPercentage', (item) => traceNumber(item, 100)) && + optional(value, 'maximumVisibilityPercentage', (item) => traceNumber(item, 100)) && + members(value.requests, 10, cycle) + ); +} +function callback(value: unknown): boolean { + return ( + traceObject(value) && + traceKeys(value, ['kind', 'runtimeSlotNumber', 'timestampMs', 'disposition', 'reason']) && + traceEnum(value.kind, TRACE_CALLBACK_KINDS) && + traceInteger(value.runtimeSlotNumber) && + traceNumber(value.timestampMs) && + traceEnum(value.disposition, ['matched', 'unmatched', 'ambiguous']) && + traceEnum(value.reason, TRACE_CALLBACK_REASONS) + ); +} +function attribution(value: unknown): boolean { + return ( + traceObject(value) && + traceKeys(value, ['reason', 'timestampMs'], ['runtimeSlotNumber']) && + traceEnum(value.reason, TRACE_ATTRIBUTION_REASONS) && + traceNumber(value.timestampMs) && + optional(value, 'runtimeSlotNumber', traceInteger) + ); +} +function coverageCounters(value: unknown): boolean { + return ( + traceObject(value) && + traceKeys(value, ['observed', 'matched', 'unmatched', 'ambiguous']) && + Object.values(value).every((item) => traceInteger(item)) + ); +} +function gpt(value: unknown, origin: string): value is TraceGptDiagnosticsV1 { + if ( + !traceObject(value) || + !traceKeys( + value, + [ + 'schema_version', + 'source_schema_version', + 'capturedAt', + 'page', + 'slots', + 'callbackIssues', + 'coverage', + 'metadata', + ], + ['attributionIssues'] + ) || + value.schema_version !== 1 || + value.source_schema_version !== 1 || + !traceTimestamp(value.capturedAt) + ) + return false; + if ( + !traceObject(value.page) || + !traceKeys(value.page, ['origin', 'pathname']) || + traceOrigin(value.page.origin) !== origin || + value.page.pathname !== '/[redacted]' + ) + return false; + if ( + !members(value.slots, 64, slot) || + !members(value.callbackIssues, 128, callback) || + !optional(value, 'attributionIssues', (item) => members(item, 128, attribution)) + ) + return false; + if ( + !traceObject(value.coverage) || + !traceKeys(value.coverage, TRACE_CALLBACK_KINDS) || + !Object.values(value.coverage).every(coverageCounters) + ) + return false; + return ( + traceObject(value.metadata) && + traceKeys( + value.metadata, + ['droppedCallbacks', 'evictedSlots', 'evictedRequestCycles'], + ['droppedAttributionIssues'] + ) && + Object.values(value.metadata).every((item) => traceInteger(item)) + ); +} + +/** Validates the supported public GPT projection before display or export. */ +export function validateTraceGptDiagnostics( + value: unknown, + origin: string +): value is TraceGptDiagnosticsV1 { + try { + const snapshot = traceJsonSnapshot(value, 10, 4 * 1024 * 1024); + return snapshot !== undefined && traceOrigin(origin) === origin && gpt(snapshot.value, origin); + } catch { + return false; + } +} + +function closeTimestamp(value: unknown, storedAtMs: number): boolean { + if (!traceTimestamp(value)) return false; + const time = Date.parse(value); + return Number.isFinite(time) && Math.abs(time - storedAtMs) <= 60000; +} +function report(value: unknown, origin: string, storedAtMs: number): value is TraceReportV1 { + if ( + !traceObject(value) || + !traceKeys(value, [ + 'schema_version', + 'captured_at', + 'request_context', + 'server_auctions', + 'slot_correlations', + 'gpt_diagnostics', + 'auction_coverage', + 'truncation', + ]) || + value.schema_version !== 1 || + !closeTimestamp(value.captured_at, storedAtMs) + ) + return false; + if ( + !validateTraceRequestContext(value.request_context) || + !members(value.server_auctions, 16, validateTraceAuctionEvidence) || + !members(value.slot_correlations, 128, validateTraceSlotCorrelation) || + !gpt(value.gpt_diagnostics, origin) || + !closeTimestamp(value.gpt_diagnostics.capturedAt, storedAtMs) + ) + return false; + if ( + !traceObject(value.truncation) || + !traceKeys(value.truncation, TRUNCATION_KEYS) || + !Object.values(value.truncation).every((item) => traceInteger(item, 65535)) + ) + return false; + const coverage = value.auction_coverage; + if (!traceObject(coverage) || !traceKeys(coverage, ['capture_status', 'issues'])) return false; + const issues = traceItems(coverage.issues, 16); + if (!issues || !issues.every((issue) => traceEnum(issue, TRACE_COVERAGE_ISSUES))) return false; + let previous = -1; + for (const issue of issues) { + const index = TRACE_COVERAGE_ISSUES.indexOf(issue as (typeof TRACE_COVERAGE_ISSUES)[number]); + if (index <= previous) return false; + previous = index; + } + const records = traceItems(value.server_auctions, 16); + if (!records) return false; + const correlations = traceItems(value.slot_correlations, 128); + if (!correlations) return false; + for (const correlation of correlations) { + if (!traceObject(correlation)) return false; + if ( + records.some( + (record) => + traceObject(record) && + record.source === 'auction_api' && + record.diagnostic_auction_id === correlation.diagnostic_auction_id + ) + ) + return false; + } + const failed = issues.some((issue) => + [ + 'evidence_projection_failed', + 'evidence_transport_failed', + 'evidence_validation_failed', + 'record_evicted', + ].includes(issue as string) + ); + const expected = + records.length > 0 + ? failed + ? 'partial' + : 'complete' + : failed + ? 'unavailable' + : 'not_observed'; + return coverage.capture_status === expected && boundedJsonShape(value, 10) !== undefined; +} + +/** Validates the exact combined report and its capture-clock relationship. */ +export function validateTraceReport( + value: unknown, + origin: string, + storedAtMs: number +): value is TraceReportV1 { + try { + const snapshot = traceJsonSnapshot(value); + return ( + snapshot !== undefined && + traceOrigin(origin) === origin && + traceNumber(storedAtMs) && + report(snapshot.value, origin, storedAtMs) + ); + } catch { + return false; + } +} +/** Validates the exact storage wrapper, bounds and expiry before report use. */ +export function validateTraceStoredReport( + value: unknown, + origin: string, + nowMs: number +): value is TraceStoredReportV1 { + try { + const snapshot = traceJsonSnapshot(value, 11); + if (!snapshot) return false; + const owned = snapshot.value; + return ( + traceObject(owned) && + traceKeys(owned, ['stored_at_ms', 'report']) && + traceNumber(nowMs) && + traceInteger(owned.stored_at_ms) && + owned.stored_at_ms - nowMs <= 60000 && + nowMs - owned.stored_at_ms <= 900000 && + validateTraceReport(owned.report, origin, owned.stored_at_ms) + ); + } catch { + return false; + } +} diff --git a/crates/trusted-server-js/lib/src/trace/report-view.ts b/crates/trusted-server-js/lib/src/trace/report-view.ts new file mode 100644 index 000000000..2c1f016e5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/report-view.ts @@ -0,0 +1,442 @@ +import { joinTraceEvidence, type TraceEvidenceView } from './correlation'; +import { copyTraceReport, downloadTraceReport, shareTraceReport } from './export'; +import { endTraceSessionAndObserve, type TraceSessionChangeResult } from './lifecycle'; +import type { TraceReportV1, TraceStoredReportV1 } from './report-types'; +import { mountTraceSetup } from './setup'; +import { deleteTraceReport, readTraceReport } from './storage'; +import { + renderAuctions, + renderCoverage, + renderRequestContext, + renderRequestDetails, +} from './report-details'; +import { capturedLabel, element, facts, label, millis, section, sizes } from './report-dom'; +import { + summarizeTraceReport, + type TraceSlotCard, + type TraceSlotFill, + type TraceSummary, +} from './view-model'; + +export interface TraceViewerOptions { + readonly origin?: string; + readonly now?: () => number; + readonly storage?: Pick; + readonly confirm?: (message: string) => boolean; + readonly copy?: typeof copyTraceReport; + readonly download?: typeof downloadTraceReport; + readonly share?: typeof shareTraceReport; +} + +const FILL_LABELS: Record = { + filled: 'Filled', + empty: 'Empty', + unknown: 'Fill unknown', +}; +const FILL_COUNT_LABELS: Record = { + filled: 'filled', + empty: 'empty', + unknown: 'fill unknown', +}; + +function chip(root: Document, fill: TraceSlotFill, text: string): HTMLElement { + const node = element(root, 'span', text); + node.className = `trace-chip trace-chip-${fill}`; + return node; +} + +function renderSummary(root: Document, article: HTMLElement, summary: TraceSummary): void { + const box = section(root, article, 'What happened', 'trace-report-summary'); + const verdict = element(root, 'div'); + verdict.className = `trace-verdict ${summary.needsAttention ? 'is-attention' : 'is-clear'}`; + const headline = element(root, 'p', summary.headline); + headline.className = 'trace-headline'; + verdict.append(headline); + for (const sentence of summary.reading) verdict.append(element(root, 'p', sentence)); + const stats = element(root, 'ul'); + stats.className = 'trace-stats'; + for (const stat of summary.stats) { + const item = element(root, 'li'); + const value = element(root, 'span', String(stat.value)); + value.className = 'trace-stat-value'; + const label = element(root, 'span', stat.label); + label.className = 'trace-stat-label'; + item.append(value, label); + stats.append(item); + } + verdict.append(stats); + if (summary.fills.length) { + const fills = element(root, 'p'); + fills.className = 'trace-chips'; + for (const entry of summary.fills) + fills.append(chip(root, entry.fill, `${entry.count} ${FILL_COUNT_LABELS[entry.fill]}`)); + verdict.append(fills); + } + box.append(verdict); + const flagged = summary.slots.filter((slot) => slot.attention.length > 0); + if (!flagged.length) return; + const attention = section(root, article, 'Needs attention', 'trace-report-attention'); + const list = element(root, 'ul'); + list.className = 'trace-attention'; + for (const slot of flagged) { + const item = element(root, 'li'); + const link = element(root, 'a', `Slot ${slot.runtimeSlotNumber}`); + link.href = `#trace-slot-${slot.runtimeSlotNumber}`; + item.append(link, element(root, 'span', ` — ${slot.attention.join('; ')}`)); + list.append(item); + } + attention.append(list); +} + +function renderTiming(root: Document, parent: HTMLElement, card: TraceSlotCard): void { + if (!card.timing.length) { + parent.append(element(root, 'p', 'Request timing unavailable')); + return; + } + const description = card.timing.map((segment) => `${segment.label} ${millis(segment.ms)}`); + const bar = element(root, 'div'); + bar.className = 'trace-timing'; + bar.setAttribute('role', 'img'); + bar.setAttribute( + 'aria-label', + `Request ${card.timingRequestNumber ?? 1} timing: ${description.join(', ')}` + ); + for (const segment of card.timing) { + const part = element(root, 'span'); + part.className = `trace-segment trace-segment-${segment.kind} trace-weight-${segment.weight}`; + bar.append(part); + } + const legend = element(root, 'ul'); + legend.className = 'trace-legend'; + legend.setAttribute('aria-hidden', 'true'); + for (const segment of card.timing) { + const item = element(root, 'li', `${segment.label} ${millis(segment.ms)}`); + item.className = `trace-legend-${segment.kind}`; + legend.append(item); + } + parent.append(bar, legend); +} + +function renderSlots( + root: Document, + article: HTMLElement, + report: TraceReportV1, + summary: TraceSummary, + joined: TraceEvidenceView | undefined +): void { + const slots = section(root, article, 'Ad slots', 'trace-report-slots'); + slots.append( + element( + root, + 'p', + 'Browser observed. Server auction → GPT request/response → creative render/load/viewability are separate observations. A filled slot does not identify an auction winner.' + ) + ); + facts(root, slots, [ + ['Browser snapshot captured at', capturedLabel(report.gpt_diagnostics.capturedAt)], + ]); + if (!report.gpt_diagnostics.slots.length) slots.append(element(root, 'p', 'Not observed')); + for (const [index, slot] of report.gpt_diagnostics.slots.entries()) { + const card = summary.slots[index]; + const node = element(root, 'article'); + node.className = 'trace-slot'; + node.id = `trace-slot-${slot.runtimeSlotNumber}`; + const header = element(root, 'header'); + header.append( + element( + root, + 'h3', + card.size + ? `Slot ${slot.runtimeSlotNumber} · ${sizes([card.size])}` + : `Slot ${slot.runtimeSlotNumber}` + ), + chip( + root, + card.fill, + card.backfill && card.fill === 'filled' ? 'Filled · backfill' : FILL_LABELS[card.fill] + ) + ); + node.append(header); + renderTiming(root, node, card); + const link = element( + root, + 'p', + card.link + ? `Linked to auction ${card.link.auctionNumber} (${card.link.sourceLabel}) · server slot ${card.link.serverSlotNumber} · ${label(card.link.candidate)}` + : 'Not linked to a server auction' + ); + link.className = card.link ? 'trace-link' : 'trace-link is-unlinked'; + node.append(link); + if (card.requestCount > 1) + node.append( + element( + root, + 'p', + `${card.requestCount} requests; timing shows request ${card.timingRequestNumber}.` + ) + ); + const details = element(root, 'details'); + details.className = 'trace-slot-details'; + details.append(element(root, 'summary', `All details for slot ${slot.runtimeSlotNumber}`)); + facts(root, details, [ + ['Binding', label(slot.binding.status)], + [ + 'Binding reason', + slot.binding.reason === undefined ? 'Unavailable' : label(slot.binding.reason), + ], + ['Current visibility percentage', slot.currentVisibilityPercentage], + ['Maximum visibility percentage', slot.maximumVisibilityPercentage], + ]); + if (!slot.requests.length) details.append(element(root, 'p', 'Requests: Not observed')); + for (const cycle of slot.requests) + renderRequestDetails(root, details, slot.runtimeSlotNumber, cycle, joined); + node.append(details); + slots.append(node); + } +} + +function renderReport( + root: Document, + report: TraceReportV1, + origin: string, + storedAtMs: number +): HTMLElement { + const article = element(root, 'article'); + article.id = 'trace-report'; + const notice = element(root, 'p', 'Browser-carried, unverified diagnostic data'); + notice.className = 'trace-notice'; + const header = element(root, 'header'); + header.className = 'trace-report-header'; + const meta = element( + root, + 'p', + `Captured ${capturedLabel(report.captured_at)} · ${report.gpt_diagnostics.page.origin} · ${plainCount(report.gpt_diagnostics.slots.length, 'ad slot', 'ad slots')} · ${plainCount(report.server_auctions.length, 'server auction', 'server auctions')}` + ); + meta.className = 'trace-meta'; + header.append( + notice, + element(root, 'h1', 'Trusted Server trace results'), + meta, + element( + root, + 'p', + 'This browser snapshot helps troubleshoot rendering. It is not proof of a server event, identity, or security incident.' + ) + ); + article.append(header); + const joined = joinTraceEvidence(report, origin, storedAtMs); + const summary = summarizeTraceReport(report, joined); + renderSummary(root, article, summary); + renderSlots(root, article, report, summary, joined); + // A visual divider, not a heading: the sections below keep their own h2 outline. + const more = element(root, 'p', 'More detail'); + more.className = 'trace-more'; + article.append(more); + renderAuctions(root, article, report, joined); + renderRequestContext(root, article, report); + renderCoverage(root, article, report); + return article; +} + +function plainCount(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +/** Mounts the trace page using the current tab's validated report and setup controls. */ +export function mountTraceViewer( + root: Document = document, + options: TraceViewerOptions = {} +): { destroy(): void } { + const main = root.querySelector('main'); + if (!main) return { destroy() {} }; + const destroySetup = mountTraceSetup(root); + const origin = options.origin ?? window.location.origin; + let stored: TraceStoredReportV1 | undefined; + let destroyed = false; + const disposers: Array<() => void> = []; + const control = (parent: Element, text: string, action: () => void): HTMLButtonElement => { + const button = element(root, 'button', text); + button.type = 'button'; + const listener = (): void => { + if (!destroyed) action(); + }; + button.addEventListener('click', listener); + disposers.push(() => button.removeEventListener('click', listener)); + parent.append(button); + return button; + }; + const destroy = (): void => { + destroyed = true; + stored = undefined; + destroySetup(); + for (const dispose of disposers) dispose(); + }; + let read: ReturnType; + try { + read = readTraceReport(origin, (options.now ?? Date.now)(), options.storage); + } catch { + read = { status: 'unavailable' }; + } + if (read.status !== 'ready') { + const notice = element( + root, + 'p', + read.status === 'absent' + ? 'No saved report. Enable tracing, return to the affected page, reload once, reproduce the problem, then select View trace results.' + : 'The saved report is unavailable, expired, or unsupported. Return to the affected page in this same tab and exact hostname, reload once, reproduce the problem, then select View trace results.' + ); + notice.id = 'trace-report-notice'; + main.prepend(notice); + return { destroy }; + } + stored = read.value; + const setup = element(root, 'details'); + setup.id = 'trace-viewer-setup'; + setup.append(element(root, 'summary', 'Setup request and tracing controls')); + for (const child of Array.from(main.children)) setup.append(child); + const article = renderReport(root, stored.report, origin, stored.stored_at_ms); + main.append(article); + const exports = section(root, article, 'Export'); + exports.append( + element( + root, + 'p', + 'Copy, Download and Share use the same public report JSON. The selected app receives this JSON when you choose Share. Nothing is uploaded by this viewer.' + ) + ); + const exportControls = element(root, 'div'); + exportControls.className = 'controls'; + exports.append(exportControls); + const exportStatus = element(root, 'p'); + exportStatus.id = 'trace-export-status'; + exportStatus.setAttribute('role', 'status'); + exportStatus.setAttribute('aria-live', 'polite'); + exports.append(exportStatus); + let exporting = false; + const exportReport = async (kind: 'Copy' | 'Download' | 'Share'): Promise => { + if (!stored || destroyed || exporting) return; + exporting = true; + const captured = stored; + try { + const action = + kind === 'Copy' + ? (options.copy ?? copyTraceReport) + : kind === 'Share' + ? (options.share ?? shareTraceReport) + : (options.download ?? downloadTraceReport); + const result = await action(captured.report, origin, captured.stored_at_ms); + if (destroyed || stored !== captured) return; + exportStatus.textContent = + result.status === 'copied' + ? 'Copied JSON.' + : result.status === 'downloaded' + ? 'Download started; completion is managed by your browser.' + : result.status === 'shared' + ? 'The share request completed.' + : kind === 'Share' && result.status === 'unsupported' + ? 'File sharing is unavailable. Use Copy or Download.' + : `${kind} could not be completed. Your report remains available; retry or choose another export.`; + } catch { + if (!destroyed && stored === captured) + exportStatus.textContent = `${kind} could not be completed. Your report remains available.`; + } finally { + exporting = false; + } + }; + for (const kind of ['Copy', 'Download', 'Share'] as const) + control(exportControls, kind, () => { + void exportReport(kind); + }); + const cleanup = section(root, main, 'Report cleanup'); + const controls = element(root, 'div'); + controls.className = 'controls'; + cleanup.append(controls); + const localStatus = element(root, 'p', 'A local report is saved in this tab.'); + localStatus.id = 'trace-cleanup-local-status'; + localStatus.setAttribute('role', 'status'); + localStatus.setAttribute('aria-live', 'polite'); + localStatus.tabIndex = -1; + cleanup.append(localStatus); + const serverStatus = element( + root, + 'p', + 'Server tracing state has not been changed by this report visit.' + ); + serverStatus.id = 'trace-cleanup-server-status'; + serverStatus.setAttribute('role', 'status'); + serverStatus.setAttribute('aria-live', 'polite'); + serverStatus.tabIndex = -1; + cleanup.append(serverStatus); + const removeLocal = (): void => { + let result: ReturnType; + try { + result = deleteTraceReport(options.storage); + } catch { + result = { status: 'unavailable' }; + } + if (destroyed) return; + if (result.status === 'deleted') { + const losingFocus = + root.activeElement === deleteButton || article.contains(root.activeElement); + stored = undefined; + article.remove(); + deleteButton.hidden = true; + localStatus.textContent = 'Local report deleted from this tab.'; + if (losingFocus) localStatus.focus(); + } else + localStatus.textContent = + 'Local report deletion failed. The report remains displayed; retry deletion.'; + }; + let ending = false; + const end = async (): Promise => { + if (destroyed || ending) return; + const retryHadFocus = root.activeElement === retryButton; + ending = true; + clearButton.disabled = retryButton.disabled = true; + serverStatus.textContent = 'Requesting tracing end and checking the next request…'; + let result: TraceSessionChangeResult; + try { + result = await endTraceSessionAndObserve(); + } catch { + result = { mutation: 'failed', observation: 'failed', confirmed: false }; + } + if (destroyed) return; + serverStatus.textContent = result.confirmed + ? 'Tracing is off — no valid diagnostics session observed.' + : result.observation === 'inactive' + ? 'End tracing unconfirmed. No valid diagnostics session was observed; tracing may remain active. Retry end tracing.' + : result.observation === 'active' + ? 'End tracing unconfirmed — a valid session is still observed. Tracing may remain active. Retry end tracing.' + : 'End tracing unconfirmed. Tracing may remain active. Retry end tracing.'; + retryButton.hidden = result.confirmed; + clearButton.disabled = retryButton.disabled = false; + if ( + result.confirmed && + retryHadFocus && + (root.activeElement === retryButton || root.activeElement === root.body) + ) + serverStatus.focus(); + ending = false; + }; + const clearButton = control(controls, 'Clear report and end tracing', () => { + if (ending) return; + let confirmed = false; + try { + confirmed = (options.confirm ?? ((message) => window.confirm(message)))( + 'Delete the report from this tab and request tracing end?' + ); + } catch { + /* No consent means no cleanup mutation. */ + } + if (!confirmed || destroyed) return; + removeLocal(); + if (!destroyed) void end(); + }); + const deleteButton = control(controls, 'Delete local report', removeLocal); + const retryButton = control(controls, 'Retry end tracing', () => { + void end(); + }); + retryButton.hidden = true; + main.append(setup); + return { destroy }; +} diff --git a/crates/trusted-server-js/lib/src/trace/report.ts b/crates/trusted-server-js/lib/src/trace/report.ts new file mode 100644 index 000000000..fda0c0c07 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/report.ts @@ -0,0 +1,325 @@ +import { traceInteger, traceKeys, traceObject, validateTraceRequestContext } from './context'; +import type { TraceAuctionEvidenceV1, TraceSlotCorrelationV1 } from './types'; +import { addOmissions } from './omissions'; +import { projectTraceGptDiagnostics } from './projection'; +import { + TRACE_COVERAGE_ISSUES, + type TraceCoverageIssue, + type TraceReportV1, + type TraceStoredReportV1, +} from './report-types'; +import { parseTraceStoredReport, traceJsonSnapshot, traceOrigin } from './report-validation'; +import { + traceEnum, + traceItems, + parseTraceAuctionTransport, + parseTraceSlotCorrelation, +} from './validation'; + +/** Collector observations keep outer losses separate from inner evidence truncation. */ +export interface TraceCollectorSnapshot { + readonly serverAuctions: readonly TraceAuctionEvidenceV1[]; + readonly slotCorrelations: readonly TraceSlotCorrelationV1[]; + readonly issues: readonly TraceCoverageIssue[]; + readonly omittedServerAuctions: number; + readonly omittedSlotCorrelations: number; +} +export interface TraceCaptureInput { + readonly requestContext: unknown; + readonly gptSource: unknown; + readonly origin: string; + readonly capturedAtMs: number; + readonly collector: unknown; +} +export type TraceCaptureResult = + | { readonly ok: true; readonly value: TraceStoredReportV1 } + | { + readonly ok: false; + readonly reason: + | 'invalid_snapshot' + | 'unsupported_source_version' + | 'omission_counter_overflow' + | 'snapshot_too_large'; + }; +type Mutable = T extends readonly (infer Element)[] + ? Mutable[] + : T extends object + ? { -readonly [Key in keyof T]: Mutable } + : T; +const MAXIMUM_BYTES = 512 * 1024; + +/** Captures one owned report while protecting every nonempty slot's newest cycle. */ +export function buildTraceReport( + input: TraceCaptureInput, + maximumBytes = MAXIMUM_BYTES +): TraceCaptureResult { + let counterFailed = false; + const add = (current: number, added: number): number => { + try { + return addOmissions(current, added); + } catch { + counterFailed = true; + throw new Error('omission_counter_overflow'); + } + }; + try { + if ( + !traceObject(input) || + !traceKeys(input, ['requestContext', 'gptSource', 'origin', 'capturedAtMs', 'collector']) + ) + return { ok: false, reason: 'invalid_snapshot' }; + const descriptors = Object.getOwnPropertyDescriptors(input); + const captureClock: unknown = descriptors.capturedAtMs.value; + const origin: unknown = descriptors.origin.value; + const requestContext: unknown = descriptors.requestContext.value; + const gptSource: unknown = descriptors.gptSource.value; + const collector: unknown = descriptors.collector.value; + if ( + !traceInteger(captureClock) || + typeof origin !== 'string' || + traceOrigin(origin) !== origin || + !traceInteger(maximumBytes, MAXIMUM_BYTES) || + maximumBytes === 0 + ) + return { ok: false, reason: 'invalid_snapshot' }; + const capturedAt = new Date(captureClock).toISOString(); + const context = traceJsonSnapshot(requestContext); + if (!context || !validateTraceRequestContext(context.value)) + return { ok: false, reason: 'invalid_snapshot' }; + const projected = projectTraceGptDiagnostics(gptSource, origin); + if (!projected.ok) + return { + ok: false, + reason: projected.reason === 'invalid_source' ? 'invalid_snapshot' : projected.reason, + }; + if (Math.abs(Date.parse(projected.value.capturedAt) - captureClock) > 60000) + return { ok: false, reason: 'invalid_snapshot' }; + const collection = traceJsonSnapshot(collector, 10, 64 * 1024 * 1024)?.value; + if ( + !traceObject(collection) || + !traceKeys(collection, [ + 'serverAuctions', + 'slotCorrelations', + 'issues', + 'omittedServerAuctions', + 'omittedSlotCorrelations', + ]) + ) + return { ok: false, reason: 'invalid_snapshot' }; + const auctions = traceItems(collection.serverAuctions, 65535 + 16); + const sidecars = traceItems(collection.slotCorrelations, 65535 + 128); + const issueItems = traceItems(collection.issues, TRACE_COVERAGE_ISSUES.length); + if ( + !auctions || + !sidecars || + !issueItems || + !issueItems.every((issue) => traceEnum(issue, TRACE_COVERAGE_ISSUES)) + ) + return { ok: false, reason: 'invalid_snapshot' }; + const copiedAuctions = auctions.map( + (record) => parseTraceAuctionTransport({ schema_version: 1, evidence: record })?.evidence + ); + const copiedSidecars = sidecars.map(parseTraceSlotCorrelation); + if ( + copiedAuctions.some((record) => record === undefined) || + copiedSidecars.some((record) => record === undefined) + ) + return { ok: false, reason: 'invalid_snapshot' }; + const apiIds = new Set( + copiedAuctions + .filter((record) => record?.source === 'auction_api') + .map((record) => record!.diagnostic_auction_id) + ); + if (copiedSidecars.some((sidecar) => apiIds.has(sidecar!.diagnostic_auction_id))) + return { ok: false, reason: 'invalid_snapshot' }; + const omittedAuctions = add( + collection.omittedServerAuctions as number, + Math.max(0, auctions.length - 16) + ); + let omittedSidecars = add( + collection.omittedSlotCorrelations as number, + Math.max(0, sidecars.length - 128) + ); + const retainedAuctions = copiedAuctions.slice(-16); + const discardedIds = new Set( + copiedAuctions + .slice(0, Math.max(0, copiedAuctions.length - 16)) + .map((record) => record!.diagnostic_auction_id) + ); + const retainedIds = new Set(retainedAuctions.map((record) => record!.diagnostic_auction_id)); + const retainedSidecars = copiedSidecars + .slice(-128) + .filter( + (sidecar) => + !discardedIds.has(sidecar!.diagnostic_auction_id) || + retainedIds.has(sidecar!.diagnostic_auction_id) + ); + omittedSidecars = add( + omittedSidecars, + Math.min(128, sidecars.length) - retainedSidecars.length + ); + const draft = traceJsonSnapshot( + { + schema_version: 1, + captured_at: capturedAt, + request_context: context.value, + server_auctions: retainedAuctions, + slot_correlations: retainedSidecars, + gpt_diagnostics: projected.value, + auction_coverage: { capture_status: 'not_observed', issues: [] }, + truncation: { + omitted_server_auctions: omittedAuctions, + omitted_slot_correlations: omittedSidecars, + omitted_request_cycles: 0, + omitted_callback_issues: 0, + omitted_attribution_issues: 0, + omitted_nested_values: projected.omittedNestedValues, + }, + }, + 10, + 16 * 1024 * 1024 + )?.value as Mutable | undefined; + if (!draft) return { ok: false, reason: 'invalid_snapshot' }; + const issues = new Set(issueItems as TraceCoverageIssue[]); + const recompute = (): void => { + if (draft.truncation.omitted_server_auctions > 0) issues.add('record_evicted'); + if (draft.truncation.omitted_slot_correlations > 0) issues.add('correlation_unavailable'); + const ordered = TRACE_COVERAGE_ISSUES.filter((issue) => issues.has(issue)); + const failed = ordered.some((issue) => + [ + 'evidence_projection_failed', + 'evidence_transport_failed', + 'evidence_validation_failed', + 'record_evicted', + ].includes(issue) + ); + draft.auction_coverage = { + capture_status: draft.server_auctions.length + ? failed + ? 'partial' + : 'complete' + : failed + ? 'unavailable' + : 'not_observed', + issues: ordered, + }; + }; + recompute(); + const wrapper = { stored_at_ms: captureClock, report: draft }; + // Measure the owned draft once, then account for removed JSON payloads, + // commas, counter digits and coverage changes without serializing it again. + // A final independent measurement enforces the budget before ingestion. + const encoder = new TextEncoder(); + const bytes = (value: unknown): number => encoder.encode(JSON.stringify(value)).length; + let reportBytes = bytes(wrapper); + const fits = (): boolean => reportBytes <= maximumBytes; + const omit = (counter: keyof TraceReportV1['truncation'], count: number): void => { + const current = draft.truncation[counter]; + const next = add(current, count); + reportBytes += String(next).length - String(current).length; + draft.truncation[counter] = next; + }; + const retain = (items: Item[], keep: (item: Item) => boolean): Item[] => { + const retained: Item[] = []; + for (const item of items) { + if (keep(item)) retained.push(item); + else reportBytes -= bytes(item); + } + reportBytes -= Math.max(0, items.length - 1) - Math.max(0, retained.length - 1); + return retained; + }; + const recomputeMeasured = (): void => { + const previous = bytes(draft.auction_coverage); + recompute(); + reportBytes += bytes(draft.auction_coverage) - previous; + }; + const pruneSidecars = ( + predicate: (sidecar: Mutable['slot_correlations'][number]) => boolean + ): void => { + const retained = retain(draft.slot_correlations, (sidecar) => !predicate(sidecar)); + omit('omitted_slot_correlations', draft.slot_correlations.length - retained.length); + draft.slot_correlations = retained; + }; + const floors = new Set( + draft.gpt_diagnostics.slots.flatMap((slot) => + slot.requests.length ? [slot.requests[slot.requests.length - 1]] : [] + ) + ); + const cycles = draft.gpt_diagnostics.slots.flatMap((slot) => + slot.requests.filter((cycle) => !floors.has(cycle)).map((cycle) => ({ slot, cycle })) + ); + cycles.sort((left, right) => { + const a = left.cycle.requestedAtMs; + const b = right.cycle.requestedAtMs; + return ( + (a === undefined ? (b === undefined ? 0 : -1) : b === undefined ? 1 : a - b) || + left.slot.runtimeSlotNumber - right.slot.runtimeSlotNumber || + left.cycle.requestNumber - right.cycle.requestNumber + ); + }); + for (const { slot, cycle } of cycles) { + if (fits()) break; + slot.requests = retain(slot.requests, (retained) => retained !== cycle); + omit('omitted_request_cycles', 1); + pruneSidecars( + (sidecar) => + sidecar.runtime_slot_number === slot.runtimeSlotNumber && + sidecar.request_number === cycle.requestNumber + ); + recomputeMeasured(); + } + const removeIssues = ( + source: Issue[] | undefined, + counter: 'omitted_callback_issues' | 'omitted_attribution_issues' + ): void => { + if (!source) return; + const ordered = source + .map((issue, index) => ({ issue, index })) + .sort((a, b) => a.issue.timestampMs - b.issue.timestampMs || a.index - b.index); + for (const { issue } of ordered) { + if (fits()) break; + const index = source.indexOf(issue); + reportBytes -= bytes(issue) + (source.length > 1 ? 1 : 0); + source.splice(index, 1); + omit(counter, 1); + } + }; + removeIssues(draft.gpt_diagnostics.callbackIssues, 'omitted_callback_issues'); + removeIssues(draft.gpt_diagnostics.attributionIssues, 'omitted_attribution_issues'); + const remainingCycles = () => draft.gpt_diagnostics.slots.flatMap((slot) => slot.requests); + const removeAuction = (record: Mutable['server_auctions'][number]): void => { + const id = record.diagnostic_auction_id; + draft.server_auctions = retain(draft.server_auctions, (retained) => retained !== record); + omit('omitted_server_auctions', 1); + for (const slot of draft.gpt_diagnostics.slots) { + const retained = retain(slot.requests, (cycle) => cycle.trustedServerAuctionId !== id); + omit('omitted_request_cycles', slot.requests.length - retained.length); + slot.requests = retained; + } + pruneSidecars((sidecar) => sidecar.diagnostic_auction_id === id); + recomputeMeasured(); + }; + for (const record of [...draft.server_auctions]) { + if (fits()) break; + if ( + !remainingCycles().some( + (cycle) => cycle.trustedServerAuctionId === record.diagnostic_auction_id + ) + ) + removeAuction(record); + } + for (const record of [...draft.server_auctions]) { + if (fits()) break; + if ( + ![...floors].some((cycle) => cycle.trustedServerAuctionId === record.diagnostic_auction_id) + ) + removeAuction(record); + } + if (!fits() || bytes(wrapper) > maximumBytes) + return { ok: false, reason: 'snapshot_too_large' }; + const result = parseTraceStoredReport(wrapper, origin, captureClock); + return result ? { ok: true, value: result } : { ok: false, reason: 'invalid_snapshot' }; + } catch { + return { ok: false, reason: counterFailed ? 'omission_counter_overflow' : 'invalid_snapshot' }; + } +} diff --git a/crates/trusted-server-js/lib/src/trace/runtime.ts b/crates/trusted-server-js/lib/src/trace/runtime.ts new file mode 100644 index 000000000..7c15030bc --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/runtime.ts @@ -0,0 +1,160 @@ +import type { AdRequest } from '../core/auction'; +import type { TsjsApi } from '../core/types'; + +import { createTraceCollector, type TraceCollector } from './collector'; +import { createTraceGptBridge, type TraceGptBridge } from './gpt'; +import { parseTraceAuctionTransport, validTraceSlotRef } from './validation'; + +export interface TraceRuntimeScope { + tsjs?: TsjsApi; + __tsjs_trace_active?: unknown; + crypto?: { randomUUID?(): string }; +} +export interface TraceApiRequestCarry { + readonly request: AdRequest; + readonly slotRefs: readonly string[]; + readonly collector: TraceCollector; +} +function currentScope(): TraceRuntimeScope | undefined { + return typeof window === 'object' ? window : undefined; +} + +/** Installs one collector on the shared facade only for the literal document gate. */ +export function installTraceRuntime( + api: TsjsApi, + scope = currentScope() +): TraceCollector | undefined { + try { + if (scope?.__tsjs_trace_active !== true) return undefined; + const collector = (api.traceEvidence ??= createTraceCollector()); + api.traceGpt ??= createTraceGptBridge(collector); + return collector; + } catch { + return undefined; + } +} + +/** Reads the existing activated facade without creating a collector or listeners. */ +export function getActiveTraceCollector(scope = currentScope()): TraceCollector | undefined { + try { + return scope?.__tsjs_trace_active === true ? scope.tsjs?.traceEvidence : undefined; + } catch { + return undefined; + } +} + +/** Reads the single core-owned slot bridge only behind the literal document gate. */ +export function getActiveTraceGptBridge(scope = currentScope()): TraceGptBridge | undefined { + try { + return scope?.__tsjs_trace_active === true ? scope.tsjs?.traceGpt : undefined; + } catch { + return undefined; + } +} + +/** Decorates only final grouped units, retaining the ordinary request on token failure. */ +export function prepareTraceAuctionRequest( + request: AdRequest, + scope = currentScope() +): TraceApiRequestCarry | undefined { + const collector = getActiveTraceCollector(scope); + if (!collector) return undefined; + const fallback = { + request, + slotRefs: Object.freeze([] as string[]), + collector, + }; + try { + if (typeof scope?.crypto?.randomUUID !== 'function') return fallback; + const refs = request.adUnits.map(() => `ts-slot-${scope.crypto!.randomUUID!()}`); + if (!refs.every(validTraceSlotRef) || new Set(refs).size !== refs.length) return fallback; + const adUnits = request.adUnits.map((unit, index) => { + const prior = unit.ext?.trusted_server; + return { + ...unit, + ext: { + ...unit.ext, + trusted_server: { + ...(typeof prior === 'object' && prior !== null && !Array.isArray(prior) ? prior : {}), + trace_slot_ref: refs[index], + }, + }, + }; + }); + return { + request: { ...request, adUnits }, + slotRefs: Object.freeze(refs), + collector, + }; + } catch { + return fallback; + } +} + +function ownMember( + value: unknown, + key: string +): { present: boolean; valid: boolean; value?: unknown } { + if (value === null || typeof value !== 'object') return { present: false, valid: true }; + try { + const member = Object.getOwnPropertyDescriptor(value, key); + return member + ? { + present: true, + valid: Object.prototype.hasOwnProperty.call(member, 'value'), + value: member.value, + } + : { present: false, valid: true }; + } catch { + return { present: true, valid: false }; + } +} + +/** Consumes optional API evidence before bids, without inspecting unrelated extensions. */ +export function observeTraceApiResponse( + carry: Pick | undefined, + response: unknown +): void { + if (!carry) return; + try { + const ext = ownMember(response, 'ext'); + if (!ext.present) return; + if (!ext.valid) { + carry.collector.recordTransport(null); + return; + } + const trusted = ownMember(ext.value, 'trusted_server'); + if (!trusted.present) return; + if (!trusted.valid) { + carry.collector.recordTransport(null); + return; + } + const member = ownMember(trusted.value, 'trace_auction'); + if (!member.present) return; + const transport = member.valid ? parseTraceAuctionTransport(member.value) : undefined; + if (!transport || (transport.evidence && transport.evidence.source !== 'auction_api')) { + carry.collector.recordTransport(null); + return; + } + if (transport.evidence && carry.slotRefs.length) { + const expected = new Set(carry.slotRefs); + const returned = transport.evidence.slots.map((slot) => slot.slot_ref); + if (returned.some((ref) => !expected.has(ref)) || new Set(returned).size !== returned.length) + carry.collector.recordInterpretationIssue('correlation_unavailable'); + } + carry.collector.recordTransport(transport); + } catch { + /* Diagnostic callbacks never change ordinary bid parsing. */ + } +} + +/** Records only a bounded API transport failure, without errors or response bodies. */ +export function observeTraceApiFailure( + carry: Pick | undefined +): void { + try { + carry?.collector.recordTransportFailure(); + } catch { + /* Diagnostic failures never change ordinary bidding. */ + } +} diff --git a/crates/trusted-server-js/lib/src/trace/setup.ts b/crates/trusted-server-js/lib/src/trace/setup.ts new file mode 100644 index 000000000..01db45aa6 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/setup.ts @@ -0,0 +1,143 @@ +import { validateTraceRequestContext } from './context'; +import { changeTraceSession, type TraceSessionAction } from './lifecycle'; +import type { CookieHealth } from './types'; + +const REPRODUCE = + 'Return to the affected page, reload once, reproduce the problem, then select View trace results.'; +const RECOVERY = + 'Reopen the affected article on the exact same hostname and in this same tab, then reload once.'; +const ON = 'Tracing is on — cookie observed by server'; +const OFF = 'Tracing is off — no valid diagnostics session observed'; + +function healthLabel(health: CookieHealth): string { + switch (health.state) { + case 'absent': + return 'Not present in this request'; + case 'present_valid': + return 'Valid shape observed'; + case 'present_invalid': + return 'Invalid shape observed'; + case 'duplicate': + return 'Multiple values observed'; + case 'unavailable': + return health.detail === 'runtime_header_ambiguous' + ? 'Unavailable — runtime-visible cookies could not be reliably inspected' + : 'Unavailable — runtime-visible cookie inspection failed'; + } +} + +function fact(root: Document, list: Element, name: string, value: string): void { + const term = root.createElement('dt'); + term.textContent = name; + const description = root.createElement('dd'); + description.textContent = value; + list.append(term, description); +} + +function setupFacts(root: Document): void { + const source = root.getElementById('trace-request-context'); + const network = root.getElementById('trace-network-facts'); + const cookies = root.getElementById('trace-cookie-facts'); + if (!source || !network || !cookies) return; + let context: unknown; + try { + context = JSON.parse(source.textContent ?? ''); + } catch { + context = undefined; + } + source.textContent = ''; + network.replaceChildren(); + cookies.replaceChildren(); + if (!validateTraceRequestContext(context)) { + fact(root, network, 'Request facts', 'Unavailable'); + fact(root, cookies, 'Cookie health', 'Unavailable'); + return; + } + const facts = context.network; + fact(root, network, 'Approximate network identifier', facts.masked_client_ip ?? 'Unavailable'); + fact(root, network, 'Country', facts.country ?? 'Unavailable'); + fact(root, network, 'Region', facts.region ?? 'Unavailable'); + fact(root, network, 'ASN', facts.asn === undefined ? 'Unavailable' : String(facts.asn)); + fact(root, network, 'HTTP version', facts.http_version ?? 'Unavailable'); + fact(root, network, 'TLS protocol', facts.tls_protocol ?? 'Unavailable'); + fact(root, network, 'TLS cipher', facts.tls_cipher ?? 'Unavailable'); + fact(root, network, 'Edge hostname', facts.edge_hostname ?? 'Unavailable'); + fact(root, network, 'Edge region', facts.edge_region ?? 'Unavailable'); + fact(root, network, 'Edge POP', facts.edge_pop ?? 'Unavailable'); + fact(root, cookies, 'Edge Cookie', healthLabel(context.cookies.ts_ec)); + fact(root, cookies, 'External IDs', healthLabel(context.cookies.ts_eids)); + fact(root, cookies, 'Tester', healthLabel(context.cookies.ts_tester)); + fact(root, cookies, 'Diagnostics session', healthLabel(context.cookies.diagnostics_session)); +} + +/** Mounts explicit setup controls without changing cookies on page load. */ +export function mountTraceSetup(root: Document = document): () => void { + setupFacts(root); + const state = root.getElementById('trace-session-state'); + const status = root.getElementById('trace-status'); + const enable = root.getElementById('trace-enable'); + const end = root.getElementById('trace-end'); + const back = root.getElementById('trace-back'); + if ( + !state || + !status || + !(enable instanceof HTMLButtonElement) || + !(end instanceof HTMLButtonElement) || + !(back instanceof HTMLButtonElement) + ) + return () => undefined; + state.textContent = + state.dataset.observedActive === 'true' + ? ON + : state.dataset.observedActive === 'false' + ? OFF + : 'Tracing state unconfirmed'; + let pending = false; + let destroyed = false; + const action = async (requested: TraceSessionAction): Promise => { + if (destroyed || pending) return; + pending = true; + enable.disabled = end.disabled = true; + status.textContent = + requested === 'enable' ? 'Verifying activation…' : 'Verifying deactivation…'; + try { + const result = await changeTraceSession(requested); + if (destroyed) return; + state.textContent = + result.observation === 'active' + ? ON + : result.observation === 'inactive' + ? OFF + : 'Tracing state unconfirmed'; + if (result.confirmed) { + status.textContent = requested === 'enable' ? REPRODUCE : OFF; + back.classList.toggle('primary', requested === 'enable'); + } else { + status.textContent = `${requested === 'enable' ? 'Activation' : 'Deactivation'} unconfirmed. Try again.`; + } + } finally { + if (!destroyed) enable.disabled = end.disabled = false; + pending = false; + } + }; + const enableClick = (): void => { + void action('enable'); + }; + const endClick = (): void => { + void action('end'); + }; + const backClick = (): void => { + if (destroyed) return; + if (window.history.length > 1) window.history.back(); + else status.textContent = RECOVERY; + }; + enable.addEventListener('click', enableClick); + end.addEventListener('click', endClick); + back.addEventListener('click', backClick); + return () => { + destroyed = true; + enable.removeEventListener('click', enableClick); + end.removeEventListener('click', endClick); + back.removeEventListener('click', backClick); + }; +} diff --git a/crates/trusted-server-js/lib/src/trace/shape.ts b/crates/trusted-server-js/lib/src/trace/shape.ts new file mode 100644 index 000000000..888ec96d9 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/shape.ts @@ -0,0 +1,32 @@ +import { traceInteger, traceOwn } from './context'; + +/** Accepts a dense bounded JSON array with no added fields or accessors. */ +export function traceArray(value: unknown, limit: number): value is unknown[] { + return traceItems(value, limit) !== undefined; +} + +/** Copies bounded own array data without using caller methods or iterators. */ +export function traceItems(value: unknown, limit: number): unknown[] | undefined { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + const length = Object.getOwnPropertyDescriptor(value, 'length')?.value; + if (!traceInteger(length, limit)) return undefined; + const keys = Reflect.ownKeys(value); + if (keys.length !== length + 1) return undefined; + if ( + !keys.every((key) => { + if (key === 'length') return true; + if (typeof key !== 'string' || !/^(?:0|[1-9]\d*)$/.test(key) || Number(key) >= length) + return false; + const property = Object.getOwnPropertyDescriptor(value, key); + return property?.enumerable === true && traceOwn(property, 'value'); + }) + ) + return undefined; + const owned: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const property = Object.getOwnPropertyDescriptor(value, String(index)); + if (!property || property.enumerable !== true || !traceOwn(property, 'value')) return undefined; + owned.push(property.value); + } + return owned; +} diff --git a/crates/trusted-server-js/lib/src/trace/storage.ts b/crates/trusted-server-js/lib/src/trace/storage.ts new file mode 100644 index 000000000..b769c6a39 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/storage.ts @@ -0,0 +1,96 @@ +import { traceObject } from './context'; +import type { TraceStoredReportV1 } from './report-types'; +import { + parseTraceStoredReport, + traceJsonSnapshot, + traceReportRejection, + type TraceReportRejection, +} from './report-validation'; + +/** One explicit snapshot replaces the prior report in this browsing context. */ +export const TRACE_REPORT_STORAGE_KEY = 'trusted-server.trace.report.v1'; +type TraceStorage = Pick; +export type TraceStorageReadResult = + | { readonly status: 'ready'; readonly value: TraceStoredReportV1 } + | { readonly status: 'absent' | 'unavailable' } + | { readonly status: 'rejected'; readonly reason: TraceReportRejection }; +export type TraceStorageWriteResult = + | { readonly status: 'stored' | 'unavailable' } + | { readonly status: 'rejected'; readonly reason: TraceReportRejection }; + +function storageOrDefault(storage?: TraceStorage): TraceStorage { + return storage ?? window.sessionStorage; +} +function rejected(value: unknown, origin: string, nowMs: number): TraceReportRejection { + const owned = traceJsonSnapshot(value, 11)?.value; + if (!traceObject(owned)) return 'invalid_report'; + return traceReportRejection(owned.report, origin, nowMs) ?? 'invalid_report'; +} + +/** Reads and validates once; rejected entries are ignored even if deletion fails. */ +export function readTraceReport( + origin: string, + nowMs: number, + storage?: TraceStorage +): TraceStorageReadResult { + let available: TraceStorage; + let serialized: string | null; + try { + available = storageOrDefault(storage); + serialized = available.getItem(TRACE_REPORT_STORAGE_KEY); + } catch { + return { status: 'unavailable' }; + } + if (serialized === null) return { status: 'absent' }; + let value: unknown; + let result: TraceStoredReportV1 | undefined; + try { + // Bound serialized UTF-8 before parsing, including otherwise ignorable whitespace. + if ( + typeof serialized === 'string' && + new TextEncoder().encode(serialized).length <= 512 * 1024 + ) { + value = JSON.parse(serialized) as unknown; + result = parseTraceStoredReport(value, origin, nowMs); + } + } catch { + /* A bounded rejection is returned below without parser text. */ + } + if (result) return { status: 'ready', value: result }; + const reason = rejected(value, origin, nowMs); + try { + available.removeItem(TRACE_REPORT_STORAGE_KEY); + } catch { + /* Rejected data remains ignored. */ + } + return { status: 'rejected', reason }; +} + +/** Stores only a fresh validated wrapper after an explicit capture action. */ +export function storeTraceReport( + value: unknown, + origin: string, + nowMs: number, + storage?: TraceStorage +): TraceStorageWriteResult { + const owned = parseTraceStoredReport(value, origin, nowMs); + if (!owned) return { status: 'rejected', reason: rejected(value, origin, nowMs) }; + try { + storageOrDefault(storage).setItem(TRACE_REPORT_STORAGE_KEY, JSON.stringify(owned)); + return { status: 'stored' }; + } catch { + return { status: 'unavailable' }; + } +} + +/** Deletes only the trace-owned key on an explicit cleanup action. */ +export function deleteTraceReport(storage?: TraceStorage): { + readonly status: 'deleted' | 'unavailable'; +} { + try { + storageOrDefault(storage).removeItem(TRACE_REPORT_STORAGE_KEY); + return { status: 'deleted' }; + } catch { + return { status: 'unavailable' }; + } +} diff --git a/crates/trusted-server-js/lib/src/trace/types.ts b/crates/trusted-server-js/lib/src/trace/types.ts new file mode 100644 index 000000000..789dd6898 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/types.ts @@ -0,0 +1,151 @@ +/** A bounded shape fact about one owned runtime-visible cookie, never its value. */ +export type CookieHealth = Readonly< + | { source: 'request'; state: 'absent' } + | { + source: 'request'; + state: 'present_valid'; + detail: + | 'valid_ec_format' + | 'valid_eids_format' + | 'valid_tester_value' + | 'valid_diagnostics_value'; + } + | { + source: 'request'; + state: 'present_invalid'; + detail: 'malformed' | 'oversized' | 'unsupported_value'; + } + | { source: 'request'; state: 'duplicate'; detail: 'multiple_values' } + | { + source: 'request'; + state: 'unavailable'; + detail: 'header_too_large' | 'header_not_utf8' | 'runtime_header_ambiguous'; + } +>; + +/** The server-produced allowlist of redacted request facts. */ +export interface TraceRequestContextV1 { + readonly schema_version: 1; + readonly captured_at: string; + readonly network: { + readonly masked_client_ip?: string; + readonly country?: string; + readonly region?: string; + readonly asn?: number; + readonly http_version?: string; + readonly tls_protocol?: string; + readonly tls_cipher?: string; + readonly edge_hostname?: string; + readonly edge_region?: string; + readonly edge_pop?: string; + }; + readonly cookies: { + readonly ts_ec: CookieHealth; + readonly ts_eids: CookieHealth; + readonly ts_tester: CookieHealth; + readonly diagnostics_session: CookieHealth; + }; +} + +/** Exact server-assigned call sites, independent of browser delivery intent. */ +export const TRACE_AUCTION_SOURCES = [ + 'initial_navigation_ssat', + 'spa_page_bids', + 'auction_api', +] as const; +export const TRACE_TERMINAL_STATUSES = [ + 'completed', + 'execution_failed', + 'dispatch_failed', + 'abandoned', + 'skipped', +] as const; +export const TRACE_TERMINAL_REASONS = [ + 'policy_skipped', + 'no_eligible_slots', + 'no_provider_launched', + 'provider_execution_failed', + 'collection_failed', + 'unknown', +] as const; +export const TRACE_PROVIDER_ROLES = ['bidder', 'mediator', 'unknown'] as const; +export const TRACE_PROVIDER_STATUSES = [ + 'success', + 'no_bid', + 'error', + 'pending', + 'abandoned', + 'unknown', +] as const; +export const TRACE_CANDIDATES = [ + 'selected', + 'no_candidate', + 'selected_unrenderable', + 'unknown', +] as const; + +/** Bounded auction-wide provider observations, without provider identity. */ +export interface TraceProviderCall { + readonly provider_number: number; + readonly role: (typeof TRACE_PROVIDER_ROLES)[number]; + readonly status: (typeof TRACE_PROVIDER_STATUSES)[number]; + readonly response_time_ms?: number; + readonly returned_bid_count: number; +} + +/** Bounded slot candidate facts using only an opaque auction-local reference. */ +export interface TraceAuctionSlot { + readonly slot_number: number; + readonly slot_ref: string; + readonly requested_sizes: readonly (readonly [number, number])[]; + readonly returned_bid_count: number; + readonly candidate: (typeof TRACE_CANDIDATES)[number]; + readonly selected_creative_size?: readonly [number, number]; +} + +/** Private SSAT/SPA identity carried into the existing GPT opportunity binding. */ +export interface TraceGptIdentity { + readonly diagnostic_auction_id: string; + readonly slot_ref: string; +} + +/** Server-produced public auction facts, distinct from auction/telemetry inputs. */ +export interface TraceAuctionEvidenceV1 { + readonly schema_version: 1; + readonly diagnostic_auction_id: string; + readonly source: (typeof TRACE_AUCTION_SOURCES)[number]; + readonly terminal_status: (typeof TRACE_TERMINAL_STATUSES)[number]; + readonly terminal_reason?: (typeof TRACE_TERMINAL_REASONS)[number]; + readonly total_time_ms?: number; + readonly provider_calls: readonly TraceProviderCall[]; + readonly slots: readonly TraceAuctionSlot[]; + readonly truncation: { + readonly omitted_provider_calls: number; + readonly omitted_slots: number; + readonly omitted_nested_values: number; + }; + readonly coverage: { readonly provider_to_slot_no_bid: 'unavailable' }; +} + +/** Exactly one server evidence record or a bounded projection failure marker. */ +export type TraceAuctionTransportV1 = Readonly< + | { + schema_version: 1; + evidence: TraceAuctionEvidenceV1; + unavailable_reason?: never; + } + | { + schema_version: 1; + unavailable_reason: 'evidence_projection_failed'; + evidence?: never; + } +>; + +/** Exact decision made by the existing GPT slot/request-cycle recorder. */ +export interface TraceSlotCorrelationV1 { + readonly schema_version: 1; + readonly diagnostic_auction_id: string; + readonly slot_ref: string; + readonly runtime_slot_number: number; + readonly request_number: number; +} diff --git a/crates/trusted-server-js/lib/src/trace/validation.ts b/crates/trusted-server-js/lib/src/trace/validation.ts new file mode 100644 index 000000000..56900ca7b --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/validation.ts @@ -0,0 +1,289 @@ +import { traceItems } from './shape'; +import { traceJsonSnapshot } from './json'; +export { traceArray, traceItems } from './shape'; +import { traceInteger, traceKeys, traceObject, traceOwn } from './context'; +import { + TRACE_AUCTION_SOURCES, + TRACE_TERMINAL_STATUSES, + TRACE_TERMINAL_REASONS, + TRACE_PROVIDER_ROLES, + TRACE_PROVIDER_STATUSES, + TRACE_CANDIDATES, + type TraceAuctionEvidenceV1, + type TraceAuctionTransportV1, + type TraceSlotCorrelationV1, + type TraceProviderCall, + type TraceAuctionSlot, +} from './types'; + +const AUCTION_TOKEN = /^ts-auc-[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$/; +const SLOT_TOKEN = /^ts-slot-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +/** Checks the exact public auction token without normalization. */ +export function validDiagnosticAuctionId(value: unknown): value is string { + return typeof value === 'string' && value.length === 39 && AUCTION_TOKEN.test(value); +} + +/** Checks the exact opaque slot-reference token without normalization. */ +export function validTraceSlotRef(value: unknown): value is string { + return typeof value === 'string' && value.length === 44 && SLOT_TOKEN.test(value); +} + +/** Requires an exact enum member without coercion. */ +export function traceEnum(value: unknown, members: readonly T[]): value is T { + return typeof value === 'string' && members.includes(value as T); +} + +/** Validates a requested/creative dimension, or an explicitly zero-capable CSS box. */ +export function traceSize(value: unknown, allowZero = false): value is [number, number] { + const items = traceItems(value, 2); + return ( + items !== undefined && + items.length === 2 && + items.every((dimension) => traceInteger(dimension, 100_000) && (allowZero || dimension > 0)) + ); +} + +function optionalInteger(value: Record, name: string, maximum: number): boolean { + return !traceOwn(value, name) || traceInteger(value[name], maximum); +} + +function provider(value: unknown): boolean { + return ( + traceObject(value) && + traceKeys( + value, + ['provider_number', 'role', 'status', 'returned_bid_count'], + ['response_time_ms'] + ) && + traceInteger(value.provider_number, 65_535) && + value.provider_number > 0 && + traceEnum(value.role, TRACE_PROVIDER_ROLES) && + traceEnum(value.status, TRACE_PROVIDER_STATUSES) && + traceInteger(value.returned_bid_count, 65_535) && + optionalInteger(value, 'response_time_ms', 0xffff_ffff) + ); +} + +function slot(value: unknown): boolean { + return ( + traceObject(value) && + traceKeys( + value, + ['slot_number', 'slot_ref', 'requested_sizes', 'returned_bid_count', 'candidate'], + ['selected_creative_size'] + ) && + traceInteger(value.slot_number, 65_535) && + value.slot_number > 0 && + validTraceSlotRef(value.slot_ref) && + traceInteger(value.returned_bid_count, 65_535) && + traceEnum(value.candidate, TRACE_CANDIDATES) && + arrayMembers(value.requested_sizes, 16, (size) => traceSize(size)) && + (!traceOwn(value, 'selected_creative_size') || traceSize(value.selected_creative_size)) + ); +} + +function evidence(value: unknown): value is TraceAuctionEvidenceV1 { + return ( + traceObject(value) && + traceKeys( + value, + [ + 'schema_version', + 'diagnostic_auction_id', + 'source', + 'terminal_status', + 'provider_calls', + 'slots', + 'truncation', + 'coverage', + ], + ['terminal_reason', 'total_time_ms'] + ) && + value.schema_version === 1 && + validDiagnosticAuctionId(value.diagnostic_auction_id) && + traceEnum(value.source, TRACE_AUCTION_SOURCES) && + traceEnum(value.terminal_status, TRACE_TERMINAL_STATUSES) && + (!traceOwn(value, 'terminal_reason') || + traceEnum(value.terminal_reason, TRACE_TERMINAL_REASONS)) && + optionalInteger(value, 'total_time_ms', 0xffff_ffff) && + arrayMembers(value.provider_calls, 16, provider) && + arrayMembers(value.slots, 64, slot) && + traceObject(value.truncation) && + traceKeys(value.truncation, [ + 'omitted_provider_calls', + 'omitted_slots', + 'omitted_nested_values', + ]) && + Object.values(value.truncation).every((count) => traceInteger(count, 65_535)) && + traceObject(value.coverage) && + traceKeys(value.coverage, ['provider_to_slot_no_bid']) && + value.coverage.provider_to_slot_no_bid === 'unavailable' + ); +} + +function arrayMembers(value: unknown, limit: number, valid: (item: unknown) => boolean): boolean { + const items = traceItems(value, limit); + return items !== undefined && items.every(valid); +} + +/** Validates the exact server-produced evidence model without modifying it. */ +export function validateTraceAuctionEvidence(value: unknown): value is TraceAuctionEvidenceV1 { + try { + const snapshot = traceJsonSnapshot(value); + return snapshot !== undefined && evidence(snapshot.value); + } catch { + return false; + } +} + +/** Validates the exclusive evidence-or-unavailable transport envelope. */ +function transport(value: unknown): value is TraceAuctionTransportV1 { + try { + if (!traceObject(value) || value.schema_version !== 1) return false; + if (traceOwn(value, 'evidence')) + return traceKeys(value, ['schema_version', 'evidence']) && evidence(value.evidence); + return ( + traceKeys(value, ['schema_version', 'unavailable_reason']) && + value.unavailable_reason === 'evidence_projection_failed' + ); + } catch { + return false; + } +} + +/** Validates a descriptor-owned observation of the exact transport envelope. */ +export function validateTraceAuctionTransport(value: unknown): value is TraceAuctionTransportV1 { + const snapshot = traceJsonSnapshot(value); + return snapshot !== undefined && transport(snapshot.value); +} + +/** Validates the trace-only exact-token GPT correlation sidecar. */ +function correlation(value: unknown): value is TraceSlotCorrelationV1 { + try { + return ( + traceObject(value) && + traceKeys(value, [ + 'schema_version', + 'diagnostic_auction_id', + 'slot_ref', + 'runtime_slot_number', + 'request_number', + ]) && + value.schema_version === 1 && + validDiagnosticAuctionId(value.diagnostic_auction_id) && + validTraceSlotRef(value.slot_ref) && + traceInteger(value.runtime_slot_number) && + value.runtime_slot_number > 0 && + traceInteger(value.request_number) && + value.request_number > 0 + ); + } catch { + return false; + } +} + +/** Validates one descriptor-owned observation of an exact GPT sidecar. */ +export function validateTraceSlotCorrelation(value: unknown): value is TraceSlotCorrelationV1 { + const snapshot = traceJsonSnapshot(value); + return snapshot !== undefined && correlation(snapshot.value); +} + +/** Copies a validated envelope into an immutable public model. */ +export function parseTraceAuctionTransport(value: unknown): TraceAuctionTransportV1 | undefined { + try { + const snapshot = traceJsonSnapshot(value); + if (!snapshot) return undefined; + value = snapshot.value; + if (!transport(value)) return undefined; + if (value.evidence === undefined) + return Object.freeze({ + schema_version: 1, + unavailable_reason: 'evidence_projection_failed', + }); + const original = value.evidence; + const copied: TraceAuctionTransportV1 = Object.freeze({ + schema_version: 1, + evidence: Object.freeze({ + schema_version: 1, + diagnostic_auction_id: original.diagnostic_auction_id, + source: original.source, + terminal_status: original.terminal_status, + ...(traceOwn(original, 'terminal_reason') + ? { terminal_reason: original.terminal_reason } + : {}), + ...(traceOwn(original, 'total_time_ms') ? { total_time_ms: original.total_time_ms } : {}), + provider_calls: Object.freeze(copyItems(original.provider_calls, 16, copyProvider)), + slots: Object.freeze(copyItems(original.slots, 64, copySlot)), + truncation: Object.freeze({ + omitted_provider_calls: original.truncation.omitted_provider_calls, + omitted_slots: original.truncation.omitted_slots, + omitted_nested_values: original.truncation.omitted_nested_values, + }), + coverage: Object.freeze({ + provider_to_slot_no_bid: original.coverage.provider_to_slot_no_bid, + }), + }), + }); + return transport(copied) ? copied : undefined; + } catch { + return undefined; + } +} + +function copySize(value: readonly [number, number]): readonly [number, number] { + const items = traceItems(value, 2); + if (!items || items.length !== 2) throw new Error('Invalid trace size'); + return Object.freeze([items[0] as number, items[1] as number]); +} + +function copyItems(value: readonly T[], limit: number, copy: (item: T) => U): U[] { + const items = traceItems(value, limit); + if (!items) throw new Error('Invalid trace array'); + const owned: U[] = []; + for (let index = 0; index < items.length; index += 1) owned.push(copy(items[index] as T)); + return owned; +} + +function copyProvider(value: TraceProviderCall): TraceProviderCall { + return Object.freeze({ + provider_number: value.provider_number, + role: value.role, + status: value.status, + returned_bid_count: value.returned_bid_count, + ...(traceOwn(value, 'response_time_ms') ? { response_time_ms: value.response_time_ms } : {}), + }); +} + +function copySlot(value: TraceAuctionSlot): TraceAuctionSlot { + return Object.freeze({ + slot_number: value.slot_number, + slot_ref: value.slot_ref, + requested_sizes: Object.freeze(copyItems(value.requested_sizes, 16, copySize)), + returned_bid_count: value.returned_bid_count, + candidate: value.candidate, + ...(traceOwn(value, 'selected_creative_size') + ? { selected_creative_size: copySize(value.selected_creative_size!) } + : {}), + }); +} + +/** Copies a validated correlation decision into an immutable public model. */ +export function parseTraceSlotCorrelation(value: unknown): TraceSlotCorrelationV1 | undefined { + try { + const snapshot = traceJsonSnapshot(value); + if (!snapshot) return undefined; + value = snapshot.value; + if (!correlation(value)) return undefined; + const copied: TraceSlotCorrelationV1 = Object.freeze({ + schema_version: 1, + diagnostic_auction_id: value.diagnostic_auction_id, + slot_ref: value.slot_ref, + runtime_slot_number: value.runtime_slot_number, + request_number: value.request_number, + }); + return correlation(copied) ? copied : undefined; + } catch { + return undefined; + } +} diff --git a/crates/trusted-server-js/lib/src/trace/view-model.ts b/crates/trusted-server-js/lib/src/trace/view-model.ts new file mode 100644 index 000000000..9b3cdfd25 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/view-model.ts @@ -0,0 +1,239 @@ +import type { TraceEvidenceView } from './correlation'; +import type { TraceGptRequestCycle, TraceReportV1 } from './report-types'; + +/** Fill state of one GPT slot across all of its retained request cycles. */ +export type TraceSlotFill = 'filled' | 'empty' | 'unknown'; + +/** One proportional segment of a slot's request timing bar. */ +export interface TraceTimingSegment { + readonly kind: 'response' | 'render' | 'load' | 'viewable'; + readonly label: string; + readonly ms: number; + /** Relative flex weight from 1 to 20; exact milliseconds stay in `ms`. */ + readonly weight: number; +} + +/** Server auction a slot was exactly joined to through a correlation record. */ +export interface TraceSlotLink { + readonly auctionNumber: number; + readonly sourceLabel: string; + readonly serverSlotNumber: number; + readonly candidate: TraceReportV1['server_auctions'][number]['slots'][number]['candidate']; +} + +/** Card-level facts for one GPT slot. */ +export interface TraceSlotCard { + readonly runtimeSlotNumber: number; + readonly fill: TraceSlotFill; + readonly backfill: boolean; + readonly size?: readonly [number, number]; + readonly requestCount: number; + readonly timingRequestNumber?: number; + readonly timing: readonly TraceTimingSegment[]; + readonly link?: TraceSlotLink; + readonly attention: readonly string[]; +} + +/** Plain-language counts shown before any detailed fact list. */ +export interface TraceSummary { + readonly headline: string; + readonly reading: readonly string[]; + readonly needsAttention: boolean; + readonly stats: readonly { readonly value: number; readonly label: string }[]; + readonly fills: readonly { readonly fill: TraceSlotFill; readonly count: number }[]; + readonly slots: readonly TraceSlotCard[]; +} + +const MAX_WEIGHT = 20; + +function plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +function fillOf(requests: readonly TraceGptRequestCycle[]): TraceSlotFill { + if (requests.some((cycle) => cycle.isEmpty === false)) return 'filled'; + if (requests.some((cycle) => cycle.isEmpty === true)) return 'empty'; + return 'unknown'; +} + +/** The first filled request explains the slot best; otherwise the latest attempt. */ +function representative( + requests: readonly TraceGptRequestCycle[] +): TraceGptRequestCycle | undefined { + return requests.find((cycle) => cycle.isEmpty === false) ?? requests[requests.length - 1]; +} + +function timing(cycle: TraceGptRequestCycle | undefined): TraceTimingSegment[] { + if (!cycle) return []; + const { durations } = cycle; + const candidates: [TraceTimingSegment['kind'], string, number | undefined][] = [ + ['response', 'response', durations.requestToResponseMs], + ['render', 'render', durations.responseToRenderMs], + durations.renderToViewableMs !== undefined + ? ['viewable', 'until viewable', durations.renderToViewableMs] + : ['load', 'until loaded', durations.renderToLoadMs], + ]; + const raw: [TraceTimingSegment['kind'], string, number][] = []; + for (const [kind, label, ms] of candidates) + if (typeof ms === 'number' && Number.isFinite(ms) && ms >= 0) raw.push([kind, label, ms]); + const total = raw.reduce((sum, [, , ms]) => sum + ms, 0); + return raw.map(([kind, label, ms]) => ({ + kind, + label, + ms, + weight: + total > 0 ? Math.min(MAX_WEIGHT, Math.max(1, Math.round((ms / total) * MAX_WEIGHT))) : 1, + })); +} + +function attentionFor(fill: TraceSlotFill, requests: readonly TraceGptRequestCycle[]): string[] { + const reasons: string[] = []; + if (fill === 'empty') reasons.push('GPT returned no ad'); + if (fill === 'unknown') reasons.push('No fill observed'); + if (requests.some((cycle) => cycle.incompleteSequence)) + reasons.push('GPT event sequence incomplete'); + if (requests.some((cycle) => (cycle.trustedServerCreativeFailures?.length ?? 0) > 0)) + reasons.push('Creative bridge failure observed'); + return reasons; +} + +function linkFor( + runtimeSlotNumber: number, + joined: TraceEvidenceView | undefined +): TraceSlotLink | undefined { + const links = (joined?.auctions ?? []).flatMap((auction, index) => + auction.slots + .filter( + (entry) => entry.correlation === 'matched' && entry.runtimeSlotNumber === runtimeSlotNumber + ) + .map((entry) => ({ + auctionNumber: index + 1, + sourceLabel: auction.sourceLabel, + serverSlotNumber: entry.serverSlot.slot_number, + candidate: entry.serverSlot.candidate, + })) + ); + return links[0]; +} + +/** + * Summarises one validated report for the trace page. + * + * Every sentence restates retained counts or classes. It never names an auction + * winner, never compares server and browser clocks, and never invents thresholds. + */ +export function summarizeTraceReport( + report: TraceReportV1, + joined: TraceEvidenceView | undefined +): TraceSummary { + const slots = report.gpt_diagnostics.slots.map((slot): TraceSlotCard => { + const fill = fillOf(slot.requests); + const cycle = representative(slot.requests); + return { + runtimeSlotNumber: slot.runtimeSlotNumber, + fill, + backfill: cycle?.isBackfill === true, + size: cycle?.size, + requestCount: slot.requests.length, + timingRequestNumber: cycle?.requestNumber, + timing: timing(cycle), + link: linkFor(slot.runtimeSlotNumber, joined), + attention: attentionFor(fill, slot.requests), + }; + }); + const auctions = report.server_auctions; + const calls = auctions.flatMap((auction) => auction.provider_calls); + const errors = calls.filter((call) => call.status === 'error').length; + const completed = auctions.filter((auction) => auction.terminal_status === 'completed').length; + // Mediator responses echo bidder bids, so only bidder calls count as returned bids. + const bids = calls + .filter((call) => call.role === 'bidder') + .reduce((sum, call) => sum + call.returned_bid_count, 0); + const selected = auctions + .flatMap((auction) => auction.slots) + .filter((slot) => slot.candidate === 'selected').length; + const count = (fill: TraceSlotFill): number => slots.filter((slot) => slot.fill === fill).length; + const filled = count('filled'); + const linked = slots.filter((slot) => slot.link).length; + + const headline = slots.length + ? `${filled} of ${plural(slots.length, 'ad slot', 'ad slots')} filled${ + auctions.length ? ` · ${plural(bids, 'Trusted Server bid', 'Trusted Server bids')}` : '' + }` + : 'No GPT ad slots observed'; + + const reading: string[] = []; + if (!auctions.length) reading.push('No server auction was captured for this page.'); + else if (completed === auctions.length) + reading.push( + auctions.length === 1 + ? 'The server auction completed.' + : auctions.length === 2 + ? 'Both server auctions completed.' + : `All ${auctions.length} server auctions completed.` + ); + else { + // Name every other recorded outcome so the reader is never left guessing. + const outcomes = new Map(); + for (const auction of auctions) { + if (auction.terminal_status === 'completed') continue; + const status = auction.terminal_status.replace(/_/g, ' '); + const outcome = auction.terminal_reason + ? `${status} (${auction.terminal_reason.replace(/_/g, ' ')})` + : status; + outcomes.set(outcome, (outcomes.get(outcome) ?? 0) + 1); + } + reading.push( + `${completed} of ${auctions.length} server auctions completed; ${[...outcomes] + .map(([outcome, total]) => `${total} ${outcome}`) + .join(', ')}.` + ); + } + if (auctions.length) + reading.push( + bids + ? `${plural(bids, 'bid was', 'bids were')} returned to Trusted Server.` + : 'No bids were returned to Trusted Server.' + ); + if (selected) + reading.push( + `Trusted Server selected a candidate for ${plural(selected, 'server slot', 'server slots')}.` + ); + if (errors) + reading.push(`${errors} of ${plural(calls.length, 'provider call', 'provider calls')} failed.`); + const classes = new Map(); + for (const slot of report.gpt_diagnostics.slots) { + const cycle = representative(slot.requests); + if (cycle?.isEmpty === false && cycle.responseClass) + classes.set(cycle.responseClass, (classes.get(cycle.responseClass) ?? 0) + 1); + } + if (classes.size) + reading.push( + `GPT classified the filled ads as ${[...classes] + .map(([name, total]) => `${total} ${name.replace(/_/g, ' ')}`) + .join(' and ')}.` + ); + if (auctions.length && slots.length && linked < slots.length) + reading.push( + `${slots.length - linked} of ${plural(slots.length, 'ad slot', 'ad slots')} could not be linked to a server auction.` + ); + + return { + headline, + reading, + needsAttention: + completed < auctions.length || errors > 0 || slots.some((slot) => slot.attention.length > 0), + stats: [ + { + value: auctions.length, + label: auctions.length === 1 ? 'server auction' : 'server auctions', + }, + { value: errors, label: errors === 1 ? 'provider error' : 'provider errors' }, + { value: linked, label: linked === 1 ? 'slot linked to TS' : 'slots linked to TS' }, + ], + fills: (['filled', 'empty', 'unknown'] as const) + .map((fill) => ({ fill, count: count(fill) })) + .filter((entry) => entry.count > 0), + slots, + }; +} diff --git a/crates/trusted-server-js/lib/src/trace/viewer.css b/crates/trusted-server-js/lib/src/trace/viewer.css new file mode 100644 index 000000000..9ea591573 --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/viewer.css @@ -0,0 +1,456 @@ +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1f2328; + --muted: #59636e; + --line: #d1d9e0; + --soft: #f6f8fa; + --accent: #0969da; + --accent-fg: #ffffff; + --ok-bg: #dafbe1; + --ok-fg: #116329; + --bad-bg: #ffebe9; + --bad-fg: #a40e26; + --unknown-bg: #eff2f5; + --unknown-fg: #424a53; + --attention-bg: #fff8c5; + --attention-line: #d4a72c; + --clear-bg: #dafbe1; + --clear-line: #4ac26b; + --segment-response: #0969da; + --segment-render: #8250df; + --segment-after: #1a7f37; + --focus: #ca6800; + font: + 1rem/1.5 system-ui, + -apple-system, + 'Segoe UI', + sans-serif; + background: var(--bg); + color: var(--fg); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --fg: #e6edf3; + --muted: #9198a1; + --line: #3d444d; + --soft: #151b23; + --accent: #4493f8; + --accent-fg: #0d1117; + --ok-bg: #12261e; + --ok-fg: #57ab5a; + --bad-bg: #2d1214; + --bad-fg: #f47067; + --unknown-bg: #212830; + --unknown-fg: #9198a1; + --attention-bg: #2e2610; + --attention-line: #9e6a03; + --clear-bg: #12261e; + --clear-line: #2b6a30; + --segment-response: #4493f8; + --segment-render: #ab7df8; + --segment-after: #3fb950; + --focus: #f0883e; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) + max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); + overflow-wrap: anywhere; + background: var(--bg); +} + +main { + max-width: 48rem; + margin: auto; +} +h1 { + font-size: 1.6rem; + line-height: 1.25; + margin: 0.25rem 0; +} +h2 { + font-size: 1.15rem; + margin: 0 0 0.5rem; +} +h3 { + font-size: 1.05rem; + margin: 0; +} +section { + margin-block: 1.5rem; +} +dl { + margin: 0.25rem 0; +} +dt { + font-weight: 650; + margin-top: 0.75rem; + color: var(--muted); +} +dd { + margin-inline: 0; +} +a { + color: var(--accent); +} +.controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} +button, +.button { + min-width: 44px; + min-height: 44px; + padding: 0.75rem 1rem; + font: inherit; + font-weight: 600; + color: inherit; + background: var(--bg); + border: 2px solid currentColor; + border-radius: 0.6rem; + cursor: pointer; + white-space: normal; +} + +.primary { + background: var(--accent); + color: var(--accent-fg); + border-color: var(--accent); +} +:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 3px; +} +button:disabled { + cursor: wait; + opacity: 0.65; +} +[role='status'] { + min-height: 3rem; +} +pre { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; +} +th, +td { + padding: 0.5rem; + text-align: start; + vertical-align: top; + overflow-wrap: anywhere; +} +[hidden] { + display: none; +} +summary { + min-height: 44px; + padding-block: 0.6rem; + cursor: pointer; +} + +/* Report header */ +.trace-report-header { + border-bottom: 1px solid var(--line); + padding-bottom: 0.75rem; +} +.trace-notice { + display: inline-block; + margin: 0; + padding: 0.15rem 0.6rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--muted); + background: var(--soft); + border: 1px solid var(--line); + border-radius: 999px; +} +.trace-meta { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +/* What happened */ +.trace-verdict { + border: 1px solid var(--attention-line); + background: var(--attention-bg); + border-radius: 1rem; + padding: 1rem; +} +.trace-verdict.is-clear { + border-color: var(--clear-line); + background: var(--clear-bg); +} +.trace-verdict p { + margin: 0.25rem 0; +} +.trace-verdict .trace-headline { + font-size: 1.2rem; + font-weight: 700; + line-height: 1.3; + margin-bottom: 0.5rem; +} +.trace-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; + margin: 0.75rem 0 0; + padding: 0; + list-style: none; +} +.trace-stats li { + background: var(--bg); + border: 1px solid var(--line); + border-radius: 0.75rem; + padding: 0.5rem 0.6rem; +} +.trace-stat-value { + display: block; + font-size: 1.4rem; + font-weight: 700; + line-height: 1.2; +} +.trace-stat-label { + display: block; + font-size: 0.875rem; + color: var(--muted); +} +.trace-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} +.trace-chip { + display: inline-block; + border-radius: 999px; + padding: 0.15rem 0.65rem; + font-size: 0.875rem; + font-weight: 600; + white-space: nowrap; +} +.trace-chip-filled { + background: var(--ok-bg); + color: var(--ok-fg); +} +.trace-chip-empty { + background: var(--bad-bg); + color: var(--bad-fg); +} +.trace-chip-unknown { + background: var(--unknown-bg); + color: var(--unknown-fg); +} + +/* Needs attention */ +.trace-attention { + margin: 0; + padding-left: 1.25rem; +} +.trace-attention li { + margin-block: 0.35rem; +} + +/* Ad slot cards */ +.trace-slot { + border: 1px solid var(--line); + border-radius: 1rem; + padding: 0.75rem 1rem; + margin-block: 0.75rem; + background: var(--bg); + scroll-margin-top: 1rem; +} +.trace-slot:target { + outline: 3px solid var(--focus); +} +.trace-slot > header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} +.trace-slot p { + margin: 0.35rem 0; +} +.trace-timing { + display: flex; + height: 0.6rem; + margin-top: 0.75rem; + border-radius: 999px; + overflow: hidden; + background: var(--unknown-bg); +} +.trace-segment { + min-width: 3px; +} +.trace-segment-response { + background: var(--segment-response); +} +.trace-segment-render { + background: var(--segment-render); +} +.trace-segment-load, +.trace-segment-viewable { + background: var(--segment-after); +} +.trace-legend { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + margin: 0.35rem 0 0; + padding: 0; + list-style: none; + font-size: 0.875rem; + color: var(--muted); +} +.trace-legend li::before { + content: ''; + display: inline-block; + width: 0.6rem; + height: 0.6rem; + margin-right: 0.35rem; + border-radius: 2px; + vertical-align: baseline; +} +.trace-legend-response::before { + background: var(--segment-response); +} +.trace-legend-render::before { + background: var(--segment-render); +} +.trace-legend-load::before, +.trace-legend-viewable::before { + background: var(--segment-after); +} +.trace-link { + font-size: 0.9rem; + color: var(--accent); +} +.trace-link.is-unlinked { + color: var(--muted); +} +.trace-slot-details > summary, +.trace-request > summary, +.trace-auction > summary { + color: var(--accent); + font-weight: 600; +} +.trace-request, +.trace-auction, +.trace-server-slot { + border-top: 1px solid var(--line); + margin-top: 0.5rem; +} +.trace-server-slot { + padding-top: 0.5rem; +} +.trace-unavailable > summary { + color: var(--muted); + font-weight: 400; +} + +/* Collapsed detail sections */ +.trace-more { + margin-top: 2rem; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} +.trace-section { + border: 1px solid var(--line); + border-radius: 0.75rem; + padding: 0 1rem; + margin-block: 0.5rem; +} +.trace-section > summary h2 { + display: inline; + font-size: 1.05rem; + margin: 0; +} +.trace-section[open] { + padding-bottom: 0.75rem; +} + +/* Timing bar weights: relative flex shares, never inline styles (CSP style-src 'self'). */ +.trace-weight-1 { + flex: 1; +} +.trace-weight-2 { + flex: 2; +} +.trace-weight-3 { + flex: 3; +} +.trace-weight-4 { + flex: 4; +} +.trace-weight-5 { + flex: 5; +} +.trace-weight-6 { + flex: 6; +} +.trace-weight-7 { + flex: 7; +} +.trace-weight-8 { + flex: 8; +} +.trace-weight-9 { + flex: 9; +} +.trace-weight-10 { + flex: 10; +} +.trace-weight-11 { + flex: 11; +} +.trace-weight-12 { + flex: 12; +} +.trace-weight-13 { + flex: 13; +} +.trace-weight-14 { + flex: 14; +} +.trace-weight-15 { + flex: 15; +} +.trace-weight-16 { + flex: 16; +} +.trace-weight-17 { + flex: 17; +} +.trace-weight-18 { + flex: 18; +} +.trace-weight-19 { + flex: 19; +} +.trace-weight-20 { + flex: 20; +} + +/* Setup controls move below the report when a report is shown. */ +#trace-viewer-setup { + border-top: 1px solid var(--line); + margin-top: 1.5rem; +} diff --git a/crates/trusted-server-js/lib/src/trace/viewer.ts b/crates/trusted-server-js/lib/src/trace/viewer.ts new file mode 100644 index 000000000..4875c2c6b --- /dev/null +++ b/crates/trusted-server-js/lib/src/trace/viewer.ts @@ -0,0 +1,8 @@ +import { mountTraceViewer } from './report-view'; +import './viewer.css'; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => mountTraceViewer(), { once: true }); +} else { + mountTraceViewer(); +} diff --git a/crates/trusted-server-js/lib/test/core/index.test.ts b/crates/trusted-server-js/lib/test/core/index.test.ts index a02082b59..d326199a6 100644 --- a/crates/trusted-server-js/lib/test/core/index.test.ts +++ b/crates/trusted-server-js/lib/test/core/index.test.ts @@ -9,6 +9,7 @@ describe('core/index', () => { await vi.resetModules(); document.body.innerHTML = ''; delete window.tsjs; + delete window.__tsjs_trace_active; }); afterEach(() => { @@ -28,6 +29,28 @@ describe('core/index', () => { expect(typeof api.getConfig).toBe('function'); expect(typeof api.requestAds).toBe('function'); }); + it('installs the literal gated shared trace facade before queued publisher callbacks', async () => { + window.__tsjs_trace_active = true; + let observed: unknown; + window.tsjs = { + que: [ + () => { + observed = window.tsjs?.traceEvidence; + }, + ], + } as TsjsApi; + await import('../../src/core/index'); + expect(observed).toBeDefined(); + expect(observed).toBe(window.tsjs?.traceEvidence); + }); + it.each([undefined, false, 'true', 1])( + 'keeps the trace facade absent for nonliteral gate %s', + async (active) => { + window.__tsjs_trace_active = active; + await import('../../src/core/index'); + expect(window.tsjs?.traceEvidence).toBeUndefined(); + } + ); it('defaults adSlots and bids so gated-off pages never see undefined', async () => { await import('../../src/core/index'); diff --git a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts index a5a229f5e..1233a6730 100644 --- a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import envelope from '../../fixtures/aps-renderer-v1.json'; import type { ApsRendererV1 } from '../../../src/core/types'; +import { GPT_DIAGNOSTICS_CREATIVE_FAILURES } from '../../../src/core/types'; import { log } from '../../../src/core/log'; import { APS_NATIVE_RENDERER_TIMEOUT_MS, @@ -9,8 +10,10 @@ import { APS_RENDERER_PATH, APS_RENDERER_SANDBOX, APS_RENDERING_MODE_ATTRIBUTE_NAME, + APS_RENDER_FAILED_MESSAGE, APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, + apsRenderFailureReason, apsRendererUrl, dispatchApsRendering as dispatchDefaultApsRendering, getApsPrebidRenderer, @@ -724,6 +727,87 @@ describe('direct APS rendering', () => { expect(document.querySelector('#fictional-slot iframe')).toBeNull(); }); + it('tears down the frame when the renderer document reports a failure reason', () => { + expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); + const iframe = document.querySelector('#fictional-slot iframe') as HTMLIFrameElement; + const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); + iframe.dispatchEvent(new Event('load')); + const sent = postMessage.mock.calls[0][0] as { nonce: string }; + + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/renderer-failed', + nonce: sent.nonce, + reason: 'descriptor_envelope', + }, + source: iframe.contentWindow, + }) + ); + + expect(document.querySelector('#fictional-slot iframe')).toBeNull(); + expect(document.querySelector('#fictional-slot span')).not.toBeNull(); + }); + + it('tears down the frame on a bad-hash report that carries no nonce to echo', () => { + expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); + const iframe = document.querySelector('#fictional-slot iframe') as HTMLIFrameElement; + iframe.dispatchEvent(new Event('load')); + + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/renderer-failed', + nonce: undefined, + reason: 'bad_hash', + }, + source: iframe.contentWindow, + }) + ); + + expect(document.querySelector('#fictional-slot iframe')).toBeNull(); + }); + + it('ignores a failure report bearing a foreign nonce', () => { + expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); + const iframe = document.querySelector('#fictional-slot iframe') as HTMLIFrameElement; + iframe.dispatchEvent(new Event('load')); + + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/renderer-failed', + nonce: 'a-foreign-nonce', + reason: 'bad_hash', + }, + source: iframe.contentWindow, + }) + ); + + expect(document.querySelector('#fictional-slot iframe')).not.toBeNull(); + }); + + it('still rejects a ready message carrying an unexpected extra key', () => { + expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); + const iframe = document.querySelector('#fictional-slot iframe') as HTMLIFrameElement; + const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); + iframe.dispatchEvent(new Event('load')); + const sent = postMessage.mock.calls[0][0] as { nonce: string }; + + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/renderer-ready', + nonce: sent.nonce, + reason: 'unexpected', + }, + source: iframe.contentWindow, + }) + ); + + expect(iframe.style.display).toBe('none'); + }); + it('removes an unacknowledged frame without clearing publisher content', () => { vi.useFakeTimers(); try { @@ -855,3 +939,170 @@ describe('Universal Creative APS source', () => { } }); }); + +describe('APS render failure reason allowlist', () => { + it('maps every reason the renderer frame and creative source can emit', () => { + expect(apsRenderFailureReason('bad_hash')).toBe('aps_bad_hash'); + expect(apsRenderFailureReason('nonce_mismatch')).toBe('aps_nonce_mismatch'); + expect(apsRenderFailureReason('descriptor_keys')).toBe('aps_descriptor_keys'); + expect(apsRenderFailureReason('descriptor_fields')).toBe('aps_descriptor_fields'); + expect(apsRenderFailureReason('descriptor_envelope')).toBe('aps_descriptor_envelope'); + expect(apsRenderFailureReason('amazon_script_error')).toBe('aps_runner_script_error'); + expect(apsRenderFailureReason('frame_timeout')).toBe('aps_frame_timeout'); + expect(apsRenderFailureReason('frame_load_error')).toBe('aps_frame_load_error'); + expect(apsRenderFailureReason('frame_reported_failure')).toBe('aps_frame_reported_failure'); + expect(apsRenderFailureReason('unknown')).toBe('aps_unknown'); + }); + + it('resolves every mapped reason to a category the diagnostics store accepts', () => { + for (const wire of [ + 'bad_hash', + 'nonce_mismatch', + 'descriptor_keys', + 'descriptor_fields', + 'descriptor_envelope', + 'amazon_script_error', + 'frame_timeout', + 'frame_load_error', + 'frame_reported_failure', + 'unknown', + ]) { + const reason = apsRenderFailureReason(wire); + expect(GPT_DIAGNOSTICS_CREATIVE_FAILURES).toContain(reason); + } + }); + + it('rejects unlisted, inherited, and non-string reasons from the cross-origin frame', () => { + expect(apsRenderFailureReason('not_a_real_reason')).toBeUndefined(); + expect(apsRenderFailureReason('__proto__')).toBeUndefined(); + expect(apsRenderFailureReason('constructor')).toBeUndefined(); + expect(apsRenderFailureReason('toString')).toBeUndefined(); + expect(apsRenderFailureReason('hasOwnProperty')).toBeUndefined(); + expect(apsRenderFailureReason(42)).toBeUndefined(); + expect(apsRenderFailureReason(undefined)).toBeUndefined(); + expect(apsRenderFailureReason(null)).toBeUndefined(); + expect(apsRenderFailureReason({ toString: () => 'frame_timeout' })).toBeUndefined(); + }); +}); + +describe('Universal Creative APS failure relay', () => { + it('relays the frame failure reason to the top window for diagnostics', async () => { + const dynamicWindow = window as unknown as { + render?: (data: Record, helper: unknown, target: Window) => Promise; + }; + const relayed: unknown[] = []; + const capture = (event: MessageEvent): void => { + const data = event.data as { message?: unknown } | undefined; + if (data && data.message === APS_RENDER_FAILED_MESSAGE) relayed.push(data); + }; + window.addEventListener('message', capture); + window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); + + try { + const rendered = dynamicWindow.render!( + { adId: 'example-ad-id', apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, + undefined, + window + ); + const iframe = document.body.querySelector('iframe')!; + const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); + iframe.dispatchEvent(new Event('load')); + const sent = postMessage.mock.calls[0][0] as { nonce: string }; + + window.dispatchEvent( + new MessageEvent('message', { + data: { + message: 'trusted-server/aps/renderer-failed', + nonce: sent.nonce, + reason: 'descriptor_envelope', + }, + source: iframe.contentWindow, + }) + ); + + await expect(rendered).rejects.toThrow(); + // `postMessage` is delivered on a later task than the rejection microtask. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(relayed).toEqual([ + { + message: APS_RENDER_FAILED_MESSAGE, + adId: 'example-ad-id', + reason: 'descriptor_envelope', + }, + ]); + } finally { + window.removeEventListener('message', capture); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('reports a frame timeout when the renderer frame never acknowledges', async () => { + const dynamicWindow = window as unknown as { + render?: (data: Record, helper: unknown, target: Window) => Promise; + }; + const relayed: unknown[] = []; + const capture = (event: MessageEvent): void => { + const data = event.data as { message?: unknown } | undefined; + if (data && data.message === APS_RENDER_FAILED_MESSAGE) relayed.push(data); + }; + window.addEventListener('message', capture); + vi.useFakeTimers(); + window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); + + try { + const rendered = dynamicWindow.render!( + { adId: 'timeout-ad-id', apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, + undefined, + window + ); + rendered.catch(() => {}); + document.body.querySelector('iframe')!.dispatchEvent(new Event('load')); + + // Async advance so the queued `postMessage` dispatch task also runs. + await vi.advanceTimersByTimeAsync(10_001); + + expect(relayed).toEqual([ + { message: APS_RENDER_FAILED_MESSAGE, adId: 'timeout-ad-id', reason: 'frame_timeout' }, + ]); + } finally { + vi.useRealTimers(); + window.removeEventListener('message', capture); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('labels a frame that fails to load', async () => { + const dynamicWindow = window as unknown as { + render?: (data: Record, helper: unknown, target: Window) => Promise; + }; + const relayed: Array<{ reason?: unknown }> = []; + const capture = (event: MessageEvent): void => { + const data = event.data as { message?: unknown; reason?: unknown } | undefined; + if (data && data.message === APS_RENDER_FAILED_MESSAGE) relayed.push(data); + }; + window.addEventListener('message', capture); + window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); + + try { + const rendered = dynamicWindow.render!( + { adId: 'load-error-ad-id', apsRenderer: descriptor(), rendererUrl: apsRendererUrl() }, + undefined, + window + ); + rendered.catch(() => {}); + const iframe = document.body.querySelector('iframe') as HTMLIFrameElement & { + onerror?: () => void; + }; + iframe.onerror?.(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(relayed.map((entry) => entry.reason)).toEqual(['frame_load_error']); + } finally { + window.removeEventListener('message', capture); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 540d15229..c873523a9 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -10,9 +10,15 @@ import { resolveFirstImpressionElement, } from '../../../src/core/first_impression'; import type { AuctionBidData, TsjsApi } from '../../../src/core/types'; +import { installTraceRuntime } from '../../../src/trace/runtime'; +import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; +import { gptTransport, joinedGptStore } from '../../trace/gpt-fixtures'; +import { AUCTION_TOKEN, SLOT_TOKEN } from '../../trace/fixtures'; import { APS_PREBID_CREATIVE_RUNNER_URL, APS_RENDERING_MODE_ATTRIBUTE_NAME, + APS_RENDER_FAILED_MESSAGE, + APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, } from '../../../src/integrations/aps/render'; let publisherNativeScript: HTMLScriptElement | undefined; @@ -215,6 +221,7 @@ describe('installTsAdInit', () => { }); afterEach(() => { + delete window.__tsjs_trace_active; document.getElementById('div-atf-sidebar')?.remove(); document.getElementById('div-new-slot')?.remove(); document.getElementById('div-atf-sidebar-2')?.remove(); @@ -224,6 +231,115 @@ describe('installTsAdInit', () => { document.querySelectorAll('[data-responsive-slot-test]').forEach((element) => element.remove()); }); + it.each(['bootstrap', 'bundle'] as const)( + 'binds the actual GPT cycle to the exact delivered slot in %s adInit', + async (implementation) => { + const { mockSlot } = configureOpportunityDiagnostics(undefined, vi.fn()); + const ts = (window as TestWindow).tsjs as TsjsApi; + window.__tsjs_trace_active = true; + const collector = installTraceRuntime(ts)!; + ts.adSlots![0]!.ext = { trusted_server: { trace_slot_ref: SLOT_TOKEN } }; + const store = new GptDiagnosticsStore({ + onTraceCorrelation: (value) => collector.recordCorrelation(value), + }); + ts.gptDiagnosticsRecorder = store; + ts.traceGpt!.observeTransport(ts.adSlots, gptTransport(), 'initial_navigation_ssat'); + await installHandoff(implementation); + ts.adInit!(); + expect(collector.snapshot().value?.slotCorrelations).toEqual([]); + store.recordSlotRequested(mockSlot); + expect(collector.snapshot().value?.slotCorrelations).toEqual([ + { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + runtime_slot_number: 1, + request_number: 1, + }, + ]); + expect(store.snapshot().slots[0]!.requests[0]).toMatchObject({ + requestPath: 'trusted_server_direct', + trustedServerOpportunity: 'no_candidate', + trustedServerAuctionId: AUCTION_TOKEN, + }); + expect(joinedGptStore(store, collector)?.auctions[0]?.slots[0]?.correlation).toBe('matched'); + expect(JSON.stringify(store.snapshot())).not.toContain(SLOT_TOKEN); + } + ); + + it.each(['bootstrap', 'bundle'] as const)( + 'retains the validated binding through the %s publisher first-impression fallback', + async (implementation) => { + vi.useFakeTimers(); + try { + const { mockPubads, mockSlot } = configureOpportunityDiagnostics( + { hb_pb: '1.10', hb_adid: 'example-creative', adm: '
Example
' }, + vi.fn() + ); + const ts = (window as TestWindow).tsjs as TsjsApi; + window.__tsjs_trace_active = true; + const collector = installTraceRuntime(ts)!; + ts.adSlots![0]!.ext = { trusted_server: { trace_slot_ref: SLOT_TOKEN } }; + const store = new GptDiagnosticsStore({ + onTraceCorrelation: (value) => collector.recordCorrelation(value), + }); + ts.gptDiagnosticsRecorder = store; + ts.traceGpt!.observeTransport(ts.adSlots, gptTransport(), 'initial_navigation_ssat'); + registerPublisherFirstImpressionAuctions(ts, ['div-atf-sidebar']); + await installHandoff(implementation); + ts.adInit!(); + expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(collector.snapshot().value?.slotCorrelations).toEqual([]); + vi.advanceTimersByTime(5001); + expect(mockPubads.refresh).toHaveBeenCalledOnce(); + store.recordSlotRequested(mockSlot); + expect(collector.snapshot().value?.slotCorrelations).toEqual([ + { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + runtime_slot_number: 1, + request_number: 1, + }, + ]); + expect(store.snapshot().slots[0]!.requests[0]).toMatchObject({ + requestPath: 'trusted_server_direct', + trustedServerOpportunity: 'renderable_candidate', + trustedServerAuctionId: AUCTION_TOKEN, + }); + expect(joinedGptStore(store, collector)?.auctions[0]?.slots[0]?.correlation).toBe( + 'matched' + ); + } finally { + vi.useRealTimers(); + } + } + ); + + it.each(['bootstrap', 'bundle'] as const)( + 'preserves a conflicting ordinary auction marker without a fabricated sidecar in %s', + async (implementation) => { + const marker = 'ts-auc-2234567812344abc8def123456789abc'; + const { mockSlot } = configureOpportunityDiagnostics({ hb_auction_id: marker }, vi.fn()); + const ts = (window as TestWindow).tsjs as TsjsApi; + window.__tsjs_trace_active = true; + const collector = installTraceRuntime(ts)!; + ts.adSlots![0]!.ext = { trusted_server: { trace_slot_ref: SLOT_TOKEN } }; + const store = new GptDiagnosticsStore({ + onTraceCorrelation: (value) => collector.recordCorrelation(value), + }); + ts.gptDiagnosticsRecorder = store; + ts.traceGpt!.observeTransport(ts.adSlots, gptTransport(), 'initial_navigation_ssat'); + await installHandoff(implementation); + ts.adInit!(); + store.recordSlotRequested(mockSlot); + expect(store.snapshot().slots[0]!.requests[0]!.trustedServerAuctionId).toBe(marker); + expect(collector.snapshot().value?.slotCorrelations).toEqual([]); + expect(collector.snapshot().value?.issues).toEqual(['correlation_unavailable']); + expect(joinedGptStore(store, collector)?.auctions[0]?.slots[0]?.correlation).toBe('unknown'); + } + ); + function configureOpportunityDiagnostics( bid: AuctionBidData | undefined, recordTrustedServerOpportunity: ReturnType, @@ -442,6 +558,65 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', 'unrenderable_candidate', 'auction-123', + undefined, + { + auctionType: 'ssat', + winner: { bidder: 'example', priceBucket: '1.00' }, + } + ); + }); + + it('snapshots auction timing with the bids before queued GPT work runs', async () => { + const recordTrustedServerOpportunity = vi.fn(); + const { mockSlot } = configureOpportunityDiagnostics( + { hb_pb: '1.00', hb_bidder: 'example', hb_adid: 'creative-1' }, + recordTrustedServerOpportunity + ); + const ts = (window as TestWindow).tsjs!; + ts.auctionDiagnostics = { auctionResolvedMs: 84 }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + let queued: (() => void) | undefined; + const googletag = (window as TestWindow).googletag as { + cmd: { push(callback: () => void): void }; + }; + googletag.cmd.push = (callback) => { + queued = callback; + }; + + ts.adInit!(); + ts.auctionDiagnostics.auctionResolvedMs = 99; + queued?.(); + + expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( + mockSlot, + 'atf_sidebar_ad', + 'unrenderable_candidate', + undefined, + undefined, + { + auctionType: 'ssat', + winner: { bidder: 'example', priceBucket: '1.00' }, + serverTimings: { auctionResolvedMs: 84 }, + } + ); + }); + + it('does not infer an SPA auction from navigation generation alone', async () => { + const recordTrustedServerOpportunity = vi.fn(); + const { mockSlot } = configureOpportunityDiagnostics(undefined, recordTrustedServerOpportunity); + (window as TestWindow).tsjs!.navGeneration = 1; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( + mockSlot, + 'atf_sidebar_ad', + 'no_candidate', + undefined, undefined ); }); @@ -3486,7 +3661,7 @@ describe('installTsRenderBridge', () => { message: 'Prebid Response', adId: renderer.bidId, renderer: expect.stringContaining('window.render=function'), - rendererVersion: 4, + rendererVersion: APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, rendererUrl: new URL('/integrations/aps/renderer', window.location.origin).href, apsRenderer: renderer, width: 300, @@ -3663,6 +3838,163 @@ describe('installTsRenderBridge', () => { foreignIframe.remove(); }); + it('records a relayed APS render failure against a real diagnostics store', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'prebid-relay-ad-id'; + const store = new GptDiagnosticsStore({ defer: () => undefined }); + (window as TestWindow).tsjs.gptDiagnosticsRecorder = store as never; + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + adUnitCode: 'div-header', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 60_000, + markUsed: vi.fn(), + }, + }; + + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + const slot = { + getSlotElementId: () => 'div-header', + getAdUnitPath: () => '/example/div-header', + }; + store.recordTrustedServerOpportunity(slot, 'div-header', 'renderable_candidate'); + store.recordSlotRequested(slot); + + // Serve the capability so the ad ID is tombstoned with its ad unit. + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), + ports: [{ postMessage: () => {} }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + // The creative frame then relays a render failure to the top window. + bridgeListener( + Object.assign(new Event('message'), { + data: { + message: APS_RENDER_FAILED_MESSAGE, + adId: prebidAdId, + reason: 'frame_timeout', + }, + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toEqual([ + 'aps_frame_timeout', + ]); + } finally { + delete (window as TestWindow).tsjs.gptDiagnosticsRecorder; + } + }); + + it('drops a relayed APS failure from a frame outside the served ad unit', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'prebid-foreign-relay-ad-id'; + const recordTrustedServerCreativeFailure = vi.fn(); + (window as TestWindow).tsjs.gptDiagnosticsRecorder = { + recordTrustedServerOpportunity: vi.fn(), + recordTrustedServerCreativeRequest: vi.fn(() => 3), + recordTrustedServerCreativeResponse: vi.fn(), + recordTrustedServerCreativeFailure, + } as never; + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + adUnitCode: 'div-header', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 60_000, + markUsed: vi.fn(), + }, + }; + + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), + ports: [{ postMessage: () => {} }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + recordTrustedServerCreativeFailure.mockClear(); + + // A frame that is not inside the served ad unit cannot forge a failure, + // even though it names a live ad ID and an allowlisted reason. + const foreignIframe = document.createElement('iframe'); + document.body.appendChild(foreignIframe); + bridgeListener( + Object.assign(new Event('message'), { + data: { message: APS_RENDER_FAILED_MESSAGE, adId: prebidAdId, reason: 'frame_timeout' }, + source: foreignIframe.contentWindow, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); + foreignIframe.remove(); + } finally { + delete (window as TestWindow).tsjs.gptDiagnosticsRecorder; + } + }); + + it('drops a relayed APS failure whose reason is not on the allowlist', async () => { + const renderer = apsRenderer(); + const prebidAdId = 'prebid-hostile-reason-ad-id'; + const recordTrustedServerCreativeFailure = vi.fn(); + (window as TestWindow).tsjs.gptDiagnosticsRecorder = { + recordTrustedServerOpportunity: vi.fn(), + recordTrustedServerCreativeRequest: vi.fn(() => 4), + recordTrustedServerCreativeResponse: vi.fn(), + recordTrustedServerCreativeFailure, + } as never; + (window as TestWindow).tsjs.apsPrebidRenderers = { + [prebidAdId]: { + adUnitCode: 'div-header', + renderer, + registeredAt: Date.now(), + expiresAt: Date.now() + 60_000, + markUsed: vi.fn(), + }, + }; + + try { + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), + ports: [{ postMessage: () => {} }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + recordTrustedServerCreativeFailure.mockClear(); + + for (const reason of ['__proto__', 'constructor', 'not_a_reason', 7]) { + bridgeListener( + Object.assign(new Event('message'), { + data: { message: APS_RENDER_FAILED_MESSAGE, adId: prebidAdId, reason }, + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + } + + expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); + } finally { + delete (window as TestWindow).tsjs.gptDiagnosticsRecorder; + } + }); + it('contract test: fails a registered APS runner without a Universal Creative response or markUsed', async () => { const renderer = apsRenderer(); const prebidAdId = 'native-prebid-decline-ad-id'; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index 46ba778dc..9cb469b84 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -781,10 +781,14 @@ describe('gpt_bootstrap.js fallback', () => { const adInit = vi.fn(); ts.adInit = adInit; ts.bids = { live_slot: { hb_pb: '2.50' } }; + ts.auctionDiagnostics = { auctionResolvedMs: 10 }; ts.navGeneration = 1; - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }); + ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, undefined, { + auctionResolvedMs: 99, + }); expect(ts.bids).toEqual({ live_slot: { hb_pb: '2.50' } }); + expect(ts.auctionDiagnostics).toEqual({ auctionResolvedMs: 10 }); window.dispatchEvent(new Event('load')); flushFrame(); @@ -813,8 +817,10 @@ describe('gpt_bootstrap.js fallback', () => { formats: [[728, 90]] as Array<[number, number]>, }; - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, [ssrSlot]); + const auctionDiagnostics = { auctionResolvedMs: 84 }; + ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, [ssrSlot], auctionDiagnostics); expect(ts.adSlots).toEqual([ssrSlot]); + expect(ts.auctionDiagnostics).toEqual(auctionDiagnostics); ts.adSlots = [liveSlot]; ts.navGeneration = 1; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts index 6ca610547..cb9ab8a0f 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts @@ -4,6 +4,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { TsjsApi } from '../../../src/core/types'; import { GPT_BOOTSTRAP_PATH } from '../../fixtures/paths'; +import { installTraceRuntime } from '../../../src/trace/runtime'; +import { AUCTION_TOKEN, SLOT_TOKEN } from '../../trace/fixtures'; type TestWindow = Window & { googletag?: unknown; @@ -83,6 +85,7 @@ describe('scheduleInitialAdInit', () => { }); afterEach(() => { + delete window.__tsjs_trace_active; history.pushState = originalPushState; history.replaceState = originalReplaceState; // Reset jsdom location back to root for the next test. @@ -98,6 +101,127 @@ describe('scheduleInitialAdInit', () => { vi.unstubAllGlobals(); }); + it('records owned initial evidence after the final generation guard and before adInit', async () => { + readyState = 'complete'; + await importGptModule(); + window.__tsjs_trace_active = true; + const ts = (window as TestWindow).tsjs!; + installTraceRuntime(ts); + const evidence = { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + source: 'initial_navigation_ssat', + terminal_status: 'completed', + provider_calls: [], + slots: [ + { + slot_number: 1, + slot_ref: SLOT_TOKEN, + requested_sizes: [[300, 250]], + candidate: 'no_candidate', + returned_bid_count: 0, + }, + ], + truncation: { omitted_provider_calls: 0, omitted_slots: 0, omitted_nested_values: 0 }, + coverage: { provider_to_slot_no_bid: 'unavailable' }, + }; + const slots = [ + { + id: 'example', + gam_unit_path: '/example/ad', + div_id: 'example', + formats: [[300, 250]] as Array<[number, number]>, + ext: { trusted_server: { trace_slot_ref: SLOT_TOKEN } }, + }, + ]; + const bids = { example: { hb_pb: '1.00' } }; + ts.adInit = vi.fn(() => { + expect(ts.traceEvidence!.snapshot().value?.serverAuctions).toEqual([evidence]); + expect(ts.adSlots).toBe(slots); + expect(ts.bids).toBe(bids); + }); + ts.scheduleInitialAdInit!(bids, slots, undefined, { schema_version: 1, evidence }); + expect(ts.traceEvidence!.snapshot().value?.serverAuctions).toEqual([]); + flushFrame(); + flushFrame(); + expect(ts.adInit).toHaveBeenCalledOnce(); + expect(ts.traceEvidence!.snapshot().value?.serverAuctions).toEqual([evidence]); + }); + + it('does not capture an initial transport abandoned before hydration', async () => { + readyState = 'complete'; + await importGptModule(); + window.__tsjs_trace_active = true; + const ts = (window as TestWindow).tsjs!; + installTraceRuntime(ts); + ts.adInit = vi.fn(); + ts.scheduleInitialAdInit!(undefined, [], undefined, { + schema_version: 1, + unavailable_reason: 'evidence_projection_failed', + }); + ts.navGeneration = 1; + flushFrame(); + flushFrame(); + expect(ts.traceEvidence!.snapshot().value?.serverAuctions).toEqual([]); + expect(ts.adInit).not.toHaveBeenCalled(); + }); + + it.each(['absent', 'malformed', 'throwing-bridge', 'inactive'] as const)( + 'preserves ordinary adInit for an %s optional trace seam', + async (kind) => { + readyState = 'complete'; + await importGptModule(); + const ts = (window as TestWindow).tsjs!; + window.__tsjs_trace_active = true; + installTraceRuntime(ts); + const observe = vi.fn(() => { + throw new Error('private-bridge'); + }); + if (kind === 'throwing-bridge' || kind === 'inactive') + ts.traceGpt = { ...ts.traceGpt!, observeTransport: observe }; + if (kind === 'inactive') window.__tsjs_trace_active = 'true'; + ts.adInit = vi.fn(); + ts.scheduleInitialAdInit!( + undefined, + [], + undefined, + kind === 'absent' ? undefined : { schema_version: 1 } + ); + flushFrame(); + flushFrame(); + expect(ts.adInit).toHaveBeenCalledOnce(); + expect(ts.traceEvidence!.snapshot().value?.issues).toEqual( + kind === 'malformed' ? ['evidence_validation_failed'] : [] + ); + if (kind === 'inactive') expect(observe).not.toHaveBeenCalled(); + } + ); + + it.each(['bootstrap', 'bootstrap-to-bundle'] as const)( + 'captures the first scheduler claim through the shared bridge (%s)', + async (mode) => { + readyState = 'complete'; + runBootstrap(); + window.__tsjs_trace_active = true; + const ts = (window as TestWindow).tsjs!; + installTraceRuntime(ts); + ts.adInit = vi.fn(); + ts.scheduleInitialAdInit!(undefined, [], undefined, { + schema_version: 1, + unavailable_reason: 'evidence_projection_failed', + }); + if (mode === 'bootstrap-to-bundle') { + await importGptModule(); + ts.adInit = vi.fn(); + ts.scheduleInitialAdInit!(undefined, [], undefined, { schema_version: 1 }); + } + flushFrame(); + flushFrame(); + expect(ts.adInit).toHaveBeenCalledOnce(); + expect(ts.traceEvidence!.snapshot().value?.issues).toEqual(['evidence_projection_failed']); + } + ); + it('applies the SSR payload and defers adInit until window load plus two animation frames', async () => { await importGptModule(); const ts = (window as TestWindow).tsjs!; @@ -322,9 +446,13 @@ describe('scheduleInitialAdInit', () => { await flushAsync(); expect(ts.navGeneration).toBe(1); ts.bids = { live_slot: { hb_pb: '2.50' } }; + ts.auctionDiagnostics = { auctionResolvedMs: 10 }; - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }); + ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, undefined, { + auctionResolvedMs: 99, + }); expect(ts.bids).toEqual({ live_slot: { hb_pb: '2.50' } }); + expect(ts.auctionDiagnostics).toEqual({ auctionResolvedMs: 10 }); window.dispatchEvent(new Event('load')); flushFrame(); @@ -343,6 +471,7 @@ describe('scheduleInitialAdInit', () => { json: async () => ({ slots: [{ id: 's1', div_id: 'div-s1' }], bids: { s1: { hb_pb: '3.00' } }, + auctionDiagnostics: { auctionResolvedMs: 84 }, }), }); await importGptModule(); @@ -353,6 +482,7 @@ describe('scheduleInitialAdInit', () => { history.pushState({}, '', '/b'); await flushAsync(); expect(ts.bids).toEqual({ s1: { hb_pb: '3.00' } }); + expect(ts.auctionDiagnostics).toEqual({ auctionResolvedMs: 84 }); expect(adInit).toHaveBeenCalledTimes(1); ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }); @@ -361,6 +491,7 @@ describe('scheduleInitialAdInit', () => { flushFrame(); expect(ts.bids).toEqual({ s1: { hb_pb: '3.00' } }); + expect(ts.auctionDiagnostics).toEqual({ auctionResolvedMs: 84 }); expect(adInit).toHaveBeenCalledTimes(1); }); @@ -378,10 +509,18 @@ describe('scheduleInitialAdInit', () => { formats: [[728, 90]] as Array<[number, number]>, }; - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, [ssrSlot]); + const auctionDiagnostics = { + auctionDispatchedMs: 4, + auctionResolvedMs: 84, + auctionCommittedMs: 85, + auctionWaitMs: 80, + auctionWaitPlacement: 'pre_header' as const, + }; + ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, [ssrSlot], auctionDiagnostics); expect(ts.adSlots).toEqual([ssrSlot]); expect(ts.bids).toEqual({ ssr_slot: { hb_pb: '1.00' } }); + expect(ts.auctionDiagnostics).toEqual(auctionDiagnostics); }); it('preserves head-injected slots when initialSlots is omitted', async () => { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 647de8967..fba638cb5 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -4,6 +4,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { TsjsApi } from '../../../src/core/types'; import { GPT_BOOTSTRAP_PATH } from '../../fixtures/paths'; +import { installTraceRuntime } from '../../../src/trace/runtime'; +import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; +import { gptTransport, joinedGptStore } from '../../trace/gpt-fixtures'; +import { AUCTION_TOKEN, SLOT_TOKEN } from '../../trace/fixtures'; type TestWindow = Window & { googletag?: unknown; @@ -54,6 +58,7 @@ describe('installSpaAuctionHook', () => { }); afterEach(() => { + delete window.__tsjs_trace_active; history.pushState = originalPushState; history.replaceState = originalReplaceState; // Reset jsdom location back to root for the next test. @@ -67,6 +72,158 @@ describe('installSpaAuctionHook', () => { vi.unstubAllGlobals(); }); + it.each(['canonical', 'legacy', 'malformed', 'absent', 'throwing-bridge', 'oversized'] as const)( + 'consumes optional %s transport only after an accepted navigation and before adInit', + async (kind) => { + const { installSpaAuctionHook } = await importGptModule(); + installSpaAuctionHook(); + const ts = (window as TestWindow).tsjs!; + window.__tsjs_trace_active = true; + const collector = installTraceRuntime(ts)!; + const slots = [ + { + id: 'example', + gam_unit_path: '/example/ad', + div_id: 'example', + formats: [[300, 250]] as Array<[number, number]>, + ext: { trusted_server: { trace_slot_ref: SLOT_TOKEN } }, + }, + ]; + if (kind === 'oversized') { + slots.push( + ...Array.from({ length: 64 }, () => ({ + ...slots[0]!, + ext: { trusted_server: { trace_slot_ref: 'private-invalid' } }, + })) + ); + } + document.body.innerHTML = '
'; + const transport = gptTransport('spa_page_bids'); + const payload = { + slots, + bids: {}, + ...(kind === 'absent' + ? {} + : { trace_auction: kind === 'malformed' ? { schema_version: 1 } : transport }), + }; + if (kind === 'legacy') fetchStub.mockResolvedValueOnce({ ok: false, status: 404 }); + fetchStub.mockResolvedValue({ ok: true, json: async () => payload }); + if (kind === 'throwing-bridge') + ts.traceGpt = { + ...ts.traceGpt!, + observePageBids: () => { + throw new Error('private-bridge'); + }, + }; + ts.adInit = vi.fn(() => { + expect(ts.adSlots).toBe(slots); + expect(collector.snapshot().value?.serverAuctions).toHaveLength( + ['canonical', 'legacy', 'oversized'].includes(kind) ? 1 : 0 + ); + }); + history.pushState({}, '', '/trace-route'); + await flushAsync(); + expect(ts.adInit).toHaveBeenCalledOnce(); + expect(collector.snapshot().value?.issues).toEqual( + kind === 'malformed' + ? ['evidence_validation_failed'] + : kind === 'oversized' + ? ['correlation_unavailable'] + : [] + ); + if (kind === 'oversized') { + expect(ts.adSlots).toHaveLength(65); + expect(ts.traceGpt!.identity(slots[0]!)).toBeUndefined(); + } + if (kind === 'legacy') expect(fetchStub).toHaveBeenCalledTimes(2); + } + ); + + it('joins the actual no-bid SPA GPT request with its validated pre-dispatch token', async () => { + const { installSpaAuctionHook, installTsAdInit } = await importGptModule(); + installSpaAuctionHook(); + const ts = (window as TestWindow).tsjs!; + window.__tsjs_trace_active = true; + const collector = installTraceRuntime(ts)!; + const slot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: () => 'example', + getTargeting: () => [], + }; + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: () => [slot], + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + vi.stubGlobal('googletag', { + cmd: { push: (callback: () => void) => callback() }, + defineSlot: () => slot, + pubads: () => pubads, + enableServices: vi.fn(), + }); + const store = new GptDiagnosticsStore({ + onTraceCorrelation: (value) => collector.recordCorrelation(value), + }); + ts.gptDiagnosticsRecorder = store; + document.body.innerHTML = '
'; + fetchStub.mockResolvedValue({ + ok: true, + json: async () => ({ + slots: [ + { + id: 'example', + gam_unit_path: '/example/ad', + div_id: 'example', + formats: [[300, 250]], + ext: { trusted_server: { trace_slot_ref: SLOT_TOKEN } }, + }, + ], + bids: {}, + trace_auction: gptTransport('spa_page_bids'), + }), + }); + installTsAdInit(); + history.pushState({}, '', '/trace-no-bid'); + await flushAsync(); + store.recordSlotRequested(slot); + expect(store.snapshot().slots[0]!.requests[0]).toMatchObject({ + requestPath: 'trusted_server_direct', + trustedServerOpportunity: 'no_candidate', + trustedServerAuctionId: AUCTION_TOKEN, + }); + expect(joinedGptStore(store, collector)?.auctions[0]?.slots[0]?.correlation).toBe('matched'); + }); + + it('does not capture a superseded SPA response', async () => { + const { installSpaAuctionHook } = await importGptModule(); + installSpaAuctionHook(); + const ts = (window as TestWindow).tsjs!; + window.__tsjs_trace_active = true; + const collector = installTraceRuntime(ts)!; + ts.adInit = vi.fn(); + let resolveOld!: (value: unknown) => void; + fetchStub.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOld = resolve; + }) + ); + fetchStub.mockResolvedValueOnce({ ok: true, json: async () => ({ slots: [], bids: {} }) }); + history.pushState({}, '', '/trace-old'); + history.pushState({}, '', '/trace-new'); + await flushAsync(); + resolveOld({ + ok: true, + json: async () => ({ slots: [], bids: {}, trace_auction: gptTransport('spa_page_bids') }), + }); + await flushAsync(); + expect(collector.snapshot().value?.serverAuctions).toEqual([]); + expect(collector.snapshot().value?.issues).toEqual([]); + }); + it('increments navGeneration only when a pathname navigation is accepted', async () => { // The deferred initial-adInit bootstrap keys off this counter, so it must // move in lockstep with the hook's own navigation identity: bumped @@ -659,9 +816,13 @@ describe('installSpaAuctionHook', () => { const ts = (window as TestWindow).tsjs!; const adInit = vi.fn(); ts.adInit = adInit; + ts.auctionDiagnostics = { auctionResolvedMs: 84 }; history.pushState({}, '', '/first'); + expect(ts.auctionDiagnostics).toBeUndefined(); + ts.auctionDiagnostics = { auctionResolvedMs: 40 }; history.pushState({}, '', '/second'); + expect(ts.auctionDiagnostics).toBeUndefined(); await flushAsync(); expect(ts.adSlots).toEqual([{ id: 'newer', div_id: 'div-newer' }]); @@ -685,13 +846,16 @@ describe('installSpaAuctionHook', () => { installSpaAuctionHook(); const ts = (window as TestWindow).tsjs!; ts.adSlots = [{ id: 'existing' } as never]; + ts.auctionDiagnostics = { auctionResolvedMs: 84 }; const adInit = vi.fn(); ts.adInit = adInit; history.pushState({}, '', '/error-page'); + expect(ts.auctionDiagnostics).toBeUndefined(); await flushAsync(); expect(ts.adSlots).toEqual([{ id: 'existing' }]); + expect(ts.auctionDiagnostics).toBeUndefined(); expect(adInit).not.toHaveBeenCalled(); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts index abbdeca69..68f4a9f9a 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts @@ -66,6 +66,8 @@ function fakeApiStore() { subscribe: vi.fn(() => () => undefined), recordTrustedServerOpportunity: vi.fn(), recordPrebidRefresh: vi.fn(), + recordPrebidAuction: vi.fn(), + recordPrebidWin: vi.fn(), recordTrustedServerCreativeRequest: vi.fn((_auctionSlotId: string) => 41), recordTrustedServerCreativeResponse: vi.fn(), recordTrustedServerCreativeFailure: vi.fn(), @@ -92,7 +94,9 @@ describe('GptDiagnosticsApiController', () => { 'subscribe', ]); expect(Object.keys(controller.recorder).sort()).toEqual([ + 'recordPrebidAuction', 'recordPrebidRefresh', + 'recordPrebidWin', 'recordTrustedServerCreativeFailure', 'recordTrustedServerCreativeRequest', 'recordTrustedServerCreativeResponse', @@ -112,7 +116,10 @@ describe('GptDiagnosticsApiController', () => { controller.recorder.recordTrustedServerOpportunity( slot, 'auction-slot-example', - 'renderable_candidate' + 'renderable_candidate', + undefined, + undefined, + { auctionType: 'ssat' } ); controller.recorder.recordPrebidRefresh(slots); const attemptId = @@ -126,7 +133,8 @@ describe('GptDiagnosticsApiController', () => { 'auction-slot-example', 'renderable_candidate', undefined, - undefined + undefined, + { auctionType: 'ssat' } ); expect(store.recordPrebidRefresh).toHaveBeenCalledTimes(1); expect(store.recordPrebidRefresh).toHaveBeenCalledWith(slots); @@ -159,6 +167,7 @@ describe('GptDiagnosticsApiController', () => { 'auction-slot-example', 'renderable_candidate', 'auction-123', + undefined, undefined ); }); @@ -225,6 +234,9 @@ describe('GptDiagnosticsApiController', () => { yieldGroupIds: [10], companyIds: [20], }, + auctionWinner: { bidder: 'example', priceBucket: '1.20' }, + serverAuctionTimings: { auctionResolvedMs: 84 }, + serverAuctionTimingOrigin: 'spa_auction' as const, trustedServerCreativeFailures: ['cache_fetch_failed' as const], }, ], @@ -280,6 +292,13 @@ describe('GptDiagnosticsApiController', () => { expect(cycle?.adManager?.companyIds).not.toBe( source.slots[0]?.requests[0]?.adManager.companyIds ); + expect(cycle?.auctionWinner).toEqual({ bidder: 'example', priceBucket: '1.20' }); + expect(cycle?.auctionWinner).not.toBe(source.slots[0]?.requests[0]?.auctionWinner); + expect(cycle?.serverAuctionTimings).toEqual({ auctionResolvedMs: 84 }); + expect(cycle?.serverAuctionTimings).not.toBe( + source.slots[0]?.requests[0]?.serverAuctionTimings + ); + expect(cycle?.serverAuctionTimingOrigin).toBe('spa_auction'); expect(snapshot.metadata).not.toBe(source.metadata); expect(snapshot.metadata.droppedAttributionIssues).toBe(2); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts index 329c4d299..a6e1f0d54 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts @@ -112,6 +112,55 @@ describe('GptDiagnosticsBadgeManager', () => { expect(layer.querySelectorAll('.tsgd-badge')).toHaveLength(1); expect(layer.querySelector('.tsgd-badge')?.dataset.runtimeSlot).toBe('1'); + expect(layer.querySelector('.tsgd-badge')?.textContent).toContain('Ad #1'); + manager.destroy(); + }); + + it('renders an accessible request-scoped control and activates its exact request', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const bindings = new FakeBindings(); + const element = document.createElement('div'); + document.body.append(element); + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rectangle(10, 100, 300, 250)); + const observedSlot = slot('accessible'); + store.recordSlotRequested(observedSlot); + store.recordSlotRequested(observedSlot); + bindings.set(1, { status: 'bound' }, element, true); + const activate = vi.fn(); + const layer = document.createElement('div'); + document.body.append(layer); + const manager = new GptDiagnosticsBadgeManager(store, bindings, { + scheduleFrame: (callback) => frames.push(callback), + onActivate: activate, + }); + manager.setLayer(layer); + runFrame(frames); + + const badge = layer.querySelector('.tsgd-badge'); + expect(badge).toBeInstanceOf(HTMLButtonElement); + expect(badge?.textContent).toContain('Ad #1 · Request #2'); + expect(badge?.getAttribute('aria-label')).toBe( + 'Open diagnostics for Ad #1, Request #2: Pending' + ); + badge?.click(); + expect(activate).toHaveBeenCalledWith(1, 2); + + badge?.focus(); + store.recordSlotRequested(observedSlot); + runFrame(frames); + const updatedBadge = layer.querySelector('.tsgd-badge'); + expect(updatedBadge).toBe(badge); + expect(document.activeElement).toBe(badge); + expect(updatedBadge?.textContent).toContain('Ad #1 · Request #3'); + updatedBadge?.click(); + expect(activate).toHaveBeenLastCalledWith(1, 3); + + const highlight = document.createElement('div'); + highlight.className = 'tsgd-highlight'; + layer.append(highlight); + manager.update(); + expect(layer.querySelector('.tsgd-highlight')).toBe(highlight); manager.destroy(); }); @@ -266,7 +315,7 @@ describe('GptDiagnosticsBadgeManager', () => { }, }) ).toBe( - 'Filled · Req 728×90, 970×250 · Fill 728×90 · Box 980×270\nResponse 276 ms · Render 42 ms\nViewable after 1 s' + 'Filled · Req 728×90, 970×250 · Fill 728×90 · Size filled 980×270\nGAM request → response 276 ms · GAM response → render 42 ms\nViewable after 1 s' ); expect( gptDiagnosticsBadgeTextForTest({ @@ -282,6 +331,17 @@ describe('GptDiagnosticsBadgeManager', () => { durations: {}, }) ).toBe('Filled · Req 300×250, 320×50, 728×90 +1'); + expect( + gptDiagnosticsBadgeTextForTest({ + requestNumber: 1, + isEmpty: false, + size: [1, 1], + observedSlotSize: [728, 90], + auctionType: 'ssat', + incompleteSequence: false, + durations: {}, + }) + ).toBe('Filled · SSAT · Size filled 728×90'); expect( gptDiagnosticsBadgeTextForTest({ requestNumber: 1, @@ -366,6 +426,8 @@ describe('GptDiagnosticsBadgeManager', () => { expect(firstBadge.style.left).toBe('100px'); expect(firstBadge.style.top).toBe('112px'); expect(firstBadge.style.maxWidth).toBe('260px'); + firstBadge.focus(); + expect(document.activeElement).toBe(firstBadge); currentRectangle = rectangle(220, 260, 300, 250); window.dispatchEvent(new Event('scroll')); @@ -373,6 +435,8 @@ describe('GptDiagnosticsBadgeManager', () => { expect(frames).toHaveLength(1); runFrame(frames); const movedBadge = layer.querySelector('.tsgd-badge')!; + expect(movedBadge).toBe(firstBadge); + expect(document.activeElement).toBe(firstBadge); expect(movedBadge.style.left).toBe('220px'); expect(movedBadge.style.top).toBe('252px'); expect(element.getAttributeNames().map((name) => [name, element.getAttribute(name)])).toEqual( diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts index fa50d6366..f66b5ef6d 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { TsjsApi } from '../../../src/core/types'; +import * as traceHandoff from '../../../src/trace/handoff'; import { installGptDiagnosticsRuntime, isGptDiagnosticsActive, @@ -81,12 +82,14 @@ beforeEach(() => { delete target.googletag; delete target.__tsjs_gpt_diagnostics_active; delete target.__tsjs_gpt_diagnostics_runtime; + delete target.__tsjs_trace_active; }); afterEach(() => { target.__tsjs_gpt_diagnostics_runtime?.destroy(); delete target.__tsjs_gpt_diagnostics_active; delete target.__tsjs_gpt_diagnostics_runtime; + delete target.__tsjs_trace_active; delete target.googletag; delete target.tsjs; vi.unstubAllGlobals(); @@ -95,6 +98,56 @@ afterEach(() => { }); describe('GPT diagnostics integration composition', () => { + it.each([undefined, false, 'true', 1])( + 'installs no trace handoff for nonliteral trace flag %s', + (active) => { + const create = vi.spyOn(traceHandoff, 'createTraceHandoff'); + const shadow = vi.spyOn(Element.prototype, 'attachShadow'); + target.__tsjs_gpt_diagnostics_active = true; + target.__tsjs_trace_active = active; + installGptStub(); + installGptDiagnosticsRuntime(target); + const root = shadow.mock.results[shadow.mock.results.length - 1]?.value as + | ShadowRoot + | undefined; + expect(create).not.toHaveBeenCalled(); + expect(root?.textContent).not.toContain('View trace results'); + expect(target.tsjs?.gptDiagnostics).toBeDefined(); + } + ); + it('connects the gated trace controls after the public snapshot API and destroys recovery with the runtime', () => { + const view = vi.fn(); + const download = vi.fn(); + const destroy = vi.fn(); + const create = vi.spyOn(traceHandoff, 'createTraceHandoff').mockImplementation((options) => { + expect(options.target.tsjs?.gptDiagnostics?.snapshot).toBeTypeOf('function'); + return { view, download, destroy }; + }); + const shadow = vi.spyOn(Element.prototype, 'attachShadow'); + target.__tsjs_gpt_diagnostics_active = true; + target.__tsjs_trace_active = true; + installGptStub(); + installGptDiagnosticsRuntime(target); + const root = shadow.mock.results[shadow.mock.results.length - 1]?.value as ShadowRoot; + const traceButton = Array.from(root.querySelectorAll('button')).find( + (item) => item.textContent === 'View trace results' + ); + expect(traceButton).toBeDefined(); + traceButton?.click(); + expect(view).toHaveBeenCalledTimes(1); + const options = create.mock.calls[0][0]; + options.onChange?.({ kind: 'storage_unavailable', downloadAvailable: true }); + const recovery = Array.from(root.querySelectorAll('button')).find( + (item) => item.textContent === 'Download trace report' + ); + expect(recovery).toBeDefined(); + recovery?.click(); + expect(download).toHaveBeenCalledTimes(1); + target.__tsjs_gpt_diagnostics_runtime?.destroy(); + expect(destroy).toHaveBeenCalledTimes(1); + traceButton?.click(); + expect(view).toHaveBeenCalledTimes(1); + }); it('has no inactive side effects', () => { const originalMutationObserver = window.MutationObserver; @@ -124,7 +177,9 @@ describe('GPT diagnostics integration composition', () => { // Evidence writers live on their own channel; the operator API stays read-only. expect(Object.keys(first!).sort()).toEqual(['export', 'hide', 'show', 'snapshot', 'subscribe']); expect(Object.keys(target.tsjs!.gptDiagnosticsRecorder!).sort()).toEqual([ + 'recordPrebidAuction', 'recordPrebidRefresh', + 'recordPrebidWin', 'recordTrustedServerCreativeFailure', 'recordTrustedServerCreativeRequest', 'recordTrustedServerCreativeResponse', diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts index 41cad667a..e8e6fef0c 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts @@ -86,6 +86,102 @@ afterEach(() => { }); describe('GptDiagnosticsOverlay', () => { + it.each(['View trace results', 'Download trace report'])( + 'retains focused %s and the live region across GPT updates', + (label) => { + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { + scheduleFrame: (callback) => callback(), + onShadowRoot: (created) => { + root = created; + }, + onViewTrace: vi.fn(), + onDownloadTrace: vi.fn(), + }); + if (!root) throw new Error('should mount diagnostics'); + try { + overlay.setTraceState({ kind: 'storage_unavailable', downloadAvailable: true }); + const control = button(root, label); + const status = root.querySelector('[aria-live="polite"]'); + control.focus(); + expect(root.activeElement).toBe(control); + store.recordSlotRequested(slot('example-slot')); + expect(button(root, label)).toBe(control); + expect(root.activeElement).toBe(control); + expect(root.querySelector('[aria-live="polite"]')).toBe(status); + } finally { + overlay.destroy(); + } + } + ); + + it('allows an explicit retry when navigation did not depart from the document', () => { + let root: ShadowRoot | undefined; + const onViewTrace = vi.fn(); + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => callback(), + onShadowRoot: (created) => { + root = created; + }, + onViewTrace, + }); + if (!root) throw new Error('should mount diagnostics'); + try { + overlay.setTraceState({ kind: 'navigating', downloadAvailable: true }); + button(root, 'View trace results').click(); + expect(onViewTrace).toHaveBeenCalledTimes(1); + } finally { + overlay.destroy(); + } + }); + + it('adds an optional prominent trace action and independent storage recovery without replacing GPT export', () => { + let root: ShadowRoot | undefined; + const onViewTrace = vi.fn(); + const onDownloadTrace = vi.fn(); + const onExport = vi.fn(); + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => callback(), + onShadowRoot: (created) => { + root = created; + }, + onViewTrace, + onDownloadTrace, + onExport, + }); + if (!root) throw new Error('should mount diagnostics'); + const view = button(root, 'View trace results'); + expect(view.className).toContain('tsgd-trace-action'); + expect(root.querySelector('style')?.textContent).toContain('min-height: 44px'); + view.click(); + expect(onViewTrace).toHaveBeenCalledTimes(1); + button(root, 'Export JSON').click(); + expect(onExport).toHaveBeenCalledTimes(1); + expect(root.textContent).not.toContain('Download trace report'); + overlay.setTraceState({ kind: 'storage_unavailable', downloadAvailable: true }); + expect(root.querySelector('[aria-live="polite"]')?.textContent).toContain('could not be saved'); + button(root, 'Download trace report').click(); + expect(onDownloadTrace).toHaveBeenCalledTimes(1); + overlay.setTraceState({ kind: 'capture_failed', downloadAvailable: false }); + expect(root.textContent).not.toContain('Download trace report'); + expect(root.querySelector('[aria-live="polite"]')?.textContent).toContain( + 'could not be captured' + ); + overlay.destroy(); + }); + it('does not create trace controls when optional trace callbacks are absent', () => { + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => callback(), + onShadowRoot: (created) => { + root = created; + }, + }); + expect(root?.textContent).not.toContain('View trace results'); + expect(root?.querySelector('[aria-live="polite"]')).toBeNull(); + overlay.destroy(); + }); it('waits for document completion and two animation frames before mounting', () => { const frames: Array<() => void> = []; const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); @@ -110,7 +206,7 @@ describe('GptDiagnosticsOverlay', () => { overlay.destroy(); }); - it('presents the request path and Trusted Server delivery evidence without winner claims', () => { + it('presents request-path, auction, winner, timing, and delivery evidence', () => { const frames: Array<() => void> = []; let now = 10; const store = new GptDiagnosticsStore({ @@ -125,7 +221,19 @@ describe('GptDiagnosticsOverlay', () => { responseSentSlot, 'auction-response-sent', 'renderable_candidate', - 'auction-123' + 'auction-123', + undefined, + { + auctionType: 'ssat', + winner: { bidder: 'example-bidder', priceBucket: '1.20', currency: 'usd' }, + serverTimings: { + auctionDispatchedMs: 4, + auctionResolvedMs: 84, + auctionCommittedMs: 85, + auctionWaitMs: 80, + auctionWaitPlacement: 'in_stream', + }, + } ); store.recordSlotRequested(responseSentSlot); now = 11; @@ -162,7 +270,19 @@ describe('GptDiagnosticsOverlay', () => { store.recordTrustedServerOpportunity( selectedSlot, 'auction-selected', - 'unrenderable_candidate' + 'unrenderable_candidate', + undefined, + undefined, + { + auctionType: 'trusted_server', + serverTimings: { + auctionDispatchedMs: 0, + auctionResolvedMs: 40, + auctionCommittedMs: 41, + auctionWaitMs: 40, + auctionWaitPlacement: 'pre_header', + }, + } ); store.recordPrebidRefresh([selectedSlot]); store.recordSlotRequested(selectedSlot); @@ -193,6 +313,10 @@ describe('GptDiagnosticsOverlay', () => { const prebidSlot = slot('prebid-slot'); now = 50; store.recordPrebidRefresh([prebidSlot]); + store.recordPrebidAuction(prebidSlot, 'example-client-auction', { + bidder: 'example-client', + priceBucket: '2.40', + }); store.recordSlotRequested(prebidSlot); now = 51; store.recordSlotResponseReceived(prebidSlot); @@ -248,8 +372,16 @@ describe('GptDiagnosticsOverlay', () => { expect(responseSentArticle).toContain('Request path: Trusted Server direct'); expect(responseSentArticle).toContain('Request intent: 1'); expect(responseSentArticle).toContain('Trusted Server auction: auction-123'); + expect(responseSentArticle).toContain('Auction evidence: SSAT: initial-page server auction'); + expect(responseSentArticle).toContain('Server auction winner: example-bidder'); + expect(responseSentArticle).toContain('Server bid price bucket: 1.20 USD'); + expect(responseSentArticle).not.toContain('(currency not supplied)'); + expect(responseSentArticle).toContain('Initial document request T0 → auction dispatched 4 ms'); + expect(responseSentArticle).toContain('Initial document request T0 → auction collected 84 ms'); + expect(responseSentArticle).toContain('Initial document request T0 → bids ready 85 ms'); + expect(responseSentArticle).toContain('Auction collection wait (in stream) 80 ms'); expect(responseSentArticle).toContain('Opportunity → request 0 ms'); - expect(responseSentArticle).toContain('Direct opportunity: Renderable candidate'); + expect(responseSentArticle).toContain('Server bid available; creative source present'); expect(responseSentArticle).toContain('Trusted Server creative request observed at 13 ms'); expect(responseSentArticle).toContain('Trusted Server markup response sent at 14 ms'); expect( @@ -258,7 +390,7 @@ describe('GptDiagnosticsOverlay', () => { expect(responseSentArticle).toContain('Creative bridge failure: cache fetch failed'); expect(responseSentArticle).toContain('Creative bridge failure: invalid cache payload'); expect(responseSentArticle).toContain('Creative bridge failure: response post failed'); - expect(responseSentArticle).toContain('Trusted Server selected; markup response sent to PUC'); + expect(responseSentArticle).toContain('Creative markup sent; execution not confirmed'); expect(responseSentArticle).toContain( 'Ad Manager reported line item 6543210987 · order 2345678901' ); @@ -270,48 +402,49 @@ describe('GptDiagnosticsOverlay', () => { expect(responseSentArticle).not.toMatch(/creative rendered|ad visible|pixels confirmed/i); const selectedArticle = slotArticle(root!, 'selected-slot').textContent ?? ''; - expect(selectedArticle).toContain('Request path: Competing paths'); - expect(selectedArticle).toContain('Direct opportunity: Unrenderable candidate'); + expect(selectedArticle).toContain('Request path: Multiple paths observed'); + expect(selectedArticle).toContain('Auction evidence: TS auction: SPA server auction'); + expect(selectedArticle).toContain('SPA page-bids request T0 → auction dispatched 0 ms'); + expect(selectedArticle).toContain('SPA page-bids request T0 → auction collected 40 ms'); + expect(selectedArticle).toContain('Server bid available; creative source incomplete'); expect(selectedArticle).toContain('Trusted Server creative request observed at 23 ms'); expect(selectedArticle).not.toContain('Trusted Server markup response sent'); - expect(selectedArticle).toContain('Trusted Server selected; no markup response confirmed'); + expect(selectedArticle).toContain( + 'Server bid selected by the creative bridge; response not confirmed' + ); const noCandidateArticle = slotArticle(root!, 'no-candidate-slot').textContent ?? ''; expect(noCandidateArticle).toContain('Request path: Trusted Server direct'); expect(noCandidateArticle).toContain('Direct opportunity: No candidate'); - expect(noCandidateArticle).toContain( - 'adInit observed no direct Trusted Server candidate for this request' - ); + expect(noCandidateArticle).toContain('No direct Trusted Server candidate'); const unattributedArticle = slotArticle(root!, 'unattributed-slot').textContent ?? ''; - expect(unattributedArticle).toContain('Request path: Unattributed'); - expect(unattributedArticle).toContain('Direct opportunity: Unknown (not observed)'); + expect(unattributedArticle).toContain('Request path: Not observed'); + expect(unattributedArticle).toContain('Direct opportunity: Not observed'); expect(unattributedArticle).toContain( - 'Delivery status unknown — required GPT or direct-candidate evidence was not observed' + 'Delivery status unknown — required evidence was not observed' ); const prebidArticle = slotArticle(root!, 'prebid-slot').textContent ?? ''; expect(prebidArticle).toContain('Request path: Prebid refresh'); - expect(prebidArticle).toContain('Direct opportunity: Unknown (not observed)'); - expect(prebidArticle).toContain( - 'Delivery status unknown — required GPT or direct-candidate evidence was not observed' - ); + expect(prebidArticle).toContain('Prebid candidate price bucket: 2.40'); + expect(prebidArticle).not.toContain('(currency not supplied)'); + expect(prebidArticle).toContain('Direct opportunity: Not observed'); + expect(prebidArticle).toContain('Delivery status unknown — required evidence was not observed'); const unconfirmedArticle = slotArticle(root!, 'unconfirmed-slot').textContent ?? ''; expect(unconfirmedArticle).toContain('Request path: Trusted Server direct'); - expect(unconfirmedArticle).toContain('Direct opportunity: Renderable candidate'); - expect(unconfirmedArticle).toContain( - 'Trusted Server candidate unconfirmed — another GAM result or a creative/bridge failure is possible' - ); + expect(unconfirmedArticle).toContain('Server bid available; creative source present'); + expect(unconfirmedArticle).toContain('Server bid available; selection not confirmed'); const pendingArticle = slotArticle(root!, 'candidate-pending-slot').textContent ?? ''; expect(pendingArticle).toContain('Request path: Trusted Server direct'); - expect(pendingArticle).toContain('Direct opportunity: Renderable candidate'); + expect(pendingArticle).toContain('Server bid available; creative source present'); expect(pendingArticle).toContain('Waiting for Trusted Server creative evidence'); const notApplicableArticle = slotArticle(root!, 'not-applicable-slot').textContent ?? ''; expect(notApplicableArticle).toContain('Request path: Trusted Server direct'); - expect(notApplicableArticle).toContain('Direct opportunity: Renderable candidate'); + expect(notApplicableArticle).toContain('Server bid available; creative source present'); expect(notApplicableArticle).not.toMatch( /Trusted Server selected|candidate unconfirmed|no direct Trusted Server candidate|Delivery status unknown|Waiting for Trusted Server creative evidence/ ); @@ -466,22 +599,38 @@ describe('GptDiagnosticsOverlay', () => { expect(root!.textContent).toContain('GPT observed'); expect(root!.textContent).toContain('callback issues'); expect(root!.textContent).toContain('attribution issues'); - expect(root!.textContent).toContain('filled-slot'); + expect(root!.textContent).toContain('Ad #1 · Request #2 · filled-slot'); expect(root!.textContent).toContain('/example/site/filled-slot'); expect(root!.textContent).toContain('Empty'); - expect(root!.textContent).toContain('Previous requests (1)'); - expect(root!.textContent).toContain('Requested slot sizes 300×250, 728×90, 320×50, 970×250'); - expect(root!.textContent).toContain('GPT-reported fill size 300×250'); - expect(root!.textContent).toContain('Observed outer slot box 320×270'); + expect(root!.textContent).toContain('Request history (1 previous)'); + expect(root!.textContent).toContain('Requested sizes 300×250, 728×90, 320×50, 970×250'); + expect(root!.textContent).toContain('GPT-reported size 300×250'); + expect(root!.textContent).toContain('Size filled 320×270'); expect(root!.textContent).toContain('Backfill yes'); expect(root!.textContent).toContain('GPT slot onload observed'); expect(root!.textContent).toContain('GPT impressionViewable observed'); - expect(root!.textContent).toContain('Request → response 10 ms'); + expect(root!.textContent).toContain('GAM request → response 10 ms'); expect(root!.textContent).toContain('GPT visibility 60%'); expect(root!.textContent).toContain('Requesting'); - expect(root!.textContent).toContain('Ambiguous binding'); + expect(root!.textContent).toContain('Ambiguous binding · duplicate_dom_id'); + expect(slotArticle(root!, 'filled-slot').textContent?.match(/Bound · Visible/g)).toHaveLength( + 1 + ); expect(root!.textContent).toContain('Incomplete sequence'); + expect(slotArticle(root!, 'pending-slot').textContent).toContain( + 'Delivery evidence: Not applicable' + ); + expect(slotArticle(root!, 'pending-slot').textContent).not.toContain( + 'Served bidder not confirmed' + ); + const emptySummary = slotArticle(root!, 'filled-slot').querySelector('.tsgd-group'); + expect(emptySummary?.textContent).toContain('Delivery evidence: Not applicable'); + expect(emptySummary?.textContent).not.toContain('Served bidder not confirmed'); + expect(root!.textContent).toContain('How to read this evidence'); + expect(root!.querySelector('a')?.href).toBe( + 'https://iabtechlab.github.io/trusted-server/guide/integrations/gpt-diagnostics-dictionary' + ); button(root!, 'Export JSON').click(); expect(exportSnapshot).toHaveBeenCalledTimes(1); @@ -522,6 +671,135 @@ describe('GptDiagnosticsOverlay', () => { overlay.destroy(); }); + it('labels unavailable SPA auction timing with the SPA request clock', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const diagnosticSlot = slot('spa-without-timings'); + store.recordTrustedServerOpportunity( + diagnosticSlot, + 'auction-spa-without-timings', + 'renderable_candidate', + undefined, + undefined, + { + auctionType: 'trusted_server', + winner: { bidder: 'example-bidder', priceBucket: '1.20' }, + } + ); + store.recordSlotRequested(diagnosticSlot); + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { + scheduleFrame: (callback) => frames.push(callback), + onShadowRoot: (createdRoot) => { + root = createdRoot; + }, + }); + runNextFrame(frames); + runNextFrame(frames); + + expect(root?.textContent).toContain( + 'SPA page-bids request T0 → auction dispatched Unavailable' + ); + expect(root?.textContent).not.toContain( + 'Initial document request T0 → auction dispatched Unavailable' + ); + overlay.destroy(); + }); + + it('announces an evicted request selection once and then clears it', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const diagnosticSlot = slot('evicted-selection'); + store.recordSlotRequested(diagnosticSlot); + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { + scheduleFrame: (callback) => frames.push(callback), + onShadowRoot: (createdRoot) => { + root = createdRoot; + }, + }); + runNextFrame(frames); + runNextFrame(frames); + + overlay.selectRequest(1, 1); + runNextFrame(frames); + let requestsToRecord = 10; + while (requestsToRecord > 0) { + store.recordSlotRequested(diagnosticSlot); + requestsToRecord -= 1; + } + runNextFrame(frames); + + expect(root?.textContent).toContain('Ad #1, Request #1 is no longer retained.'); + store.recordSlotResponseReceived(diagnosticSlot); + runNextFrame(frames); + expect(root?.textContent).not.toContain('Ad #1, Request #1 is no longer retained.'); + overlay.destroy(); + }); + + it('reveals an exact request and locates it without mutating publisher markup', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const bindings = new FakeBindings(); + const publisherSlot = document.createElement('div'); + publisherSlot.id = 'locatable-slot'; + publisherSlot.className = 'publisher-class'; + publisherSlot.style.minHeight = '250px'; + document.body.append(publisherSlot); + const originalMarkup = publisherSlot.outerHTML; + const scrollIntoView = vi.fn(); + publisherSlot.scrollIntoView = scrollIntoView; + vi.spyOn(publisherSlot, 'getBoundingClientRect').mockReturnValue({ + left: 10, + top: 20, + width: 300, + height: 250, + } as DOMRect); + const observedSlot = slot('locatable-slot'); + store.recordSlotRequested(observedSlot); + store.recordSlotRequested(observedSlot); + bindings.set(1, { status: 'bound' }, publisherSlot, true); + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(store, bindings, { + scheduleFrame: (callback) => frames.push(callback), + onShadowRoot: (createdRoot) => { + root = createdRoot; + }, + }); + runNextFrame(frames); + runNextFrame(frames); + + overlay.selectRequest(1, 1); + runNextFrame(frames); + const selected = root?.querySelector( + '[data-runtime-slot="1"][data-request-number="1"]' + ); + expect(selected?.getAttribute('aria-current')).toBe('true'); + expect(root?.textContent).toContain('Request history (1 previous)'); + const focus = vi.spyOn(HTMLElement.prototype, 'focus'); + selected?.focus(); + const focusCallsBeforeUpdate = focus.mock.calls.length; + const history = root?.querySelector('.tsgd-history'); + expect(history?.open).toBe(true); + if (history) history.open = false; + + store.recordSlotResponseReceived(observedSlot); + runNextFrame(frames); + expect(focus.mock.calls.length).toBeGreaterThan(focusCallsBeforeUpdate); + expect(root?.querySelector('.tsgd-history')?.open).toBe(false); + + button(root!, 'Locate on page').click(); + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: 'auto', + block: 'center', + inline: 'nearest', + }); + runNextFrame(frames); + expect(root?.querySelector('.tsgd-highlight')).not.toBeNull(); + expect(publisherSlot.outerHTML).toBe(originalMarkup); + overlay.destroy(); + }); + it('does not remove a publisher element that collides with the host ID', async () => { const frames: Array<() => void> = []; const publisherElement = document.createElement('div'); @@ -568,14 +846,20 @@ describe('GptDiagnosticsOverlay', () => { runNextFrame(frames); const content = root!.querySelector('.tsgd-content')!; - const history = root!.querySelector('details')!; + const history = root!.querySelector('.tsgd-history')!; + const help = root!.querySelector('.tsgd-help')!; + const technical = root!.querySelector('.tsgd-technical')!; history.open = true; + help.open = true; + technical.open = true; content.scrollTop = 42; store.recordSlotResponseReceived(diagnosticSlot); runNextFrame(frames); expect(root!.textContent).toContain('Rendered (fill unknown)'); - expect(root!.querySelector('details')?.open).toBe(true); + expect(root!.querySelector('.tsgd-history')?.open).toBe(true); + expect(root!.querySelector('.tsgd-help')?.open).toBe(true); + expect(root!.querySelector('.tsgd-technical')?.open).toBe(true); expect(root!.querySelector('.tsgd-content')?.scrollTop).toBe(42); overlay.destroy(); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts index 52aef6a7f..b507687a5 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { AUCTION_TOKEN, SLOT_TOKEN } from '../../trace/fixtures'; import { CREATIVE_ATTEMPT_WINDOW_MS, GptDiagnosticsStore, @@ -14,6 +15,7 @@ import { TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS, type GptDiagnosticsSlotLike, } from '../../../src/integrations/gpt_diagnostics/store'; +import { GPT_DIAGNOSTICS_CREATIVE_FAILURES } from '../../../src/core/types'; function fakeSlot(elementId: string, adUnitPath = `/example/${elementId}`): GptDiagnosticsSlotLike { return { @@ -67,12 +69,184 @@ function last(values: readonly T[]): T | undefined { } describe('GptDiagnosticsStore', () => { + it.each([undefined, 'private-malformed', 'ts-auc-2234567812344abc8def123456789abc'])( + 'retains ordinary marker %s without fabricating a trace sidecar', + (marker) => { + const onTraceCorrelation = vi.fn(); + const store = new GptDiagnosticsStore({ now: () => 1, onTraceCorrelation }); + const slot = fakeSlot('example-slot'); + store.recordTrustedServerOpportunity( + slot, + 'private-slot', + 'no_candidate', + marker, + undefined, + undefined, + { + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + } + ); + store.recordSlotRequested(slot); + expect(onTraceCorrelation).not.toHaveBeenCalled(); + expect(store.snapshot().slots[0]!.requests[0]).toMatchObject({ + requestPath: 'trusted_server_direct', + }); + expect(store.snapshot().slots[0]!.requests[0]!.trustedServerAuctionId).toBe(marker); + } + ); + + it('emits a sidecar for an exactly matching marker after ordinary trimming', () => { + const onTraceCorrelation = vi.fn(); + const store = new GptDiagnosticsStore({ now: () => 1, onTraceCorrelation }); + const slot = fakeSlot('example-slot'); + store.recordTrustedServerOpportunity( + slot, + 'private-slot', + 'no_candidate', + ` ${AUCTION_TOKEN} `, + undefined, + undefined, + { + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + } + ); + store.recordSlotRequested(slot); + expect(onTraceCorrelation).toHaveBeenCalledOnce(); + expect(store.snapshot().slots[0]!.requests[0]!.trustedServerAuctionId).toBe(AUCTION_TOKEN); + }); + it.each(['renderable_candidate', 'unrenderable_candidate', 'no_candidate'] as const)( + 'emits a trace identity only at the concrete %s request binding', + (opportunity) => { + const onTraceCorrelation = vi.fn(); + const store = new GptDiagnosticsStore({ now: () => 10, onTraceCorrelation }); + const slot = fakeSlot('private-slot'); + store.recordTrustedServerOpportunity( + slot, + 'private-auction-slot', + opportunity, + AUCTION_TOKEN, + [[300, 250]], + undefined, + { diagnostic_auction_id: AUCTION_TOKEN, slot_ref: SLOT_TOKEN } + ); + expect(onTraceCorrelation).not.toHaveBeenCalled(); + store.recordSlotRequested(slot); + expect(onTraceCorrelation).toHaveBeenCalledExactlyOnceWith({ + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + runtime_slot_number: 1, + request_number: 1, + }); + store.recordSlotRequested(slot); + expect(onTraceCorrelation).toHaveBeenCalledOnce(); + const cycles = store.snapshot().slots[0]!.requests; + expect(cycles[0]).toMatchObject({ + requestPath: 'trusted_server_direct', + trustedServerOpportunity: opportunity, + trustedServerAuctionId: AUCTION_TOKEN, + }); + expect(cycles[1]).toMatchObject({ requestPath: 'unattributed' }); + expect(JSON.stringify(store.snapshot())).not.toContain(SLOT_TOKEN); + } + ); + it('uses the explicit creative-attempt and attribution retention bounds', () => { expect(CREATIVE_ATTEMPT_WINDOW_MS).toBe(30_000); expect(MAX_CREATIVE_ATTEMPTS).toBe(128); expect(MAX_ATTRIBUTION_ISSUES).toBe(128); }); + it('keeps sidecars on the consumed source decision with competing refreshes and exact expiry', () => { + let now = 0; + const onTraceCorrelation = vi.fn(); + const store = new GptDiagnosticsStore({ now: () => now, onTraceCorrelation }); + const slot = fakeSlot('example-slot'); + const identity = { diagnostic_auction_id: AUCTION_TOKEN, slot_ref: SLOT_TOKEN }; + store.recordTrustedServerOpportunity( + slot, + 'private-slot', + 'no_candidate', + AUCTION_TOKEN, + undefined, + undefined, + identity + ); + store.recordPrebidRefresh([slot]); + now = 4_999; + store.recordSlotRequested(slot); + expect(onTraceCorrelation).toHaveBeenCalledOnce(); + expect(store.snapshot().slots[0]!.requests[0]!.requestPath).toBe('competing'); + store.recordTrustedServerOpportunity( + slot, + 'private-slot', + 'no_candidate', + AUCTION_TOKEN, + undefined, + undefined, + identity + ); + now += REQUEST_PATH_ATTRIBUTION_WINDOW_MS; + store.recordSlotRequested(slot); + expect(onTraceCorrelation).toHaveBeenCalledOnce(); + expect(store.snapshot().slots[0]!.requests[1]!.requestPath).toBe('unattributed'); + }); + + it('owns valid identity data and keeps ordinary cycles intact when a trace callback throws', () => { + const onTraceCorrelation = vi.fn(() => { + throw new Error('private-callback'); + }); + const store = new GptDiagnosticsStore({ now: () => 1, onTraceCorrelation }); + const slot = fakeSlot('example-slot'); + const identity = { diagnostic_auction_id: AUCTION_TOKEN, slot_ref: SLOT_TOKEN }; + store.recordTrustedServerOpportunity( + slot, + 'private-slot', + 'no_candidate', + AUCTION_TOKEN, + undefined, + undefined, + identity + ); + identity.slot_ref = 'private-mutated'; + expect(() => store.recordSlotRequested(slot)).not.toThrow(); + expect(onTraceCorrelation).toHaveBeenCalledExactlyOnceWith({ + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + runtime_slot_number: 1, + request_number: 1, + }); + expect(store.snapshot().coverage.slotRequested.matched).toBe(1); + expect(store.snapshot().slots[0]!.requests).toHaveLength(1); + }); + + it('ignores unbound/invalid identities without reading caller token getters', () => { + const onTraceCorrelation = vi.fn(); + const store = new GptDiagnosticsStore({ now: () => 1, onTraceCorrelation }); + const slot = fakeSlot('example-slot'); + const getter = vi.fn(() => { + throw new Error('private-token'); + }); + const identity = { diagnostic_auction_id: AUCTION_TOKEN, slot_ref: SLOT_TOKEN }; + Object.defineProperty(identity, 'slot_ref', { get: getter }); + store.recordTrustedServerOpportunity( + slot, + 'private-slot', + 'no_candidate', + undefined, + undefined, + undefined, + identity + ); + store.recordSlotRequested(slot); + expect(getter).not.toHaveBeenCalled(); + expect(onTraceCorrelation).not.toHaveBeenCalled(); + expect(store.snapshot().coverage.slotRequested.matched).toBe(1); + }); + it('records a complete filled lifecycle with valid timings and visibility', () => { let now = 10; const store = new GptDiagnosticsStore({ now: () => now }); @@ -420,7 +594,7 @@ describe('GptDiagnosticsStore', () => { assertCoverageEquation(store); }); - it('evicts least-recently-active slots and re-enters only on a new request', () => { + it('evicts least-recently-active slots and preserves their number on re-entry', () => { let now = 0; const store = new GptDiagnosticsStore({ now: () => ++now }); const slots = Array.from({ length: MAX_DIAGNOSTIC_SLOTS + 1 }, (_, index) => @@ -444,7 +618,7 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRequested(slots[1]); store.recordSlotResponseReceived(slots[1]); const reentered = store.snapshot().slots.find((slot) => slot.slotElementId === 'lru-1'); - expect(reentered).toMatchObject({ runtimeSlotNumber: 66 }); + expect(reentered).toMatchObject({ runtimeSlotNumber: 2 }); expect(reentered?.requests[0]).toMatchObject({ requestNumber: 2 }); expect(reentered?.requests[0].responseAtMs).toBeDefined(); expect(store.snapshot().slots).toHaveLength(MAX_DIAGNOSTIC_SLOTS); @@ -572,6 +746,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'trusted_server_direct', expectedOpportunity: 'renderable_candidate', + expectedAuctionType: undefined, }, { name: 'a direct unrenderable candidate', @@ -580,6 +755,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'trusted_server_direct', expectedOpportunity: 'unrenderable_candidate', + expectedAuctionType: undefined, }, { name: 'a direct request without a candidate', @@ -588,6 +764,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'trusted_server_direct', expectedOpportunity: 'no_candidate', + expectedAuctionType: undefined, }, { name: 'a Prebid refresh', @@ -596,6 +773,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'prebid_refresh', expectedOpportunity: undefined, + expectedAuctionType: undefined, }, { name: 'competing direct and Prebid evidence', @@ -604,6 +782,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'competing', expectedOpportunity: 'renderable_candidate', + expectedAuctionType: undefined, }, { name: 'an unattributed request', @@ -612,6 +791,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'unattributed', expectedOpportunity: undefined, + expectedAuctionType: undefined, }, { name: 'a publisher refresh', @@ -620,14 +800,25 @@ describe('GptDiagnosticsStore', () => { publisher: true, expectedPath: 'publisher_refresh', expectedOpportunity: undefined, + expectedAuctionType: undefined, }, { - name: 'competing Prebid and publisher evidence', + name: 'direct auction with publisher refresh evidence', + direct: 'renderable_candidate', + prebid: false, + publisher: true, + expectedPath: 'competing', + expectedOpportunity: 'renderable_candidate', + expectedAuctionType: undefined, + }, + { + name: 'client-side auction with publisher refresh evidence', direct: undefined, prebid: true, publisher: true, expectedPath: 'competing', expectedOpportunity: undefined, + expectedAuctionType: undefined, }, { name: 'competing all source evidence', @@ -636,10 +827,11 @@ describe('GptDiagnosticsStore', () => { publisher: true, expectedPath: 'competing', expectedOpportunity: 'renderable_candidate', + expectedAuctionType: undefined, }, ] as const)( 'attributes $name without inferring demand ownership', - ({ direct, prebid, publisher, expectedPath, expectedOpportunity }) => { + ({ direct, prebid, publisher, expectedPath, expectedOpportunity, expectedAuctionType }) => { const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); const slot = fakeSlot('path-slot'); @@ -653,9 +845,160 @@ describe('GptDiagnosticsStore', () => { const cycle = store.snapshot().slots[0].requests[0]; expect(cycle.requestPath).toBe(expectedPath); expect(cycle.trustedServerOpportunity).toBe(expectedOpportunity); + expect(cycle.auctionType).toBe(expectedAuctionType); } ); + it('retains a completed Prebid candidate and only an exact correlated win', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('prebid-facts'); + + store.recordPrebidRefresh([slot]); + store.recordPrebidAuction(slot, 'auction-client-1', { + bidder: ' example-client ', + priceBucket: '2.40', + currency: 'eur', + }); + store.recordSlotRequested(slot); + store.recordPrebidWin(slot, 'other-auction', { + bidder: 'wrong', + priceBucket: '9.99', + }); + store.recordPrebidWin(slot, 'auction-client-1', { + bidder: ' example-client ', + priceBucket: '2.40', + currency: 'eur', + }); + + expect(store.snapshot().slots[0].requests[0]).toMatchObject({ + auctionType: 'client_side', + prebidAuction: { + auctionId: 'auction-client-1', + targetingCandidate: { + bidder: 'example-client', + priceBucket: '2.40', + currency: 'EUR', + }, + win: { bidder: 'example-client', priceBucket: '2.40', currency: 'EUR' }, + }, + }); + }); + + it('does not carry an earlier auction into a later bare Prebid refresh', () => { + let now = 10; + const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); + const slot = fakeSlot('prebid-replaced-intent'); + store.recordPrebidRefresh([slot]); + store.recordPrebidAuction(slot, 'auction-client-1', { + bidder: 'example-client', + priceBucket: '2.40', + }); + + now = 20; + store.recordPrebidRefresh([slot]); + store.recordSlotRequested(slot); + store.recordPrebidWin(slot, 'auction-client-1', { + bidder: 'example-client', + priceBucket: '2.40', + }); + + const cycle = store.snapshot().slots[0].requests[0]; + expect(cycle.requestPath).toBe('prebid_refresh'); + expect(cycle.auctionType).toBeUndefined(); + expect(cycle.prebidAuction).toBeUndefined(); + }); + + it('retains bounded winner and server timing facts for a Trusted Server auction', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('auction-facts'); + + store.recordTrustedServerOpportunity( + slot, + 'auction-slot', + 'renderable_candidate', + 'auction-123', + [[300, 250]], + { + auctionType: 'trusted_server', + winner: { bidder: ' example-bidder ', priceBucket: '1.20' }, + serverTimings: { + auctionDispatchedMs: 4, + auctionResolvedMs: 84, + auctionCommittedMs: 85, + auctionWaitMs: 80, + auctionWaitPlacement: 'pre_header', + }, + } + ); + store.recordSlotRequested(slot); + + expect(store.snapshot().slots[0].requests[0]).toMatchObject({ + auctionType: 'trusted_server', + auctionWinner: { bidder: 'example-bidder', priceBucket: '1.20' }, + serverAuctionTimings: { + auctionDispatchedMs: 4, + auctionResolvedMs: 84, + auctionCommittedMs: 85, + auctionWaitMs: 80, + auctionWaitPlacement: 'pre_header', + }, + }); + }); + + it('does not treat a Prebid refresh route as client-auction evidence', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('competing-spa-auction'); + + store.recordTrustedServerOpportunity( + slot, + 'auction-slot', + 'renderable_candidate', + undefined, + undefined, + { + auctionType: 'trusted_server', + serverTimings: { auctionDispatchedMs: 0, auctionResolvedMs: 84 }, + } + ); + store.recordPrebidRefresh([slot]); + store.recordSlotRequested(slot); + + expect(store.snapshot().slots[0].requests[0]).toMatchObject({ + auctionType: 'trusted_server', + serverAuctionTimingOrigin: 'spa_auction', + serverAuctionTimings: { auctionDispatchedMs: 0, auctionResolvedMs: 84 }, + }); + }); + + it('drops malformed winner and auction timing fields at the diagnostics boundary', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('invalid-auction-facts'); + + store.recordTrustedServerOpportunity( + slot, + 'auction-slot', + 'renderable_candidate', + undefined, + undefined, + { + auctionType: 'invalid' as never, + winner: { bidder: 'example-bidder', priceBucket: 'not-a-price' }, + serverTimings: { + auctionDispatchedMs: -1, + auctionResolvedMs: Number.NaN, + auctionCommittedMs: 0x1_0000_0000, + auctionWaitPlacement: 'elsewhere' as never, + }, + } + ); + store.recordSlotRequested(slot); + + const cycle = store.snapshot().slots[0].requests[0]; + expect(cycle.auctionType).toBeUndefined(); + expect(cycle.auctionWinner).toBeUndefined(); + expect(cycle.serverAuctionTimings).toBeUndefined(); + }); + it('consumes direct and Prebid markers exactly once', () => { let now = 10; const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); @@ -1420,6 +1763,27 @@ describe('GptDiagnosticsStore', () => { expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_response_sent'); }); + // The runtime allowlist and the exported union are derived from one array, so + // a member that exists at compile time must also be recordable at runtime. A + // hand-written allowlist previously drifted and dropped reasons silently. + it('records every declared creative failure category', () => { + for (const reason of GPT_DIAGNOSTICS_CREATIVE_FAILURES) { + const store = new GptDiagnosticsStore({ now: () => 0, defer: () => undefined }); + const slot = fakeSlot(`declared-${reason}`); + associateSlot(store, slot, `auction-${reason}`); + store.recordSlotRequested(slot); + const attemptId = store.recordTrustedServerCreativeRequest(`auction-${reason}`)!; + + store.recordTrustedServerCreativeFailure(attemptId, reason); + + expect( + store.snapshot().slots[0].requests[0].trustedServerCreativeFailures, + `should record the \`${reason}\` category` + ).toEqual([reason]); + expect(store.snapshot().attributionIssues).toEqual([]); + } + }); + it('keeps an asynchronous response on its originating cycle after a newer refresh', () => { let now = 0; const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts index f4f4c7486..411acf1de 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts @@ -81,6 +81,8 @@ describe('GPT diagnostics public types', () => { expectTypeOf().toEqualTypeOf< | 'recordTrustedServerOpportunity' | 'recordPrebidRefresh' + | 'recordPrebidAuction' + | 'recordPrebidWin' | 'recordTrustedServerCreativeRequest' | 'recordTrustedServerCreativeResponse' | 'recordTrustedServerCreativeFailure' diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 0c209b89a..c432af797 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -213,6 +213,11 @@ type RequestBidsArg = Parameters['requestBid /** The bid adapter spec object registered via `pbjs.registerBidAdapter`. */ interface TestAdapterSpec { + onTimeout?: (bids: Array>) => void; + onBidderError?: (value: { + error: unknown; + bidderRequest: { bids: Array> }; + }) => void; code: string; supportedMediaTypes: string[]; isBidRequestValid: (bid: Record) => boolean; @@ -364,6 +369,9 @@ import type { TsjsApi } from '../../../src/core/types'; import { GptDiagnosticsObserver } from '../../../src/integrations/gpt_diagnostics/observer'; import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; import envelope from '../../fixtures/aps-renderer-v1.json'; +import { installTraceRuntime } from '../../../src/trace/runtime'; +import { gptTransport } from '../../trace/gpt-fixtures'; +import { SLOT_TOKEN } from '../../trace/fixtures'; // installPrebidNpm is a per-page no-op once the sentinel is set (the module // self-init above already set it), so every test starts from a clean page. @@ -2543,9 +2551,9 @@ describe('prebid/installPrebidNpm', () => { expect(logged.some((value) => JSON.stringify(value).includes(sentinel))).toBe(false); }); - it('buildRequests clears stale ts-eids cookie when current Prebid EIDs are absent', () => { - const spec = getAdapterSpec(); + it('installation expires the retired ts-eids cookie when EIDs are absent', () => { document.cookie = 'ts-eids=stale-value'; + const spec = getAdapterSpec(); mockGetUserIdsAsEids.mockReturnValue([]); spec.buildRequests([ @@ -2615,6 +2623,45 @@ describe('prebid/installPrebidNpm', () => { ]); }); + it('buildRequests preserves supplied EID provenance including empty matcher and zero mm', () => { + const spec = getAdapterSpec(); + mockGetUserIdsAsEids.mockReturnValue([ + { + source: 'id5-sync.com', + inserter: 'Lockr-For-Publishers', + matcher: '', + mm: 0, + uids: [{ id: 'opaque-id', atype: 1, ext: { linkType: 1 } }], + }, + { + source: 'other.example', + inserter: 1, + matcher: null, + mm: 1.5, + uids: [{ id: 'other-id' }], + }, + ]); + const result = spec.buildRequests([ + { + adUnitCode: 'slot', + bidder: 'trustedServer', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + params: {}, + }, + ]); + expect(JSON.parse(result.data).eids).toEqual([ + { + source: 'id5-sync.com', + inserter: 'Lockr-For-Publishers', + matcher: '', + mm: 0, + uids: [{ id: 'opaque-id', atype: 1, ext: { linkType: 1 } }], + }, + { source: 'other.example', uids: [{ id: 'other-id' }] }, + ]); + expect(document.cookie).not.toMatch(/(?:^|; )ts-eids=/); + }); + it('buildRequests uses custom endpoint when configured', () => { mockRegisterBidAdapter.mockClear(); installPrebidNpm({ endpoint: '/custom/auction' }); @@ -3157,7 +3204,7 @@ describe('prebid/installPrebidNpm', () => { expect(hasTsBidder).toBe(true); }); - it('syncs a structured ts-eids cookie after bidsBackHandler', () => { + it('never writes a ts-eids cookie after bidsBackHandler', () => { mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { opts?.bidsBackHandler?.(); }); @@ -3176,20 +3223,10 @@ describe('prebid/installPrebidNpm', () => { adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], } as unknown as RequestBidsArg); - const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; - expect(cookieValue).toBeDefined(); - expect(JSON.parse(atob(cookieValue!))).toEqual([ - { - source: 'sharedid.org', - uids: [ - { id: 'shared_123', atype: 3 }, - { id: 'shared_456', ext: { provider: 'example' } }, - ], - }, - ]); + expect(document.cookie).not.toMatch(/(?:^|; )ts-eids=/); }); - it('preserves an opaque LiveRamp envelope in the ts-eids cookie', () => { + it('does not persist an opaque LiveRamp envelope in a cookie', () => { mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { opts?.bidsBackHandler?.(); }); @@ -3205,140 +3242,10 @@ describe('prebid/installPrebidNpm', () => { adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], } as unknown as RequestBidsArg); - const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; - expect(cookieValue).toBeDefined(); - expect(JSON.parse(atob(cookieValue!))).toEqual([ - { - source: 'liveramp.com', - uids: [{ id: 'opaque-test-envelope', atype: 3 }], - }, - ]); + expect(document.cookie).not.toMatch(/(?:^|; )ts-eids=/); }); - it('trims an oversized ts-eids cookie deterministically and logs which sources were dropped', () => { - const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - // A long uid value per source makes the encoded payload exceed - // MAX_EID_COOKIE_BYTES (3072) once several sources are present, the - // same way a real page with many ID modules would. - const longUid = 'x'.repeat(400); - mockGetUserIdsAsEids.mockReturnValue([ - { source: 'id5-sync.com', uids: [{ id: longUid, atype: 1 }] }, - { source: 'liveramp.com', uids: [{ id: longUid, atype: 3 }] }, - { source: 'criteo.com', uids: [{ id: longUid, atype: 1 }] }, - { source: 'uidapi.com', uids: [{ id: longUid, atype: 3 }] }, - { source: 'pubcid.org', uids: [{ id: longUid, atype: 1 }] }, - { source: 'sharedid.org', uids: [{ id: longUid, atype: 1 }] }, - { source: 'dropped.example', uids: [{ id: longUid, atype: 1 }] }, - ]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; - expect(cookieValue).toBeDefined(); - expect(cookieValue!.length).toBeLessThanOrEqual(3072); - - const persistedSources = (JSON.parse(atob(cookieValue!)) as Array<{ source: string }>).map( - (eid) => eid.source - ); - expect(persistedSources).not.toContain('dropped.example'); - expect(persistedSources).toContain('id5-sync.com'); - - const loggedMessage = debugSpy.mock.calls - .map((call) => call.at(-1)) - .find( - (arg): arg is string => typeof arg === 'string' && arg.includes('ts-eids cookie exceeded') - ); - expect(loggedMessage).toBeDefined(); - expect(loggedMessage).toContain('dropped.example'); - expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('ts-eids cookie exceeded')); - }); - - it('logs a source whose UIDs were partially trimmed but retained', () => { - const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - // A single source with many UIDs is trimmed down to fit rather than - // dropped outright — it stays in the payload, but with fewer UIDs. - const longUid = 'x'.repeat(400); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'id5-sync.com', - uids: Array.from({ length: 10 }, (_, i) => ({ id: `${longUid}${i}`, atype: 1 })), - }, - ]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; - expect(cookieValue).toBeDefined(); - - const persisted = JSON.parse(atob(cookieValue!)) as Array<{ - source: string; - uids: unknown[]; - }>; - expect(persisted.map((eid) => eid.source)).toContain('id5-sync.com'); - const persistedSource = persisted.find((eid) => eid.source === 'id5-sync.com'); - expect(persistedSource!.uids.length).toBeLessThan(10); - - const loggedMessage = debugSpy.mock.calls - .map((call) => call.at(-1)) - .find( - (arg): arg is string => typeof arg === 'string' && arg.includes('ts-eids cookie exceeded') - ); - expect(loggedMessage).toBeDefined(); - expect(loggedMessage).toContain('trimmed uids from sources: id5-sync.com'); - }); - - it('reports a source trimmed and then dropped only as dropped', () => { - const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - // The tail source has several UIDs that are each too large to fit: the - // trimmer first removes UIDs from it, then drops it entirely. - const hugeUid = 'y'.repeat(2500); - mockGetUserIdsAsEids.mockReturnValue([ - { source: 'id5-sync.com', uids: [{ id: 'kept', atype: 1 }] }, - { - source: 'dropped.example', - uids: Array.from({ length: 3 }, (_, i) => ({ id: `${hugeUid}${i}`, atype: 1 })), - }, - ]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; - expect(cookieValue).toBeDefined(); - const persistedSources = (JSON.parse(atob(cookieValue!)) as Array<{ source: string }>).map( - (eid) => eid.source - ); - expect(persistedSources).toEqual(['id5-sync.com']); - - const loggedMessage = debugSpy.mock.calls - .map((call) => call.at(-1)) - .find( - (arg): arg is string => typeof arg === 'string' && arg.includes('ts-eids cookie exceeded') - ); - expect(loggedMessage).toBeDefined(); - expect(loggedMessage).toContain('dropped sources: dropped.example'); - expect(loggedMessage).not.toContain('trimmed uids from sources'); - }); - - it('clears ts-eids cookie after bidsBackHandler when no current EIDs remain', () => { + it('expires the retired cookie on installation before any auction', () => { document.cookie = `ts-eids=${btoa(JSON.stringify([{ source: 'sharedid.org', uids: [{ id: 'stale' }] }]))}`; mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { opts?.bidsBackHandler?.(); @@ -4461,6 +4368,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockPbjs.requestBids = mockRequestBids; mockPbjs.removeAdUnit = mockRemoveAdUnit; delete (mockPbjs as unknown as Record).__tsRemoveAdUnitWrapped; + delete (mockPbjs as unknown as Record).__tsDiagnosticsBidWonInstalled; mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); @@ -4481,6 +4389,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { testWindow.tsjs = undefined; delete testWindow.googletag; document.body.replaceChildren(); + vi.restoreAllMocks(); }); function installGpt(slots: Array>) { @@ -5748,6 +5657,136 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } }); + it('leaves Prebid auction identity and event listeners unchanged when diagnostics is inactive', () => { + const getTargeting = vi.fn(() => []); + const slot = { + getSlotElementId: () => 'example-inactive-slot', + getTargeting, + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + mockPbjs.setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts) => { + opts.bidsBackHandler?.({}, false, 'example-inactive-auction'); + }); + + pubads.refresh([slot]); + + const request = mockRequestBids.mock.calls[0][0]; + expect(request).not.toHaveProperty('auctionId'); + expect(mockOnEvent).not.toHaveBeenCalledWith('bidWon', expect.any(Function)); + expect(getTargeting).not.toHaveBeenCalledWith('hb_bidder'); + expect(getTargeting).not.toHaveBeenCalledWith('hb_pb'); + expect(getTargeting).not.toHaveBeenCalledWith('hb_cur'); + }); + + it('records completed client auction evidence only for the exact Prebid attempt', () => { + const getTargeting = vi.fn((key: string) => + key === 'hb_bidder' ? ['example-client'] : key === 'hb_pb' ? ['2.40'] : [] + ); + const slot = { + getSlotElementId: () => 'example-client-slot', + getTargeting, + clearTargeting: vi.fn(), + }; + const recordPrebidRefresh = vi.fn(); + const recordPrebidAuction = vi.fn(); + const recordPrebidWin = vi.fn(); + testWindow.tsjs = { + gptDiagnosticsRecorder: { + recordPrebidRefresh, + recordPrebidAuction, + recordPrebidWin, + }, + }; + const { pubads } = installGpt([slot]); + mockPbjs.setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts) => { + opts.bidsBackHandler?.({}, false, 'example-client-auction'); + }); + + pubads.refresh([slot]); + + expect(mockRequestBids.mock.calls[0][0]).not.toHaveProperty('auctionId'); + expect(mockOnEvent).toHaveBeenCalledWith('bidWon', expect.any(Function)); + expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); + expect(recordPrebidAuction).toHaveBeenCalledWith(slot, 'example-client-auction', { + bidder: 'example-client', + priceBucket: '2.40', + }); + expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( + recordPrebidAuction.mock.invocationCallOrder[0] + ); + expect(getTargeting).not.toHaveBeenCalledWith('hb_cur'); + const bidWon = mockOnEvent.mock.calls.find(([event]) => event === 'bidWon')?.[1]; + expect(bidWon).toBeTypeOf('function'); + bidWon?.({ + auctionId: 'other-auction', + adUnitCode: 'example-client-slot', + adserverTargeting: { hb_bidder: 'wrong-client', hb_pb: '9.99' }, + }); + expect(recordPrebidWin).not.toHaveBeenCalled(); + bidWon?.({ + auctionId: 'example-client-auction', + latestTargetedAuctionId: 'later-client-auction', + adUnitCode: 'example-client-slot', + adserverTargeting: { hb_bidder: 'stale-client', hb_pb: '9.99' }, + }); + expect(recordPrebidWin).not.toHaveBeenCalled(); + bidWon?.({ + auctionId: 'example-client-auction', + latestTargetedAuctionId: 'example-client-auction', + adUnitCode: 'example-client-slot', + adserverTargeting: { hb_bidder: 'example-client', hb_pb: '2.40' }, + }); + expect(recordPrebidWin).toHaveBeenCalledWith(slot, 'example-client-auction', { + bidder: 'example-client', + priceBucket: '2.40', + }); + }); + + it.each(['expiry', 'navigation change'] as const)( + 'rejects a Prebid bidWon observation after %s', + (reason) => { + let now = 0; + vi.spyOn(performance, 'now').mockImplementation(() => now); + const adUnitCode = `example-${reason.replace(' ', '-')}`; + const slot = { + getSlotElementId: () => adUnitCode, + getTargeting: (key: string) => + key === 'hb_bidder' ? ['example-client'] : key === 'hb_pb' ? ['2.40'] : [], + clearTargeting: vi.fn(), + }; + const recordPrebidWin = vi.fn(); + testWindow.tsjs = { + navGeneration: 1, + gptDiagnosticsRecorder: { + recordPrebidRefresh: vi.fn(), + recordPrebidAuction: vi.fn(), + recordPrebidWin, + }, + }; + const { pubads } = installGpt([slot]); + mockPbjs.setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts) => { + opts.bidsBackHandler?.({}, false, 'example-client-auction'); + }); + + pubads.refresh([slot]); + const bidWon = mockOnEvent.mock.calls.find(([event]) => event === 'bidWon')?.[1]; + expect(bidWon).toBeTypeOf('function'); + if (reason === 'expiry') now = 30_001; + else testWindow.tsjs!.navGeneration = 2; + bidWon?.({ + auctionId: 'example-client-auction', + adUnitCode, + adserverTargeting: { hb_bidder: 'example-client', hb_pb: '2.40' }, + }); + + expect(recordPrebidWin).not.toHaveBeenCalled(); + } + ); + it('records a publisher delivery refresh immediately before its GPT request', () => { const slot = { getSlotElementId: () => 'example-delivery-marker', @@ -7424,12 +7463,17 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } }); - it('completes a synthetic refresh when targeting throws', () => { + it('completes a synthetic refresh without recording auction evidence when targeting throws', () => { const slot = { getSlotElementId: () => 'example-throwing-targeting', getTargeting: () => [], clearTargeting: vi.fn(), }; + const recordPrebidRefresh = vi.fn(); + const recordPrebidAuction = vi.fn(); + testWindow.tsjs = { + gptDiagnosticsRecorder: { recordPrebidRefresh, recordPrebidAuction }, + }; const { originalRefresh, pubads } = installGpt([slot]); mockPbjs.setTargetingForGPTAsync = vi.fn(() => { throw new Error('example targeting failure'); @@ -7439,6 +7483,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { pubads.refresh([slot]); + expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); + expect(recordPrebidAuction).not.toHaveBeenCalled(); expect(originalRefresh).toHaveBeenCalledTimes(1); expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); @@ -8089,3 +8135,362 @@ describe('prebid self-init user ID module timing', () => { expect(userSyncCallCount()).toBe(1); }); }); + +describe('Prebid registered trace transport hooks', () => { + function setup(...flags: unknown[]) { + const flag = flags.length ? flags[0] : true; + window.tsjs = {} as TsjsApi; + window.__tsjs_trace_active = flag as boolean; + if (flag === true) installTraceRuntime(window.tsjs); + installPrebidNpm(); + return mockRegisterBidAdapter.mock.calls[ + mockRegisterBidAdapter.mock.calls.length - 1 + ]![2] as TestAdapterSpec; + } + function original(id: string, code = 'example-unit', bidderRequestId = 'example-request') { + return { + bidId: id, + bidderRequestId, + adUnitCode: code, + bidder: 'trustedServer', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + params: {}, + }; + } + function result() { + return { + body: { + ext: { + trusted_server: { + trace_auction: { + ...gptTransport(), + evidence: { ...gptTransport().evidence!, source: 'auction_api' }, + }, + }, + }, + }, + }; + } + beforeEach(() => { + vi.useFakeTimers(); + mockRegisterBidAdapter.mockClear(); + mockGetConfig.mockReturnValue(3000); + vi.spyOn(window.crypto, 'randomUUID').mockReturnValue('12345678-1234-4abc-8def-123456789abc'); + }); + afterEach(() => { + window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false })); + vi.useRealTimers(); + vi.restoreAllMocks(); + delete window.tsjs; + delete window.__tsjs_trace_active; + }); + it.each([undefined, false, 'true', 1])( + 'adds no hooks, listeners, tokens or timers for gate %s', + (flag) => { + const listen = vi.spyOn(window, 'addEventListener'); + const uuid = window.crypto.randomUUID; + const spec = setup(flag); + expect(spec).not.toHaveProperty('onTimeout'); + expect(spec).not.toHaveProperty('onBidderError'); + const request = spec.buildRequests([original('first')]); + expect(JSON.parse(request.data as unknown as string).adUnits[0]).not.toHaveProperty('ext'); + expect(uuid).not.toHaveBeenCalled(); + expect(listen.mock.calls.filter(([name]) => name === 'pagehide')).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + } + ); + it('uses final grouped units and consumes API transport before ordinary bid parsing exactly once', () => { + const spec = setup(); + const input = [original('first'), original('second')]; + const request = spec.buildRequests(input); + const payload = JSON.parse(request.data as unknown as string); + expect(payload.adUnits).toHaveLength(1); + expect(payload.adUnits[0].ext.trusted_server.trace_slot_ref).toBe(SLOT_TOKEN); + expect(input[0]).not.toHaveProperty('ext'); + const received = result(); + Object.defineProperty(received.body, 'seatbid', { + get() { + expect(window.tsjs!.traceEvidence!.captureStatus()).toBe('complete'); + return []; + }, + }); + expect(spec.interpretResponse(received, request)).toEqual([]); + spec.interpretResponse(received, request); + spec.onTimeout!([original('first')]); + spec.onBidderError!({ error: new Error('private'), bidderRequest: { bids: input } }); + expect(window.tsjs!.traceEvidence!.snapshot().value?.serverAuctions).toHaveLength(1); + expect(window.tsjs!.traceEvidence!.snapshot().value?.slotCorrelations).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + }); + it('isolates concurrent request IDs and consumes timeout/error only once before callbacks', () => { + const spec = setup(); + const first = spec.buildRequests([original('first')]); + const second = spec.buildRequests([original('second')]); + const collector = window.tsjs!.traceEvidence!; + expect(first).toBeDefined(); + spec.onTimeout!([original('first')]); + spec.onBidderError!({ + error: { + get message() { + throw Error('private'); + }, + }, + bidderRequest: { bids: [original('first')] }, + }); + spec.interpretResponse(result(), second); + expect(collector.captureStatus()).toBe('partial'); + expect(collector.snapshot().value?.serverAuctions).toHaveLength(1); + expect(vi.getTimerCount()).toBe(0); + }); + it('retains ordinary payload on throwing token generator and still observes independent evidence', () => { + const spec = setup(); + vi.mocked(window.crypto.randomUUID).mockImplementation(() => { + throw Error('private'); + }); + const request = spec.buildRequests([original('first')]); + expect(JSON.parse(request.data as unknown as string).adUnits[0]).not.toHaveProperty('ext'); + spec.interpretResponse(result(), request); + expect(window.tsjs!.traceEvidence!.captureStatus()).toBe('complete'); + }); + it('keeps missing optional evidence unobserved and malformed evidence bounded without changing bids', () => { + const spec = setup(); + const absent = spec.buildRequests([original('first')]); + expect(spec.interpretResponse({ body: {} }, absent)).toEqual([]); + expect(window.tsjs!.traceEvidence!.captureStatus()).toBe('not_observed'); + const malformed = spec.buildRequests([original('second')]); + expect( + spec.interpretResponse( + { body: { ext: { trusted_server: { trace_auction: null } } } }, + malformed + ) + ).toEqual([]); + expect(window.tsjs!.traceEvidence!.snapshot().value?.issues).toEqual([ + 'evidence_validation_failed', + ]); + }); + it('clears BFCache markers, permits resumed requests, and destroys nonpersisted page timers', () => { + const spec = setup(); + spec.buildRequests([original('first')]); + expect(vi.getTimerCount()).toBe(1); + window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })); + expect(vi.getTimerCount()).toBe(0); + spec.onTimeout!([original('first')]); + expect(window.tsjs!.traceEvidence!.captureStatus()).toBe('not_observed'); + const resumed = spec.buildRequests([original('second')]); + expect(vi.getTimerCount()).toBe(1); + window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false })); + expect(vi.getTimerCount()).toBe(0); + spec.interpretResponse(result(), resumed); + spec.buildRequests([original('third')]); + expect(vi.getTimerCount()).toBe(0); + expect(window.tsjs!.traceEvidence!.captureStatus()).toBe('not_observed'); + }); + it('uses distinct final unit refs while retaining all original bid IDs for one request', () => { + const spec = setup(); + vi.mocked(window.crypto.randomUUID) + .mockReturnValueOnce('12345678-1234-4abc-8def-123456789abc') + .mockReturnValueOnce('22345678-1234-4abc-8def-123456789abc'); + const request = spec.buildRequests([ + original('first', 'unit-a'), + original('second', 'unit-a'), + original('third', 'unit-b'), + ]); + const payload = JSON.parse(request.data as unknown as string); + expect( + payload.adUnits.map( + (unit: { ext: { trusted_server: { trace_slot_ref: string } } }) => + unit.ext.trusted_server.trace_slot_ref + ) + ).toEqual([SLOT_TOKEN, 'ts-slot-22345678-1234-4abc-8def-123456789abc']); + spec.onTimeout!([original('second')]); + expect(window.tsjs!.traceEvidence!.captureStatus()).toBe('unavailable'); + spec.interpretResponse(result(), request); + expect(window.tsjs!.traceEvidence!.snapshot().value?.serverAuctions).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + }); + it('swallows throwing diagnostic callbacks while returning ordinary auction bids unchanged', () => { + window.tsjs = {} as TsjsApi; + window.__tsjs_trace_active = true; + const collector = installTraceRuntime(window.tsjs)!; + window.tsjs.traceEvidence = { + ...collector, + recordTransport: () => { + throw Error('private-transport'); + }, + recordTransportFailure: () => { + throw Error('private-failure'); + }, + }; + installPrebidNpm(); + const spec = mockRegisterBidAdapter.mock.calls[ + mockRegisterBidAdapter.mock.calls.length - 1 + ]![2] as TestAdapterSpec; + const request = spec.buildRequests([original('first')]); + const received = result(); + Object.assign(received.body, { + seatbid: [ + { + seat: 'example-bidder', + bid: [ + { impid: 'example-unit', adm: '
Example creative
', price: 1, w: 300, h: 250 }, + ], + }, + ], + }); + expect(spec.interpretResponse(received, request)).toHaveLength(1); + spec.buildRequests([original('second')]); + expect(() => spec.onTimeout!([original('second')])).not.toThrow(); + expect(vi.getTimerCount()).toBe(0); + }); + it('retires colliding live IDs while ordinary bid delivery and earlier capture remain intact', () => { + const spec = setup(); + const collector = window.tsjs!.traceEvidence!; + collector.recordTransport(result().body.ext.trusted_server.trace_auction); + const first = spec.buildRequests([original('shared')]); + const collision = spec.buildRequests([original('shared')]); + expect(vi.getTimerCount()).toBe(2); + spec.onTimeout!([original('shared')]); + spec.onBidderError!({ error: Error('private'), bidderRequest: { bids: [original('shared')] } }); + const received = result(); + Object.assign(received.body, { + seatbid: [ + { + seat: 'example-bidder', + bid: [{ impid: 'example-unit', adm: '
Example
', price: 1, w: 300, h: 250 }], + }, + ], + }); + expect(spec.interpretResponse(received, first)).toHaveLength(1); + expect(spec.interpretResponse(received, collision)).toHaveLength(1); + expect(collector.captureStatus()).toBe('complete'); + expect(collector.snapshot().value?.serverAuctions).toHaveLength(3); + }); + it('keeps ordinary bid parsing when the optional request trace handle getter throws', () => { + const spec = setup(); + const request = spec.buildRequests([original('first')]); + Object.defineProperty(request, 'tracePending', { + get: () => { + throw Error('private-handle'); + }, + }); + const received = result(); + Object.assign(received.body, { + seatbid: [ + { + seat: 'example-bidder', + bid: [{ impid: 'example-unit', adm: '
Example
', price: 1, w: 300, h: 250 }], + }, + ], + }); + expect(() => spec.interpretResponse(received, request)).not.toThrow(); + expect(spec.interpretResponse(received, request)).toHaveLength(1); + expect(window.tsjs!.traceEvidence!.captureStatus()).toBe('not_observed'); + }); + it('declines trace setup if its lifecycle listener cannot install while registering the ordinary adapter', () => { + const listen = window.addEventListener.bind(window); + vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => { + if (type === 'pagehide') throw Error('private-listener'); + listen(type, listener, options); + }); + let spec!: TestAdapterSpec; + expect(() => { + spec = setup(); + }).not.toThrow(); + expect(spec).not.toHaveProperty('onTimeout'); + expect(spec).not.toHaveProperty('onBidderError'); + const request = spec.buildRequests([original('first')]); + expect(JSON.parse(request.data as unknown as string).adUnits[0]).not.toHaveProperty('ext'); + expect(vi.getTimerCount()).toBe(0); + }); + it('retains valid capped server evidence from a 65-unit request without changing ordinary bids', () => { + const spec = setup(); + let generated = 0; + vi.mocked(window.crypto.randomUUID).mockImplementation( + () => + `${(++generated).toString(16).padStart(8, '0')}-1234-4abc-8def-123456789abc` as ReturnType< + Crypto['randomUUID'] + > + ); + const requests = Array.from({ length: 65 }, (_, index) => + original(`bid-${index}`, `unit-${index}`) + ); + const request = spec.buildRequests(requests); + const payload = JSON.parse(request.data as unknown as string); + const envelope = gptTransport(); + envelope.evidence.source = 'auction_api'; + envelope.evidence.truncation.omitted_slots = 1; + envelope.evidence.slots = payload.adUnits + .slice(0, 64) + .map((unit: { ext: { trusted_server: { trace_slot_ref: string } } }, index: number) => ({ + ...envelope.evidence.slots[0]!, + slot_number: index + 1, + slot_ref: unit.ext.trusted_server.trace_slot_ref, + })); + const received = { + body: { + ext: { trusted_server: { trace_auction: envelope } }, + seatbid: [ + { + seat: 'example-bidder', + bid: [{ impid: 'unit-64', adm: '
Example
', price: 1, w: 300, h: 250 }], + }, + ], + }, + }; + expect(payload.adUnits).toHaveLength(65); + expect(spec.interpretResponse(received, request)).toHaveLength(1); + const capture = window.tsjs!.traceEvidence!.snapshot().value!; + expect(capture.serverAuctions).toHaveLength(1); + expect(capture.serverAuctions[0]!.slots).toHaveLength(64); + expect(capture.serverAuctions[0]!.truncation.omitted_slots).toBe(1); + expect(capture.slotCorrelations).toEqual([]); + expect(window.tsjs!.traceEvidence!.captureStatus()).toBe('complete'); + expect(vi.getTimerCount()).toBe(0); + }); + it('keeps over-cap exact response capture and never attributes unseen colliding timeout IDs', () => { + const spec = setup(); + const first = spec.buildRequests([original('bid-2048')]); + const large = spec.buildRequests( + Array.from({ length: 2049 }, (_, index) => original(`bid-${index}`)) + ); + const later = spec.buildRequests([original('bid-2047')]); + spec.onTimeout!([original('bid-2048'), original('bid-2047')]); + spec.onBidderError!({ + error: Error('private'), + bidderRequest: { bids: [original('bid-2048')] }, + }); + const collector = window.tsjs!.traceEvidence!; + expect(collector.captureStatus()).toBe('not_observed'); + expect(collector.snapshot().value?.issues).toEqual(['correlation_unavailable']); + for (const request of [first, large, later]) + expect(spec.interpretResponse(result(), request)).toEqual([]); + expect(collector.captureStatus()).toBe('complete'); + expect(collector.snapshot().value?.serverAuctions).toHaveLength(3); + expect(vi.getTimerCount()).toBe(0); + }); + it('does not add a second lifecycle listener on repeated shim installation', () => { + const listen = vi.spyOn(window, 'addEventListener'); + setup(); + installPrebidNpm(); + expect(listen.mock.calls.filter(([name]) => name === 'pagehide')).toHaveLength(1); + }); + it.each(['timeout', 'error'])( + 'ignores a retired %s hook when a newer request reuses the original bid ID', + (mode) => { + const spec = setup(); + const old = original('reused', 'example-unit', 'request-old'); + const first = spec.buildRequests([old]); + spec.interpretResponse(result(), first); + const next = spec.buildRequests([original('reused', 'example-unit', 'request-new')]); + if (mode === 'timeout') spec.onTimeout!([old]); + else spec.onBidderError!({ error: Error('private'), bidderRequest: { bids: [old] } }); + expect(vi.getTimerCount()).toBe(1); + expect(window.tsjs!.traceEvidence!.snapshot().value?.issues).toEqual([ + 'correlation_unavailable', + ]); + spec.interpretResponse(result(), next); + expect(window.tsjs!.traceEvidence!.snapshot().value?.serverAuctions).toHaveLength(2); + expect(vi.getTimerCount()).toBe(0); + } + ); +}); diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 24db647d3..de1d49781 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -25,6 +25,7 @@ let analyticsArtifact; let noAnalyticsArtifact; let managedUserIdArtifact; let shimCode; +let coreCode; async function buildArtifact(modules) { await main(['--modules-json', JSON.stringify(modules), '--out', outputDirectory]); @@ -63,6 +64,31 @@ beforeAll(async () => { }) ); shimCode = fs.readFileSync(path.join(outputDirectory, 'tsjs-prebid.js'), 'utf8'); + await build({ + configFile: false, + root: libDir, + build: { + emptyOutDir: false, + outDir: outputDirectory, + assetsDir: '.', + sourcemap: false, + minify: 'esbuild', + rollupOptions: { + input: path.join(libDir, 'src', 'core', 'index.ts'), + output: { + format: 'iife', + dir: outputDirectory, + entryFileNames: 'tsjs-core.js', + inlineDynamicImports: true, + extend: false, + name: 'tsjs_core', + }, + }, + }, + logLevel: 'warn', + }); + coreCode = fs.readFileSync(path.join(outputDirectory, 'tsjs-core.js'), 'utf8'); + console.log('[trace-prebid-artifact] shim characters:', shimCode.length); }, 240_000); afterAll(() => { @@ -305,12 +331,12 @@ describe('tsjs-prebid production artifacts', () => { expect(shimCode).not.toContain(analyticsArtifact.manifest.prebidVersion); expect(shimCode).not.toContain('_pbjsGlobals'); expect(analyticsArtifact.bundleCode.length).toBeGreaterThan(200_000); - // A value-import of Prebid or a private rendering helper would multiply - // the shim size. The bound sits just above the normal compact shim output, - // which is roughly 40 KB: tight enough that material growth has to be - // noticed and re-justified here, and far enough below a multiplication - // that one still fails loudly. The bundle beside it is 200 KB and up. - expect(shimCode.length).toBeLessThan(41_000); + // The original shim measures 40,354 characters. Shared strict trace JSON + // validation, request-ID hook association, and bounded pending lifecycle add + // 11,676 characters (52,030 total). Auction diagnostics evidence adds 2,829 + // more in this release candidate (54,859 total). Keep a narrow 55.5 KB guard: Prebid remains a separate artifact above 200 KB, and importing + // its runtime or private rendering helpers must still fail this regression. + expect(shimCode.length).toBeLessThan(55_500); expect(shimCode).toContain('markWinningBidAsUsed'); }); @@ -547,3 +573,218 @@ describe('tsjs-prebid production artifacts', () => { expect(noAnalyticsArtifact.manifest.sri).toBe(sri); }); }); + +describe('active trace through real Prebid 10.26 registered adapters', () => { + function tracedPage(mode) { + const dom = createPage(); + const pageWindow = dom.window; + const stubs = installNetworkAndConsoleStubs(pageWindow); + installServerState(pageWindow); + pageWindow.TextEncoder = TextEncoder; + pageWindow.__tsjs_trace_active = true; + pageWindow.eval(coreCode); + pageWindow.eval(noAnalyticsArtifact.bundleCode); + pageWindow.pbjs.setConfig({ bidderTimeout: 25 }); + const register = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); + let spec; + pageWindow.pbjs.registerBidAdapter = (adapter, code, registered) => { + if (code === 'trustedServer') { + spec = registered; + if (typeof registered.onTimeout === 'function') + registered.onTimeout = vi.fn(registered.onTimeout); + if (typeof registered.onBidderError === 'function') + registered.onBidderError = vi.fn(registered.onBidderError); + } + return register(adapter, code, registered); + }; + if (mode === 'timeout') stubs.fetchSpy.mockImplementation(() => new Promise(() => {})); + if (mode === 'error') stubs.fetchSpy.mockRejectedValue(new Error('private-network-error')); + pageWindow.eval(shimCode); + return { dom, pageWindow, stubs, spec }; + } + function auction(pageWindow, timeout = 25, bidId) { + return new Promise((resolve) => + pageWindow.pbjs.requestBids({ + adUnits: [ + { + code: 'example-trace-unit', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'trustedServer', params: {}, ...(bidId ? { bid_id: bidId } : {}) }], + }, + ], + timeout, + bidsBackHandler: resolve, + }) + ); + } + it.each(['timeout', 'error'])( + 'routes actual %s through registerBidAdapter/newBidder/adapterManager exactly once', + async (mode) => { + const { dom, pageWindow, stubs, spec } = tracedPage(mode); + try { + expect(spec.onTimeout).toEqual(expect.any(Function)); + expect(spec.onBidderError).toEqual(expect.any(Function)); + const bidderEvents = []; + pageWindow.pbjs.onEvent(mode === 'timeout' ? 'bidTimeout' : 'bidderError', (event) => + bidderEvents.push(event) + ); + const completed = auction(pageWindow); + await vi.waitFor( + () => expect(mode === 'timeout' ? spec.onTimeout : spec.onBidderError).toHaveBeenCalled(), + { timeout: 5000 } + ); + await completed; + const captured = pageWindow.tsjs.traceEvidence.snapshot(); + expect(pageWindow.tsjs.traceEvidence.captureStatus()).toBe('unavailable'); + expect(captured.value.issues).toEqual(['evidence_transport_failed']); + expect(captured.value.slotCorrelations).toEqual([]); + expect(bidderEvents.length).toBeGreaterThan(0); + expect(JSON.stringify(captured)).not.toContain('private-network-error'); + const callback = mode === 'timeout' ? spec.onTimeout : spec.onBidderError; + callback(...callback.mock.calls[0]); + expect(pageWindow.tsjs.traceEvidence.snapshot()).toEqual(captured); + expectNoUnexpectedNetworkActivity(stubs); + } finally { + dom.window.dispatchEvent(new dom.window.PageTransitionEvent('pagehide')); + dom.window.close(); + } + }, + 10000 + ); + it('consumes readable optional evidence in the real registered interpretResponse before the ordinary no-bid callback', async () => { + const { dom, pageWindow, stubs } = tracedPage('response'); + try { + stubs.fetchSpy.mockImplementation(async (_resource, init) => { + const payload = JSON.parse(init?.body ?? (await _resource.text())); + const ref = payload.adUnits[0].ext.trusted_server.trace_slot_ref; + return new Response( + JSON.stringify({ + seatbid: [], + ext: { + trusted_server: { + trace_auction: { + schema_version: 1, + evidence: { + schema_version: 1, + diagnostic_auction_id: 'ts-auc-1234567812344abc8def123456789abc', + source: 'auction_api', + terminal_status: 'completed', + provider_calls: [], + slots: [ + { + slot_number: 1, + slot_ref: ref, + requested_sizes: [[300, 250]], + returned_bid_count: 0, + candidate: 'no_candidate', + }, + ], + truncation: { + omitted_provider_calls: 0, + omitted_slots: 0, + omitted_nested_values: 0, + }, + coverage: { provider_to_slot_no_bid: 'unavailable' }, + }, + }, + }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + }); + await auction(pageWindow, 1000); + expect(pageWindow.tsjs.traceEvidence.captureStatus()).toBe('complete'); + const captured = pageWindow.tsjs.traceEvidence.snapshot().value; + expect(captured.serverAuctions).toHaveLength(1); + expect(captured.issues).toEqual(['correlation_unavailable']); + expect(captured.slotCorrelations).toEqual([]); + expectNoUnexpectedNetworkActivity(stubs); + } finally { + dom.window.dispatchEvent(new dom.window.PageTransitionEvent('pagehide')); + dom.window.close(); + } + }, 10000); + it.each(['timeout', 'error'])( + 'preserves a newer reused bid ID through a repeated old actual %s hook', + async (mode) => { + const { dom, pageWindow, stubs, spec } = tracedPage(mode); + try { + const callback = mode === 'timeout' ? spec.onTimeout : spec.onBidderError; + const first = auction(pageWindow, 25, 'example-reused-bid'); + await vi.waitFor(() => expect(callback).toHaveBeenCalled(), { timeout: 5000 }); + await first; + const oldArguments = callback.mock.calls[0]; + const oldBids = mode === 'timeout' ? oldArguments[0] : oldArguments[0].bidderRequest.bids; + expect(oldBids[0].bidId).toBe('example-reused-bid'); + expect(oldBids[0].bidderRequestId).toEqual(expect.any(String)); + let deliver; + let nextPayload; + stubs.fetchSpy.mockImplementation(async (resource, init) => { + nextPayload = JSON.parse(init?.body ?? (await resource.text())); + return await new Promise((resolve) => { + deliver = resolve; + }); + }); + const build = spec.buildRequests; + let nextBids; + spec.buildRequests = (...args) => { + nextBids = args[0]; + return build(...args); + }; + const next = auction(pageWindow, 2000, 'example-reused-bid'); + await vi.waitFor(() => expect(deliver).toEqual(expect.any(Function))); + expect(nextBids[0].bidId).toBe(oldBids[0].bidId); + expect(nextBids[0].bidderRequestId).not.toBe(oldBids[0].bidderRequestId); + callback(...oldArguments); + deliver( + new Response( + JSON.stringify({ + seatbid: [], + ext: { + trusted_server: { + trace_auction: { + schema_version: 1, + evidence: { + schema_version: 1, + diagnostic_auction_id: 'ts-auc-1234567812344abc8def123456789abc', + source: 'auction_api', + terminal_status: 'completed', + provider_calls: [], + slots: [ + { + slot_number: 1, + slot_ref: nextPayload.adUnits[0].ext.trusted_server.trace_slot_ref, + requested_sizes: [[300, 250]], + returned_bid_count: 0, + candidate: 'no_candidate', + }, + ], + truncation: { + omitted_provider_calls: 0, + omitted_slots: 0, + omitted_nested_values: 0, + }, + coverage: { provider_to_slot_no_bid: 'unavailable' }, + }, + }, + }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + await next; + const capture = pageWindow.tsjs.traceEvidence.snapshot().value; + expect(capture.serverAuctions).toHaveLength(1); + expect(capture.issues).toEqual(['evidence_transport_failed', 'correlation_unavailable']); + expect(capture.slotCorrelations).toEqual([]); + expectNoUnexpectedNetworkActivity(stubs); + } finally { + dom.window.dispatchEvent(new dom.window.PageTransitionEvent('pagehide')); + dom.window.close(); + } + }, + 10000 + ); +}); diff --git a/crates/trusted-server-js/lib/test/trace-assets.test.mjs b/crates/trusted-server-js/lib/test/trace-assets.test.mjs new file mode 100644 index 000000000..4df1d6994 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace-assets.test.mjs @@ -0,0 +1,34 @@ +// @vitest-environment node +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { traceSourceDigest } from '../trace-asset-sources.mjs'; + +const library = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +describe('versioned trace assets', () => { + it('retains independently embedded committed bytes with exact URLs and strong digests', () => { + const manifestFile = path.join(library, 'trace-assets-manifest.json'); + expect(fs.existsSync(manifestFile), 'should commit the trace asset manifest').toBe(true); + const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')); + expect(manifest.schema_version).toBe(1); + expect(manifest.source_sha256).toBe(traceSourceDigest(library)); + expect(manifest.assets.map((asset) => asset.path)).toEqual([ + '/_ts/trace/assets/v1.js', + '/_ts/trace/assets/v1.css', + ]); + for (const asset of manifest.assets) { + const frozen = fs.readFileSync(path.join(library, 'trace-assets', asset.file)); + const built = fs.readFileSync(path.join(library, '..', 'dist', 'trace', asset.file)); + expect(built).toEqual(frozen); + expect(sha256(frozen)).toBe(asset.sha256); + expect(asset.sha256).toMatch(/^[a-f\d]{64}$/); + } + expect(fs.existsSync(path.join(library, '..', 'dist', 'tsjs-trace.js'))).toBe(false); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/collector.test.ts b/crates/trusted-server-js/lib/test/trace/collector.test.ts new file mode 100644 index 000000000..3751e4c53 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/collector.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTraceCollector } from '../../src/trace/collector'; + +import { SLOT_TOKEN } from './fixtures'; + +function transport(number = 1, source = 'initial_navigation_ssat') { + return { + schema_version: 1, + evidence: { + schema_version: 1, + diagnostic_auction_id: token(number), + source, + terminal_status: 'completed', + provider_calls: [], + slots: [], + truncation: { omitted_provider_calls: 0, omitted_slots: 0, omitted_nested_values: 0 }, + coverage: { provider_to_slot_no_bid: 'unavailable' }, + }, + }; +} +function token(number: number) { + return `ts-auc-${number.toString(16).padStart(8, '0')}12344abc8def123456789abc`; +} +function sidecar(number = 1) { + return { + schema_version: 1, + diagnostic_auction_id: token(number), + slot_ref: SLOT_TOKEN, + runtime_slot_number: 1, + request_number: number, + }; +} +function collector() { + const result = createTraceCollector(); + if (typeof result !== 'object' || result === null) throw new Error('should create collector'); + return result; +} +describe('bounded memory-only trace collector', () => { + it('cannot capture an invalid own enum disguised by a proxy getter', () => { + const value = collector(); + const original = transport(); + original.evidence.terminal_status = 'private-invalid-enum'; + let reads = 0; + original.evidence = new Proxy(original.evidence, { + get(target, key, receiver) { + reads += 1; + if (key === 'terminal_status') return 'completed'; + return Reflect.get(target, key, receiver); + }, + }); + value.recordTransport(original); + expect(value.captureStatus()).toBe('unavailable'); + expect(value.snapshot().value?.issues).toEqual(['evidence_validation_failed']); + expect(reads).toBe(0); + }); + it('does not touch storage or start network requests during collection', () => { + const get = vi.spyOn(Storage.prototype, 'getItem'); + const set = vi.spyOn(Storage.prototype, 'setItem'); + const remove = vi.spyOn(Storage.prototype, 'removeItem'); + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + const value = collector(); + value.recordTransport(transport()); + value.recordCorrelation(sidecar()); + value.snapshot(); + expect(get).not.toHaveBeenCalled(); + expect(set).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + it('treats absent optional transport as no observation without resetting earlier evidence', () => { + const value = collector(); + value.recordTransport(undefined); + expect(value.captureStatus()).toBe('not_observed'); + value.recordTransport(transport()); + value.recordTransport(undefined); + expect(value.captureStatus()).toBe('complete'); + expect(value.snapshot().value?.serverAuctions).toHaveLength(1); + }); + it('records bounded capture failures in unique enum order without retaining input text', () => { + const value = collector(); + value.recordTransportFailure(); + value.recordTransport(null); + value.recordTransport({ schema_version: 1, unavailable_reason: 'evidence_projection_failed' }); + value.recordTransportFailure(); + expect(value.captureStatus()).toBe('unavailable'); + expect(value.snapshot().value?.issues).toEqual([ + 'evidence_projection_failed', + 'evidence_transport_failed', + 'evidence_validation_failed', + ]); + value.recordTransport(transport()); + expect(value.captureStatus()).toBe('partial'); + expect(JSON.stringify(value.snapshot())).not.toContain('private'); + }); + it('retains newest sixteen records in observation order and exact eviction counts', () => { + const value = collector(); + for (let number = 1; number <= 18; number += 1) value.recordTransport(transport(number)); + const snapshot = value.snapshot().value; + expect(snapshot?.serverAuctions.map((record) => record.diagnostic_auction_id)).toEqual( + Array.from({ length: 16 }, (_, i) => token(i + 3)) + ); + expect(snapshot?.omittedServerAuctions).toBe(2); + expect(snapshot?.issues).toEqual(['record_evicted']); + expect(value.captureStatus()).toBe('partial'); + }); + it('keeps sidecar-only loss and interpretation limits separate from server coverage', () => { + const value = collector(); + value.recordTransport(transport()); + for (let number = 1; number <= 130; number += 1) value.recordCorrelation(sidecar(number)); + value.recordInterpretationIssue('external_client_side_unobservable'); + value.recordCorrelation({ private_value: 'private-sidecar' }); + expect(value.snapshot().value?.slotCorrelations).toHaveLength(128); + expect(value.snapshot().value?.omittedSlotCorrelations).toBe(2); + expect(value.snapshot().value?.issues).toEqual([ + 'correlation_unavailable', + 'external_client_side_unobservable', + ]); + expect(value.captureStatus()).toBe('complete'); + }); + it('prunes and counts sidecars when their known unique auction is evicted', () => { + const value = collector(); + value.recordTransport(transport(1)); + value.recordCorrelation(sidecar(1)); + for (let number = 2; number <= 17; number += 1) value.recordTransport(transport(number)); + expect(value.snapshot().value?.slotCorrelations).toEqual([]); + expect(value.snapshot().value?.omittedSlotCorrelations).toBe(1); + expect(value.snapshot().value?.issues).toEqual(['record_evicted', 'correlation_unavailable']); + }); + it('keeps API records independent and declines any supplied API sidecar', () => { + const value = collector(); + value.recordTransport(transport(1, 'auction_api')); + value.recordCorrelation(sidecar(1)); + expect(value.snapshot().value?.serverAuctions).toHaveLength(1); + expect(value.snapshot().value?.slotCorrelations).toEqual([]); + expect(value.captureStatus()).toBe('complete'); + expect(value.snapshot().value?.issues).toEqual(['correlation_unavailable']); + }); + it('returns fresh frozen arrays while caller objects remain mutable and isolated', () => { + const value = collector(); + const original = transport(); + value.recordTransport(original); + const first = value.snapshot(); + const second = value.snapshot(); + expect(first).toEqual(second); + expect(first.value).not.toBe(second.value); + expect(Object.isFrozen(first.value?.serverAuctions)).toBe(true); + expect(Object.isFrozen(original.evidence)).toBe(false); + original.evidence.diagnostic_auction_id = token(2); + expect(first.value?.serverAuctions[0].diagnostic_auction_id).toBe(token(1)); + }); + it('invalidates capture on checked eviction overflow without wrapping or throwing into ads', () => { + const value = collector(); + const record = transport(); + for (let count = 0; count < 65535 + 17; count += 1) value.recordTransport(record); + expect(value.snapshot()).toEqual({ ok: false, reason: 'omission_counter_overflow' }); + }, 15000); + it('ignores late callbacks after destruction and removes retained local evidence', () => { + const value = collector(); + value.recordTransport(transport()); + value.destroy(); + value.recordTransport(transport()); + value.recordCorrelation(sidecar()); + value.recordTransportFailure(); + expect(value.snapshot().value?.serverAuctions).toEqual([]); + expect(value.snapshot().value?.slotCorrelations).toEqual([]); + expect(value.captureStatus()).toBe('not_observed'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/context.test.ts b/crates/trusted-server-js/lib/test/trace/context.test.ts new file mode 100644 index 000000000..4bf51b897 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/context.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest'; + +import { validateTraceRequestContext } from '../../src/trace/context'; + +function context() { + return { + schema_version: 1, + captured_at: '2026-10-05T10:15:30.123Z', + network: {}, + cookies: { + ts_ec: { source: 'request', state: 'absent' }, + ts_eids: { source: 'request', state: 'absent' }, + ts_tester: { source: 'request', state: 'absent' }, + diagnostics_session: { source: 'request', state: 'absent' }, + }, + }; +} + +describe('redacted trace request context validation', () => { + it('accepts missing optional platform facts and all specified cookie states', () => { + expect(validateTraceRequestContext(context())).toBe(true); + const value = context(); + Object.assign(value.cookies, { + ts_ec: { source: 'request', state: 'present_valid', detail: 'valid_ec_format' }, + ts_eids: { source: 'request', state: 'present_invalid', detail: 'malformed' }, + ts_tester: { source: 'request', state: 'duplicate', detail: 'multiple_values' }, + diagnostics_session: { + source: 'request', + state: 'unavailable', + detail: 'runtime_header_ambiguous', + }, + }); + expect(validateTraceRequestContext(value)).toBe(true); + }); + + it.each(['192.0.2.0/24', '2001:db8:1234::/48', '::/48'])( + 'accepts the display-only masked prefix %s', + (masked_client_ip) => { + const value = context(); + Object.assign(value.network, { masked_client_ip }); + expect(validateTraceRequestContext(value)).toBe(true); + } + ); + + it.each([ + '192.0.2.129', + '192.0.2.129/24', + '192.0.2.0/32', + '256.0.2.0/24', + '01.0.2.0/24', + '2001:db8:1234:5678::1', + '2001:db8:1234:1::/48', + 'example.com', + ])('rejects a full address or incorrectly masked prefix %s', (masked_client_ip) => { + const value = context(); + Object.assign(value.network, { masked_client_ip }); + expect(validateTraceRequestContext(value)).toBe(false); + }); + + it.each([ + '2026-02-30T10:00:00Z', + '2026-10-05T25:00:00Z', + '2026-10-05T10:00:00+00:00', + '2026-10-05', + '2026-10-05T10:00:60Z', + '2026-10-05t10:00:00z', + '2026-10-05T10:15:30Z\n', + '2026-10-05T10:15:30Z\r', + '2026-10-05T10:15:30Z\u2028', + '2026-10-05T10:15:30Z\u2029', + ])('rejects an invalid or noncanonical UTC capture time %s', (captured_at) => { + expect(validateTraceRequestContext({ ...context(), captured_at })).toBe(false); + }); + + it.each([ + { country: 'USA' }, + { country: 'é' }, + { asn: -1 }, + { asn: 4_294_967_296 }, + { asn: 1.5 }, + { region: 'é'.repeat(17) }, + { tls_cipher: 'x'.repeat(33) }, + { edge_hostname: 'x'.repeat(129) }, + { region: 'a\n' }, + { region: 'a\u0085' }, + { region: 'a\u202e' }, + { region: '\ud800' }, + { ja4: 'secret-fingerprint' }, + { full_client_ip: '192.0.2.129' }, + ])('rejects forbidden or invalid platform facts %j', (network) => { + expect(validateTraceRequestContext({ ...context(), network })).toBe(false); + }); + + it.each([ + { state: 'absent', detail: 'malformed' }, + { state: 'present_valid', detail: 'valid_ec_format' }, + { state: 'present_invalid', detail: 'runtime_header_ambiguous' }, + { state: 'unavailable', detail: 'multiple_values' }, + { state: 'duplicate', detail: 'header_too_large' }, + { state: 'absent', value: 'raw-cookie-sentinel' }, + { state: 'unavailable', detail: 'unknown_reason' }, + { state: 'present_valid' }, + ])('rejects invalid session state/detail pairs and added values %j', (health) => { + const value = context(); + Object.assign(value.cookies, { diagnostics_session: { source: 'request', ...health } }); + expect(validateTraceRequestContext(value)).toBe(false); + }); + + it('requires exactly the four owned cookie names and rejects extra envelope fields', () => { + const value = context(); + expect(validateTraceRequestContext({ ...value, path: '/secret-sentinel' })).toBe(false); + expect(validateTraceRequestContext({ ...value, schema_version: 2 })).toBe(false); + expect( + validateTraceRequestContext({ ...value, cookies: { ...value.cookies, other: {} } }) + ).toBe(false); + expect(validateTraceRequestContext({ ...value, cookies: {} })).toBe(false); + }); + + it.each([ + ['ts_ec', 'valid_ec_format'], + ['ts_eids', 'valid_eids_format'], + ['ts_tester', 'valid_tester_value'], + ['diagnostics_session', 'valid_diagnostics_value'], + ])('binds the valid detail to its exact owned cookie %s', (name, detail) => { + const value = context(); + Object.assign(value.cookies, { [name]: { source: 'request', state: 'present_valid', detail } }); + expect(validateTraceRequestContext(value)).toBe(true); + Object.assign(value.cookies, { + [name]: { + source: 'request', + state: 'present_valid', + detail: detail === 'valid_ec_format' ? 'valid_diagnostics_value' : 'valid_ec_format', + }, + }); + expect(validateTraceRequestContext(value)).toBe(false); + }); + + it.each(['header_too_large', 'header_not_utf8', 'runtime_header_ambiguous'])( + 'preserves the aggregate unavailable reason on every owned cookie: %s', + (detail) => { + const value = context(); + for (const name of Object.keys(value.cookies)) + Object.assign(value.cookies, { + [name]: { source: 'request', state: 'unavailable', detail }, + }); + expect(validateTraceRequestContext(value)).toBe(true); + } + ); + + it('never coerces objects into a permitted detail or invokes getters', () => { + const value = context(); + Object.assign(value.cookies, { + diagnostics_session: { + source: 'request', + state: 'unavailable', + detail: { toString: () => 'runtime_header_ambiguous' }, + }, + }); + expect(validateTraceRequestContext(value)).toBe(false); + expect( + validateTraceRequestContext( + Object.defineProperty(context(), 'network', { + enumerable: true, + get: () => { + throw new Error('should not invoke getters'); + }, + }) + ) + ).toBe(false); + expect( + validateTraceRequestContext( + new Proxy( + {}, + { + getPrototypeOf: () => { + throw new Error('should fail closed'); + }, + } + ) + ) + ).toBe(false); + }); + + it('preserves valid boundary-sized Unicode and unsigned ASN facts', () => { + const value = context(); + Object.assign(value.network, { + country: 'US', + region: 'é'.repeat(16), + edge_hostname: 'x'.repeat(128), + edge_region: 'x'.repeat(128), + edge_pop: 'x'.repeat(32), + http_version: 'HTTP/2', + tls_protocol: 'TLSv1.3', + tls_cipher: 'example-cipher', + asn: 4_294_967_295, + }); + expect(validateTraceRequestContext(value)).toBe(true); + expect(validateTraceRequestContext({ ...value, captured_at: '2024-02-29T00:00:00Z' })).toBe( + true + ); + expect(validateTraceRequestContext({ ...value, captured_at: '1900-02-29T00:00:00Z' })).toBe( + false + ); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/correlation.test.ts b/crates/trusted-server-js/lib/test/trace/correlation.test.ts new file mode 100644 index 000000000..1a0c3a680 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/correlation.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; + +import { joinTraceEvidence } from '../../src/trace/correlation'; + +import { reportFixture, TRACE_NOW, TRACE_ORIGIN, AUCTION_TOKEN, SLOT_TOKEN } from './fixtures'; + +function fixture(source = 'initial_navigation_ssat') { + const report = reportFixture(); + return { + ...report, + server_auctions: [ + { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + source, + terminal_status: 'completed', + provider_calls: [ + { provider_number: 1, role: 'bidder', status: 'no_bid', returned_bid_count: 0 }, + ], + slots: [ + { + slot_number: 1, + slot_ref: SLOT_TOKEN, + requested_sizes: [[300, 250]], + returned_bid_count: 0, + candidate: 'no_candidate', + }, + ], + truncation: { omitted_provider_calls: 0, omitted_slots: 0, omitted_nested_values: 0 }, + coverage: { provider_to_slot_no_bid: 'unavailable' }, + }, + ], + slot_correlations: + source === 'auction_api' + ? [] + : [ + { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + runtime_slot_number: 1, + request_number: 1, + }, + ], + auction_coverage: { + capture_status: 'complete', + issues: source === 'auction_api' ? ['correlation_unavailable'] : [], + }, + }; +} +function view(report: ReturnType) { + const result = joinTraceEvidence(report, TRACE_ORIGIN, TRACE_NOW); + if (typeof result !== 'object' || result === null || !('auctions' in result)) + throw new Error('should join report'); + return result; +} +describe('exact trace evidence associations', () => { + it.each([ + ['initial_navigation_ssat', 'Initial-page server auction (SSAT)'], + ['spa_page_bids', 'Trusted Server page-refresh auction'], + ])( + 'joins a supported %s record only with both tokens and exact GPT identity', + (source, label) => { + const report = fixture(source); + const result = view(report); + expect(result.auctions[0].sourceLabel).toBe(label); + expect(result.auctions[0].slots[0].correlation).toBe('matched'); + expect(result.auctions[0].slots[0].cycle).toEqual( + report.gpt_diagnostics.slots[0].requests[0] + ); + expect(result.auctions[0].relativeMilestonesLabel).toBe('Unavailable in v1'); + expect(result.auctions[0].providerScopeLabel).toBe( + 'Auction-wide provider status; per-slot no-bid reason unavailable' + ); + expect(report).toEqual(fixture(source)); + } + ); + it('keeps API evidence independent of GPT and rejects API sidecars', () => { + const report = fixture('auction_api'); + expect(view(report).auctions[0]).toMatchObject({ + sourceLabel: 'Trusted Server auction API', + slots: [{ correlation: 'unknown' }], + }); + Object.assign(report, { slot_correlations: fixture().slot_correlations }); + expect(joinTraceEvidence(report, TRACE_ORIGIN, TRACE_NOW)).toBeUndefined(); + }); + it.each([ + 'missing-sidecar', + 'missing-cycle', + 'wrong-auction', + 'duplicate-sidecar', + 'duplicate-record', + 'duplicate-slot', + 'conflicting-sidecar', + 'duplicate-cycle', + ])('leaves %s as Correlation unknown', (kind) => { + const report = fixture(); + const raw = report as unknown as { + server_auctions: { slots: unknown[] }[]; + slot_correlations: { diagnostic_auction_id: string; slot_ref: string }[]; + }; + if (kind === 'missing-sidecar') raw.slot_correlations = []; + if (kind === 'missing-cycle') report.gpt_diagnostics.slots[0].requests = []; + if (kind === 'wrong-auction') + report.gpt_diagnostics.slots[0].requests[0].trustedServerAuctionId = + 'ts-auc-0000000112344abc8def123456789abc'; + if (kind === 'duplicate-sidecar') + raw.slot_correlations.push(structuredClone(raw.slot_correlations[0])); + if (kind === 'duplicate-record') + raw.server_auctions.push(structuredClone(raw.server_auctions[0])); + if (kind === 'duplicate-slot') + raw.server_auctions[0].slots.push(structuredClone(raw.server_auctions[0].slots[0])); + if (kind === 'conflicting-sidecar') + raw.slot_correlations.push({ + ...raw.slot_correlations[0], + slot_ref: 'ts-slot-00000001-1234-4abc-8def-123456789abc', + }); + if (kind === 'duplicate-cycle') + report.gpt_diagnostics.slots[0].requests.push( + structuredClone(report.gpt_diagnostics.slots[0].requests[0]) + ); + expect(view(report).auctions[0].slots[0].correlation).toBe('unknown'); + }); + it.each([ + ['prebid_refresh', 'Browser refresh observed; winner not determined'], + ['publisher_refresh', 'Browser refresh observed; winner not determined'], + ['competing', 'Multiple or unknown delivery paths'], + ['unattributed', 'Multiple or unknown delivery paths'], + ])('preserves %s path meaning rather than inferring a winner', (path, label) => { + const report = fixture(); + Object.assign(report.gpt_diagnostics.slots[0].requests[0], { + requestPath: path, + trustedServerCreativeResponseAtMs: undefined, + }); + Reflect.deleteProperty( + report.gpt_diagnostics.slots[0].requests[0], + 'trustedServerCreativeResponseAtMs' + ); + const slot = view(report).auctions[0].slots[0]; + expect(slot.pathLabel).toBe(label); + expect(slot.creativeLabel).not.toBe('Trusted Server creative rendered'); + }); + it('requires matched nonempty render and creative-bridge evidence for the participation label', () => { + const report = fixture(); + expect(view(report).auctions[0].slots[0].creativeLabel).toBe( + 'Trusted Server creative rendered' + ); + Object.assign(report.gpt_diagnostics.slots[0].requests[0], { isEmpty: true }); + expect(view(report).auctions[0].slots[0].creativeLabel).toBe('Participation unconfirmed'); + }); + it('cannot claim participation from a selected candidate and GPT fill alone', () => { + const report = fixture(); + report.server_auctions[0].slots[0].candidate = 'selected'; + Reflect.deleteProperty( + report.gpt_diagnostics.slots[0].requests[0], + 'trustedServerCreativeResponseAtMs' + ); + expect(view(report).auctions[0].slots[0].creativeLabel).toBe('Participation unconfirmed'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/direct-api.test.ts b/crates/trusted-server-js/lib/test/trace/direct-api.test.ts new file mode 100644 index 000000000..b3214b5d8 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/direct-api.test.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildAdRequest, sendAuction } from '../../src/core/auction'; +import type { TsjsApi } from '../../src/core/types'; +import { installTraceRuntime } from '../../src/trace/runtime'; + +import { SLOT_TOKEN, AUCTION_TOKEN } from './fixtures'; + +function request() { + return buildAdRequest([ + { code: 'example-slot', mediaTypes: { banner: { sizes: [[300, 250]] } } }, + ]); +} +function bids() { + return { + seatbid: [ + { + seat: 'example-bidder', + bid: [ + { + impid: 'example-slot', + adm: '
Example creative
', + price: 1, + w: 300, + h: 250, + }, + ], + }, + ], + }; +} +function transport() { + return { + schema_version: 1, + evidence: { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + source: 'auction_api', + terminal_status: 'completed', + provider_calls: [], + slots: [ + { + slot_number: 1, + slot_ref: SLOT_TOKEN, + requested_sizes: [[300, 250]], + returned_bid_count: 1, + candidate: 'selected', + selected_creative_size: [300, 250], + }, + ], + truncation: { + omitted_provider_calls: 0, + omitted_slots: 0, + omitted_nested_values: 0, + }, + coverage: { provider_to_slot_no_bid: 'unavailable' }, + }, + }; +} +describe('direct API trace observer preserves ordinary bids', () => { + const fetch = vi.fn(); + beforeEach(() => { + window.tsjs = {} as TsjsApi; + window.__tsjs_trace_active = true; + vi.stubGlobal('fetch', fetch); + fetch.mockReset(); + vi.spyOn(window.crypto, 'randomUUID').mockReturnValue('12345678-1234-4abc-8def-123456789abc'); + installTraceRuntime(window.tsjs); + }); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + delete window.tsjs; + delete window.__tsjs_trace_active; + }); + it('decorates final grouped units and records API evidence before parsing ordinary bids', async () => { + let parsedBids = false; + const result = { + ...bids(), + ext: { trusted_server: { trace_auction: transport() } }, + }; + Object.defineProperty(result, 'seatbid', { + get() { + parsedBids = true; + expect(window.tsjs?.traceEvidence?.snapshot().value?.serverAuctions).toHaveLength(1); + return bids().seatbid; + }, + }); + fetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => result, + } as Response); + const ordinary = request(); + const received = await sendAuction('/auction', ordinary); + expect(parsedBids).toBe(true); + expect(received[0]).toMatchObject({ + impid: 'example-slot', + adm: '
Example creative
', + width: 300, + height: 250, + }); + const body = JSON.parse(fetch.mock.calls[0][1]?.body as string); + expect(body.adUnits[0].ext.trusted_server.trace_slot_ref).toBe(SLOT_TOKEN); + expect(ordinary.adUnits[0]).not.toHaveProperty('ext'); + expect(window.tsjs?.traceEvidence?.captureStatus()).toBe('complete'); + expect(window.tsjs?.traceEvidence?.snapshot().value?.issues).toEqual([ + 'correlation_unavailable', + ]); + expect(window.tsjs?.traceEvidence?.snapshot().value?.slotCorrelations).toEqual([]); + }); + it('records a supplied unreadable namespace as validation failure while preserving bids', async () => { + const trusted = new Proxy( + { trace_auction: transport() }, + { + getOwnPropertyDescriptor() { + throw new Error('private-descriptor-error'); + }, + } + ); + const response = { ...bids(), ext: { trusted_server: trusted } }; + fetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => response, + } as Response); + expect(await sendAuction('/auction', request())).toHaveLength(1); + expect(window.tsjs?.traceEvidence?.snapshot().value?.issues).toEqual([ + 'evidence_validation_failed', + ]); + expect(JSON.stringify(window.tsjs?.traceEvidence?.snapshot())).not.toContain('private'); + }); + it.each([null, { schema_version: 1, unavailable_reason: 'evidence_projection_failed' }])( + 'preserves ordinary bid results when supplied evidence is invalid or unavailable', + async (value) => { + fetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ...bids(), + ext: { trusted_server: { trace_auction: value } }, + }), + { headers: { 'content-type': 'application/json' } } + ) + ); + const received = await sendAuction('/auction', request()); + expect(received).toHaveLength(1); + expect(window.tsjs?.traceEvidence?.captureStatus()).toBe('unavailable'); + expect(window.tsjs?.traceEvidence?.snapshot().value?.issues).toEqual([ + value === null ? 'evidence_validation_failed' : 'evidence_projection_failed', + ]); + } + ); + it('adds no issue for absent optional response evidence and does not reset earlier records', async () => { + window.tsjs?.traceEvidence?.recordTransport(transport()); + fetch.mockResolvedValueOnce( + new Response(JSON.stringify(bids()), { + headers: { 'content-type': 'application/json' }, + }) + ); + expect(await sendAuction('/auction', request())).toHaveLength(1); + expect(window.tsjs?.traceEvidence?.captureStatus()).toBe('complete'); + }); + it.each(['non-ok', 'unreadable-json', 'fetch-rejected', 'non-json'])( + 'records only a bounded transport failure for %s', + async (failure) => { + if (failure === 'non-ok') + fetch.mockResolvedValueOnce(new Response('private-body', { status: 500 })); + if (failure === 'unreadable-json') + fetch.mockResolvedValueOnce( + new Response('private-invalid-json', { + headers: { 'content-type': 'application/json' }, + }) + ); + if (failure === 'fetch-rejected') + fetch.mockRejectedValueOnce(new Error('private-fetch-error')); + if (failure === 'non-json') + fetch.mockResolvedValueOnce( + new Response('private-body', { + headers: { 'content-type': 'text/plain' }, + }) + ); + expect(await sendAuction('/auction', request())).toEqual([]); + expect(window.tsjs?.traceEvidence?.snapshot().value?.issues).toEqual([ + 'evidence_transport_failed', + ]); + expect(JSON.stringify(window.tsjs?.traceEvidence?.snapshot())).not.toContain('private'); + } + ); + it('keeps the ordinary request and bids intact when token generation or collector callbacks throw', async () => { + vi.mocked(window.crypto.randomUUID).mockImplementation(() => { + throw new Error('private-random-error'); + }); + const ordinary = request(); + const prior = window.tsjs!.traceEvidence!; + window.tsjs!.traceEvidence = { + ...prior, + recordTransport() { + throw new Error('private-callback-error'); + }, + }; + fetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ...bids(), + ext: { trusted_server: { trace_auction: transport() } }, + }), + { headers: { 'content-type': 'application/json' } } + ) + ); + expect(await sendAuction('/auction', ordinary)).toHaveLength(1); + expect(JSON.parse(fetch.mock.calls[0][1]?.body as string)).toEqual(ordinary); + }); + it.each([undefined, false, 'true', 1])( + 'adds no tokens or observers for nonliteral gate %s', + async (active) => { + window.__tsjs_trace_active = active; + fetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ...bids(), + ext: { trusted_server: { trace_auction: transport() } }, + }), + { headers: { 'content-type': 'application/json' } } + ) + ); + const ordinary = request(); + expect(await sendAuction('/auction', ordinary)).toHaveLength(1); + expect(window.crypto.randomUUID).not.toHaveBeenCalled(); + expect(JSON.parse(fetch.mock.calls[0][1]?.body as string)).toEqual(ordinary); + expect(window.tsjs?.traceEvidence?.captureStatus()).toBe('not_observed'); + } + ); +}); diff --git a/crates/trusted-server-js/lib/test/trace/evidence.test.ts b/crates/trusted-server-js/lib/test/trace/evidence.test.ts new file mode 100644 index 000000000..c3960238e --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/evidence.test.ts @@ -0,0 +1,484 @@ +import { describe, expect, it } from 'vitest'; + +import type { TraceAuctionTransportV1 } from '../../src/trace/types'; +import { + validateTraceAuctionEvidence, + validateTraceAuctionTransport, + validateTraceSlotCorrelation, + parseTraceAuctionTransport, + parseTraceSlotCorrelation, +} from '../../src/trace/validation'; + +const AUCTION = 'ts-auc-1234567812344abc8def123456789abc'; +const SLOT = 'ts-slot-12345678-1234-4abc-8def-123456789abc'; + +describe('immutable evidence ingestion', () => { + it('rejects invalid own fields masked by caller get traps', () => { + const original = evidence(); + Object.assign(original, { terminal_status: 'private-invalid-enum' }); + let reads = 0; + const wrapped = new Proxy(original, { + get(target, key, receiver) { + reads += 1; + if (key === 'terminal_status') return 'completed'; + return Reflect.get(target, key, receiver); + }, + }); + expect(parseTraceAuctionTransport({ schema_version: 1, evidence: wrapped })).toBeUndefined(); + expect(reads).toBe(0); + const invalidSidecar = { + schema_version: 1, + diagnostic_auction_id: 'private-invalid-token', + slot_ref: SLOT, + runtime_slot_number: 1, + request_number: 1, + }; + expect( + parseTraceSlotCorrelation( + new Proxy(invalidSidecar, { + get(target, key, receiver) { + reads += 1; + if (key === 'diagnostic_auction_id') return AUCTION; + return Reflect.get(target, key, receiver); + }, + }) + ) + ).toBeUndefined(); + expect(reads).toBe(0); + }); + it('ingests recursively wrapped own data without reading caller properties', () => { + let reads = 0; + const wrap = (value: unknown): unknown => { + if (value === null || typeof value !== 'object') return value; + const data = Array.isArray(value) + ? value.map(wrap) + : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, wrap(item)])); + return new Proxy(data, { + get() { + reads += 1; + throw new Error('private-get'); + }, + }); + }; + const value = parseTraceAuctionTransport(wrap({ schema_version: 1, evidence: evidence() })); + expect(value).toEqual({ schema_version: 1, evidence: evidence() }); + expect( + parseTraceSlotCorrelation( + wrap({ + schema_version: 1, + diagnostic_auction_id: AUCTION, + slot_ref: SLOT, + runtime_slot_number: 1, + request_number: 1, + }) + ) + ).toBeDefined(); + expect(reads).toBe(0); + }); + it('ignores caller-controlled array iteration methods when validating', () => { + const value = evidence(); + const invalid = [{ private_provider_name: 'private-provider' }]; + Object.assign(value, { + provider_calls: new Proxy(invalid, { + get(target, name, receiver) { + if (name === 'every') return () => true; + return Reflect.get(target, name, receiver); + }, + }), + }); + expect(validateTraceAuctionEvidence(value)).toBe(false); + expect(parseTraceAuctionTransport({ schema_version: 1, evidence: value })).toBeUndefined(); + }); + + it('copies array elements without invoking caller-controlled map or length', () => { + const value = evidence(); + const original = value.provider_calls; + let customCalls = 0; + value.provider_calls = new Proxy(original, { + get(target, name, receiver) { + if (name === 'map') + return () => { + customCalls += 1; + return original; + }; + if (name === 'length') { + customCalls += 1; + return 0; + } + return Reflect.get(target, name, receiver); + }, + }); + const result = parseTraceAuctionTransport({ schema_version: 1, evidence: value }); + expect(result).toBeDefined(); + expect(customCalls).toBe(0); + expect(Object.isFrozen(original)).toBe(false); + expect(Object.isFrozen(original[0])).toBe(false); + if (!result || result.evidence === undefined) throw new Error('should parse evidence'); + expect(result.evidence.provider_calls).not.toBe(original); + expect(result.evidence.provider_calls[0]).not.toBe(original[0]); + original[0].returned_bid_count = 99; + expect(result.evidence.provider_calls[0].returned_bid_count).toBe(0); + }); + it('keeps transport members exclusive at the public type boundary', () => { + const record = evidence(); + if (!validateTraceAuctionEvidence(record)) throw new Error('should validate fixture'); + const both = { + schema_version: 1, + evidence: record, + unavailable_reason: 'evidence_projection_failed', + } as const; + // @ts-expect-error Both transport alternatives cannot be present together. + const invalid: TraceAuctionTransportV1 = both; + expect(validateTraceAuctionTransport(invalid)).toBe(false); + }); + it('copies and freezes every retained field without retaining source arrays', () => { + const original = evidence(); + Object.assign(original, { total_time_ms: 0, terminal_reason: 'unknown' }); + Object.assign(original.provider_calls[0], { response_time_ms: 0 }); + Object.assign(original.slots[0], { selected_creative_size: [300, 250] }); + const envelope = { schema_version: 1, evidence: original }; + const result = parseTraceAuctionTransport(envelope); + expect(result).toEqual(envelope); + expect(result).not.toBe(envelope); + if (!result || result.evidence === undefined) throw new Error('should parse evidence'); + expect(result.evidence).not.toBe(original); + const assertFrozen = (value: unknown): void => { + if (typeof value !== 'object' || value === null) return; + expect(Object.isFrozen(value)).toBe(true); + Object.values(value).forEach(assertFrozen); + }; + assertFrozen(result); + original.slots[0].requested_sizes[0][0] = 999; + original.provider_calls[0].returned_bid_count = 99; + expect(result.evidence.slots[0].requested_sizes[0]).toEqual([300, 250]); + expect(result.evidence.provider_calls[0].returned_bid_count).toBe(0); + expect(Object.isFrozen(original)).toBe(false); + }); + + it('copies an explicit unavailable transport and the exact correlation decision', () => { + const unavailable = { + schema_version: 1, + unavailable_reason: 'evidence_projection_failed', + }; + const result = parseTraceAuctionTransport(unavailable); + expect(result).toEqual(unavailable); + expect(Object.isFrozen(result)).toBe(true); + const original = { + schema_version: 1, + diagnostic_auction_id: AUCTION, + slot_ref: SLOT, + runtime_slot_number: 1, + request_number: 2, + }; + const sidecar = parseTraceSlotCorrelation(original); + expect(sidecar).toEqual(original); + expect(Object.isFrozen(sidecar)).toBe(true); + original.request_number = 3; + expect(sidecar?.request_number).toBe(2); + }); + + it('rejects invalid inputs without serialization or getter invocation', () => { + let calls = 0; + const value = { + schema_version: 1, + evidence: evidence(), + toJSON: () => { + calls += 1; + throw new Error('should not call'); + }, + }; + expect(parseTraceAuctionTransport(value)).toBeUndefined(); + expect(parseTraceSlotCorrelation(value)).toBeUndefined(); + expect(calls).toBe(0); + expect( + parseTraceAuctionTransport( + new Proxy( + {}, + { + getPrototypeOf() { + throw new Error('should reject'); + }, + } + ) + ) + ).toBeUndefined(); + }); + + it('copies stable own facts without invoking changing property reads', () => { + let reads = 0; + const source = new Proxy(evidence(), { + get(target, name, receiver) { + if (name === 'diagnostic_auction_id' && ++reads > 1) return 'private-id'; + return Reflect.get(target, name, receiver); + }, + }); + expect(parseTraceAuctionTransport({ schema_version: 1, evidence: source })).toEqual({ + schema_version: 1, + evidence: evidence(), + }); + expect(reads).toBe(0); + }); +}); + +function evidence() { + return { + schema_version: 1, + diagnostic_auction_id: AUCTION, + source: 'initial_navigation_ssat', + terminal_status: 'completed', + provider_calls: [ + { + provider_number: 1, + role: 'bidder', + status: 'success', + returned_bid_count: 0, + }, + ], + slots: [ + { + slot_number: 1, + slot_ref: SLOT, + requested_sizes: [[300, 250]], + returned_bid_count: 0, + candidate: 'no_candidate', + }, + ], + truncation: { + omitted_provider_calls: 0, + omitted_slots: 0, + omitted_nested_values: 0, + }, + coverage: { provider_to_slot_no_bid: 'unavailable' }, + }; +} + +describe('bounded server auction evidence contracts', () => { + it('accepts zero-bid evidence, empty observations and numeric boundaries', () => { + const value = evidence(); + expect(validateTraceAuctionEvidence(value)).toBe(true); + expect(validateTraceAuctionEvidence({ ...value, provider_calls: [], slots: [] })).toBe(true); + Object.assign(value, { + total_time_ms: 4_294_967_295, + terminal_reason: 'unknown', + }); + Object.assign(value.provider_calls[0], { + response_time_ms: 4_294_967_295, + returned_bid_count: 65_535, + }); + Object.assign(value.slots[0], { + selected_creative_size: [100_000, 1], + returned_bid_count: 65_535, + }); + expect(validateTraceAuctionEvidence(value)).toBe(true); + }); + + it.each(['initial_navigation_ssat', 'spa_page_bids', 'auction_api'])( + 'accepts source %s', + (source) => { + expect(validateTraceAuctionEvidence({ ...evidence(), source })).toBe(true); + } + ); + it.each(['completed', 'execution_failed', 'dispatch_failed', 'abandoned', 'skipped'])( + 'accepts terminal status %s', + (terminal_status) => { + expect(validateTraceAuctionEvidence({ ...evidence(), terminal_status })).toBe(true); + } + ); + it.each([ + 'policy_skipped', + 'no_eligible_slots', + 'no_provider_launched', + 'provider_execution_failed', + 'collection_failed', + 'unknown', + ])('accepts terminal reason %s', (terminal_reason) => { + expect(validateTraceAuctionEvidence({ ...evidence(), terminal_reason })).toBe(true); + }); + it.each(['bidder', 'mediator', 'unknown'])('accepts provider role %s', (role) => { + const value = evidence(); + Object.assign(value.provider_calls[0], { role }); + expect(validateTraceAuctionEvidence(value)).toBe(true); + }); + it.each(['success', 'no_bid', 'error', 'pending', 'abandoned', 'unknown'])( + 'accepts provider status %s', + (status) => { + const value = evidence(); + Object.assign(value.provider_calls[0], { status }); + expect(validateTraceAuctionEvidence(value)).toBe(true); + } + ); + it.each(['selected', 'no_candidate', 'selected_unrenderable', 'unknown'])( + 'accepts candidate %s', + (candidate) => { + const value = evidence(); + Object.assign(value.slots[0], { candidate }); + expect(validateTraceAuctionEvidence(value)).toBe(true); + } + ); + + it.each([ + { schema_version: 2 }, + { diagnostic_auction_id: 'internal-id-sentinel' }, + { source: 'initial_navigation' }, + { terminal_status: 'timeout' }, + { terminal_reason: 'raw-error-sentinel' }, + { total_time_ms: 4_294_967_296 }, + { total_time_ms: -1 }, + { total_time_ms: 0.5 }, + { total_time_ms: Infinity }, + { provider_calls: undefined }, + { coverage: { provider_to_slot_no_bid: 'known' } }, + { provider_name: 'fictional-private-provider' }, + { price: 1 }, + { + truncation: { + omitted_provider_calls: 65_536, + omitted_slots: 0, + omitted_nested_values: 0, + }, + }, + ])('rejects extra fields, unsafe numbers and unknown enums %j', (fields) => { + expect(validateTraceAuctionEvidence({ ...evidence(), ...fields })).toBe(false); + }); + + it('rejects unknown properties at every nested boundary', () => { + for (const target of ['provider', 'slot', 'truncation', 'coverage'] as const) { + const value = evidence(); + Object.assign( + target === 'provider' + ? value.provider_calls[0] + : target === 'slot' + ? value.slots[0] + : value[target], + { secret: 'private-sentinel' } + ); + expect(validateTraceAuctionEvidence(value)).toBe(false); + } + }); + + it.each([ + { provider_number: 0 }, + { provider_number: 65_536 }, + { provider_number: 1.5 }, + { role: 'named-provider' }, + { status: 'raw-error' }, + { returned_bid_count: -1 }, + { returned_bid_count: 65_536 }, + { response_time_ms: 4_294_967_296 }, + ])('rejects invalid provider fields %j', (fields) => { + const value = evidence(); + Object.assign(value.provider_calls[0], fields); + expect(validateTraceAuctionEvidence(value)).toBe(false); + }); + + it.each([ + { slot_number: 0 }, + { slot_number: 65_536 }, + { slot_ref: 'raw-slot-sentinel' }, + { candidate: 'filled' }, + { requested_sizes: [[0, 250]] }, + { requested_sizes: [[300, 0]] }, + { requested_sizes: [[100_001, 1]] }, + { requested_sizes: [[300.5, 250]] }, + { requested_sizes: [[300, 250, 1]] }, + { selected_creative_size: [0, 250] }, + { selected_creative_size: [300, 250, 1] }, + { returned_bid_count: 65_536 }, + ])('rejects invalid slot fields %j', (fields) => { + const value = evidence(); + Object.assign(value.slots[0], fields); + expect(validateTraceAuctionEvidence(value)).toBe(false); + }); + + it('rejects oversized inner models without truncating them', () => { + const value = evidence(); + expect( + validateTraceAuctionEvidence({ + ...value, + provider_calls: Array.from({ length: 17 }, () => value.provider_calls[0]), + }) + ).toBe(false); + expect( + validateTraceAuctionEvidence({ + ...value, + slots: Array.from({ length: 65 }, () => value.slots[0]), + }) + ).toBe(false); + Object.assign(value.slots[0], { + requested_sizes: Array.from({ length: 17 }, () => [300, 250]), + }); + expect(validateTraceAuctionEvidence(value)).toBe(false); + }); + + it('does not invoke forbidden accessors or throw on proxy inspection', () => { + const value = Object.defineProperty(evidence(), 'source', { + enumerable: true, + get: () => { + throw new Error('private-sentinel'); + }, + }); + expect(validateTraceAuctionEvidence(value)).toBe(false); + expect( + validateTraceAuctionEvidence( + new Proxy( + {}, + { + getPrototypeOf: () => { + throw new Error('private-sentinel'); + }, + } + ) + ) + ).toBe(false); + }); + + it('accepts exactly one evidence-or-unavailable transport member', () => { + expect( + validateTraceAuctionTransport({ + schema_version: 1, + evidence: evidence(), + }) + ).toBe(true); + expect( + validateTraceAuctionTransport({ + schema_version: 1, + unavailable_reason: 'evidence_projection_failed', + }) + ).toBe(true); + for (const value of [ + {}, + { schema_version: 1 }, + { schema_version: 2, evidence: evidence() }, + { + schema_version: 1, + evidence: evidence(), + unavailable_reason: 'evidence_projection_failed', + }, + { schema_version: 1, unavailable_reason: 'private-parser-error' }, + { schema_version: 1, evidence: evidence(), secret: 'private-sentinel' }, + ]) + expect(validateTraceAuctionTransport(value)).toBe(false); + }); + + it('accepts an exact trace-only sidecar with safe positive browser sequence numbers', () => { + const value = { + schema_version: 1, + diagnostic_auction_id: AUCTION, + slot_ref: SLOT, + runtime_slot_number: 1, + request_number: Number.MAX_SAFE_INTEGER, + }; + expect(validateTraceSlotCorrelation(value)).toBe(true); + for (const fields of [ + { schema_version: 2 }, + { diagnostic_auction_id: 'private-auction-id' }, + { slot_ref: 'private-dom-id' }, + { runtime_slot_number: 0 }, + { request_number: 0 }, + { request_number: 0.5 }, + { request_number: Number.MAX_SAFE_INTEGER + 1 }, + { slotElementId: 'private-slot-sentinel' }, + ]) + expect(validateTraceSlotCorrelation({ ...value, ...fields })).toBe(false); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/export.test.ts b/crates/trusted-server-js/lib/test/trace/export.test.ts new file mode 100644 index 000000000..202b38aee --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/export.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + copyTraceReport, + downloadTraceReport, + formatTraceReport, + shareTraceReport, +} from '../../src/trace/export'; + +import { reportFixture, TRACE_NOW, TRACE_ORIGIN } from './fixtures'; + +describe('equivalent explicit local trace exports', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.replaceChildren(); + }); + it('formats only the validated identical public report for all export paths', () => { + const report = reportFixture(); + const json = formatTraceReport(report, TRACE_ORIGIN, TRACE_NOW); + expect(json).toBe(JSON.stringify(report, null, 2)); + expect(JSON.parse(json!)).toEqual(report); + expect(json).not.toContain('stored_at_ms'); + expect(JSON.parse(json!).request_context.cookies.diagnostics_session.detail).toBe( + 'runtime_header_ambiguous' + ); + expect( + formatTraceReport({ ...report, private_value: 'secret' }, TRACE_ORIGIN, TRACE_NOW) + ).toBeUndefined(); + }); + it('downloads the same report with deferred independent cleanup for repeated taps', () => { + const created: Blob[] = []; + const create = vi.fn((blob: Blob) => { + created.push(blob); + return `blob:trace-${created.length}`; + }); + const revoke = vi.fn(); + vi.stubGlobal( + 'URL', + class extends URL { + static createObjectURL = create; + static revokeObjectURL = revoke; + } + ); + const clicks: { href: string; download: string; connected: boolean }[] = []; + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement + ) { + clicks.push({ href: this.href, download: this.download, connected: this.isConnected }); + expect(revoke).not.toHaveBeenCalled(); + }); + expect(downloadTraceReport(reportFixture(), TRACE_ORIGIN, TRACE_NOW)).toEqual({ + status: 'downloaded', + }); + expect(downloadTraceReport(reportFixture(), TRACE_ORIGIN, TRACE_NOW)).toEqual({ + status: 'downloaded', + }); + expect(clicks).toEqual([ + { href: 'blob:trace-1', download: 'trusted-server-trace-v1.json', connected: true }, + { href: 'blob:trace-2', download: 'trusted-server-trace-v1.json', connected: true }, + ]); + expect(document.querySelector('a')).toBeNull(); + expect(created.map((blob) => blob.type)).toEqual(['application/json', 'application/json']); + vi.advanceTimersByTime(999); + expect(revoke).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(revoke.mock.calls).toEqual([['blob:trace-1'], ['blob:trace-2']]); + }); + it('still schedules URL cleanup when the browser download click throws', () => { + const revoke = vi.fn(); + vi.stubGlobal( + 'URL', + class extends URL { + static createObjectURL = vi.fn(() => 'blob:failed-click'); + static revokeObjectURL = revoke; + } + ); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => { + throw new Error('private-click-error'); + }); + expect(downloadTraceReport(reportFixture(), TRACE_ORIGIN, TRACE_NOW)).toEqual({ + status: 'failed', + }); + expect(document.querySelector('a')).toBeNull(); + expect(revoke).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1000); + expect(revoke).toHaveBeenCalledWith('blob:failed-click'); + }); + it('copies formatted report JSON only when the explicit action runs', async () => { + const writeText = vi.fn(async () => undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + expect(writeText).not.toHaveBeenCalled(); + expect(await copyTraceReport(reportFixture(), TRACE_ORIGIN, TRACE_NOW)).toEqual({ + status: 'copied', + }); + expect(writeText).toHaveBeenCalledWith(JSON.stringify(reportFixture(), null, 2)); + writeText.mockRejectedValueOnce(new Error('private-clipboard-error')); + expect(await copyTraceReport(reportFixture(), TRACE_ORIGIN, TRACE_NOW)).toEqual({ + status: 'failed', + }); + }); + it('returns unsupported without attempting clipboard or URL fallback', async () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + vi.stubGlobal('navigator', {}); + expect(await copyTraceReport(reportFixture(), TRACE_ORIGIN, TRACE_NOW)).toEqual({ + status: 'unsupported', + }); + expect(await shareTraceReport(reportFixture(), TRACE_ORIGIN, TRACE_NOW)).toEqual({ + status: 'unsupported', + }); + expect(fetch).not.toHaveBeenCalled(); + }); + it('shares a JSON File with exactly the formatted report and no URL', async () => { + let offered: File | undefined; + const canShare = vi.fn((data: ShareData) => { + offered = data.files?.[0]; + return true; + }); + const share = vi.fn(async (_data: ShareData) => undefined); + vi.stubGlobal('navigator', { canShare, share }); + expect(await shareTraceReport(reportFixture(), TRACE_ORIGIN, TRACE_NOW)).toEqual({ + status: 'shared', + }); + expect(offered?.name).toBe('trusted-server-trace-v1.json'); + expect(offered?.type).toBe('application/json'); + expect(Object.keys(share.mock.calls[0][0])).toEqual(['files']); + expect(share.mock.calls[0][0].files?.[0]).toBe(offered); + expect(offered?.size).toBe( + new TextEncoder().encode(JSON.stringify(reportFixture(), null, 2)).length + ); + }); + it('leaves the valid model available after share refusal or rejection', async () => { + const report = reportFixture(); + const share = vi.fn(async () => undefined); + vi.stubGlobal('navigator', { canShare: () => false, share }); + expect(await shareTraceReport(report, TRACE_ORIGIN, TRACE_NOW)).toEqual({ + status: 'unsupported', + }); + expect(share).not.toHaveBeenCalled(); + vi.stubGlobal('navigator', { + canShare: () => true, + share: async () => { + throw new DOMException('User cancelled', 'AbortError'); + }, + }); + expect(await shareTraceReport(report, TRACE_ORIGIN, TRACE_NOW)).toEqual({ status: 'failed' }); + expect(formatTraceReport(report, TRACE_ORIGIN, TRACE_NOW)).toBe( + JSON.stringify(report, null, 2) + ); + }); + it('declines every action before browser side effects for an invalid report', async () => { + const writeText = vi.fn(); + const share = vi.fn(); + const create = vi.fn(); + vi.stubGlobal('navigator', { clipboard: { writeText }, canShare: () => true, share }); + vi.stubGlobal( + 'URL', + class extends URL { + static createObjectURL = create; + } + ); + const invalid = { ...reportFixture(), private_value: 'secret' }; + expect(downloadTraceReport(invalid, TRACE_ORIGIN, TRACE_NOW)).toEqual({ + status: 'invalid_report', + }); + expect(await copyTraceReport(invalid, TRACE_ORIGIN, TRACE_NOW)).toEqual({ + status: 'invalid_report', + }); + expect(await shareTraceReport(invalid, TRACE_ORIGIN, TRACE_NOW)).toEqual({ + status: 'invalid_report', + }); + expect(writeText).not.toHaveBeenCalled(); + expect(share).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/fixtures.ts b/crates/trusted-server-js/lib/test/trace/fixtures.ts new file mode 100644 index 000000000..57a82355d --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/fixtures.ts @@ -0,0 +1,218 @@ +import type { GptDiagnosticsExportV1 } from '../../src/core/types'; + +export const TRACE_ORIGIN = 'https://publisher.example.com'; +export const TRACE_TIME = '2026-10-05T10:15:30.123Z'; +export const TRACE_NOW = Date.parse(TRACE_TIME); +export const AUCTION_TOKEN = 'ts-auc-1234567812344abc8def123456789abc'; +export const SLOT_TOKEN = 'ts-slot-12345678-1234-4abc-8def-123456789abc'; + +/** Complete current TS Console fixture with separate forbidden identifier sentinels. */ +export function gptSourceFixture(): GptDiagnosticsExportV1 { + return { + version: 1, + capturedAt: TRACE_TIME, + page: { origin: TRACE_ORIGIN, pathname: '/private-page-secret?private-query=secret' }, + slots: [ + { + runtimeSlotNumber: 1, + slotElementId: 'private-slot-element', + adUnitPath: 'private-ad-unit', + binding: { status: 'bound', reason: 'missing_element' }, + currentVisibilityPercentage: 12.5, + maximumVisibilityPercentage: 100, + requests: [ + { + requestNumber: 1, + requestedAtMs: 1.5, + responseAtMs: 2, + renderAtMs: 3, + loadAtMs: 4, + viewableAtMs: 5, + durations: { + requestToResponseMs: 0.5, + responseToRenderMs: 1, + requestToRenderMs: 1.5, + renderToLoadMs: 1, + renderToViewableMs: 2, + }, + isEmpty: false, + requestedSlotSizes: [ + [300, 250], + [320, 50], + ], + size: [300, 250], + observedSlotSize: [0, 0], + isBackfill: true, + slotContentChanged: true, + incompleteSequence: false, + adManager: { + lineItemId: 900001, + creativeId: 900002, + campaignId: 900003, + advertiserId: 900004, + sourceAgnosticLineItemId: 900005, + sourceAgnosticCreativeId: 900006, + yieldGroupIds: [900007], + companyIds: [900008], + }, + responseClass: 'backfill', + requestPath: 'trusted_server_direct', + requestIntentId: 1, + trustedServerAuctionId: AUCTION_TOKEN, + opportunityToRequestMs: 0.5, + replacedRequestNumber: 0, + previousRenderToRequestMs: 0.5, + creativeChanged: true, + previousCreativeId: 900009, + loadObservedBeforeRender: false, + trustedServerOpportunity: 'renderable_candidate', + trustedServerCreativeRequestAtMs: 1, + trustedServerCreativeResponseAtMs: 2, + trustedServerCreativeFailures: [ + 'missing_render_source', + 'cache_fetch_failed', + 'invalid_cache_payload', + 'response_post_failed', + ], + delivery: 'trusted_server_response_sent', + }, + ], + }, + ], + callbackIssues: [ + { + kind: 'slotRenderEnded', + runtimeSlotNumber: 1, + slotElementId: 'private-callback-element', + timestampMs: 0.5, + disposition: 'matched', + reason: 'invalid_event_order', + }, + ], + attributionIssues: [ + { + reason: 'creative_request_without_slot', + timestampMs: 0.5, + runtimeSlotNumber: 1, + slotElementId: 'private-attribution-element', + }, + ], + coverage: { + slotRequested: { observed: 1, matched: 1, unmatched: 0, ambiguous: 0 }, + slotResponseReceived: { observed: 1, matched: 1, unmatched: 0, ambiguous: 0 }, + slotRenderEnded: { observed: 1, matched: 1, unmatched: 0, ambiguous: 0 }, + slotOnload: { observed: 1, matched: 1, unmatched: 0, ambiguous: 0 }, + impressionViewable: { observed: 1, matched: 1, unmatched: 0, ambiguous: 0 }, + slotVisibilityChanged: { observed: 1, matched: 1, unmatched: 0, ambiguous: 0 }, + }, + metadata: { + droppedCallbacks: 0, + droppedAttributionIssues: 0, + evictedSlots: 0, + evictedRequestCycles: 0, + }, + }; +} + +/** Valid projected fixture built explicitly without any excluded source fields. */ +export function projectedGptFixture() { + const source = gptSourceFixture(); + const cycle = source.slots[0].requests[0]; + return { + schema_version: 1, + source_schema_version: 1, + capturedAt: TRACE_TIME, + page: { origin: TRACE_ORIGIN, pathname: '/[redacted]' }, + slots: [ + { + runtimeSlotNumber: 1, + binding: { status: 'bound', reason: 'missing_element' }, + currentVisibilityPercentage: 12.5, + maximumVisibilityPercentage: 100, + requests: [ + { + requestNumber: 1, + requestedAtMs: 1.5, + responseAtMs: 2, + renderAtMs: 3, + loadAtMs: 4, + viewableAtMs: 5, + durations: cycle.durations, + isEmpty: false, + requestedSlotSizes: [ + [300, 250], + [320, 50], + ], + size: [300, 250], + observedSlotSize: [0, 0], + isBackfill: true, + slotContentChanged: true, + incompleteSequence: false, + responseClass: 'backfill', + requestPath: 'trusted_server_direct', + requestIntentId: 1, + trustedServerAuctionId: AUCTION_TOKEN, + opportunityToRequestMs: 0.5, + replacedRequestNumber: 0, + previousRenderToRequestMs: 0.5, + creativeChanged: true, + loadObservedBeforeRender: false, + trustedServerOpportunity: 'renderable_candidate', + trustedServerCreativeRequestAtMs: 1, + trustedServerCreativeResponseAtMs: 2, + trustedServerCreativeFailures: cycle.trustedServerCreativeFailures, + delivery: 'trusted_server_response_sent', + }, + ], + }, + ], + callbackIssues: [ + { + kind: 'slotRenderEnded', + runtimeSlotNumber: 1, + timestampMs: 0.5, + disposition: 'matched', + reason: 'invalid_event_order', + }, + ], + attributionIssues: [ + { reason: 'creative_request_without_slot', timestampMs: 0.5, runtimeSlotNumber: 1 }, + ], + coverage: source.coverage, + metadata: source.metadata, + }; +} + +export function reportFixture() { + return { + schema_version: 1, + captured_at: TRACE_TIME, + request_context: { + schema_version: 1, + captured_at: '2026-10-05T09:15:30Z', + network: {}, + cookies: { + ts_ec: { source: 'request', state: 'absent' }, + ts_eids: { source: 'request', state: 'absent' }, + ts_tester: { source: 'request', state: 'absent' }, + diagnostics_session: { + source: 'request', + state: 'unavailable', + detail: 'runtime_header_ambiguous', + }, + }, + }, + server_auctions: [], + slot_correlations: [], + gpt_diagnostics: projectedGptFixture(), + auction_coverage: { capture_status: 'not_observed', issues: [] }, + truncation: { + omitted_server_auctions: 0, + omitted_slot_correlations: 0, + omitted_request_cycles: 0, + omitted_callback_issues: 0, + omitted_attribution_issues: 0, + omitted_nested_values: 0, + }, + }; +} diff --git a/crates/trusted-server-js/lib/test/trace/gpt-fixtures.ts b/crates/trusted-server-js/lib/test/trace/gpt-fixtures.ts new file mode 100644 index 000000000..1d25e6128 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/gpt-fixtures.ts @@ -0,0 +1,61 @@ +import type { GptDiagnosticsStore } from '../../src/integrations/gpt_diagnostics/store'; +import { GptDiagnosticsApiController } from '../../src/integrations/gpt_diagnostics/api'; +import type { TraceCollector } from '../../src/trace/collector'; +import { joinTraceEvidence } from '../../src/trace/correlation'; +import { buildTraceReport } from '../../src/trace/report'; + +import { AUCTION_TOKEN, SLOT_TOKEN, TRACE_NOW, TRACE_ORIGIN, reportFixture } from './fixtures'; + +/** Returns the actual public API snapshot, retaining its optional undefined members. */ +export function observedGptSource(store: GptDiagnosticsStore) { + const controller = new GptDiagnosticsApiController( + store, + { + exportBinding: () => ({ status: 'bound' }), + subscribe: () => () => {}, + }, + { show: () => {}, hide: () => {} }, + { now: () => new Date(TRACE_NOW) } + ); + const source = controller.snapshot(); + controller.destroy(); + return { ...source, page: { ...source.page, origin: TRACE_ORIGIN } }; +} + +/** Exercises the real public-cycle projection and exact V4 join with store observations. */ +export function joinedGptStore(store: GptDiagnosticsStore, collector: TraceCollector) { + const source = observedGptSource(store); + const captured = buildTraceReport({ + requestContext: reportFixture().request_context, + gptSource: source, + origin: TRACE_ORIGIN, + capturedAtMs: TRACE_NOW, + collector: collector.snapshot().value, + }); + if (!captured.ok) throw new Error(`should capture observed GPT cycles: ${captured.reason}`); + return joinTraceEvidence(captured.value.report, TRACE_ORIGIN, TRACE_NOW); +} + +export function gptTransport(source = 'initial_navigation_ssat') { + return { + schema_version: 1, + evidence: { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + source, + terminal_status: 'completed', + provider_calls: [], + slots: [ + { + slot_number: 1, + slot_ref: SLOT_TOKEN, + requested_sizes: [[300, 250]], + returned_bid_count: 0, + candidate: 'no_candidate', + }, + ], + truncation: { omitted_provider_calls: 0, omitted_slots: 0, omitted_nested_values: 0 }, + coverage: { provider_to_slot_no_bid: 'unavailable' }, + }, + }; +} diff --git a/crates/trusted-server-js/lib/test/trace/gpt-transport.test.ts b/crates/trusted-server-js/lib/test/trace/gpt-transport.test.ts new file mode 100644 index 000000000..ddc0e8525 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/gpt-transport.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { AuctionSlot, TsjsApi } from '../../src/core/types'; +import { installTraceRuntime } from '../../src/trace/runtime'; + +import { AUCTION_TOKEN, SLOT_TOKEN } from './fixtures'; +import { gptTransport } from './gpt-fixtures'; + +function slot(ref: unknown = SLOT_TOKEN): AuctionSlot { + return { + id: 'private-id', + gam_unit_path: '/private-path', + div_id: 'private-div', + formats: [[300, 250]], + ext: { trusted_server: { trace_slot_ref: ref } }, + }; +} +function setup(active: unknown = true) { + const api = {} as TsjsApi; + const scope = { tsjs: api, __tsjs_trace_active: active }; + const collector = installTraceRuntime(api, scope); + return { api, collector }; +} +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('shared SSAT/SPA slot identity bridge', () => { + it('retains evidence but binds no prefix when delivered slots exceed the 64-slot cap', () => { + const { api, collector } = setup(); + const first = slot(); + const slots = [first, ...Array.from({ length: 64 }, () => slot('private-invalid'))]; + // The tail may hide a duplicate, so a bounded inspection cannot bind even a valid prefix. + api.traceGpt!.observeTransport(slots, gptTransport(), 'initial_navigation_ssat'); + expect(collector!.captureStatus()).toBe('complete'); + expect(collector!.snapshot().value?.serverAuctions).toHaveLength(1); + expect(collector!.snapshot().value?.issues).toEqual(['correlation_unavailable']); + expect(api.traceGpt!.identity(first)).toBeUndefined(); + expect(slots).toHaveLength(65); + expect(slots[0]).toBe(first); + }); + it('retains validated server capture when only delivered array descriptors are unreadable', () => { + const { api, collector } = setup(); + const slots = new Proxy([slot()], { + getOwnPropertyDescriptor: () => { + throw new Error('private-descriptor'); + }, + }); + expect(() => + api.traceGpt!.observeTransport(slots, gptTransport(), 'initial_navigation_ssat') + ).not.toThrow(); + expect(collector!.captureStatus()).toBe('complete'); + expect(collector!.snapshot().value?.issues).toEqual(['correlation_unavailable']); + }); + it.each([false, undefined, 'true', 1])( + 'allocates no bridge or bindings for gate %s', + (active) => { + const api = {} as TsjsApi; + const collector = installTraceRuntime(api, { tsjs: api, __tsjs_trace_active: active }); + expect(collector).toBeUndefined(); + expect(api.traceGpt).toBeUndefined(); + } + ); + it('retains owned evidence and exact object bindings without changing slots or reading excluded fields', () => { + const { api, collector } = setup(); + const original = slot(); + const unsafe = vi.fn(() => { + throw new Error('private-field'); + }); + Object.defineProperty(original.ext!, 'private_extension', { get: unsafe }); + Object.defineProperty(original, 'private_field', { get: unsafe }); + const transport = gptTransport(); + const reads = vi.fn(() => { + throw new Error('private-get'); + }); + const owned = new Proxy(transport, { get: reads }); + api.traceGpt!.observeTransport([original], owned, 'initial_navigation_ssat'); + expect(collector!.captureStatus()).toBe('complete'); + expect(api.traceGpt!.identity(original)).toEqual({ + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + }); + expect(api.traceGpt!.identity({ ...original })).toBeUndefined(); + expect(unsafe).not.toHaveBeenCalled(); + expect(reads).not.toHaveBeenCalled(); + expect(Object.isFrozen(original)).toBe(false); + expect(Object.isFrozen(transport)).toBe(false); + const identity = api.traceGpt!.identity(original)!; + expect(Object.isFrozen(identity)).toBe(true); + original.ext!.trusted_server!.trace_slot_ref = 'private-mutated'; + transport.evidence.slots[0]!.slot_ref = 'private-mutated'; + expect(identity.slot_ref).toBe(SLOT_TOKEN); + expect(collector!.snapshot().value?.serverAuctions[0]!.slots[0]!.slot_ref).toBe(SLOT_TOKEN); + }); + it.each([ + 'missing', + 'malformed', + 'duplicate-delivered', + 'duplicate-evidence', + 'conflicting', + 'accessor', + 'throwing-descriptor', + ] as const)('limits only correlation for a %s delivered reference', (kind) => { + const { api, collector } = setup(); + const original = slot(); + const slots = [original]; + const transport = gptTransport(); + if (kind === 'missing') delete original.ext; + if (kind === 'malformed') original.ext!.trusted_server!.trace_slot_ref = 'private-token'; + if (kind === 'duplicate-delivered') slots.push(slot()); + if (kind === 'duplicate-evidence') + transport.evidence.slots.push({ ...transport.evidence.slots[0]!, slot_number: 2 }); + if (kind === 'conflicting') + original.ext!.trusted_server!.trace_slot_ref = 'ts-slot-12345679-1234-4abc-8def-123456789abc'; + const getter = vi.fn(() => { + throw new Error('private-reference'); + }); + if (kind === 'accessor') + Object.defineProperty(original.ext!.trusted_server!, 'trace_slot_ref', { get: getter }); + if (kind === 'throwing-descriptor') + original.ext = new Proxy(original.ext!, { getOwnPropertyDescriptor: getter }); + api.traceGpt!.observeTransport(slots, transport, 'initial_navigation_ssat'); + expect(collector!.captureStatus()).toBe('complete'); + expect(collector!.snapshot().value?.issues).toEqual(['correlation_unavailable']); + expect(api.traceGpt!.identity(original)).toBeUndefined(); + expect(slots[0]).toBe(original); + if (kind === 'accessor') expect(getter).not.toHaveBeenCalled(); + }); + it('preserves earlier collector capture but clears stale bindings when the next optional member is absent', () => { + const { api, collector } = setup(); + const original = slot(); + api.traceGpt!.observeTransport([original], gptTransport(), 'initial_navigation_ssat'); + api.traceGpt!.observeTransport([original], undefined, 'initial_navigation_ssat'); + expect(api.traceGpt!.identity(original)).toBeUndefined(); + expect(collector!.snapshot().value?.serverAuctions).toHaveLength(1); + expect(collector!.snapshot().value?.issues).toEqual([]); + }); + it.each(['malformed', 'projection', 'api', 'wrong-source'] as const)( + 'keeps %s transport handling separate from correlation', + (kind) => { + const { api, collector } = setup(); + const original = slot(); + const value = + kind === 'projection' + ? { schema_version: 1, unavailable_reason: 'evidence_projection_failed' } + : kind === 'malformed' + ? { schema_version: 1 } + : gptTransport(kind === 'api' ? 'auction_api' : 'spa_page_bids'); + api.traceGpt!.observeTransport([original], value, 'initial_navigation_ssat'); + expect(api.traceGpt!.identity(original)).toBeUndefined(); + expect(collector!.snapshot().value?.issues).toEqual([ + kind === 'projection' ? 'evidence_projection_failed' : 'evidence_validation_failed', + ]); + } + ); + it('reads only an own data SPA transport member without caller getters or unrelated extensions', () => { + const { api, collector } = setup(); + const original = slot(); + const getter = vi.fn(() => { + throw new Error('private-member'); + }); + api.traceGpt!.observePageBids(Object.create({ trace_auction: gptTransport('spa_page_bids') }), [ + original, + ]); + expect(collector!.snapshot().value?.issues).toEqual([]); + const response = { trace_auction: gptTransport('spa_page_bids') }; + api.traceGpt!.observePageBids(new Proxy(response, { get: getter }), [original]); + expect(collector!.captureStatus()).toBe('complete'); + expect(getter).not.toHaveBeenCalled(); + api.traceGpt!.observePageBids(Object.defineProperty({}, 'trace_auction', { get: getter }), [ + original, + ]); + expect(collector!.snapshot().value?.issues).toEqual(['evidence_validation_failed']); + expect(getter).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/handoff.test.ts b/crates/trusted-server-js/lib/test/trace/handoff.test.ts new file mode 100644 index 000000000..c51279e06 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/handoff.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { TsjsApi } from '../../src/core/types'; +import { createTraceCollector } from '../../src/trace/collector'; +import type { downloadTraceReport } from '../../src/trace/export'; +import { createTraceHandoff } from '../../src/trace/handoff'; + +import { gptSourceFixture, reportFixture, TRACE_NOW, TRACE_ORIGIN } from './fixtures'; + +function setup(active: unknown = true) { + const order: string[] = []; + const snapshot = vi.fn(() => { + order.push('snapshot'); + return gptSourceFixture(); + }); + const collector = createTraceCollector(); + const target = { + __tsjs_trace_active: active, + __tsjs_trace_request_context: reportFixture().request_context, + tsjs: { + traceEvidence: collector, + gptDiagnostics: { snapshot }, + } as unknown as TsjsApi, + location: { + origin: TRACE_ORIGIN, + assign: vi.fn((path: string) => { + order.push(`navigate:${path}`); + }), + }, + sessionStorage: { + getItem: vi.fn(() => null), + setItem: vi.fn((_key: string, _value: string) => { + order.push('store'); + }), + removeItem: vi.fn(), + }, + }; + const states: unknown[] = []; + const download = vi.fn(() => ({ status: 'downloaded' })); + const options = { + target, + now: () => TRACE_NOW, + onChange: (state: unknown): void => { + states.push(state); + }, + download, + }; + return { order, snapshot, collector, target, states, download, options }; +} +function handoff(options: ReturnType['options']) { + const result = createTraceHandoff(options); + if (typeof result !== 'object' || result === null) throw new Error('should create handoff'); + return result; +} +describe('explicit same-tab trace handoff', () => { + it.each([undefined, false, 'true', 1])( + 'creates no action or storage access for nonliteral gate %s', + (active) => { + const fixture = setup(); + fixture.target.__tsjs_trace_active = active; + expect(createTraceHandoff(fixture.options)).toBeUndefined(); + expect(fixture.snapshot).not.toHaveBeenCalled(); + expect(fixture.target.sessionStorage.getItem).not.toHaveBeenCalled(); + expect(fixture.target.sessionStorage.setItem).not.toHaveBeenCalled(); + } + ); + it('captures only on tap then stores a validated wrapper before same-tab navigation', () => { + const fixture = setup(); + const action = handoff(fixture.options); + expect(fixture.order).toEqual([]); + expect(fixture.target.sessionStorage.getItem).not.toHaveBeenCalled(); + action.view(); + expect(fixture.order).toEqual(['snapshot', 'store', `navigate:${TRACE_ORIGIN}/_ts/trace`]); + const wrapper = JSON.parse(fixture.target.sessionStorage.setItem.mock.calls[0][1]); + expect(wrapper.report.request_context).toEqual(fixture.target.__tsjs_trace_request_context); + expect(wrapper.report.gpt_diagnostics.page.pathname).toBe('/[redacted]'); + expect(wrapper.stored_at_ms).toBe(TRACE_NOW); + }); + it('keeps the handoff on the publisher origin when the document has an external base', () => { + const fixture = setup(); + fixture.target.location.assign.mockImplementation((url) => { + fixture.order.push(`navigate:${new URL(url, 'https://other.example/').href}`); + }); + handoff(fixture.options).view(); + expect(fixture.order).toEqual(['snapshot', 'store', `navigate:${TRACE_ORIGIN}/_ts/trace`]); + }); + it('preserves a valid immutable combined report for explicit direct download after storage failure', () => { + const fixture = setup(); + fixture.target.sessionStorage.setItem.mockImplementation(() => { + throw new Error('private-quota-error'); + }); + const action = handoff(fixture.options); + action.view(); + expect(fixture.target.location.assign).not.toHaveBeenCalled(); + expect(fixture.download).not.toHaveBeenCalled(); + expect(fixture.states[fixture.states.length - 1]).toMatchObject({ + kind: 'storage_unavailable', + downloadAvailable: true, + }); + fixture.target.__tsjs_trace_request_context.network = { + private_value: 'changed-after-capture', + } as object; + action.download(); + const report = fixture.download.mock.calls[0][0]; + expect(report).toMatchObject({ request_context: reportFixture().request_context }); + expect(Object.isFrozen(report)).toBe(true); + expect(fixture.download.mock.calls[0].slice(1)).toEqual([TRACE_ORIGIN, TRACE_NOW]); + }); + it.each([ + 'missing-context', + 'invalid-context', + 'bad-gpt-version', + 'gpt-throws', + 'collector-overflow', + 'clock-invalid', + ])('never writes, navigates or offers a combined artifact after %s', (failure) => { + const fixture = setup(); + if (failure === 'missing-context') + Object.assign(fixture.target, { + __tsjs_trace_request_context: undefined, + }); + if (failure === 'invalid-context') + Object.assign(fixture.target, { + __tsjs_trace_request_context: { private_value: 'secret' }, + }); + if (failure === 'bad-gpt-version') + fixture.snapshot.mockReturnValue({ + ...gptSourceFixture(), + version: 2, + } as never); + if (failure === 'gpt-throws') + fixture.snapshot.mockImplementation(() => { + throw new Error('private-source-error'); + }); + if (failure === 'collector-overflow') + Object.assign(fixture.target.tsjs, { + traceEvidence: { + snapshot: () => ({ + ok: false, + reason: 'omission_counter_overflow', + }), + }, + }); + if (failure === 'clock-invalid') fixture.options.now = () => Number.NaN; + const action = handoff(fixture.options); + action.view(); + action.download(); + expect(fixture.target.sessionStorage.setItem).not.toHaveBeenCalled(); + expect(fixture.target.location.assign).not.toHaveBeenCalled(); + expect(fixture.download).not.toHaveBeenCalled(); + expect(JSON.stringify(fixture.states)).not.toContain('private'); + expect(fixture.states[fixture.states.length - 1]).toMatchObject({ + kind: 'capture_failed', + downloadAvailable: false, + }); + }); + it('remains on the publisher page if its navigation attempt fails after storage succeeds', () => { + const fixture = setup(); + fixture.target.location.assign.mockImplementation(() => { + throw new Error('private-navigation-error'); + }); + const action = handoff(fixture.options); + action.view(); + expect(fixture.target.sessionStorage.setItem).toHaveBeenCalledTimes(1); + expect(fixture.states[fixture.states.length - 1]).toMatchObject({ + kind: 'navigation_unavailable', + downloadAvailable: true, + }); + }); + it.each(['capturing', 'snapshot', 'storage'])( + 'stops capture when destroyed from %s callback', + (phase) => { + const fixture = setup(); + if (phase === 'capturing') fixture.options.onChange = () => action.destroy(); + if (phase === 'snapshot') + fixture.snapshot.mockImplementation(() => { + action.destroy(); + return gptSourceFixture(); + }); + if (phase === 'storage') + fixture.target.sessionStorage.setItem.mockImplementation(() => action.destroy()); + const action = handoff(fixture.options); + action.view(); + action.download(); + expect(fixture.target.location.assign).not.toHaveBeenCalled(); + expect(fixture.download).not.toHaveBeenCalled(); + if (phase === 'capturing') expect(fixture.snapshot).not.toHaveBeenCalled(); + if (phase !== 'storage') expect(fixture.target.sessionStorage.setItem).not.toHaveBeenCalled(); + } + ); + it('destroys retained recovery data and ignores later retry callbacks', () => { + const fixture = setup(); + fixture.target.sessionStorage.setItem.mockImplementation(() => { + throw new Error('private-quota-error'); + }); + const action = handoff(fixture.options); + action.view(); + action.destroy(); + action.download(); + action.view(); + expect(fixture.snapshot).toHaveBeenCalledTimes(1); + expect(fixture.download).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/lifecycle.test.ts b/crates/trusted-server-js/lib/test/trace/lifecycle.test.ts new file mode 100644 index 000000000..d7a4e7327 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/lifecycle.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; + +import { changeTraceSession, endTraceSessionAndObserve } from '../../src/trace/lifecycle'; + +describe('deliberate trace session changes', () => { + const request = vi.fn(); + + beforeEach(() => { + request.mockReset(); + vi.stubGlobal('fetch', request); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each(['enable', 'end'] as const)( + 'verifies %s through a separate state GET', + async (action) => { + const active = action === 'enable'; + request.mockResolvedValueOnce(new Response('{}', { status: 200 })); + request.mockResolvedValueOnce(new Response(JSON.stringify({ observed_active: active }))); + const historyLength = window.history.length; + const result = await changeTraceSession(action); + + expect(request.mock.calls).toEqual([ + [ + `/_ts/trace/${action}`, + { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + headers: { 'X-TS-Trace-Action': action }, + }, + ], + ['/_ts/trace/state', { method: 'GET', credentials: 'same-origin', cache: 'no-store' }], + ]); + expect(result).toEqual({ + mutation: 'requested', + observation: active ? 'active' : 'inactive', + confirmed: true, + }); + expect(window.history.length).toBe(historyLength); + } + ); + + it('does not confirm activation when the browser fails to return a valid session', async () => { + request.mockResolvedValueOnce(new Response('{}')); + request.mockResolvedValueOnce(new Response('{"observed_active":false}')); + expect(await changeTraceSession('enable')).toEqual({ + mutation: 'requested', + observation: 'inactive', + confirmed: false, + }); + }); + + it('does not confirm deactivation when a valid session is still observed', async () => { + request.mockResolvedValueOnce(new Response('{}')); + request.mockResolvedValueOnce(new Response('{"observed_active":true}')); + expect(await changeTraceSession('end')).toEqual({ + mutation: 'requested', + observation: 'active', + confirmed: false, + }); + }); + + it.each([ + '{}', + '{"observed_active":"false"}', + '{"observed_active":false,"extra":true}', + '[]', + 'null', + '{invalid-json', + ])( + 'rejects an invalid state response without discarding the mutation result: %s', + async (body) => { + request.mockResolvedValueOnce(new Response('{}')); + request.mockResolvedValueOnce(new Response(body)); + expect(await changeTraceSession('enable')).toEqual({ + mutation: 'requested', + observation: 'failed', + confirmed: false, + }); + } + ); + + it('retains requested mutation when the verification request fails', async () => { + request.mockResolvedValueOnce(new Response('{}')); + request.mockRejectedValueOnce(new TypeError('offline')); + expect(await changeTraceSession('end')).toEqual({ + mutation: 'requested', + observation: 'failed', + confirmed: false, + }); + }); + + it('rejects a non-successful state response', async () => { + request.mockResolvedValueOnce(new Response('{}')); + request.mockResolvedValueOnce(new Response('{"observed_active":false}', { status: 500 })); + expect((await changeTraceSession('end')).observation).toBe('failed'); + }); + + it.each([403, 404, 413, 500])( + 'keeps a rejected mutation (%s) separate from state observation', + async (status) => { + request.mockResolvedValueOnce(new Response('{}', { status })); + expect(await changeTraceSession('enable')).toEqual({ + mutation: 'failed', + observation: 'not_attempted', + confirmed: false, + }); + expect(request).toHaveBeenCalledTimes(1); + } + ); + + it('permits an explicit retry after a failed mutation', async () => { + request.mockRejectedValueOnce(new TypeError('offline')); + expect((await changeTraceSession('enable')).mutation).toBe('failed'); + request.mockResolvedValueOnce(new Response('{}')); + request.mockResolvedValueOnce(new Response('{"observed_active":true}')); + expect((await changeTraceSession('enable')).confirmed).toBe(true); + expect(request).toHaveBeenCalledTimes(3); + }); + + it.each(['rejected', 'offline'] as const)( + 'observes state independently after an end POST is %s', + async (failure) => { + if (failure === 'offline') request.mockRejectedValueOnce(new Error('private-network-error')); + else request.mockResolvedValueOnce(new Response('{}', { status: 500 })); + request.mockResolvedValueOnce(new Response('{"observed_active":false}')); + expect(await endTraceSessionAndObserve()).toEqual({ + mutation: 'failed', + observation: 'inactive', + confirmed: false, + }); + expect(request.mock.calls.map(([path]) => path)).toEqual([ + '/_ts/trace/end', + '/_ts/trace/state', + ]); + } + ); + + it('keeps both failed end mutation and failed independent observation bounded', async () => { + request.mockRejectedValueOnce(new Error('private-post-error')); + request.mockRejectedValueOnce(new Error('private-state-error')); + expect(await endTraceSessionAndObserve()).toEqual({ + mutation: 'failed', + observation: 'failed', + confirmed: false, + }); + expect(request).toHaveBeenCalledTimes(2); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/pending.test.ts b/crates/trusted-server-js/lib/test/trace/pending.test.ts new file mode 100644 index 000000000..3fcf506a8 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/pending.test.ts @@ -0,0 +1,257 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createTraceCollector } from '../../src/trace/collector'; +import { createTracePending, pendingExpiry } from '../../src/trace/pending'; + +import { gptTransport } from './gpt-fixtures'; +import { SLOT_TOKEN } from './fixtures'; + +function carry() { + return { collector: createTraceCollector(), slotRefs: [SLOT_TOKEN] }; +} +function response() { + return { + ext: { + trusted_server: { + trace_auction: { + ...gptTransport(), + evidence: { ...gptTransport().evidence!, source: 'auction_api' }, + }, + }, + }, + }; +} +function bindings(bidId = 'example-bid', bidderRequestId = 'example-request') { + return [{ bidId, bidderRequestId, slotRef: SLOT_TOKEN }]; +} +function identities(bidIds: readonly string[], bidderRequestId = 'example-request') { + return bidIds.map((bidId) => ({ bidId, bidderRequestId })); +} +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); +describe('bounded Prebid trace pending records', () => { + it.each([0, 2000, 2 ** 31 - 1 - 5000])('captures timeout %s once', (timeout) => { + expect(pendingExpiry(100, timeout)).toBe(100 + timeout + 5000); + }); + it.each([undefined, -1, 1.5, NaN, Infinity, '2000', 2 ** 31 - 5000])( + 'defaults invalid timeout %s to 3000', + (timeout) => { + expect(pendingExpiry(100, timeout)).toBe(8100); + } + ); + it.each([-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER])('declines unsafe clock %s', (now) => { + expect(pendingExpiry(now, 3000)).toBeUndefined(); + }); + it.each([0, 2000, 2 ** 31 - 1 - 5000])( + 'arms one bounded browser timer for configured timeout %s', + (timeout) => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const schedule = vi.fn((callback: () => void, delay: number) => setTimeout(callback, delay)); + const pending = createTracePending({ timeout: () => timeout, schedule }); + const value = carry(); + const handle = pending.add(bindings(), value); + expect(schedule.mock.calls[0]![1]).toBe(timeout + 5000); + vi.advanceTimersByTime(timeout + 4999); + pending.response(handle, response()); + expect(value.collector.captureStatus()).toBe('complete'); + expect(vi.getTimerCount()).toBe(0); + } + ); + it('records readable API evidence only once and never emits GPT sidecars', () => { + const pending = createTracePending({ now: () => 0 }); + const value = carry(); + const handle = pending.add(bindings(), value); + pending.response(handle, response()); + pending.response(handle, response()); + pending.failure(identities(['example-bid'])); + expect(value.collector.snapshot().value?.serverAuctions).toHaveLength(1); + expect(value.collector.snapshot().value?.slotCorrelations).toEqual([]); + expect(value.collector.captureStatus()).toBe('complete'); + pending.destroy(); + }); + it('consumes matching original IDs before callback and isolates concurrent requests', () => { + const pending = createTracePending({ now: () => 0 }); + const a = carry(); + const b = carry(); + pending.add(bindings('first'), a); + const second = pending.add(bindings('second'), b); + pending.failure(identities(['first'])); + pending.failure(identities(['first'])); + pending.response(second, response()); + expect(a.collector.captureStatus()).toBe('unavailable'); + expect(b.collector.captureStatus()).toBe('complete'); + pending.destroy(); + }); + it('retires expiry without inventing a transport outcome and captures timeout only at creation', () => { + vi.useFakeTimers(); + let timeout = 0; + const readTimeout = vi.fn(() => timeout); + const pending = createTracePending({ now: () => Date.now(), timeout: readTimeout }); + const value = carry(); + const handle = pending.add(bindings(), value); + timeout = 100000; + vi.advanceTimersByTime(5000); + pending.failure(identities(['example-bid'])); + pending.response(handle, response()); + expect(readTimeout).toHaveBeenCalledTimes(1); + expect(value.collector.captureStatus()).toBe('not_observed'); + expect(vi.getTimerCount()).toBe(0); + }); + it('checks expiry synchronously when a delayed callback beats its timer', () => { + let now = 0; + const value = carry(); + const pending = createTracePending({ now: () => now, timeout: () => 0 }); + const handle = pending.add(bindings(), value); + now = 5000; + pending.response(handle, response()); + expect(value.collector.captureStatus()).toBe('not_observed'); + pending.destroy(); + }); + it('evicts the oldest of 128 records without failures and destroys all timers', () => { + vi.useFakeTimers(); + const pending = createTracePending({ now: () => Date.now() }); + const values = Array.from({ length: 129 }, carry); + const handles = values.map((value, index) => pending.add(bindings(`bid-${index}`), value)); + expect(vi.getTimerCount()).toBe(128); + pending.response(handles[0], response()); + expect(values[0].collector.captureStatus()).toBe('not_observed'); + pending.destroy(); + expect(vi.getTimerCount()).toBe(0); + pending.failure(identities(['bid-1'])); + expect(values[1].collector.captureStatus()).toBe('not_observed'); + expect(pending.add(bindings(), carry())).toBeUndefined(); + }); + it('owns bindings/refs and removes state before a throwing/reentrant diagnostic callback', () => { + const pending = createTracePending({ now: () => 0 }); + const collector = createTraceCollector(); + const failure = vi.fn(() => { + pending.failure(identities(['original'])); + throw new Error('private-error'); + }); + const value = { + collector: { ...collector, recordTransportFailure: failure }, + slotRefs: [SLOT_TOKEN], + }; + const list = bindings('original'); + pending.add(list, value); + list[0].bidId = 'mutated'; + value.slotRefs[0] = 'mutated'; + expect(() => pending.failure(identities(['original']))).not.toThrow(); + expect(failure).toHaveBeenCalledTimes(1); + pending.destroy(); + }); + it('declines clock overflow and ambiguous IDs without disturbing previous capture', () => { + let now = 0; + const pending = createTracePending({ now: () => now }); + const value = carry(); + value.collector.recordTransport(response().ext.trusted_server.trace_auction); + pending.add(bindings(), value); + expect(pending.add(bindings(), carry())).toBeDefined(); + now = Number.MAX_SAFE_INTEGER; + expect(pending.add(bindings('new'), value)).toBeUndefined(); + now = 0; + pending.failure(identities(['example-bid'])); + expect(value.collector.captureStatus()).toBe('complete'); + pending.destroy(); + }); + it('keeps exact response handles for every collided request while declining ambiguous hooks', () => { + vi.useFakeTimers(); + const pending = createTracePending({ now: () => Date.now() }); + const a = carry(); + const b = carry(); + const c = carry(); + const first = pending.add(bindings('first'), a); + const second = pending.add(bindings('second'), b); + const collision = pending.add([...bindings('first'), ...bindings('second')], c); + expect(collision).toBeDefined(); + expect(vi.getTimerCount()).toBe(3); + pending.failure(identities(['first', 'second'])); + expect([a, b, c].map((value) => value.collector.captureStatus())).toEqual([ + 'not_observed', + 'not_observed', + 'not_observed', + ]); + pending.response(first, response()); + pending.response(second, response()); + pending.response(collision, response()); + expect([a, b, c].map((value) => value.collector.captureStatus())).toEqual([ + 'complete', + 'complete', + 'complete', + ]); + expect(vi.getTimerCount()).toBe(0); + pending.destroy(); + }); + it('keeps over-cap responses and disables current and later hooks that could collide with unseen IDs', () => { + vi.useFakeTimers(); + const pending = createTracePending({ now: () => Date.now() }); + const a = carry(); + const b = carry(); + const c = carry(); + const first = pending.add(bindings('bid-2048'), a); + const overflow = pending.add( + Array.from({ length: 2049 }, (_, index) => bindings(`bid-${index}`)[0]!), + b + ); + const later = pending.add(bindings('bid-2047'), c); + expect(overflow).toBeDefined(); + expect(later).toBeDefined(); + pending.failure(identities(['bid-2048', 'bid-2047'])); + expect([a, b, c].map((value) => value.collector.captureStatus())).toEqual([ + 'not_observed', + 'not_observed', + 'not_observed', + ]); + pending.response(first, response()); + pending.response(overflow, response()); + pending.response(later, response()); + expect([a, b, c].map((value) => value.collector.captureStatus())).toEqual([ + 'complete', + 'complete', + 'complete', + ]); + expect(vi.getTimerCount()).toBe(0); + }); + it.each(['response', 'failure'])( + 'isolates a reused original ID after old %s retirement', + (retire) => { + vi.useFakeTimers(); + const pending = createTracePending({ now: () => Date.now() }); + const old = carry(); + const next = carry(); + const first = pending.add(bindings('reused', 'request-old'), old); + if (retire === 'response') pending.response(first, response()); + else pending.failure(identities(['reused'], 'request-old')); + const second = pending.add(bindings('reused', 'request-new'), next); + pending.failure(identities(['reused'], 'request-old')); + expect(next.collector.captureStatus()).toBe('not_observed'); + expect(vi.getTimerCount()).toBe(1); + pending.response(second, response()); + expect(next.collector.captureStatus()).toBe('complete'); + expect(vi.getTimerCount()).toBe(0); + } + ); + it.each([undefined, '', 1, 'x'.repeat(129)])( + 'keeps malformed request ID %s response-only', + (bidderRequestId) => { + vi.useFakeTimers(); + const pending = createTracePending({ now: () => Date.now() }); + const value = carry(); + const handle = pending.add( + [{ bidId: 'reused', bidderRequestId } as ReturnType[number]], + value + ); + pending.failure([ + { bidId: 'reused', bidderRequestId } as ReturnType[number], + ]); + expect(value.collector.captureStatus()).toBe('not_observed'); + expect(value.collector.snapshot().value?.issues).toEqual(['correlation_unavailable']); + pending.response(handle, response()); + expect(value.collector.captureStatus()).toBe('complete'); + expect(vi.getTimerCount()).toBe(0); + } + ); +}); diff --git a/crates/trusted-server-js/lib/test/trace/projection.test.ts b/crates/trusted-server-js/lib/test/trace/projection.test.ts new file mode 100644 index 000000000..46773c080 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/projection.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { GptDiagnosticsStore } from '../../src/integrations/gpt_diagnostics/store'; +import { addOmissions, projectTraceGptDiagnostics } from '../../src/trace/projection'; + +import { gptSourceFixture, projectedGptFixture, TRACE_ORIGIN } from './fixtures'; +import { observedGptSource } from './gpt-fixtures'; + +describe('explicit GPT public projection', () => { + const optionalPaths = [ + ['attributionIssues'], + ['metadata', 'droppedAttributionIssues'], + ['slots', '0', 'binding', 'reason'], + ['slots', '0', 'currentVisibilityPercentage'], + ['slots', '0', 'requests', '0', 'requestedAtMs'], + ['slots', '0', 'requests', '0', 'durations', 'requestToResponseMs'], + ['slots', '0', 'requests', '0', 'size'], + ['slots', '0', 'requests', '0', 'observedSlotSize'], + ['slots', '0', 'requests', '0', 'requestedSlotSizes'], + ['slots', '0', 'requests', '0', 'trustedServerCreativeFailures'], + ['slots', '0', 'requests', '0', 'trustedServerAuctionId'], + ['attributionIssues', '0', 'runtimeSlotNumber'], + ]; + function parentAt(source: unknown, path: string[]): Record { + let parent = source as Record; + for (const key of path.slice(0, -1)) parent = parent[key] as Record; + return parent; + } + it.each(optionalPaths)( + 'omits optional own undefined like an absent JSON member: %j', + (...path) => { + const absent = gptSourceFixture(); + const present = gptSourceFixture(); + const key = path[path.length - 1]!; + delete parentAt(absent, path)[key]; + parentAt(present, path)[key] = undefined; + const expected = projectTraceGptDiagnostics(absent, TRACE_ORIGIN); + expect(expected.ok).toBe(true); + expect(projectTraceGptDiagnostics(present, TRACE_ORIGIN)).toEqual(expected); + } + ); + it.each(['required', 'unknown', 'null', 'accessor', 'malformed'] as const)( + 'keeps %s public-source members strict while omitting optional undefined', + (kind) => { + const source = gptSourceFixture(); + const cycle = source.slots[0]!.requests[0]! as unknown as Record; + const getter = vi.fn(() => undefined); + if (kind === 'required') cycle.requestNumber = undefined; + if (kind === 'unknown') cycle.privateFutureField = undefined; + if (kind === 'null') cycle.size = null; + if (kind === 'accessor') Object.defineProperty(cycle, 'size', { get: getter }); + if (kind === 'malformed') cycle.size = ['300', 250]; + expect(projectTraceGptDiagnostics(source, TRACE_ORIGIN)).toEqual({ + ok: false, + reason: 'invalid_source', + }); + expect(getter).not.toHaveBeenCalled(); + } + ); + it.each(['no_candidate', 'renderable_candidate'] as const)( + 'projects actual pending %s API snapshots with optional undefined values', + (opportunity) => { + const store = new GptDiagnosticsStore({ now: () => 1 }); + const slot = { getSlotElementId: () => 'example' }; + store.recordTrustedServerOpportunity(slot, 'example', opportunity); + store.recordSlotRequested(slot); + const source = observedGptSource(store); + expect( + Object.getOwnPropertyDescriptor(source.slots[0]!.requests[0]!, 'size')?.value + ).toBeUndefined(); + const projected = projectTraceGptDiagnostics(source, TRACE_ORIGIN); + expect(projected.ok).toBe(true); + if (!projected.ok) throw new Error('should project actual pending cycles'); + expect(projected.value.slots[0]!.requests[0]!.trustedServerOpportunity).toBe(opportunity); + expect( + Object.prototype.hasOwnProperty.call(projected.value.slots[0]!.requests[0]!, 'size') + ).toBe(false); + } + ); + it('classifies inspection failures without reading caller-controlled errors', () => { + let reads = 0; + const failure = Object.defineProperty(new Error('private-message'), 'message', { + get() { + reads += 1; + throw new Error('should not inspect'); + }, + }); + const source = new Proxy(gptSourceFixture(), { + ownKeys() { + throw failure; + }, + }); + expect(projectTraceGptDiagnostics(source, TRACE_ORIGIN)).toEqual({ + ok: false, + reason: 'invalid_source', + }); + expect(reads).toBe(0); + const proxyError = new Proxy(new Error('private-message'), { + getPrototypeOf() { + throw new Error('should not inspect'); + }, + }); + expect( + projectTraceGptDiagnostics( + new Proxy(gptSourceFixture(), { + ownKeys() { + throw proxyError; + }, + }), + TRACE_ORIGIN + ) + ).toEqual({ ok: false, reason: 'invalid_source' }); + }); + it('copies every public source member and excludes every private sentinel', () => { + const source = gptSourceFixture(); + const result = projectTraceGptDiagnostics(source, TRACE_ORIGIN); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('should project source'); + expect(result.value).toEqual(projectedGptFixture()); + expect(result.omittedNestedValues).toBe(0); + const json = JSON.stringify(result.value); + for (const sentinel of [ + 'private-page-secret', + 'private-slot-element', + 'private-ad-unit', + 'private-callback-element', + 'private-attribution-element', + '900001', + '900002', + '900003', + '900004', + '900005', + '900006', + '900007', + '900008', + '900009', + 'adManager', + 'previousCreativeId', + 'slotElementId', + 'adUnitPath', + ]) + expect(json).not.toContain(sentinel); + source.slots[0].requests[0].durations.requestToResponseMs = 999; + source.slots[0].requests[0].requestedSlotSizes = [[1, 1]]; + expect(result.value.slots[0].requests[0].durations.requestToResponseMs).toBe(0.5); + expect(result.value.slots[0].requests[0].requestedSlotSizes).toEqual([ + [300, 250], + [320, 50], + ]); + const frozen = (value: unknown): void => { + if (typeof value !== 'object' || value === null) return; + expect(Object.isFrozen(value)).toBe(true); + Object.values(value).forEach(frozen); + }; + frozen(result.value); + expect(Object.isFrozen(source.slots[0].requests[0])).toBe(false); + }); + it('does not inspect excluded contents and rejects source accessors without invoking them', () => { + const source = gptSourceFixture(); + let calls = 0; + Object.assign(source.slots[0].requests[0], { + adManager: new Proxy( + {}, + { + get() { + calls += 1; + throw new Error('should not call'); + }, + getPrototypeOf() { + calls += 1; + throw new Error('should not call'); + }, + } + ), + previousCreativeId: { private: 'ignored' }, + }); + Object.assign(source.slots[0], { slotElementId: '\ud800', adUnitPath: 42 }); + source.page.pathname = '\u0000' + 'x'.repeat(1024); + expect(projectTraceGptDiagnostics(source, TRACE_ORIGIN).ok).toBe(true); + expect(calls).toBe(0); + Object.defineProperty(source.slots[0].requests[0], 'adManager', { + enumerable: true, + get() { + calls += 1; + return {}; + }, + }); + expect(projectTraceGptDiagnostics(source, TRACE_ORIGIN)).toEqual({ + ok: false, + reason: 'invalid_source', + }); + expect(calls).toBe(0); + }); + it('accepts and drops TS Console auction evidence on request cycles', () => { + const source = gptSourceFixture(); + Object.assign(source.slots[0].requests[0], { + auctionType: 'private-auction-type', + auctionWinner: { bidder: 'private-winner', priceBucket: '1.00', currency: 'USD' }, + prebidAuction: { auctionId: 'private-prebid-auction' }, + serverAuctionTimings: { private: 'private-server-timings' }, + serverAuctionTimingOrigin: 'private-timing-origin', + }); + const result = projectTraceGptDiagnostics(source, TRACE_ORIGIN); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('should project source with auction evidence'); + expect(result.value).toEqual(projectedGptFixture()); + const json = JSON.stringify(result.value); + for (const sentinel of [ + 'auctionType', + 'auctionWinner', + 'prebidAuction', + 'serverAuctionTimings', + 'serverAuctionTimingOrigin', + 'private-', + ]) + expect(json).not.toContain(sentinel); + }); + it('drops APS renderer failure reasons and still rejects unknown failures', () => { + const source = gptSourceFixture(); + const cycle = source.slots[0].requests[0] as unknown as Record; + cycle.trustedServerCreativeFailures = [ + 'cache_fetch_failed', + 'aps_bad_hash', + 'aps_frame_timeout', + ]; + const result = projectTraceGptDiagnostics(source, TRACE_ORIGIN); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('should project source with APS failure reasons'); + expect(result.value.slots[0]!.requests[0]!.trustedServerCreativeFailures).toEqual([ + 'cache_fetch_failed', + ]); + expect(JSON.stringify(result.value)).not.toContain('aps_'); + cycle.trustedServerCreativeFailures = ['private-unknown-failure']; + expect(projectTraceGptDiagnostics(source, TRACE_ORIGIN)).toEqual({ + ok: false, + reason: 'invalid_source', + }); + }); + it('rejects every extra source key at each source boundary', () => { + const source = gptSourceFixture(); + const targets = [ + source, + source.page, + source.slots[0], + source.slots[0].binding, + source.slots[0].requests[0], + source.slots[0].requests[0].durations, + source.callbackIssues[0], + source.attributionIssues![0], + source.coverage, + source.coverage.slotRequested, + source.metadata, + ]; + for (const target of targets) { + Object.assign(target, { future: 'private-value' }); + expect(projectTraceGptDiagnostics(source, TRACE_ORIGIN)).toEqual({ + ok: false, + reason: 'invalid_source', + }); + Reflect.deleteProperty(target, 'future'); + } + }); + it('rejects invalid retained values and incompatible source versions without repairs', () => { + const source = gptSourceFixture(); + Object.assign(source, { version: 2 }); + expect(projectTraceGptDiagnostics(source, TRACE_ORIGIN)).toEqual({ + ok: false, + reason: 'unsupported_source_version', + }); + Object.assign(source, { version: 1 }); + Object.assign(source.slots[0].requests[0], { requestedAtMs: '1' }); + expect(projectTraceGptDiagnostics(source, TRACE_ORIGIN)).toEqual({ + ok: false, + reason: 'invalid_source', + }); + source.slots[0].requests[0].requestedAtMs = 0.5; + source.page.origin = 'https://attacker.example.com'; + expect(projectTraceGptDiagnostics(source, TRACE_ORIGIN).ok).toBe(false); + }); + it('preserves source optionality and canonicalizes origin during literal path redaction', () => { + const source = gptSourceFixture(); + delete source.attributionIssues; + delete source.metadata.droppedAttributionIssues; + source.page.origin = 'https://PUBLISHER.EXAMPLE.COM:443'; + const result = projectTraceGptDiagnostics(source, TRACE_ORIGIN); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('should project source'); + expect(result.value.page).toEqual({ origin: TRACE_ORIGIN, pathname: '/[redacted]' }); + expect(result.value).not.toHaveProperty('attributionIssues'); + expect(result.value.metadata).not.toHaveProperty('droppedAttributionIssues'); + }); + it('retains first sixteen nested values, validates omitted values and counts each omission once', () => { + const source = gptSourceFixture(); + source.slots[0].requests[0].requestedSlotSizes = Array.from({ length: 17 }, (_, index) => [ + index + 1, + 1, + ]); + source.slots[0].requests[0].trustedServerCreativeFailures = Array.from( + { length: 17 }, + () => 'missing_render_source' + ); + const result = projectTraceGptDiagnostics(source, TRACE_ORIGIN); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('should project source'); + expect(result.omittedNestedValues).toBe(2); + expect(result.value.slots[0].requests[0].requestedSlotSizes).toHaveLength(16); + expect(result.value.slots[0].requests[0].requestedSlotSizes?.[15]).toEqual([16, 1]); + Object.assign(source.slots[0].requests[0], { + trustedServerCreativeFailures: [ + ...source.slots[0].requests[0].trustedServerCreativeFailures!, + 'private-error', + ], + }); + expect(projectTraceGptDiagnostics(source, TRACE_ORIGIN)).toEqual({ + ok: false, + reason: 'invalid_source', + }); + }); + it('rejects unsupported source cardinality and omission overflow', () => { + const source = gptSourceFixture(); + source.slots = Array.from({ length: 65 }, () => gptSourceFixture().slots[0]); + expect(projectTraceGptDiagnostics(source, TRACE_ORIGIN)).toEqual({ + ok: false, + reason: 'invalid_source', + }); + const cycles = gptSourceFixture(); + cycles.slots[0].requests = Array.from( + { length: 11 }, + () => gptSourceFixture().slots[0].requests[0] + ); + expect(projectTraceGptDiagnostics(cycles, TRACE_ORIGIN).ok).toBe(false); + const oversized = gptSourceFixture(); + oversized.slots[0].requests[0].trustedServerCreativeFailures = Array.from( + { length: 65552 }, + () => 'missing_render_source' + ); + expect(projectTraceGptDiagnostics(oversized, TRACE_ORIGIN)).toEqual({ + ok: false, + reason: 'omission_counter_overflow', + }); + }); + it('never invokes source get traps, array methods or iterators', () => { + const source = gptSourceFixture(); + let calls = 0; + source.slots[0].requests[0] = new Proxy(source.slots[0].requests[0], { + get() { + calls += 1; + throw new Error('should not call'); + }, + }); + source.slots = new Proxy(source.slots, { + get() { + calls += 1; + throw new Error('should not call'); + }, + }); + expect(projectTraceGptDiagnostics(source, TRACE_ORIGIN).ok).toBe(true); + expect(calls).toBe(0); + }); +}); +describe('checked public omission counters', () => { + it('adds exact zero and u16-boundary counts', () => { + expect(addOmissions(0, 0)).toBe(0); + expect(addOmissions(65534, 1)).toBe(65535); + expect(addOmissions(65535, 0)).toBe(65535); + }); + it.each([ + [65535, 1], + [-1, 1], + [0, -1], + [0, 0.5], + [NaN, 0], + [0, Infinity], + [65536, 0], + ])('rejects invalid or overflowing %s + %s', (current, added) => { + expect(() => addOmissions(current, added)).toThrow('omission_counter_overflow'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/report.test.ts b/crates/trusted-server-js/lib/test/trace/report.test.ts new file mode 100644 index 000000000..ab816d684 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/report.test.ts @@ -0,0 +1,431 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { buildTraceReport } from '../../src/trace/report'; + +import { + gptSourceFixture, + reportFixture, + TRACE_NOW, + TRACE_ORIGIN, + AUCTION_TOKEN, + SLOT_TOKEN, +} from './fixtures'; + +function auction(number = 1) { + return { + schema_version: 1, + diagnostic_auction_id: token(number), + source: 'initial_navigation_ssat', + terminal_status: 'completed', + provider_calls: [], + slots: [], + truncation: { omitted_provider_calls: 0, omitted_slots: 0, omitted_nested_values: 0 }, + coverage: { provider_to_slot_no_bid: 'unavailable' }, + }; +} +function token(number: number) { + return `ts-auc-${number.toString(16).padStart(8, '0')}12344abc8def123456789abc`; +} +function sidecar(number = 1) { + return { + schema_version: 1, + diagnostic_auction_id: token(number), + slot_ref: SLOT_TOKEN, + runtime_slot_number: 1, + request_number: 1, + }; +} +function input() { + return { + requestContext: reportFixture().request_context, + gptSource: gptSourceFixture(), + origin: TRACE_ORIGIN, + capturedAtMs: TRACE_NOW, + collector: { + serverAuctions: [], + slotCorrelations: [], + issues: [], + omittedServerAuctions: 0, + omittedSlotCorrelations: 0, + }, + }; +} +function success(result: ReturnType) { + if ( + typeof result !== 'object' || + result === null || + !('ok' in result) || + result.ok !== true || + !('value' in result) + ) + throw new Error('should build report'); + return result.value; +} +describe('combined trace capture', () => { + it('rejects a caller capture-clock getter before reading it or invoking serialization', () => { + let reads = 0; + let serialized = 0; + const source = input(); + Object.defineProperty(source, 'capturedAtMs', { + enumerable: true, + get() { + reads += 1; + return reads === 4 + ? { + toJSON() { + serialized += 1; + return TRACE_NOW; + }, + } + : TRACE_NOW; + }, + }); + expect(buildTraceReport(source)).toEqual({ ok: false, reason: 'invalid_snapshot' }); + expect(reads).toBe(0); + expect(serialized).toBe(0); + }); + it('uses a frozen top-level own data observation without caller get traps', () => { + let reads = 0; + const source = new Proxy(input(), { + get() { + reads += 1; + throw new Error('private-get'); + }, + }); + const result = success(buildTraceReport(source)); + expect(result.stored_at_ms).toBe(TRACE_NOW); + expect(reads).toBe(0); + }); + it('projects an immutable report with independent capture time and original cookie observations', () => { + const source = input(); + const result = success(buildTraceReport(source)); + expect(result.stored_at_ms).toBe(TRACE_NOW); + expect(result.report.captured_at).toBe(new Date(TRACE_NOW).toISOString()); + expect(result.report.request_context).toEqual(source.requestContext); + expect(result.report.gpt_diagnostics.page.pathname).toBe('/[redacted]'); + expect(JSON.stringify(result)).not.toContain('private-'); + expect(Object.isFrozen(result.report.gpt_diagnostics.slots[0].requests[0])).toBe(true); + source.gptSource.slots[0].requests[0].requestNumber = 99; + expect(result.report.gpt_diagnostics.slots[0].requests[0].requestNumber).toBe(1); + }); + it('retains newest cardinality records and merges collector losses once', () => { + const source = input(); + Object.assign(source.collector, { + serverAuctions: Array.from({ length: 18 }, (_, i) => auction(i + 1)), + slotCorrelations: Array.from({ length: 130 }, (_, i) => sidecar(i + 1)), + omittedServerAuctions: 4, + omittedSlotCorrelations: 7, + }); + const result = success(buildTraceReport(source)).report; + expect(result.server_auctions.map((record) => record.diagnostic_auction_id)).toEqual( + Array.from({ length: 16 }, (_, i) => token(i + 3)) + ); + expect(result.slot_correlations).toHaveLength(128); + expect(result.truncation.omitted_server_auctions).toBe(6); + expect(result.truncation.omitted_slot_correlations).toBe(9); + expect(result.truncation.omitted_request_cycles).toBe(0); + expect(result.auction_coverage).toEqual({ + capture_status: 'partial', + issues: ['record_evicted', 'correlation_unavailable'], + }); + }); + it('rejects invalid inner evidence even if that record would be discarded', () => { + const source = input(); + Object.assign(source.collector, { + serverAuctions: [ + { ...auction(), private_value: 'secret' }, + ...Array.from({ length: 16 }, (_, i) => auction(i + 2)), + ], + }); + expect(buildTraceReport(source)).toEqual({ ok: false, reason: 'invalid_snapshot' }); + }); + it('rejects checked omission overflow', () => { + const source = input(); + Object.assign(source.collector, { + serverAuctions: Array.from({ length: 17 }, (_, i) => auction(i + 1)), + omittedServerAuctions: 65535, + }); + expect(buildTraceReport(source)).toEqual({ ok: false, reason: 'omission_counter_overflow' }); + }); + it('removes oldest non-floor cycles and their sidecars while preserving output order', () => { + const source = input(); + const original = source.gptSource.slots[0].requests[0]; + source.gptSource.slots[0].requests = [ + { ...original, requestNumber: 1, requestedAtMs: 20 }, + { ...original, requestNumber: 2, requestedAtMs: undefined }, + { ...original, requestNumber: 3, requestedAtMs: 1 }, + ]; + delete source.gptSource.slots[0].requests[1].requestedAtMs; + Object.assign(source.collector, { + slotCorrelations: [{ ...sidecar(), diagnostic_auction_id: AUCTION_TOKEN, request_number: 2 }], + }); + const full = success(buildTraceReport(source)); + const budget = new TextEncoder().encode(JSON.stringify(full)).length - 1; + const result = success(buildTraceReport(source, budget)).report; + expect(result.gpt_diagnostics.slots[0].requests.map((cycle) => cycle.requestNumber)).toEqual([ + 1, 3, + ]); + expect(result.slot_correlations).toEqual([]); + expect(result.truncation.omitted_request_cycles).toBe(1); + expect(result.truncation.omitted_slot_correlations).toBe(1); + expect(result.auction_coverage.capture_status).toBe('not_observed'); + }); + it('never removes a protected floor or substitutes an empty GPT snapshot', () => { + expect(buildTraceReport(input(), 100)).toEqual({ ok: false, reason: 'snapshot_too_large' }); + expect(buildTraceReport(input(), 512 * 1024 + 1)).toEqual({ + ok: false, + reason: 'invalid_snapshot', + }); + }); + it('accepts an exact protected-floor byte budget and rejects one byte less', () => { + const source = input(); + Object.assign(source.requestContext.network, { region: 'é"\\' }); + source.gptSource.callbackIssues = []; + source.gptSource.attributionIssues = []; + const full = success(buildTraceReport(source)); + const bytes = new TextEncoder().encode(JSON.stringify(full)).length; + expect(success(buildTraceReport(source, bytes))).toEqual(full); + expect(buildTraceReport(source, bytes - 1)).toEqual({ + ok: false, + reason: 'snapshot_too_large', + }); + }); + it('accounts exactly for issue commas and the omission counter growing to two digits', () => { + const source = input(); + const issue = source.gptSource.callbackIssues[0]; + source.gptSource.callbackIssues = Array.from({ length: 10 }, (_, i) => ({ + ...issue, + timestampMs: i + 1, + })); + source.gptSource.attributionIssues = []; + const full = success(buildTraceReport(source)); + for (const removed of [9, 10]) { + const expected = { + ...full, + report: { + ...full.report, + gpt_diagnostics: { + ...full.report.gpt_diagnostics, + callbackIssues: full.report.gpt_diagnostics.callbackIssues.slice(removed), + }, + truncation: { ...full.report.truncation, omitted_callback_issues: removed }, + }, + }; + const budget = new TextEncoder().encode(JSON.stringify(expected)).length; + expect(success(buildTraceReport(source, budget))).toEqual(expected); + } + }); + for (const seed of [0, 9, 99, 999, 9999]) { + it(`accounts exactly for auction and sidecar removal with omission seed ${seed}`, () => { + const source = input(); + Object.assign(source.requestContext.network, { region: 'é"\\' }); + source.gptSource.callbackIssues = []; + source.gptSource.attributionIssues = []; + Object.assign(source.collector, { + serverAuctions: [auction(1), auction(2)], + slotCorrelations: [sidecar(1), sidecar(2)], + omittedServerAuctions: seed, + omittedSlotCorrelations: seed, + }); + const full = success(buildTraceReport(source)); + const expected = { + ...full, + report: { + ...full.report, + server_auctions: full.report.server_auctions.slice(1), + slot_correlations: full.report.slot_correlations.slice(1), + truncation: { + ...full.report.truncation, + omitted_server_auctions: seed + 1, + omitted_slot_correlations: seed + 1, + }, + auction_coverage: { + capture_status: 'partial', + issues: ['record_evicted', 'correlation_unavailable'], + }, + }, + }; + const budget = new TextEncoder().encode(JSON.stringify(expected)).length; + expect(success(buildTraceReport(source, budget))).toEqual(expected); + }); + } + it('rejects checked omission overflow during byte-budget auction removal', () => { + const source = input(); + source.gptSource.callbackIssues = []; + source.gptSource.attributionIssues = []; + Object.assign(source.collector, { serverAuctions: [auction()], omittedServerAuctions: 65535 }); + const full = success(buildTraceReport(source)); + const budget = new TextEncoder().encode(JSON.stringify(full)).length - 1; + expect(buildTraceReport(source, budget)).toEqual({ + ok: false, + reason: 'omission_counter_overflow', + }); + }); + it('removes an uncorrelated auction and recomputes coverage when no evidence remains', () => { + const source = input(); + source.gptSource.callbackIssues = []; + source.gptSource.attributionIssues = []; + Object.assign(source.collector, { serverAuctions: [auction()], slotCorrelations: [sidecar()] }); + const full = success(buildTraceReport(source)); + const result = success( + buildTraceReport(source, new TextEncoder().encode(JSON.stringify(full)).length - 1) + ).report; + expect(result.server_auctions).toEqual([]); + expect(result.slot_correlations).toEqual([]); + expect(result.auction_coverage).toEqual({ + capture_status: 'unavailable', + issues: ['record_evicted', 'correlation_unavailable'], + }); + }); + it('protects any floor auction token even without a valid joining sidecar', () => { + const source = input(); + source.gptSource.callbackIssues = []; + source.gptSource.attributionIssues = []; + Object.assign(source.collector, { + serverAuctions: [{ ...auction(), diagnostic_auction_id: AUCTION_TOKEN }], + }); + const full = success(buildTraceReport(source)); + expect( + buildTraceReport(source, new TextEncoder().encode(JSON.stringify(full)).length - 1) + ).toEqual({ ok: false, reason: 'snapshot_too_large' }); + }); + it('rejects a fractional capture clock and an API sidecar', () => { + const source = input(); + expect(buildTraceReport({ ...source, capturedAtMs: TRACE_NOW + 0.5 })).toEqual({ + ok: false, + reason: 'invalid_snapshot', + }); + Object.assign(source.collector, { + serverAuctions: [{ ...auction(), source: 'auction_api' }], + slotCorrelations: [sidecar()], + }); + expect(buildTraceReport(source)).toEqual({ ok: false, reason: 'invalid_snapshot' }); + }); + it('counts sidecars pruned when their known auction is discarded at initial cardinality', () => { + const source = input(); + Object.assign(source.collector, { + serverAuctions: Array.from({ length: 17 }, (_, i) => auction(i + 1)), + slotCorrelations: [sidecar(1)], + }); + const result = success(buildTraceReport(source)).report; + expect(result.slot_correlations).toEqual([]); + expect(result.truncation.omitted_slot_correlations).toBe(1); + }); + it('rejects a capture clock outside the four-digit UTC range', () => { + expect(buildTraceReport({ ...input(), capturedAtMs: Date.UTC(10000, 0, 1) })).toEqual({ + ok: false, + reason: 'invalid_snapshot', + }); + }); + it('removes oldest callback issues before attribution issues without reordering survivors', () => { + const source = input(); + const issue = source.gptSource.callbackIssues[0]; + source.gptSource.callbackIssues = [ + { ...issue, timestampMs: 20 }, + { ...issue, timestampMs: 1 }, + { ...issue, timestampMs: 10 }, + ]; + const full = success(buildTraceReport(source)); + const result = success( + buildTraceReport(source, new TextEncoder().encode(JSON.stringify(full)).length - 1) + ).report; + expect(result.gpt_diagnostics.callbackIssues.map((value) => value.timestampMs)).toEqual([ + 20, 10, + ]); + expect(result.gpt_diagnostics.attributionIssues).toHaveLength(1); + expect(result.truncation.omitted_callback_issues).toBe(1); + expect(result.truncation.omitted_attribution_issues).toBe(0); + }); + it('bounds a fully populated real-size fixture to 512 KiB while retaining every slot floor', () => { + const source = input(); + source.requestContext.network = { region: 'é'.repeat(16), edge_region: '界'.repeat(42) }; + const slot = source.gptSource.slots[0]; + source.gptSource.slots = Array.from({ length: 64 }, (_, i) => ({ + ...structuredClone(slot), + runtimeSlotNumber: i + 1, + requests: Array.from({ length: 10 }, (_, j) => ({ + ...structuredClone(slot.requests[0]), + requestNumber: j + 1, + requestedAtMs: j + 1, + requestedSlotSizes: Array.from({ length: 16 }, () => [100000, 100000] as [number, number]), + })), + })); + source.gptSource.callbackIssues = Array.from({ length: 128 }, () => + structuredClone(source.gptSource.callbackIssues[0]) + ); + source.gptSource.attributionIssues = Array.from({ length: 128 }, () => + structuredClone(source.gptSource.attributionIssues![0]) + ); + Object.assign(source.collector, { + serverAuctions: Array.from({ length: 16 }, (_, i) => ({ + ...auction(i + 1), + provider_calls: Array.from({ length: 16 }, (_, j) => ({ + provider_number: j + 1, + role: 'bidder', + status: 'success', + returned_bid_count: 65535, + response_time_ms: 4294967295, + })), + slots: Array.from({ length: 64 }, (_, j) => ({ + slot_number: j + 1, + slot_ref: SLOT_TOKEN, + requested_sizes: Array.from({ length: 16 }, () => [100000, 100000]), + returned_bid_count: 65535, + candidate: 'selected', + selected_creative_size: [100000, 100000], + })), + })), + slotCorrelations: Array.from({ length: 128 }, (_, i) => ({ + ...sidecar(i + 1), + request_number: 10, + runtime_slot_number: (i % 64) + 1, + })), + }); + const serialization = vi.spyOn(JSON, 'stringify'); + let result: ReturnType; + let completeMeasurements: number; + try { + result = success(buildTraceReport(source)); + completeMeasurements = serialization.mock.calls.filter( + ([value]) => + value !== null && + typeof value === 'object' && + Object.prototype.hasOwnProperty.call(value, 'stored_at_ms') && + Object.prototype.hasOwnProperty.call(value, 'report') + ).length; + } finally { + serialization.mockRestore(); + } + expect(completeMeasurements).toBeLessThanOrEqual(2); + expect(new TextEncoder().encode(JSON.stringify(result)).length).toBeLessThanOrEqual(512 * 1024); + expect(result.report.gpt_diagnostics.slots).toHaveLength(64); + expect( + result.report.gpt_diagnostics.slots.every( + (value) => value.requests[value.requests.length - 1]?.requestNumber === 10 + ) + ).toBe(true); + expect(result.report.truncation.omitted_request_cycles).toBeGreaterThan(0); + expect(result.report.truncation).toEqual({ + omitted_server_auctions: 3, + omitted_slot_correlations: 3, + omitted_request_cycles: 64 * 9, + omitted_callback_issues: 128, + omitted_attribution_issues: 128, + omitted_nested_values: 0, + }); + expect(result.report.server_auctions.map((record) => record.diagnostic_auction_id)).toEqual( + Array.from({ length: 13 }, (_, i) => token(i + 4)) + ); + expect(result.report.slot_correlations.map((sidecar) => sidecar.diagnostic_auction_id)).toEqual( + Array.from({ length: 125 }, (_, i) => token(i + 4)) + ); + expect(new TextEncoder().encode(JSON.stringify(result)).length).toBeGreaterThan( + JSON.stringify(result).length + ); + expect(result.report.request_context.cookies.diagnostics_session).toEqual({ + source: 'request', + state: 'unavailable', + detail: 'runtime_header_ambiguous', + }); + }, 15000); +}); diff --git a/crates/trusted-server-js/lib/test/trace/runtime.test.ts b/crates/trusted-server-js/lib/test/trace/runtime.test.ts new file mode 100644 index 000000000..d8a1da487 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/runtime.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { TsjsApi } from '../../src/core/types'; +import { buildAdRequest } from '../../src/core/auction'; +import { + getActiveTraceCollector, + installTraceRuntime, + prepareTraceAuctionRequest, +} from '../../src/trace/runtime'; + +function setup(active: unknown = true) { + const api = {} as TsjsApi; + const randomUUID = vi.fn(() => '12345678-1234-4abc-8def-123456789abc'); + const scope = { tsjs: api, __tsjs_trace_active: active, crypto: { randomUUID } }; + return { api, scope, randomUUID }; +} +function request() { + return buildAdRequest([ + { + code: 'example-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bidder: 'example-bidder', + }, + { + adUnitCode: 'example-slot', + mediaTypes: { banner: { sizes: [[320, 50]] } }, + bidder: 'example-other', + }, + ]); +} +describe('one strictly gated page trace facade', () => { + it.each([undefined, false, 'true', 1, null])( + 'does not activate or mint tokens for nonliteral gate %s', + (active) => { + const fixture = setup(); + fixture.scope.__tsjs_trace_active = active; + expect(installTraceRuntime(fixture.api, fixture.scope)).toBeUndefined(); + expect(getActiveTraceCollector(fixture.scope)).toBeUndefined(); + expect(prepareTraceAuctionRequest(request(), fixture.scope)).toBeUndefined(); + expect(fixture.randomUUID).not.toHaveBeenCalled(); + expect(Object.keys(fixture.api)).toEqual([]); + } + ); + it('reuses the page facade across independent module instances without storage access', async () => { + const fixture = setup(); + const storage = vi.spyOn(Storage.prototype, 'getItem'); + const first = installTraceRuntime(fixture.api, fixture.scope); + expect(first).toBeDefined(); + expect(getActiveTraceCollector(fixture.scope)).toBe(first); + vi.resetModules(); + const independent = await import('../../src/trace/runtime'); + expect(independent.installTraceRuntime(fixture.api, fixture.scope)).toBe(first); + expect(storage).not.toHaveBeenCalled(); + storage.mockRestore(); + }); + it('mints one opaque ref after final grouping and preserves original input payload', () => { + const fixture = setup(); + installTraceRuntime(fixture.api, fixture.scope); + const original = request(); + const before = structuredClone(original); + const result = prepareTraceAuctionRequest(original, fixture.scope); + expect(result).toMatchObject({ + request: { + adUnits: [ + { + code: 'example-slot', + ext: { + trusted_server: { trace_slot_ref: 'ts-slot-12345678-1234-4abc-8def-123456789abc' }, + }, + }, + ], + }, + slotRefs: ['ts-slot-12345678-1234-4abc-8def-123456789abc'], + }); + expect(fixture.randomUUID).toHaveBeenCalledTimes(1); + expect(original).toEqual(before); + }); + it('returns an observer without client refs if Web Crypto is unavailable', () => { + const fixture = setup(); + installTraceRuntime(fixture.api, fixture.scope); + const scope = { ...fixture.scope, crypto: undefined }; + const original = request(); + expect(prepareTraceAuctionRequest(original, scope)).toMatchObject({ + request: original, + slotRefs: [], + }); + }); + it('fails open when token generation throws or returns malformed or duplicate tokens', () => { + const fixture = setup(); + installTraceRuntime(fixture.api, fixture.scope); + const original = request(); + fixture.randomUUID.mockImplementation(() => { + throw new Error('private-random-error'); + }); + expect(prepareTraceAuctionRequest(original, fixture.scope)).toMatchObject({ + request: original, + slotRefs: [], + }); + fixture.randomUUID.mockReturnValue('private-invalid-token'); + expect(prepareTraceAuctionRequest(original, fixture.scope)).toMatchObject({ + request: original, + slotRefs: [], + }); + fixture.randomUUID.mockReturnValue('12345678-1234-4abc-8def-123456789abc'); + const two = buildAdRequest([{ code: 'example-one' }, { code: 'example-two' }]); + expect(prepareTraceAuctionRequest(two, fixture.scope)).toMatchObject({ + request: two, + slotRefs: [], + }); + expect(original.adUnits[0]).not.toHaveProperty('ext'); + }); + it('does not create a new collector from an auction caller before core initialization', () => { + const fixture = setup(); + expect(prepareTraceAuctionRequest(request(), fixture.scope)).toBeUndefined(); + expect(fixture.randomUUID).not.toHaveBeenCalled(); + expect(Object.keys(fixture.api)).toEqual([]); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/setup.test.ts b/crates/trusted-server-js/lib/test/trace/setup.test.ts new file mode 100644 index 000000000..b41bfb587 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/setup.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; + +import { mountTraceSetup } from '../../src/trace/setup'; + +const setupContext = { + schema_version: 1, + captured_at: '2026-10-05T10:15:30Z', + network: { masked_client_ip: '192.0.2.0/24' }, + cookies: Object.fromEntries( + ['ts_ec', 'ts_eids', 'ts_tester', 'diagnostics_session'].map((name) => [ + name, + { source: 'request', state: 'unavailable', detail: 'runtime_header_ambiguous' }, + ]) + ), +}; + +function fixture(active = false) { + document.body.innerHTML = ` +

+

+

+    
+ + + `; + document.querySelector('#trace-request-context')!.textContent = JSON.stringify(setupContext); +} + +async function flush() { + await vi.waitFor(() => + expect(document.querySelector('#trace-enable')?.disabled).toBe(false) + ); +} + +describe('mobile trace setup', () => { + const request = vi.fn(); + beforeEach(() => { + request.mockReset(); + vi.stubGlobal('fetch', request); + fixture(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + document.body.replaceChildren(); + }); + + it('renders setup facts without automatically activating or claiming history', () => { + mountTraceSetup(); + expect(request).not.toHaveBeenCalled(); + expect(document.querySelector('#trace-session-state')?.textContent).toBe( + 'Tracing is off — no valid diagnostics session observed' + ); + expect(document.querySelector('#trace-network-facts')?.textContent).toContain('192.0.2.0/24'); + expect(document.querySelector('#trace-cookie-facts')?.textContent).toContain( + 'runtime-visible cookies could not be reliably inspected' + ); + }); + + it('renders existing server observation without a mutation request', () => { + fixture(true); + mountTraceSetup(); + expect(document.querySelector('#trace-session-state')?.textContent).toBe( + 'Tracing is on — cookie observed by server' + ); + expect(request).not.toHaveBeenCalled(); + }); + + it.each([null, '', 'yes', '1', 'False'])( + 'does not invent an inactive observation from invalid setup state %s', + (attribute) => { + const state = document.querySelector('#trace-session-state')!; + if (attribute === null) state.removeAttribute('data-observed-active'); + else state.setAttribute('data-observed-active', attribute); + mountTraceSetup(); + expect(state.textContent).toBe('Tracing state unconfirmed'); + expect(request).not.toHaveBeenCalled(); + } + ); + + it('confirms activation only after state verification and gives reload instructions', async () => { + request.mockResolvedValueOnce(new Response('{}')); + request.mockResolvedValueOnce(new Response('{"observed_active":true}')); + mountTraceSetup(); + document.querySelector('#trace-enable')!.click(); + expect(document.querySelector('#trace-end')?.disabled).toBe(true); + await flush(); + expect(document.querySelector('#trace-session-state')?.textContent).toContain('Tracing is on'); + expect(document.querySelector('#trace-status')?.textContent).toContain('reload once'); + expect(document.querySelector('#trace-status')?.textContent).toContain('View trace results'); + }); + + it('keeps ambiguous activation unconfirmed and allows explicit retry', async () => { + request.mockResolvedValueOnce(new Response('{}')); + request.mockResolvedValueOnce(new Response('{"observed_active":false}')); + mountTraceSetup(); + document.querySelector('#trace-enable')!.click(); + await flush(); + expect(document.querySelector('#trace-status')?.textContent).toContain( + 'Activation unconfirmed' + ); + expect(document.querySelector('#trace-session-state')?.textContent).toContain( + 'no valid diagnostics session observed' + ); + request.mockResolvedValueOnce(new Response('{}')); + request.mockResolvedValueOnce(new Response('{"observed_active":true}')); + document.querySelector('#trace-enable')!.click(); + await flush(); + expect(document.querySelector('#trace-session-state')?.textContent).toContain('Tracing is on'); + }); + + it('allows end with ambiguous cookies and describes observation without claiming absence', async () => { + request.mockResolvedValueOnce(new Response('{}')); + request.mockResolvedValueOnce(new Response('{"observed_active":false}')); + mountTraceSetup(); + document.querySelector('#trace-end')!.click(); + await flush(); + expect(document.querySelector('#trace-status')?.textContent).toContain( + 'no valid diagnostics session observed' + ); + expect(document.body.textContent).not.toContain('cookie absent'); + }); + + it('reports failed observation and restores action controls', async () => { + request.mockResolvedValueOnce(new Response('{}')); + request.mockRejectedValueOnce(new Error('secret-error-sentinel')); + mountTraceSetup(); + document.querySelector('#trace-end')!.click(); + await flush(); + expect(document.querySelector('#trace-status')?.textContent).toContain( + 'Deactivation unconfirmed' + ); + expect(document.querySelector('#trace-session-state')?.textContent).toBe( + 'Tracing state unconfirmed' + ); + expect(document.body.textContent).not.toContain('secret-error-sentinel'); + }); + + it('fails closed on malformed setup facts without displaying a forbidden value', () => { + document.querySelector('#trace-request-context')!.textContent = + '{"secret":"raw-cookie-sentinel"}'; + mountTraceSetup(); + expect(document.querySelector('#trace-network-facts')?.textContent).toContain('Unavailable'); + expect(document.querySelector('#trace-cookie-facts')?.textContent).not.toContain( + 'raw-cookie-sentinel' + ); + }); + + it('uses history only on explicit back and supplies a same-host same-tab fallback', () => { + const back = vi.spyOn(window.history, 'back').mockImplementation(() => {}); + vi.spyOn(window.history, 'length', 'get').mockReturnValue(1); + mountTraceSetup(); + document.querySelector('#trace-back')!.click(); + expect(back).not.toHaveBeenCalled(); + expect(document.querySelector('#trace-status')?.textContent).toContain( + 'exact same hostname and in this same tab' + ); + vi.spyOn(window.history, 'length', 'get').mockReturnValue(2); + document.querySelector('#trace-back')!.click(); + expect(back).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/storage.test.ts b/crates/trusted-server-js/lib/test/trace/storage.test.ts new file mode 100644 index 000000000..c234b404c --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/storage.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + readTraceReport, + storeTraceReport, + deleteTraceReport, + TRACE_REPORT_STORAGE_KEY, +} from '../../src/trace/storage'; +import { validateTraceStoredReport } from '../../src/trace/report-validation'; + +import { reportFixture, TRACE_ORIGIN, TRACE_NOW } from './fixtures'; + +function fixture() { + return { stored_at_ms: TRACE_NOW, report: reportFixture() }; +} +function memory(initial: string | null = null) { + let value = initial; + return { + getItem: vi.fn((key: string) => (key === TRACE_REPORT_STORAGE_KEY ? value : null)), + setItem: vi.fn((key: string, next: string) => { + if (key === TRACE_REPORT_STORAGE_KEY) value = next; + }), + removeItem: vi.fn((key: string) => { + if (key === TRACE_REPORT_STORAGE_KEY) value = null; + }), + }; +} +describe('one origin-local validated trace report', () => { + it('requires a safe integer capture timestamp in the wrapper validator', () => { + expect( + validateTraceStoredReport( + { ...fixture(), stored_at_ms: TRACE_NOW + 0.5 }, + TRACE_ORIGIN, + TRACE_NOW + ) + ).toBe(false); + }); + it('stores only the validated exact wrapper and replaces the previous explicit capture', () => { + const storage = memory(); + expect(storeTraceReport(fixture(), TRACE_ORIGIN, TRACE_NOW, storage)).toEqual({ + status: 'stored', + }); + expect(storage.setItem).toHaveBeenCalledWith( + TRACE_REPORT_STORAGE_KEY, + JSON.stringify(fixture()) + ); + const next = fixture(); + next.report.request_context.network = {}; + expect(storeTraceReport(next, TRACE_ORIGIN, TRACE_NOW, storage)).toEqual({ status: 'stored' }); + expect(storage.setItem).toHaveBeenCalledTimes(2); + }); + it('reads an independently owned frozen model without writing it back', () => { + const storage = memory(JSON.stringify(fixture())); + const result = readTraceReport(TRACE_ORIGIN, TRACE_NOW, storage); + expect(result).toEqual({ status: 'ready', value: fixture() }); + expect(storage.setItem).not.toHaveBeenCalled(); + expect(storage.removeItem).not.toHaveBeenCalled(); + if (typeof result !== 'object' || result === null || !('value' in result)) + throw new Error('should read report'); + expect(Object.isFrozen(result.value)).toBe(true); + }); + it('distinguishes absent data from unavailable storage without writes', () => { + expect(readTraceReport(TRACE_ORIGIN, TRACE_NOW, memory())).toEqual({ status: 'absent' }); + const storage = memory(); + storage.getItem.mockImplementation(() => { + throw new Error('private-read-error'); + }); + expect(readTraceReport(TRACE_ORIGIN, TRACE_NOW, storage)).toEqual({ status: 'unavailable' }); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + it.each([ + '{', + JSON.stringify({ stored_at_ms: TRACE_NOW }), + JSON.stringify({ ...fixture(), private_value: 'secret' }), + JSON.stringify({ ...fixture(), stored_at_ms: TRACE_NOW + 0.5 }), + JSON.stringify({ ...fixture(), stored_at_ms: TRACE_NOW + 60001 }), + JSON.stringify({ ...fixture(), stored_at_ms: TRACE_NOW - 900001 }), + JSON.stringify({ ...fixture(), stored_at_ms: -1 }), + ])('removes and ignores rejected storage %#', (serialized) => { + const storage = memory(serialized); + expect(readTraceReport(TRACE_ORIGIN, TRACE_NOW, storage)).toMatchObject({ status: 'rejected' }); + expect(storage.removeItem).toHaveBeenCalledWith(TRACE_REPORT_STORAGE_KEY); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + it('accepts exactly fifteen minutes and sixty seconds of future clock skew', () => { + expect( + readTraceReport(TRACE_ORIGIN, TRACE_NOW + 900000, memory(JSON.stringify(fixture()))) + ).toMatchObject({ status: 'ready' }); + expect( + readTraceReport(TRACE_ORIGIN, TRACE_NOW - 60000, memory(JSON.stringify(fixture()))) + ).toMatchObject({ status: 'ready' }); + expect( + readTraceReport(TRACE_ORIGIN, TRACE_NOW - 60001, memory(JSON.stringify(fixture()))) + ).toMatchObject({ status: 'rejected' }); + }); + it('rejects another origin and an unsupported public version with a bounded reason', () => { + expect( + readTraceReport('https://other.example.com', TRACE_NOW, memory(JSON.stringify(fixture()))) + ).toMatchObject({ status: 'rejected', reason: 'invalid_report' }); + const value = fixture(); + value.report.schema_version = 2; + expect(readTraceReport(TRACE_ORIGIN, TRACE_NOW, memory(JSON.stringify(value)))).toMatchObject({ + status: 'rejected', + reason: 'unsupported_report_version', + }); + }); + it('ignores rejected entries even when removal throws', () => { + const storage = memory('{'); + storage.removeItem.mockImplementation(() => { + throw new Error('private-remove-error'); + }); + expect(readTraceReport(TRACE_ORIGIN, TRACE_NOW, storage)).toMatchObject({ status: 'rejected' }); + }); + it('rejects oversized serialized UTF-8 before parsing and never writes invalid models', () => { + const storage = memory(' '.repeat(512 * 1024 + 1)); + expect(readTraceReport(TRACE_ORIGIN, TRACE_NOW, storage)).toEqual({ + status: 'rejected', + reason: 'invalid_report', + }); + expect( + storeTraceReport({ ...fixture(), private_value: 'secret' }, TRACE_ORIGIN, TRACE_NOW, storage) + ).toEqual({ status: 'rejected', reason: 'invalid_report' }); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + it('preserves the report model when writing fails and exposes no exception text', () => { + const storage = memory(); + storage.setItem.mockImplementation(() => { + throw new Error('private-write-error'); + }); + const source = fixture(); + expect(storeTraceReport(source, TRACE_ORIGIN, TRACE_NOW, storage)).toEqual({ + status: 'unavailable', + }); + expect(source).toEqual(fixture()); + expect(storage.removeItem).not.toHaveBeenCalled(); + }); + it('deletes explicitly and reports failures without modifying another storage key', () => { + const storage = memory(JSON.stringify(fixture())); + expect(deleteTraceReport(storage)).toEqual({ status: 'deleted' }); + expect(storage.removeItem).toHaveBeenCalledWith(TRACE_REPORT_STORAGE_KEY); + storage.removeItem.mockImplementation(() => { + throw new Error('private-remove-error'); + }); + expect(deleteTraceReport(storage)).toEqual({ status: 'unavailable' }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/tokens.test.ts b/crates/trusted-server-js/lib/test/trace/tokens.test.ts new file mode 100644 index 000000000..83987fe1d --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/tokens.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { validDiagnosticAuctionId, validTraceSlotRef } from '../../src/trace/validation'; + +export const AUCTION = 'ts-auc-1234567812344abc8def123456789abc'; +export const SLOT = 'ts-slot-12345678-1234-4abc-8def-123456789abc'; + +describe('exact opaque trace tokens', () => { + it('accepts only the producer-specific UUID v4 formats', () => { + expect(validDiagnosticAuctionId(AUCTION)).toBe(true); + expect(validTraceSlotRef(SLOT)).toBe(true); + }); + + it.each([ + null, + 1, + '', + 'internal-auction-id', + AUCTION.toUpperCase(), + ` ${AUCTION}`, + `${AUCTION} `, + AUCTION.replace('4abc', '5abc'), + AUCTION.replace('8def', '7def'), + AUCTION.replace('8def', 'cdef'), + 'ts-auc-12345678-1234-4abc-8def-123456789abc', + `${AUCTION}\n`, + `${AUCTION}\u202e`, + SLOT, + ])('rejects every alternate auction-token spelling %j', (value) => { + expect(validDiagnosticAuctionId(value)).toBe(false); + }); + + it.each([ + null, + 1, + '', + 'raw-slot-element-id', + SLOT.toUpperCase(), + ` ${SLOT}`, + `${SLOT} `, + SLOT.replace('4abc', '3abc'), + SLOT.replace('8def', '7def'), + SLOT.replace('8def', 'cdef'), + 'ts-slot-1234567812344abc8def123456789abc', + `${SLOT}\n`, + `${SLOT}\u202e`, + AUCTION, + ])('rejects every alternate slot-token spelling %j', (value) => { + expect(validTraceSlotRef(value)).toBe(false); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/types.test.ts b/crates/trusted-server-js/lib/test/trace/types.test.ts new file mode 100644 index 000000000..1783baf40 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/types.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; + +import type { + GptDiagnosticsExportV1, + GptDiagnosticsSlotExport, + GptDiagnosticsRequestCycle, + GptDiagnosticsBinding, + GptDiagnosticsDurations, + GptDiagnosticsCallbackIssue, + GptDiagnosticsAttributionIssue, + GptDiagnosticsCoverageCounters, + GptDiagnosticsAdManagerIdentity, +} from '../../src/core/types'; + +type Treatment = 'copy' | 'exclude' | 'transform'; + +const root = { + version: 'transform', + capturedAt: 'copy', + page: 'transform', + slots: 'transform', + callbackIssues: 'transform', + attributionIssues: 'transform', + coverage: 'transform', + metadata: 'transform', +} satisfies Record; +const page = { origin: 'copy', pathname: 'transform' } satisfies Record< + keyof GptDiagnosticsExportV1['page'], + Treatment +>; +const slot = { + runtimeSlotNumber: 'copy', + slotElementId: 'exclude', + adUnitPath: 'exclude', + binding: 'transform', + currentVisibilityPercentage: 'copy', + maximumVisibilityPercentage: 'copy', + requests: 'transform', +} satisfies Record; +const cycle = { + requestNumber: 'copy', + requestedAtMs: 'copy', + responseAtMs: 'copy', + renderAtMs: 'copy', + loadAtMs: 'copy', + viewableAtMs: 'copy', + durations: 'transform', + isEmpty: 'copy', + requestedSlotSizes: 'copy', + size: 'copy', + observedSlotSize: 'copy', + isBackfill: 'copy', + slotContentChanged: 'copy', + incompleteSequence: 'copy', + adManager: 'exclude', + responseClass: 'copy', + requestPath: 'copy', + requestIntentId: 'copy', + trustedServerAuctionId: 'copy', + opportunityToRequestMs: 'copy', + replacedRequestNumber: 'copy', + previousRenderToRequestMs: 'copy', + creativeChanged: 'copy', + previousCreativeId: 'exclude', + loadObservedBeforeRender: 'copy', + trustedServerOpportunity: 'copy', + trustedServerCreativeRequestAtMs: 'copy', + trustedServerCreativeResponseAtMs: 'copy', + trustedServerCreativeFailures: 'copy', + delivery: 'copy', + auctionType: 'exclude', + auctionWinner: 'exclude', + prebidAuction: 'exclude', + serverAuctionTimings: 'exclude', + serverAuctionTimingOrigin: 'exclude', +} satisfies Record; +const binding = { status: 'copy', reason: 'copy' } satisfies Record< + keyof GptDiagnosticsBinding, + Treatment +>; +const durations = { + requestToResponseMs: 'copy', + responseToRenderMs: 'copy', + requestToRenderMs: 'copy', + renderToLoadMs: 'copy', + renderToViewableMs: 'copy', +} satisfies Record; +const callback = { + kind: 'copy', + runtimeSlotNumber: 'copy', + slotElementId: 'exclude', + timestampMs: 'copy', + disposition: 'copy', + reason: 'copy', +} satisfies Record; +const attribution = { + reason: 'copy', + timestampMs: 'copy', + runtimeSlotNumber: 'copy', + slotElementId: 'exclude', +} satisfies Record; +const counters = { + observed: 'copy', + matched: 'copy', + unmatched: 'copy', + ambiguous: 'copy', +} satisfies Record; +const coverage = { + slotRequested: 'transform', + slotResponseReceived: 'transform', + slotRenderEnded: 'transform', + slotOnload: 'transform', + impressionViewable: 'transform', + slotVisibilityChanged: 'transform', +} satisfies Record; +const metadata = { + droppedCallbacks: 'copy', + droppedAttributionIssues: 'copy', + evictedSlots: 'copy', + evictedRequestCycles: 'copy', +} satisfies Record; +const adManager = { + lineItemId: 'exclude', + creativeId: 'exclude', + campaignId: 'exclude', + advertiserId: 'exclude', + sourceAgnosticLineItemId: 'exclude', + sourceAgnosticCreativeId: 'exclude', + yieldGroupIds: 'exclude', + companyIds: 'exclude', +} satisfies Record; + +// @ts-expect-error Future or missing source members must be classified explicitly. +const incomplete: Record = { requestNumber: 'copy' }; + +describe('explicit current GPT source member classification', () => { + it('records every excluded identifier and each deliberate transformation', () => { + expect(cycle.adManager).toBe('exclude'); + expect(cycle.previousCreativeId).toBe('exclude'); + expect(slot.slotElementId).toBe('exclude'); + expect(slot.adUnitPath).toBe('exclude'); + expect(callback.slotElementId).toBe('exclude'); + expect(attribution.slotElementId).toBe('exclude'); + expect(Object.values(adManager).every((value) => value === 'exclude')).toBe(true); + expect(root.version).toBe('transform'); + expect(page.pathname).toBe('transform'); + expect(Object.keys(binding)).toHaveLength(2); + expect(Object.keys(durations)).toHaveLength(5); + expect(Object.keys(counters)).toHaveLength(4); + expect(Object.keys(coverage)).toHaveLength(6); + expect(Object.keys(metadata)).toHaveLength(4); + expect(incomplete.requestNumber).toBe('copy'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/validation.test.ts b/crates/trusted-server-js/lib/test/trace/validation.test.ts new file mode 100644 index 000000000..8a00041ae --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/validation.test.ts @@ -0,0 +1,596 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseTraceReport, + parseTraceStoredReport, + traceReportRejection, + boundedJsonShape, + validateTraceGptDiagnostics, + validateTraceReport, + validateTraceStoredReport, +} from '../../src/trace/report-validation'; +import { + TRACE_BINDING_REASONS, + TRACE_CALLBACK_KINDS, + TRACE_CALLBACK_REASONS, + TRACE_ATTRIBUTION_REASONS, + TRACE_RESPONSE_CLASSES, + TRACE_REQUEST_PATHS, + TRACE_OPPORTUNITIES, + TRACE_DELIVERIES, +} from '../../src/trace/report-types'; + +import { + projectedGptFixture, + reportFixture, + TRACE_ORIGIN, + TRACE_TIME, + TRACE_NOW, +} from './fixtures'; +import { AUCTION_TOKEN, SLOT_TOKEN } from './fixtures'; + +function valid(value: unknown) { + return validateTraceGptDiagnostics(value, TRACE_ORIGIN); +} + +describe('strict combined report ingestion', () => { + it('returns a frozen owned model instead of retaining a proxy that changes later reads', () => { + const value = reportFixture(); + const original = value.gpt_diagnostics.metadata; + let traps = 0; + value.gpt_diagnostics.metadata = new Proxy(original, { + get() { + traps += 1; + throw new Error('should not call'); + }, + }); + const model = parseTraceReport(value, TRACE_ORIGIN, TRACE_NOW); + expect(model).toBeDefined(); + expect(traps).toBe(0); + expect(Object.is(model, value)).toBe(false); + expect(model?.gpt_diagnostics.metadata).not.toBe(original); + expect(Object.isFrozen(original)).toBe(false); + original.droppedCallbacks = 99; + expect(model?.gpt_diagnostics.metadata.droppedCallbacks).toBe(0); + const frozen = (object: unknown): void => { + if (typeof object !== 'object' || object === null) return; + expect(Object.isFrozen(object)).toBe(true); + Object.values(object).forEach(frozen); + }; + frozen(model); + const wrapper = parseTraceStoredReport( + { stored_at_ms: TRACE_NOW, report: reportFixture() }, + TRACE_ORIGIN, + TRACE_NOW + ); + expect(Object.isFrozen(wrapper)).toBe(true); + expect(wrapper?.stored_at_ms).toBe(TRACE_NOW); + expect( + parseTraceStoredReport( + { stored_at_ms: TRACE_NOW, report: reportFixture() }, + TRACE_ORIGIN, + TRACE_NOW + 900001 + ) + ).toBeUndefined(); + }); + it('validates the same own counter and enum data that is measured for storage', () => { + const counter = reportFixture(); + Object.assign(counter.gpt_diagnostics.metadata, { droppedCallbacks: 'private-counter' }); + counter.gpt_diagnostics.metadata = new Proxy(counter.gpt_diagnostics.metadata, { + get(target, name, receiver) { + return name === 'droppedCallbacks' ? 0 : Reflect.get(target, name, receiver); + }, + }); + expect(validateTraceReport(counter, TRACE_ORIGIN, TRACE_NOW)).toBe(false); + const enumValue = projectedGptFixture(); + enumValue.slots[0].requests[0].delivery = 'private-delivery'; + enumValue.slots[0].requests[0] = new Proxy(enumValue.slots[0].requests[0], { + get(target, name, receiver) { + return name === 'delivery' ? 'unknown' : Reflect.get(target, name, receiver); + }, + }); + expect(valid(enumValue)).toBe(false); + const token = projectedGptFixture(); + token.slots[0].requests[0].trustedServerAuctionId = 'private-id'; + token.slots[0].requests[0] = new Proxy(token.slots[0].requests[0], { + get(target, name, receiver) { + return name === 'trustedServerAuctionId' + ? AUCTION_TOKEN + : Reflect.get(target, name, receiver); + }, + }); + expect(valid(token)).toBe(false); + }); + it('accepts numeric maxima and rejects fractional identifiers without changing fractional timing', () => { + const value = projectedGptFixture(); + const cycle = value.slots[0].requests[0]; + Object.assign(cycle, { + requestNumber: Number.MAX_SAFE_INTEGER, + requestIntentId: 0, + replacedRequestNumber: Number.MAX_SAFE_INTEGER, + requestedAtMs: Number.MAX_SAFE_INTEGER, + size: [100000, 100000], + observedSlotSize: [0, 100000], + }); + Object.assign(value.metadata, { droppedCallbacks: Number.MAX_SAFE_INTEGER }); + expect(valid(value)).toBe(true); + cycle.requestNumber = 0.5; + expect(valid(value)).toBe(false); + }); + + it('enforces the 255 UTF-8 byte origin cap at the exact boundary', () => { + const prefix = `https://${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.`; + const origin = prefix + 'b'.repeat(55); + expect(new TextEncoder().encode(origin).length).toBe(255); + const value = projectedGptFixture(); + value.page.origin = origin; + expect(validateTraceGptDiagnostics(value, origin)).toBe(true); + value.page.origin = origin + 'b'; + expect(validateTraceGptDiagnostics(value, origin + 'b')).toBe(false); + }); + + it('validates full nested report containers and the runtime ambiguity detail pair', () => { + const value = reportFixture(); + expect(boundedJsonShape(value, 8)).toBeDefined(); + expect(boundedJsonShape(value, 7)).toBeUndefined(); + Object.assign(value.request_context.cookies.diagnostics_session, { state: 'present_invalid' }); + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(false); + }); + it('returns bounded actionable categories for incompatible versions', () => { + expect(traceReportRejection(reportFixture(), TRACE_ORIGIN, TRACE_NOW)).toBeUndefined(); + expect( + traceReportRejection({ ...reportFixture(), schema_version: 2 }, TRACE_ORIGIN, TRACE_NOW) + ).toBe('unsupported_report_version'); + const gpt = reportFixture(); + Object.assign(gpt.gpt_diagnostics, { schema_version: 2 }); + expect(traceReportRejection(gpt, TRACE_ORIGIN, TRACE_NOW)).toBe('unsupported_gpt_version'); + Object.assign(gpt.gpt_diagnostics, { schema_version: 1, source_schema_version: 2 }); + expect(traceReportRejection(gpt, TRACE_ORIGIN, TRACE_NOW)).toBe( + 'unsupported_gpt_source_version' + ); + const auction = reportFixture(); + Object.assign(auction, { server_auctions: [{ schema_version: 2 }] }); + expect(traceReportRejection(auction, TRACE_ORIGIN, TRACE_NOW)).toBe( + 'unsupported_auction_version' + ); + const correlation = reportFixture(); + Object.assign(correlation, { slot_correlations: [{ schema_version: 2 }] }); + expect(traceReportRejection(correlation, TRACE_ORIGIN, TRACE_NOW)).toBe( + 'unsupported_correlation_version' + ); + expect(traceReportRejection({ private: 'private-error' }, TRACE_ORIGIN, TRACE_NOW)).toBe( + 'invalid_report' + ); + }); + it('accepts every supported enum at its own boundary', () => { + for (const reason of TRACE_BINDING_REASONS) { + const value = projectedGptFixture(); + value.slots[0].binding.reason = reason; + expect(valid(value)).toBe(true); + } + for (const kind of TRACE_CALLBACK_KINDS) { + const value = projectedGptFixture(); + value.callbackIssues[0].kind = kind; + expect(valid(value)).toBe(true); + } + for (const reason of TRACE_CALLBACK_REASONS) { + const value = projectedGptFixture(); + value.callbackIssues[0].reason = reason; + expect(valid(value)).toBe(true); + } + for (const reason of TRACE_ATTRIBUTION_REASONS) { + const value = projectedGptFixture(); + value.attributionIssues[0].reason = reason; + expect(valid(value)).toBe(true); + } + for (const [key, members] of [ + ['responseClass', TRACE_RESPONSE_CLASSES], + ['requestPath', TRACE_REQUEST_PATHS], + ['trustedServerOpportunity', TRACE_OPPORTUNITIES], + ['delivery', TRACE_DELIVERIES], + ] as const) { + for (const member of members) { + const value = projectedGptFixture(); + Object.assign(value.slots[0].requests[0], { [key]: member }); + expect(valid(value)).toBe(true); + } + } + }); + + it('counts actual nested containers from the report root and enforces compact UTF-8 bytes', () => { + let depthTen: unknown = null; + for (let index = 0; index < 10; index += 1) depthTen = { child: depthTen }; + expect(boundedJsonShape(depthTen, 10)).toBeDefined(); + expect(boundedJsonShape({ child: depthTen }, 10)).toBeUndefined(); + const text = { value: 'é😀' }; + const bytes = new TextEncoder().encode(JSON.stringify(text)).length; + expect(boundedJsonShape(text, 10, bytes)).toBe(bytes); + expect(boundedJsonShape(text, 10, bytes - 1)).toBeUndefined(); + const cyclical: { child?: unknown } = {}; + cyclical.child = cyclical; + expect(boundedJsonShape(cyclical)).toBeUndefined(); + let calls = 0; + expect( + boundedJsonShape({ + toJSON() { + calls += 1; + return {}; + }, + }) + ).toBeUndefined(); + expect(calls).toBe(0); + }); + + it('rejects unsafe Unicode and controls at text-bearing boundaries', () => { + for (const suffix of ['\u0000', '\u001f', '\u007f', '\u009f', '\u202e', '\u2066', '\ud800']) { + const value = projectedGptFixture(); + value.page.origin = TRACE_ORIGIN + suffix; + expect(valid(value)).toBe(false); + value.page.origin = TRACE_ORIGIN; + value.capturedAt = TRACE_TIME + suffix; + expect(valid(value)).toBe(false); + } + const value = projectedGptFixture(); + value.page.pathname = '/private-path'; + expect(valid(value)).toBe(false); + }); + + it('enforces server/sidecar caps, status semantics and the API correlation exclusion', () => { + const auction = { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + source: 'initial_navigation_ssat', + terminal_status: 'completed', + provider_calls: [], + slots: [], + truncation: { omitted_provider_calls: 0, omitted_slots: 0, omitted_nested_values: 0 }, + coverage: { provider_to_slot_no_bid: 'unavailable' }, + }; + const correlation = { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + runtime_slot_number: 1, + request_number: 1, + }; + const value = reportFixture(); + Object.assign(value, { + server_auctions: Array.from({ length: 16 }, () => auction), + slot_correlations: Array.from({ length: 128 }, () => correlation), + }); + value.auction_coverage.capture_status = 'complete'; + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(true); + Object.assign(value, { server_auctions: Array.from({ length: 17 }, () => auction) }); + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(false); + Object.assign(value, { + server_auctions: [auction], + slot_correlations: Array.from({ length: 129 }, () => correlation), + }); + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(false); + Object.assign(value, { slot_correlations: [correlation] }); + Object.assign(value.auction_coverage, { + capture_status: 'partial', + issues: ['evidence_transport_failed'], + }); + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(true); + auction.source = 'auction_api'; + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(false); + }); + + it('rejects a shape-valid combined report whose compact wrapper exceeds 512 KiB', () => { + const gpt = projectedGptFixture(); + gpt.slots = Array.from({ length: 64 }, () => { + const slot = projectedGptFixture().slots[0]; + slot.requests = Array.from({ length: 10 }, () => projectedGptFixture().slots[0].requests[0]); + return slot; + }); + expect(valid(gpt)).toBe(true); + const report = reportFixture(); + report.gpt_diagnostics = gpt; + const wrapper = { stored_at_ms: TRACE_NOW, report }; + expect(new TextEncoder().encode(JSON.stringify(wrapper)).length).toBeGreaterThan(512 * 1024); + expect(validateTraceStoredReport(wrapper, TRACE_ORIGIN, TRACE_NOW)).toBe(false); + }); + it('accepts the explicit projection and complete wrapper, including zero CSS size and fractional browser durations', () => { + expect(valid(projectedGptFixture())).toBe(true); + expect(validateTraceReport(reportFixture(), TRACE_ORIGIN, TRACE_NOW)).toBe(true); + expect( + validateTraceStoredReport( + { stored_at_ms: TRACE_NOW, report: reportFixture() }, + TRACE_ORIGIN, + TRACE_NOW + ) + ).toBe(true); + }); + it('retains source optionality without requiring later optional fields', () => { + const value = projectedGptFixture(); + Reflect.deleteProperty(value, 'attributionIssues'); + Reflect.deleteProperty(value.metadata, 'droppedAttributionIssues'); + value.slots = [ + { + runtimeSlotNumber: 0, + binding: { status: 'bound' }, + requests: [{ requestNumber: 0, durations: {}, incompleteSequence: true }], + }, + ] as typeof value.slots; + expect(valid(value)).toBe(true); + }); + it.each([ + 'adManager', + 'previousCreativeId', + 'slotElementId', + 'adUnitPath', + '__proto__', + 'constructor', + 'future', + ])('rejects forbidden request-cycle property %s', (key) => { + const value = projectedGptFixture(); + Object.defineProperty(value.slots[0].requests[0], key, { + enumerable: true, + value: 'private-sentinel', + }); + expect(valid(value)).toBe(false); + }); + it('rejects extra keys at every nested boundary', () => { + const value = projectedGptFixture(); + const objects = [ + value, + value.page, + value.slots[0], + value.slots[0].binding, + value.slots[0].requests[0], + value.slots[0].requests[0].durations, + value.callbackIssues[0], + value.attributionIssues[0], + value.coverage, + value.coverage.slotRequested, + value.metadata, + ]; + for (const target of objects) { + Object.assign(target, { future: 'private-sentinel' }); + expect(valid(value)).toBe(false); + Reflect.deleteProperty(target, 'future'); + } + }); + it.each([0, 2, '1', null])('rejects unsupported GPT/source/report versions %s', (version) => { + expect(valid({ ...projectedGptFixture(), schema_version: version })).toBe(false); + expect(valid({ ...projectedGptFixture(), source_schema_version: version })).toBe(false); + expect( + validateTraceReport({ ...reportFixture(), schema_version: version }, TRACE_ORIGIN, TRACE_NOW) + ).toBe(false); + }); + it.each([ + 'https://attacker.example.com', + 'ftp://publisher.example.com', + 'https://publisher.example.com/', + 'https://user@publisher.example.com', + 'https://@publisher.example.com', + 'https://publisher.example.com?', + 'https://publisher.example.com#', + 'https://publisher.example.com\\', + 'https://publisher.example.com\n', + 'https://publisher.example.com,https://publisher.example.com', + ])('rejects a foreign or repaired origin %s', (origin) => { + const value = projectedGptFixture(); + value.page.origin = origin; + expect(valid(value)).toBe(false); + }); + it('accepts a canonical equivalent origin without changing the input', () => { + const value = projectedGptFixture(); + value.page.origin = 'https://PUBLISHER.EXAMPLE.COM:443'; + expect(valid(value)).toBe(true); + expect(value.page.origin).toBe('https://PUBLISHER.EXAMPLE.COM:443'); + }); + it.each(['adManager', 'previousCreativeId', 'slotElementId', 'adUnitPath'])( + 'rejects a prohibited slot/issue field %s', + (key) => { + for (const where of ['slot', 'callback', 'attribution']) { + const value = projectedGptFixture(); + const target = + where === 'slot' + ? value.slots[0] + : where === 'callback' + ? value.callbackIssues[0] + : value.attributionIssues[0]; + Object.assign(target, { [key]: 'private-sentinel' }); + expect(valid(value)).toBe(false); + } + } + ); + it.each([NaN, Infinity, -1, Number.MAX_SAFE_INTEGER + 1, '1', null])( + 'rejects invalid browser timing %s', + (time) => { + const value = projectedGptFixture(); + Object.assign(value.slots[0].requests[0], { requestedAtMs: time }); + expect(valid(value)).toBe(false); + } + ); + it.each([-1, 100.1, NaN, Infinity, '1'])('rejects invalid visibility %s', (percentage) => { + const value = projectedGptFixture(); + Object.assign(value.slots[0], { currentVisibilityPercentage: percentage }); + expect(valid(value)).toBe(false); + }); + it('enforces each array and nested numeric bound at its edge', () => { + const value = projectedGptFixture(); + value.slots = Array.from({ length: 64 }, () => projectedGptFixture().slots[0]); + expect(valid(value)).toBe(true); + value.slots.push(value.slots[0]); + expect(valid(value)).toBe(false); + const cycles = projectedGptFixture(); + cycles.slots[0].requests = Array.from( + { length: 10 }, + () => projectedGptFixture().slots[0].requests[0] + ); + expect(valid(cycles)).toBe(true); + cycles.slots[0].requests.push(cycles.slots[0].requests[0]); + expect(valid(cycles)).toBe(false); + for (const key of ['callbackIssues', 'attributionIssues'] as const) { + const issues = projectedGptFixture(); + Object.assign(issues, { [key]: Array.from({ length: 128 }, () => issues[key][0]) }); + expect(valid(issues)).toBe(true); + issues[key].push(issues[key][0] as never); + expect(valid(issues)).toBe(false); + } + const sizes = projectedGptFixture(); + sizes.slots[0].requests[0].requestedSlotSizes = Array.from({ length: 16 }, () => [1, 100000]); + expect(valid(sizes)).toBe(true); + sizes.slots[0].requests[0].requestedSlotSizes.push([1, 1]); + expect(valid(sizes)).toBe(false); + const failures = projectedGptFixture(); + failures.slots[0].requests[0].trustedServerCreativeFailures = Array.from( + { length: 16 }, + () => 'missing_render_source' + ); + expect(valid(failures)).toBe(true); + failures.slots[0].requests[0].trustedServerCreativeFailures?.push('missing_render_source'); + expect(valid(failures)).toBe(false); + }); + it('rejects invalid dimension shapes and retains zero-capable observed CSS boxes only', () => { + for (const key of ['size', 'observedSlotSize'] as const) { + for (const dimensions of [[-1, 1], [1.5, 1], [100001, 1], [1], [1, 1, 1], ['1', 1]]) { + const value = projectedGptFixture(); + Object.assign(value.slots[0].requests[0], { [key]: dimensions }); + expect(valid(value)).toBe(false); + } + } + const zero = projectedGptFixture(); + zero.slots[0].requests[0].size = [0, 0]; + expect(valid(zero)).toBe(false); + }); + it('rejects unknown enums and malformed tokens without coercion', () => { + const updates = [ + { responseClass: 'future' }, + { requestPath: '/private/path' }, + { trustedServerOpportunity: 'winner' }, + { delivery: 'rendered' }, + { trustedServerCreativeFailures: ['raw-error'] }, + { trustedServerAuctionId: 'internal-id' }, + { incompleteSequence: 1 }, + ]; + for (const update of updates) { + const value = projectedGptFixture(); + Object.assign(value.slots[0].requests[0], update); + expect(valid(value)).toBe(false); + } + for (const update of [{ status: 'future' }, { reason: 'private-error' }]) { + const value = projectedGptFixture(); + Object.assign(value.slots[0].binding, update); + expect(valid(value)).toBe(false); + } + for (const update of [ + { kind: 'future' }, + { disposition: 'future' }, + { reason: 'private-error' }, + ]) { + const value = projectedGptFixture(); + Object.assign(value.callbackIssues[0], update); + expect(valid(value)).toBe(false); + } + const attribution = projectedGptFixture(); + attribution.attributionIssues[0].reason = 'future'; + expect(valid(attribution)).toBe(false); + }); + it('enforces finite integer counters and omission counters', () => { + for (const count of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + const value = projectedGptFixture(); + value.metadata.droppedCallbacks = count; + expect(valid(value)).toBe(false); + } + for (const count of [65536, -1, 0.5]) { + const value = reportFixture(); + value.truncation.omitted_nested_values = count; + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(false); + } + const value = reportFixture(); + value.truncation.omitted_nested_values = 65535; + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(true); + }); + it('requires capture times close to wrapper time and leaves earlier request time eligible', () => { + const value = reportFixture(); + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW + 60000)).toBe(true); + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW + 60001)).toBe(false); + value.gpt_diagnostics.capturedAt = '2026-02-30T00:00:00Z'; + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(false); + value.gpt_diagnostics.capturedAt = TRACE_TIME; + value.request_context.captured_at = '2025-01-01T00:00:00Z'; + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(true); + }); + it('requires exact storage keys, finite times, bounded age and rollback rejection', () => { + const wrapper = { stored_at_ms: TRACE_NOW, report: reportFixture() }; + expect(validateTraceStoredReport(wrapper, TRACE_ORIGIN, TRACE_NOW + 900000)).toBe(true); + expect(validateTraceStoredReport(wrapper, TRACE_ORIGIN, TRACE_NOW + 900001)).toBe(false); + expect(validateTraceStoredReport(wrapper, TRACE_ORIGIN, TRACE_NOW - 1)).toBe(true); + expect(validateTraceStoredReport(wrapper, TRACE_ORIGIN, TRACE_NOW - 60000)).toBe(true); + expect(validateTraceStoredReport(wrapper, TRACE_ORIGIN, TRACE_NOW - 60001)).toBe(false); + expect(validateTraceStoredReport({ ...wrapper, future: 1 }, TRACE_ORIGIN, TRACE_NOW)).toBe( + false + ); + for (const stored_at_ms of [-1, NaN, Infinity, '1', null]) + expect(validateTraceStoredReport({ ...wrapper, stored_at_ms }, TRACE_ORIGIN, TRACE_NOW)).toBe( + false + ); + }); + it('recomputes capture status and requires distinct enum-order coverage issues', () => { + const value = reportFixture(); + Object.assign(value.auction_coverage, { + capture_status: 'unavailable', + issues: ['record_evicted'], + }); + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(true); + value.auction_coverage.capture_status = 'not_observed'; + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(false); + Object.assign(value.auction_coverage, { + capture_status: 'not_observed', + issues: ['correlation_unavailable', 'external_client_side_unobservable'], + }); + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(true); + Object.assign(value.auction_coverage, { + issues: ['external_client_side_unobservable', 'correlation_unavailable'], + }); + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(false); + Object.assign(value.auction_coverage, { + issues: ['record_evicted', 'record_evicted'], + capture_status: 'unavailable', + }); + expect(validateTraceReport(value, TRACE_ORIGIN, TRACE_NOW)).toBe(false); + }); + it('rejects accessors and throwing proxies without invoking user serialization', () => { + let calls = 0; + const value = projectedGptFixture(); + Object.defineProperty(value.slots[0], 'binding', { + enumerable: true, + get() { + calls += 1; + throw new Error('should not call'); + }, + }); + expect(valid(value)).toBe(false); + expect(calls).toBe(0); + expect( + valid( + new Proxy( + {}, + { + getPrototypeOf() { + throw new Error('should reject'); + }, + } + ) + ) + ).toBe(false); + expect( + validateTraceStoredReport( + new Proxy( + {}, + { + ownKeys() { + throw new Error('should reject'); + }, + } + ), + TRACE_ORIGIN, + TRACE_NOW + ) + ).toBe(false); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/view-model.test.ts b/crates/trusted-server-js/lib/test/trace/view-model.test.ts new file mode 100644 index 000000000..667e6f3f2 --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/view-model.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest'; + +import type { TraceEvidenceView } from '../../src/trace/correlation'; +import type { TraceReportV1 } from '../../src/trace/report-types'; +import { summarizeTraceReport } from '../../src/trace/view-model'; + +import { AUCTION_TOKEN, SLOT_TOKEN, reportFixture } from './fixtures'; + +type Auction = TraceReportV1['server_auctions'][number]; +type Cycle = TraceReportV1['gpt_diagnostics']['slots'][number]['requests'][number]; + +function auction(overrides: Partial = {}): Auction { + return { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + source: 'initial_navigation_ssat', + terminal_status: 'completed', + total_time_ms: 10, + provider_calls: [ + { provider_number: 1, role: 'bidder', status: 'no_bid', returned_bid_count: 0 }, + ], + slots: [ + { + slot_number: 1, + slot_ref: SLOT_TOKEN, + requested_sizes: [[300, 250]], + returned_bid_count: 0, + candidate: 'no_candidate', + }, + ], + truncation: { omitted_provider_calls: 0, omitted_slots: 0, omitted_nested_values: 0 }, + coverage: { provider_to_slot_no_bid: 'unavailable' }, + ...overrides, + } as Auction; +} + +function report(auctions: Auction[] = [], cycles?: Partial[]): TraceReportV1 { + const base = reportFixture() as unknown as TraceReportV1; + const template = base.gpt_diagnostics.slots[0]; + const slots = (cycles ?? [{}]).map((cycle, index) => ({ + ...template, + runtimeSlotNumber: index + 1, + requests: [{ ...template.requests[0], ...cycle } as Cycle], + })); + return { + ...base, + server_auctions: auctions, + gpt_diagnostics: { ...base.gpt_diagnostics, slots }, + } as TraceReportV1; +} + +describe('trace report summary', () => { + it('counts filled slots and bidder-only bids without naming a winner', () => { + const summary = summarizeTraceReport( + report( + [ + auction({ + provider_calls: [ + { provider_number: 1, role: 'bidder', status: 'success', returned_bid_count: 2 }, + { provider_number: 2, role: 'mediator', status: 'success', returned_bid_count: 2 }, + ], + } as Partial), + ], + [{ isEmpty: false }, { isEmpty: true }] + ), + undefined + ); + expect(summary.headline).toBe('1 of 2 ad slots filled · 2 Trusted Server bids'); + expect(summary.reading).toContain('2 bids were returned to Trusted Server.'); + expect(summary.reading.join(' ')).not.toMatch(/won|winner/i); + expect(summary.fills).toEqual([ + { fill: 'filled', count: 1 }, + { fill: 'empty', count: 1 }, + ]); + }); + + it('reports incomplete auctions, provider failures and unlinked slots', () => { + const summary = summarizeTraceReport( + report([ + auction({ + provider_calls: [ + { provider_number: 1, role: 'bidder', status: 'error', returned_bid_count: 0 }, + { provider_number: 2, role: 'bidder', status: 'no_bid', returned_bid_count: 0 }, + ], + } as Partial), + auction({ terminal_status: 'execution_failed' } as Partial), + ]), + undefined + ); + expect(summary.reading).toEqual([ + '1 of 2 server auctions completed; 1 execution failed.', + 'No bids were returned to Trusted Server.', + '1 of 3 provider calls failed.', + 'GPT classified the filled ads as 1 backfill.', + '1 of 1 ad slot could not be linked to a server auction.', + ]); + expect(summary.needsAttention).toBe(true); + expect(summary.stats.map((stat) => stat.value)).toEqual([2, 1, 0]); + }); + + it('names one, two and many completed auctions naturally', () => { + const phrase = (count: number): string | undefined => + summarizeTraceReport(report(Array.from({ length: count }, () => auction())), undefined) + .reading[0]; + expect([phrase(1), phrase(2), phrase(3)]).toEqual([ + 'The server auction completed.', + 'Both server auctions completed.', + 'All 3 server auctions completed.', + ]); + }); + + it('names the recorded outcome of auctions that did not complete', () => { + const summary = summarizeTraceReport( + report([ + auction(), + auction({ + terminal_status: 'skipped', + terminal_reason: 'no_eligible_slots', + } as Partial), + auction({ + terminal_status: 'skipped', + terminal_reason: 'no_eligible_slots', + } as Partial), + auction({ terminal_status: 'abandoned' } as Partial), + ]), + undefined + ); + expect(summary.reading[0]).toBe( + '1 of 4 server auctions completed; 2 skipped (no eligible slots), 1 abandoned.' + ); + }); + + it('explains a page without auctions or slots', () => { + const empty = report(); + const summary = summarizeTraceReport( + { ...empty, gpt_diagnostics: { ...empty.gpt_diagnostics, slots: [] } }, + undefined + ); + expect(summary.headline).toBe('No GPT ad slots observed'); + expect(summary.reading).toEqual(['No server auction was captured for this page.']); + expect(summary.slots).toEqual([]); + }); + + it('flags empty, unknown, incomplete and creative-failure slots', () => { + const summary = summarizeTraceReport( + report( + [], + [ + { isEmpty: true, trustedServerCreativeFailures: [] }, + { isEmpty: undefined, trustedServerCreativeFailures: [] }, + { isEmpty: false, incompleteSequence: true, trustedServerCreativeFailures: [] }, + { isEmpty: false, trustedServerCreativeFailures: ['cache_fetch_failed'] }, + { isEmpty: false, incompleteSequence: false, trustedServerCreativeFailures: [] }, + ] + ), + undefined + ); + expect(summary.slots.map((slot) => slot.attention)).toEqual([ + ['GPT returned no ad'], + ['No fill observed'], + ['GPT event sequence incomplete'], + ['Creative bridge failure observed'], + [], + ]); + }); + + it('weights timing segments proportionally, clamped to 1-20, preferring viewable', () => { + const [viewable] = summarizeTraceReport( + report( + [], + [ + { + durations: { + requestToResponseMs: 900, + responseToRenderMs: 1, + renderToLoadMs: 50, + renderToViewableMs: 1099, + }, + }, + ] + ), + undefined + ).slots; + expect(viewable.timing.map((segment) => [segment.kind, segment.weight])).toEqual([ + ['response', 9], + ['render', 1], + ['viewable', 11], + ]); + const [loaded] = summarizeTraceReport( + report([], [{ durations: { requestToResponseMs: 10, renderToLoadMs: 30 } }]), + undefined + ).slots; + expect(loaded.timing.map((segment) => segment.kind)).toEqual(['response', 'load']); + const [none] = summarizeTraceReport(report([], [{ durations: {} }]), undefined).slots; + expect(none.timing).toEqual([]); + }); + + it('links a slot only through an exact matched correlation', () => { + const base = report([auction()]); + const joined = { + auctions: [ + { + evidence: base.server_auctions[0], + sourceLabel: 'Initial-page server auction (SSAT)', + slots: [ + { + serverSlot: base.server_auctions[0].slots[0], + correlation: 'matched', + runtimeSlotNumber: 1, + requestNumber: 1, + }, + ], + }, + ], + } as unknown as TraceEvidenceView; + const [card] = summarizeTraceReport(base, joined).slots; + expect(card.link).toEqual({ + auctionNumber: 1, + sourceLabel: 'Initial-page server auction (SSAT)', + serverSlotNumber: 1, + candidate: 'no_candidate', + }); + const unmatched = { + auctions: [ + { + ...joined.auctions[0], + slots: [{ ...joined.auctions[0].slots[0], correlation: 'unknown' }], + }, + ], + } as unknown as TraceEvidenceView; + expect(summarizeTraceReport(base, unmatched).slots[0].link).toBeUndefined(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/trace/viewer.test.ts b/crates/trusted-server-js/lib/test/trace/viewer.test.ts new file mode 100644 index 000000000..dea2f1a1f --- /dev/null +++ b/crates/trusted-server-js/lib/test/trace/viewer.test.ts @@ -0,0 +1,482 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { mountTraceViewer } from '../../src/trace/report-view'; +import { TRACE_REPORT_STORAGE_KEY } from '../../src/trace/storage'; +import { formatTraceReport } from '../../src/trace/export'; +import type { + copyTraceReport, + downloadTraceReport, + shareTraceReport, +} from '../../src/trace/export'; + +import { reportFixture, TRACE_NOW, TRACE_ORIGIN, AUCTION_TOKEN, SLOT_TOKEN } from './fixtures'; + +function page() { + document.body.innerHTML = + '

Trusted Server ad diagnostics

Setup request

'; + document.getElementById('trace-request-context')!.textContent = JSON.stringify({ + ...reportFixture().request_context, + network: { edge_hostname: 'setup-only.example.com' }, + }); +} +function setup(value: unknown = reportFixture()) { + let serialized: string | null = + value === null ? null : JSON.stringify({ stored_at_ms: TRACE_NOW, report: value }); + const storage = { + getItem: vi.fn(() => serialized), + setItem: vi.fn(), + removeItem: vi.fn((_key: string) => { + serialized = null; + }), + }; + const copy = vi.fn(async () => ({ status: 'copied' })); + const download = vi.fn(() => ({ status: 'downloaded' })); + const share = vi.fn(async () => ({ status: 'shared' })); + const options = { + storage, + now: () => TRACE_NOW, + origin: TRACE_ORIGIN, + confirm: vi.fn(() => true), + copy, + download, + share, + }; + return { storage, options, copy, download, share }; +} +function button(label: string): HTMLButtonElement { + const result = Array.from(document.querySelectorAll('button')).find( + (candidate) => candidate.textContent === label + ); + if (!result) throw new Error(`should find ${label} control`); + return result; +} +function joinedReport() { + const report = { + ...reportFixture(), + server_auctions: [ + { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + source: 'initial_navigation_ssat', + terminal_status: 'completed', + total_time_ms: 10, + provider_calls: [ + { provider_number: 1, role: 'bidder', status: 'no_bid', returned_bid_count: 0 }, + ], + slots: [ + { + slot_number: 1, + slot_ref: SLOT_TOKEN, + requested_sizes: [[300, 250]], + returned_bid_count: 0, + candidate: 'no_candidate', + }, + ], + truncation: { omitted_provider_calls: 0, omitted_slots: 0, omitted_nested_values: 0 }, + coverage: { provider_to_slot_no_bid: 'unavailable' }, + }, + ], + slot_correlations: [ + { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + runtime_slot_number: 1, + request_number: 1, + }, + ], + auction_coverage: { capture_status: 'complete', issues: [] }, + }; + return report; +} +const request = vi.fn(); +beforeEach(() => { + page(); + request.mockReset(); + vi.stubGlobal('fetch', request); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + document.body.replaceChildren(); +}); + +describe('consolidated trace report viewer', () => { + it('keeps setup read-only when no report exists', () => { + const fixture = setup(null); + mountTraceViewer(document, fixture.options); + expect(document.getElementById('trace-report')).toBeNull(); + expect(document.body.textContent).toContain('No saved report'); + expect(document.body.textContent).toContain('setup-only.example.com'); + expect(request).not.toHaveBeenCalled(); + expect(fixture.storage.setItem).not.toHaveBeenCalled(); + }); + it.each(['expired', 'hostile', 'unsupported', 'wrong-origin'])( + 'ignores and removes a %s report with actionable reproduction guidance', + (reason) => { + const report = reportFixture(); + if (reason === 'hostile') + Object.assign(report, { private_value: '' }); + if (reason === 'unsupported') report.schema_version = 2; + if (reason === 'wrong-origin') + report.gpt_diagnostics.page.origin = 'https://other.example.com'; + const fixture = setup(report); + if (reason === 'expired') fixture.options.now = () => TRACE_NOW + 15 * 60 * 1000 + 1; + fixture.storage.removeItem.mockImplementation(() => { + throw new Error('private-storage-error'); + }); + mountTraceViewer(document, fixture.options); + expect(document.getElementById('trace-report')).toBeNull(); + expect(document.body.textContent).toContain('reload once'); + expect(document.body.textContent).not.toContain('private'); + expect(fixture.storage.removeItem).toHaveBeenCalledWith(TRACE_REPORT_STORAGE_KEY); + expect(request).not.toHaveBeenCalled(); + } + ); + it('renders all sections with provenance and keeps setup facts out of publisher facts', () => { + const fixture = setup(); + mountTraceViewer(document, fixture.options); + const report = document.getElementById('trace-report'); + expect(report?.textContent).toContain('Browser-carried, unverified diagnostic data'); + for (const title of [ + 'What happened', + 'Ad slots', + 'Server auctions', + 'Publisher request', + 'Cookie health', + 'Coverage and ambiguity', + 'Export', + ]) + expect(report?.textContent).toContain(title); + expect(report?.textContent).toContain('Browser observed'); + expect(report?.textContent).toContain('Correlation unknown'); + expect(report?.textContent).toContain('0 × 0'); + expect(report?.textContent).toContain('Unavailable'); + expect(report?.textContent).not.toContain('setup-only.example.com'); + expect(document.body.textContent).toContain('setup-only.example.com'); + expect(report?.querySelector('style, [style], script, iframe, img')).toBeNull(); + expect(request).not.toHaveBeenCalled(); + }); + it('displays all four ambiguous cookie rows as unavailable for reliable inspection', () => { + const report = reportFixture(); + for (const name of ['ts_ec', 'ts_eids', 'ts_tester', 'diagnostics_session']) + Object.assign(report.request_context.cookies, { + [name]: { source: 'request', state: 'unavailable', detail: 'runtime_header_ambiguous' }, + }); + const fixture = setup(report); + mountTraceViewer(document, fixture.options); + const text = document.getElementById('trace-report-cookies')?.textContent ?? ''; + expect(text.match(/could not be reliably inspected/g)).toHaveLength(4); + expect(text).not.toContain('invalid UTF'); + expect(text).not.toContain('Not present'); + }); + it('leads with what happened, links slots to auctions and keeps bars CSP-safe', () => { + const report = joinedReport(); + const cycle = report.gpt_diagnostics.slots[0].requests[0]; + cycle.requestedAtMs = 4175.300000000047; + cycle.durations.requestToResponseMs = 550.2999999998137; + const fixture = setup(report); + mountTraceViewer(document, fixture.options); + const summary = document.getElementById('trace-report-summary'); + expect(summary?.querySelector('.trace-headline')?.textContent).toBe( + '1 of 1 ad slot filled · 0 Trusted Server bids' + ); + expect(summary?.textContent).toContain('The server auction completed.'); + expect(summary?.textContent).toContain('No bids were returned to Trusted Server.'); + const card = document.getElementById('trace-slot-1'); + expect(card?.querySelector('h3')?.textContent).toBe('Slot 1 · 300 × 250'); + expect(card?.querySelector('.trace-chip')?.textContent).toBe('Filled · backfill'); + expect(card?.querySelector('.trace-link')?.textContent).toBe( + 'Linked to auction 1 (Initial-page server auction (SSAT)) · server slot 1 · No candidate' + ); + const bar = card?.querySelector('.trace-timing'); + expect(bar?.getAttribute('role')).toBe('img'); + expect(bar?.getAttribute('aria-label')).toContain('response 550.3 ms'); + for (const segment of Array.from(bar?.children ?? [])) + expect(segment.className).toMatch(/trace-weight-([1-9]|1\d|20)$/); + const attention = document.querySelector('#trace-report-attention a'); + expect(attention?.getAttribute('href')).toBe('#trace-slot-1'); + const text = document.getElementById('trace-report')?.textContent ?? ''; + expect(text).toContain('4175.3 ms'); + expect(text).not.toMatch(/\d\.\d{2,} ms/); + expect(text).not.toMatch(/\bwon\b|\bwinner was\b/i); + expect(document.querySelector('#trace-report [style]')).toBeNull(); + expect(document.getElementById('trace-report-request')?.textContent).toContain( + '2026-10-05 09:15:30 UTC' + ); + const divider = Array.from(document.querySelectorAll('#trace-report *')).find( + (node) => node.textContent === 'More detail' + ); + expect(divider?.tagName).toBe('P'); + expect( + Array.from(document.querySelectorAll('#trace-report h2'), (heading) => heading.textContent) + ).not.toContain('More detail'); + const visible = Array.from(document.querySelectorAll('#trace-report dl > dd')).filter( + (value) => !value.closest('details.trace-unavailable') + ); + expect(visible.map((value) => value.textContent)).not.toContain('Unavailable'); + const folded = document.querySelector('#trace-report details.trace-unavailable'); + expect(folded?.querySelector('summary')?.textContent).toMatch(/^\d+ fields? unavailable$/); + expect(folded?.hasAttribute('open')).toBe(false); + for (const id of ['trace-report-request', 'trace-report-cookies', 'trace-report-coverage']) + expect(document.getElementById(id)?.hasAttribute('open')).toBe(false); + }); + it('renders exact joined server evidence and retains plain refresh path labels without inferring a winner', () => { + const report = joinedReport(); + report.gpt_diagnostics.slots[0].requests[0].requestPath = 'prebid_refresh'; + const fixture = setup(report); + mountTraceViewer(document, fixture.options); + const text = document.getElementById('trace-report')?.textContent; + expect(text).toContain('Initial-page server auction (SSAT)'); + expect(text).toContain( + 'Produced by Trusted Server; copied through an untrusted browser snapshot' + ); + expect(text).toContain('Auction-wide provider status; per-slot no-bid reason unavailable'); + expect(text).toContain('Unavailable in v1'); + expect(text).toContain('Browser refresh observed; winner not determined'); + expect(text).toContain('Trusted Server creative rendered'); + expect(text).toContain('must not be summed as unique bids'); + expect(text).toContain('Correlation record 1'); + expect(text).toContain('Browser observed correlation'); + expect(text).not.toContain('client-side auction won'); + }); + it('uses one immutable public model for explicit Copy, Share and Download and retains it after failures', async () => { + const fixture = setup(); + fixture.copy.mockResolvedValue({ status: 'failed' }); + fixture.share.mockResolvedValue({ status: 'unsupported' }); + mountTraceViewer(document, fixture.options); + expect(fixture.copy).not.toHaveBeenCalled(); + expect(fixture.share).not.toHaveBeenCalled(); + expect(fixture.download).not.toHaveBeenCalled(); + button('Copy').click(); + await vi.waitFor(() => + expect(document.getElementById('trace-export-status')?.textContent).toContain( + 'Copy could not' + ) + ); + button('Share').click(); + await vi.waitFor(() => + expect(document.getElementById('trace-export-status')?.textContent).toContain( + 'File sharing is unavailable' + ) + ); + button('Download').click(); + const copied = fixture.copy.mock.calls[0]; + const shared = fixture.share.mock.calls[0]; + const downloaded = fixture.download.mock.calls[0]; + expect(copied).toEqual(shared); + expect(copied).toEqual(downloaded); + expect(formatTraceReport(...copied)).toBe(JSON.stringify(reportFixture(), null, 2)); + expect(Object.isFrozen(copied[0])).toBe(true); + expect(document.getElementById('trace-report')).not.toBeNull(); + expect(document.body.textContent).toContain('selected app receives this JSON'); + }); + it('requires confirmation before any cleanup mutation', () => { + const fixture = setup(); + fixture.options.confirm.mockReturnValue(false); + mountTraceViewer(document, fixture.options); + button('Clear report and end tracing').click(); + expect(fixture.storage.removeItem).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); + expect(document.getElementById('trace-report')).not.toBeNull(); + }); + it('deletes the local report independently without confirmation or any server request', () => { + const fixture = setup(); + mountTraceViewer(document, fixture.options); + button('Delete local report').click(); + expect(fixture.options.confirm).not.toHaveBeenCalled(); + expect(fixture.storage.removeItem).toHaveBeenCalledWith(TRACE_REPORT_STORAGE_KEY); + expect(request).not.toHaveBeenCalled(); + expect(document.getElementById('trace-report')).toBeNull(); + expect(document.body.textContent).toContain('Local report deleted'); + }); + it('moves focus to the local status when deletion hides its focused control', () => { + const fixture = setup(); + mountTraceViewer(document, fixture.options); + const remove = button('Delete local report'); + remove.focus(); + remove.click(); + expect(remove.hidden).toBe(true); + expect(document.activeElement).toBe(document.getElementById('trace-cleanup-local-status')); + }); + it('keeps another cleanup control focused during local deletion', () => { + const fixture = setup(); + mountTraceViewer(document, fixture.options); + const clear = button('Clear report and end tracing'); + clear.focus(); + button('Delete local report').click(); + expect(document.activeElement).toBe(clear); + }); + for (const local of ['deleted', 'failed'] as const) + for (const mutation of ['requested', 'failed'] as const) + for (const observation of ['inactive', 'active', 'failed'] as const) { + it(`separates local ${local}, end ${mutation}, observed ${observation}`, async () => { + const fixture = setup(); + if (local === 'failed') + fixture.storage.removeItem.mockImplementation(() => { + throw new Error('private-deletion-error'); + }); + request.mockResolvedValueOnce( + new Response('{}', { status: mutation === 'requested' ? 200 : 500 }) + ); + if (observation === 'failed') + request.mockRejectedValueOnce(new Error('private-state-error')); + else + request.mockResolvedValueOnce( + new Response(JSON.stringify({ observed_active: observation === 'active' })) + ); + mountTraceViewer(document, fixture.options); + button('Clear report and end tracing').click(); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => + expect(document.getElementById('trace-cleanup-server-status')?.textContent).toContain( + mutation === 'requested' && observation === 'inactive' + ? 'Tracing is off' + : 'unconfirmed' + ) + ); + expect(document.getElementById('trace-report') === null).toBe(local === 'deleted'); + expect(document.getElementById('trace-cleanup-local-status')?.textContent).toContain( + local === 'deleted' ? 'Local report deleted' : 'deletion failed' + ); + expect(request.mock.calls.map(([path]) => path)).toEqual([ + '/_ts/trace/end', + '/_ts/trace/state', + ]); + expect(document.body.textContent).not.toContain('private-'); + expect(document.body.textContent).not.toContain('cookie absent'); + if (local === 'failed') expect(button('Delete local report').disabled).toBe(false); + if (!(mutation === 'requested' && observation === 'inactive')) + expect(button('Retry end tracing').hidden).toBe(false); + }); + } + it('ignores removed controls and late async results after viewer destruction', async () => { + const fixture = setup(); + let resolveCopy: ((result: Awaited>) => void) | undefined; + fixture.copy.mockImplementation( + () => + new Promise((resolve) => { + resolveCopy = resolve; + }) + ); + const viewer = mountTraceViewer(document, fixture.options); + const copy = button('Copy'); + const enable = button('Enable tracing'); + copy.click(); + viewer.destroy(); + copy.click(); + enable.click(); + resolveCopy?.({ status: 'copied' }); + await Promise.resolve(); + expect(fixture.copy).toHaveBeenCalledTimes(1); + expect(request).not.toHaveBeenCalled(); + expect(document.getElementById('trace-export-status')?.textContent).not.toBe('Copied JSON.'); + }); + it('allows separate local deletion and server retry after both earlier cleanup steps fail', async () => { + const fixture = setup(); + fixture.storage.removeItem.mockImplementationOnce(() => { + throw new Error('private-storage-error'); + }); + request.mockResolvedValueOnce(new Response('{}', { status: 500 })); + request.mockResolvedValueOnce(new Response('{"observed_active":true}')); + mountTraceViewer(document, fixture.options); + button('Clear report and end tracing').click(); + await vi.waitFor(() => expect(button('Retry end tracing').hidden).toBe(false)); + button('Delete local report').click(); + expect(document.getElementById('trace-report')).toBeNull(); + request.mockResolvedValueOnce(new Response('{}')); + request.mockResolvedValueOnce(new Response('{"observed_active":false}')); + const retry = button('Retry end tracing'); + retry.focus(); + retry.click(); + await vi.waitFor(() => + expect(document.getElementById('trace-cleanup-server-status')?.textContent).toContain( + 'Tracing is off' + ) + ); + expect(fixture.options.confirm).toHaveBeenCalledTimes(1); + expect(fixture.storage.removeItem).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenCalledTimes(4); + expect(document.activeElement).toBe(document.getElementById('trace-cleanup-server-status')); + expect(document.getElementById('trace-cleanup-local-status')?.textContent).toContain( + 'Local report deleted' + ); + }); + it('does not move focus back from another control when a pending retry succeeds', async () => { + const fixture = setup(); + request.mockResolvedValueOnce(new Response('{}', { status: 500 })); + request.mockResolvedValueOnce(new Response('{"observed_active":true}')); + mountTraceViewer(document, fixture.options); + button('Clear report and end tracing').click(); + await vi.waitFor(() => expect(button('Retry end tracing').hidden).toBe(false)); + request.mockResolvedValueOnce(new Response('{}')); + let resolveState: ((response: Response) => void) | undefined; + request.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveState = resolve; + }) + ); + const retry = button('Retry end tracing'); + retry.focus(); + retry.click(); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(4)); + const retained = button('Return to previous page'); + retained.focus(); + resolveState?.(new Response('{"observed_active":false}')); + await vi.waitFor(() => expect(retry.hidden).toBe(true)); + expect(document.activeElement).toBe(retained); + }); + it('still deletes the report and attempts state verification when both network steps are offline', async () => { + const fixture = setup(); + request.mockRejectedValueOnce(new Error('private-post-error')); + request.mockRejectedValueOnce(new Error('private-get-error')); + mountTraceViewer(document, fixture.options); + button('Clear report and end tracing').click(); + await vi.waitFor(() => + expect(document.getElementById('trace-cleanup-server-status')?.textContent).toContain( + 'may remain active' + ) + ); + expect(request).toHaveBeenCalledTimes(2); + expect(document.getElementById('trace-report')).toBeNull(); + expect(document.body.textContent).not.toContain('private-'); + }); + it('keeps local deletion available while the independent end request is pending', async () => { + const fixture = setup(); + fixture.storage.removeItem.mockImplementationOnce(() => { + throw new Error('private-storage-error'); + }); + let resolvePost: ((value: Response) => void) | undefined; + request.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePost = resolve; + }) + ); + request.mockResolvedValueOnce(new Response('{"observed_active":false}')); + mountTraceViewer(document, fixture.options); + button('Clear report and end tracing').click(); + expect(button('Delete local report').disabled).toBe(false); + button('Delete local report').click(); + expect(document.getElementById('trace-report')).toBeNull(); + resolvePost?.(new Response('{}')); + await vi.waitFor(() => + expect(document.getElementById('trace-cleanup-server-status')?.textContent).toContain( + 'Tracing is off' + ) + ); + expect(request).toHaveBeenCalledTimes(2); + }); + it('renders an allowed hostile-looking network value as text without executable content or inline style', () => { + const report = reportFixture(); + Object.assign(report.request_context.network, { tls_cipher: '' }); + const fixture = setup(report); + mountTraceViewer(document, fixture.options); + expect(document.getElementById('trace-report')?.textContent).toContain( + '' + ); + expect(document.querySelector('img, iframe, script, [style]')).toBeNull(); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/trace-asset-sources.mjs b/crates/trusted-server-js/lib/trace-asset-sources.mjs new file mode 100644 index 000000000..c9d399894 --- /dev/null +++ b/crates/trusted-server-js/lib/trace-asset-sources.mjs @@ -0,0 +1,27 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** Hashes the explicit trace build inputs to reject stale assets when building is skipped. */ +export function traceSourceDigest(library) { + const sources = ['build-all.mjs', 'trace-asset-sources.mjs', 'package-lock.json']; + function discover(directory) { + for (const entry of fs.readdirSync(path.join(library, directory), { withFileTypes: true })) { + const relative = `${directory}/${entry.name}`; + if (entry.isDirectory()) discover(relative); + else if (entry.isFile() && /\.(?:ts|css)$/.test(entry.name)) sources.push(relative); + } + } + discover('src/trace'); + const hash = createHash('sha256'); + for (const relative of sources.sort((left, right) => + Buffer.compare(Buffer.from(left), Buffer.from(right)) + )) { + hash + .update(relative) + .update('\0') + .update(fs.readFileSync(path.join(library, relative))) + .update('\0'); + } + return hash.digest('hex'); +} diff --git a/crates/trusted-server-js/lib/trace-assets-manifest.json b/crates/trusted-server-js/lib/trace-assets-manifest.json new file mode 100644 index 000000000..6ace3cb54 --- /dev/null +++ b/crates/trusted-server-js/lib/trace-assets-manifest.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "source_sha256": "4c08d409c7d35eec2ba33a0cd340463b4573c646a35087020214c0606962fcf9", + "assets": [ + { + "path": "/_ts/trace/assets/v1.js", + "file": "v1.js", + "sha256": "485cc5b912aea256e432876117e9e3ebb9e5d912a607db033cceff7aa402afa6" + }, + { + "path": "/_ts/trace/assets/v1.css", + "file": "v1.css", + "sha256": "d3289f4dab38cf1321a07aa6c8e7989eb1c96a6885656a68d7faab8b94a1006b" + } + ] +} diff --git a/crates/trusted-server-js/lib/trace-assets/v1.css b/crates/trusted-server-js/lib/trace-assets/v1.css new file mode 100644 index 000000000..ac52f225e --- /dev/null +++ b/crates/trusted-server-js/lib/trace-assets/v1.css @@ -0,0 +1 @@ +:root{color-scheme:light dark;--bg: #ffffff;--fg: #1f2328;--muted: #59636e;--line: #d1d9e0;--soft: #f6f8fa;--accent: #0969da;--accent-fg: #ffffff;--ok-bg: #dafbe1;--ok-fg: #116329;--bad-bg: #ffebe9;--bad-fg: #a40e26;--unknown-bg: #eff2f5;--unknown-fg: #424a53;--attention-bg: #fff8c5;--attention-line: #d4a72c;--clear-bg: #dafbe1;--clear-line: #4ac26b;--segment-response: #0969da;--segment-render: #8250df;--segment-after: #1a7f37;--focus: #ca6800;font:1rem/1.5 system-ui,-apple-system,Segoe UI,sans-serif;background:var(--bg);color:var(--fg)}@media(prefers-color-scheme:dark){:root{--bg: #0d1117;--fg: #e6edf3;--muted: #9198a1;--line: #3d444d;--soft: #151b23;--accent: #4493f8;--accent-fg: #0d1117;--ok-bg: #12261e;--ok-fg: #57ab5a;--bad-bg: #2d1214;--bad-fg: #f47067;--unknown-bg: #212830;--unknown-fg: #9198a1;--attention-bg: #2e2610;--attention-line: #9e6a03;--clear-bg: #12261e;--clear-line: #2b6a30;--segment-response: #4493f8;--segment-render: #ab7df8;--segment-after: #3fb950;--focus: #f0883e}}*{box-sizing:border-box}body{margin:0;padding:max(1rem,env(safe-area-inset-top)) max(1rem,env(safe-area-inset-right)) max(1rem,env(safe-area-inset-bottom)) max(1rem,env(safe-area-inset-left));overflow-wrap:anywhere;background:var(--bg)}main{max-width:48rem;margin:auto}h1{font-size:1.6rem;line-height:1.25;margin:.25rem 0}h2{font-size:1.15rem;margin:0 0 .5rem}h3{font-size:1.05rem;margin:0}section{margin-block:1.5rem}dl{margin:.25rem 0}dt{font-weight:650;margin-top:.75rem;color:var(--muted)}dd{margin-inline:0}a{color:var(--accent)}.controls{display:flex;flex-wrap:wrap;gap:.75rem}button,.button{min-width:44px;min-height:44px;padding:.75rem 1rem;font:inherit;font-weight:600;color:inherit;background:var(--bg);border:2px solid currentColor;border-radius:.6rem;cursor:pointer;white-space:normal}.primary{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}button:disabled{cursor:wait;opacity:.65}[role=status]{min-height:3rem}pre{white-space:pre-wrap;overflow-wrap:anywhere}table{width:100%;table-layout:fixed;border-collapse:collapse}th,td{padding:.5rem;text-align:start;vertical-align:top;overflow-wrap:anywhere}[hidden]{display:none}summary{min-height:44px;padding-block:.6rem;cursor:pointer}.trace-report-header{border-bottom:1px solid var(--line);padding-bottom:.75rem}.trace-notice{display:inline-block;margin:0;padding:.15rem .6rem;font-size:.875rem;font-weight:600;color:var(--muted);background:var(--soft);border:1px solid var(--line);border-radius:999px}.trace-meta{margin:0;color:var(--muted);font-size:.9rem}.trace-verdict{border:1px solid var(--attention-line);background:var(--attention-bg);border-radius:1rem;padding:1rem}.trace-verdict.is-clear{border-color:var(--clear-line);background:var(--clear-bg)}.trace-verdict p{margin:.25rem 0}.trace-verdict .trace-headline{font-size:1.2rem;font-weight:700;line-height:1.3;margin-bottom:.5rem}.trace-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.5rem;margin:.75rem 0 0;padding:0;list-style:none}.trace-stats li{background:var(--bg);border:1px solid var(--line);border-radius:.75rem;padding:.5rem .6rem}.trace-stat-value{display:block;font-size:1.4rem;font-weight:700;line-height:1.2}.trace-stat-label{display:block;font-size:.875rem;color:var(--muted)}.trace-chips{display:flex;flex-wrap:wrap;gap:.4rem}.trace-chip{display:inline-block;border-radius:999px;padding:.15rem .65rem;font-size:.875rem;font-weight:600;white-space:nowrap}.trace-chip-filled{background:var(--ok-bg);color:var(--ok-fg)}.trace-chip-empty{background:var(--bad-bg);color:var(--bad-fg)}.trace-chip-unknown{background:var(--unknown-bg);color:var(--unknown-fg)}.trace-attention{margin:0;padding-left:1.25rem}.trace-attention li{margin-block:.35rem}.trace-slot{border:1px solid var(--line);border-radius:1rem;padding:.75rem 1rem;margin-block:.75rem;background:var(--bg);scroll-margin-top:1rem}.trace-slot:target{outline:3px solid var(--focus)}.trace-slot>header{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.5rem}.trace-slot p{margin:.35rem 0}.trace-timing{display:flex;height:.6rem;margin-top:.75rem;border-radius:999px;overflow:hidden;background:var(--unknown-bg)}.trace-segment{min-width:3px}.trace-segment-response{background:var(--segment-response)}.trace-segment-render{background:var(--segment-render)}.trace-segment-load,.trace-segment-viewable{background:var(--segment-after)}.trace-legend{display:flex;flex-wrap:wrap;gap:.25rem 1rem;margin:.35rem 0 0;padding:0;list-style:none;font-size:.875rem;color:var(--muted)}.trace-legend li:before{content:"";display:inline-block;width:.6rem;height:.6rem;margin-right:.35rem;border-radius:2px;vertical-align:baseline}.trace-legend-response:before{background:var(--segment-response)}.trace-legend-render:before{background:var(--segment-render)}.trace-legend-load:before,.trace-legend-viewable:before{background:var(--segment-after)}.trace-link{font-size:.9rem;color:var(--accent)}.trace-link.is-unlinked{color:var(--muted)}.trace-slot-details>summary,.trace-request>summary,.trace-auction>summary{color:var(--accent);font-weight:600}.trace-request,.trace-auction,.trace-server-slot{border-top:1px solid var(--line);margin-top:.5rem}.trace-server-slot{padding-top:.5rem}.trace-unavailable>summary{color:var(--muted);font-weight:400}.trace-more{margin-top:2rem;font-size:.875rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}.trace-section{border:1px solid var(--line);border-radius:.75rem;padding:0 1rem;margin-block:.5rem}.trace-section>summary h2{display:inline;font-size:1.05rem;margin:0}.trace-section[open]{padding-bottom:.75rem}.trace-weight-1{flex:1}.trace-weight-2{flex:2}.trace-weight-3{flex:3}.trace-weight-4{flex:4}.trace-weight-5{flex:5}.trace-weight-6{flex:6}.trace-weight-7{flex:7}.trace-weight-8{flex:8}.trace-weight-9{flex:9}.trace-weight-10{flex:10}.trace-weight-11{flex:11}.trace-weight-12{flex:12}.trace-weight-13{flex:13}.trace-weight-14{flex:14}.trace-weight-15{flex:15}.trace-weight-16{flex:16}.trace-weight-17{flex:17}.trace-weight-18{flex:18}.trace-weight-19{flex:19}.trace-weight-20{flex:20}#trace-viewer-setup{border-top:1px solid var(--line);margin-top:1.5rem} diff --git a/crates/trusted-server-js/lib/trace-assets/v1.js b/crates/trusted-server-js/lib/trace-assets/v1.js new file mode 100644 index 000000000..7426caefa --- /dev/null +++ b/crates/trusted-server-js/lib/trace-assets/v1.js @@ -0,0 +1 @@ +(function(){"use strict";function E(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function b(e){if(e===null||typeof e!="object"||Array.isArray(e))return!1;const t=Object.getPrototypeOf(e);return t!==Object.prototype&&t!==null?!1:Reflect.ownKeys(e).every(r=>{if(typeof r!="string")return!1;const n=Object.getOwnPropertyDescriptor(e,r);return n?.enumerable===!0&&E(n,"value")})}function g(e,t,r=[]){return t.every(n=>E(e,n))&&Object.keys(e).every(n=>t.includes(n)||r.includes(n))}function F(e,t=128){if(typeof e!="string")return!1;for(const r of e){const n=r.codePointAt(0);if(n<=31||n>=127&&n<=159||n>=55296&&n<=57343||n===1564||n===8206||n===8207||n>=8234&&n<=8238||n>=8294&&n<=8297)return!1}return new TextEncoder().encode(e).length<=t}function W(e){if(typeof e!="string")return!1;const t=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?Z$/.exec(e);if(!t)return!1;const[,r,n,s,i,o,c]=t,a=Number(r),p=Number(n),l=Number(s),m=[31,a%4===0&&(a%100!==0||a%400===0)?29:28,31,30,31,30,31,31,30,31,30,31];return p>=1&&p<=12&&l>=1&&l<=m[p-1]&&Number(i)<=23&&Number(o)<=59&&Number(c)<=59}function S(e,t=Number.MAX_SAFE_INTEGER){return typeof e=="number"&&Number.isSafeInteger(e)&&e>=0&&e<=t}function Oe(e){if(!F(e))return!1;const t=/^((?:0|[1-9]\d{0,2}))\.((?:0|[1-9]\d{0,2}))\.((?:0|[1-9]\d{0,2}))\.0\/24$/.exec(e);if(t)return t.slice(1).every(s=>Number(s)<=255);if(!e.endsWith("::/48"))return!1;const r=e.slice(0,-3),n=r.slice(0,-2);if(n!==""&&!/^[\da-f]{1,4}(?::[\da-f]{1,4}){0,2}$/.test(n))return!1;try{return new URL(`http://[${r}]/`).hostname===`[${r}]`}catch{return!1}}function K(e,t){if(!b(e)||e.source!=="request")return!1;if(e.state==="absent")return g(e,["source","state"]);if(!g(e,["source","state","detail"])||typeof e.detail!="string")return!1;switch(e.state){case"present_valid":return e.detail===t;case"present_invalid":return["malformed","oversized","unsupported_value"].includes(e.detail);case"duplicate":return e.detail==="multiple_values";case"unavailable":return["header_too_large","header_not_utf8","runtime_header_ambiguous"].includes(e.detail);default:return!1}}function oe(e){try{return xe(e)}catch{return!1}}function xe(e){if(!b(e)||!g(e,["schema_version","captured_at","network","cookies"])||e.schema_version!==1||!W(e.captured_at)||!b(e.network)||!b(e.cookies))return!1;const t=e.network,r={country:2,region:32,http_version:32,tls_protocol:32,tls_cipher:32,edge_hostname:128,edge_region:128,edge_pop:32};if(!g(t,[],["masked_client_ip","asn",...Object.keys(r)])||E(t,"masked_client_ip")&&!Oe(t.masked_client_ip)||E(t,"asn")&&!S(t.asn,4294967295))return!1;for(const[s,i]of Object.entries(r))if(E(t,s)&&!F(t[s],i))return!1;if(typeof t.country=="string"&&!/^[\x20-\x7e]{0,2}$/.test(t.country))return!1;const n=e.cookies;return g(n,["ts_ec","ts_eids","ts_tester","diagnostics_session"])&&K(n.ts_ec,"valid_ec_format")&&K(n.ts_eids,"valid_eids_format")&&K(n.ts_tester,"valid_tester_value")&&K(n.diagnostics_session,"valid_diagnostics_value")}function $(e,t){if(!Array.isArray(e)||Object.getPrototypeOf(e)!==Array.prototype)return;const r=Object.getOwnPropertyDescriptor(e,"length")?.value;if(!S(r,t))return;const n=Reflect.ownKeys(e);if(n.length!==r+1||!n.every(i=>{if(i==="length")return!0;if(typeof i!="string"||!/^(?:0|[1-9]\d*)$/.test(i)||Number(i)>=r)return!1;const o=Object.getOwnPropertyDescriptor(e,i);return o?.enumerable===!0&&E(o,"value")}))return;const s=[];for(let i=0;i(s+=n.encode(a).length,s<=r),c=(a,p)=>{if(a===null||typeof a=="boolean"||typeof a=="number")return(typeof a!="number"||Number.isFinite(a))&&o(JSON.stringify(a))?{value:a}:void 0;if(typeof a=="string")return F(a,r)&&o(JSON.stringify(a))?{value:a}:void 0;if(p>t||typeof a!="object"||i.has(a))return;i.add(a);let l=!0,f;if(Array.isArray(a)){const m=[];f=m;const v=$(a,r);if(!v||!o("["))l=!1;else for(let y=0;yS(n,1e5)&&(t||n>0))}function ue(e,t,r){return!E(e,t)||S(e[t],r)}function We(e){return b(e)&&g(e,["provider_number","role","status","returned_bid_count"],["response_time_ms"])&&S(e.provider_number,65535)&&e.provider_number>0&&w(e.role,Ge)&&w(e.status,Ke)&&S(e.returned_bid_count,65535)&&ue(e,"response_time_ms",4294967295)}function Xe(e){return b(e)&&g(e,["slot_number","slot_ref","requested_sizes","returned_bid_count","candidate"],["selected_creative_size"])&&S(e.slot_number,65535)&&e.slot_number>0&&le(e.slot_ref)&&S(e.returned_bid_count,65535)&&w(e.candidate,Ye)&&Z(e.requested_sizes,16,t=>V(t))&&(!E(e,"selected_creative_size")||V(e.selected_creative_size))}function Ze(e){return b(e)&&g(e,["schema_version","diagnostic_auction_id","source","terminal_status","provider_calls","slots","truncation","coverage"],["terminal_reason","total_time_ms"])&&e.schema_version===1&&X(e.diagnostic_auction_id)&&w(e.source,De)&&w(e.terminal_status,Fe)&&(!E(e,"terminal_reason")||w(e.terminal_reason,Ve))&&ue(e,"total_time_ms",4294967295)&&Z(e.provider_calls,16,We)&&Z(e.slots,64,Xe)&&b(e.truncation)&&g(e.truncation,["omitted_provider_calls","omitted_slots","omitted_nested_values"])&&Object.values(e.truncation).every(t=>S(t,65535))&&b(e.coverage)&&g(e.coverage,["provider_to_slot_no_bid"])&&e.coverage.provider_to_slot_no_bid==="unavailable"}function Z(e,t,r){const n=$(e,t);return n!==void 0&&n.every(r)}function Qe(e){try{const t=M(e);return t!==void 0&&Ze(t.value)}catch{return!1}}function et(e){try{return b(e)&&g(e,["schema_version","diagnostic_auction_id","slot_ref","runtime_slot_number","request_number"])&&e.schema_version===1&&X(e.diagnostic_auction_id)&&le(e.slot_ref)&&S(e.runtime_slot_number)&&e.runtime_slot_number>0&&S(e.request_number)&&e.request_number>0}catch{return!1}}function tt(e){const t=M(e);return t!==void 0&&et(t.value)}const nt=["requestNumber","requestedAtMs","responseAtMs","renderAtMs","loadAtMs","viewableAtMs","durations","isEmpty","requestedSlotSizes","size","observedSlotSize","isBackfill","slotContentChanged","incompleteSequence","responseClass","requestPath","requestIntentId","trustedServerAuctionId","opportunityToRequestMs","replacedRequestNumber","previousRenderToRequestMs","creativeChanged","loadObservedBeforeRender","trustedServerOpportunity","trustedServerCreativeRequestAtMs","trustedServerCreativeResponseAtMs","trustedServerCreativeFailures","delivery"],rt=["requestToResponseMs","responseToRenderMs","requestToRenderMs","renderToLoadMs","renderToViewableMs"],st=["requestedAtMs","responseAtMs","renderAtMs","loadAtMs","viewableAtMs","opportunityToRequestMs","previousRenderToRequestMs","trustedServerCreativeRequestAtMs","trustedServerCreativeResponseAtMs"],it=["isEmpty","isBackfill","slotContentChanged","creativeChanged","loadObservedBeforeRender"],ot=["omitted_server_auctions","omitted_slot_correlations","omitted_request_cycles","omitted_callback_issues","omitted_attribution_issues","omitted_nested_values"];function pe(e,t,r){const n=M(e);if(!(!n||ee(t)!==t||!L(r)||!me(n.value,t,r)))return Q(n.value),n.value}function at(e,t,r){const n=M(e,11);if(!(!n||!_t(n.value,t,r)))return Q(n.value),n.value}function Q(e){typeof e!="object"||e===null||(Object.values(e).forEach(Q),Object.freeze(e))}function ct(e,t,r){try{const n=M(e);if(!n)return"invalid_report";if(e=n.value,_e(e,t,r))return;if(!b(e))return"invalid_report";if(E(e,"schema_version")&&e.schema_version!==1)return"unsupported_report_version";const s=e.gpt_diagnostics;if(b(s)){if(E(s,"schema_version")&&s.schema_version!==1)return"unsupported_gpt_version";if(E(s,"source_schema_version")&&s.source_schema_version!==1)return"unsupported_gpt_source_version"}return $(e.server_auctions,16)?.some(c=>b(c)&&E(c,"schema_version")&&c.schema_version!==1)?"unsupported_auction_version":$(e.slot_correlations,128)?.some(c=>b(c)&&E(c,"schema_version")&&c.schema_version!==1)?"unsupported_correlation_version":"invalid_report"}catch{return"invalid_report"}}function L(e,t=Number.MAX_SAFE_INTEGER){return typeof e=="number"&&Number.isFinite(e)&&e>=0&&e<=t}function ee(e){if(!F(e,255)||!/^https?:\/\//i.test(e))return;const t=e.slice(e.indexOf("://")+3);if(!t||/[\s/@?#,\\]/.test(t))return;const r=t.startsWith("[")?t.slice(t.indexOf("]")+1):t.includes(":")?t.slice(t.lastIndexOf(":")):"";if(!(r!==""&&(!/^:\d+$/.test(r)||Number(r.slice(1))>65535)))try{const n=new URL(e);return!["http:","https:"].includes(n.protocol)||!n.hostname||n.username||n.password||n.search||n.hash||n.pathname!=="/"?void 0:n.origin}catch{return}}function I(e,t,r){const n=$(e,t);return n!==void 0&&n.every(r)}function C(e,t,r){return!E(e,t)||r(e[t])}function dt(e){return!b(e)||!g(e,["requestNumber","durations","incompleteSequence"],nt)||!S(e.requestNumber)||typeof e.incompleteSequence!="boolean"||!b(e.durations)||!g(e.durations,[],rt)||!Object.values(e.durations).every(t=>L(t))||!st.every(t=>C(e,t,L))||!it.every(t=>C(e,t,r=>typeof r=="boolean"))?!1:["requestIntentId","replacedRequestNumber"].every(t=>C(e,t,S))&&C(e,"trustedServerAuctionId",X)&&C(e,"requestedSlotSizes",t=>I(t,16,r=>V(r)))&&C(e,"size",t=>V(t))&&C(e,"observedSlotSize",t=>V(t,!0))&&C(e,"responseClass",t=>w(t,je))&&C(e,"requestPath",t=>w(t,Pe))&&C(e,"trustedServerOpportunity",t=>w(t,Ue))&&C(e,"delivery",t=>w(t,ze))&&C(e,"trustedServerCreativeFailures",t=>I(t,16,r=>w(r,Be)))}function lt(e){return!b(e)||!g(e,["runtimeSlotNumber","binding","requests"],["currentVisibilityPercentage","maximumVisibilityPercentage"])||!S(e.runtimeSlotNumber)||!b(e.binding)||!g(e.binding,["status"],["reason"])||!w(e.binding.status,["bound","unbound","ambiguous"])||!C(e.binding,"reason",t=>w(t,Me))?!1:C(e,"currentVisibilityPercentage",t=>L(t,100))&&C(e,"maximumVisibilityPercentage",t=>L(t,100))&&I(e.requests,10,dt)}function ut(e){return b(e)&&g(e,["kind","runtimeSlotNumber","timestampMs","disposition","reason"])&&w(e.kind,ce)&&S(e.runtimeSlotNumber)&&L(e.timestampMs)&&w(e.disposition,["matched","unmatched","ambiguous"])&&w(e.reason,Le)}function pt(e){return b(e)&&g(e,["reason","timestampMs"],["runtimeSlotNumber"])&&w(e.reason,Ie)&&L(e.timestampMs)&&C(e,"runtimeSlotNumber",S)}function ft(e){return b(e)&&g(e,["observed","matched","unmatched","ambiguous"])&&Object.values(e).every(t=>S(t))}function mt(e,t){return!b(e)||!g(e,["schema_version","source_schema_version","capturedAt","page","slots","callbackIssues","coverage","metadata"],["attributionIssues"])||e.schema_version!==1||e.source_schema_version!==1||!W(e.capturedAt)||!b(e.page)||!g(e.page,["origin","pathname"])||ee(e.page.origin)!==t||e.page.pathname!=="/[redacted]"||!I(e.slots,64,lt)||!I(e.callbackIssues,128,ut)||!C(e,"attributionIssues",r=>I(r,128,pt))||!b(e.coverage)||!g(e.coverage,ce)||!Object.values(e.coverage).every(ft)?!1:b(e.metadata)&&g(e.metadata,["droppedCallbacks","evictedSlots","evictedRequestCycles"],["droppedAttributionIssues"])&&Object.values(e.metadata).every(r=>S(r))}function fe(e,t){if(!W(e))return!1;const r=Date.parse(e);return Number.isFinite(r)&&Math.abs(r-t)<=6e4}function me(e,t,r){if(!b(e)||!g(e,["schema_version","captured_at","request_context","server_auctions","slot_correlations","gpt_diagnostics","auction_coverage","truncation"])||e.schema_version!==1||!fe(e.captured_at,r)||!oe(e.request_context)||!I(e.server_auctions,16,Qe)||!I(e.slot_correlations,128,tt)||!mt(e.gpt_diagnostics,t)||!fe(e.gpt_diagnostics.capturedAt,r)||!b(e.truncation)||!g(e.truncation,ot)||!Object.values(e.truncation).every(l=>S(l,65535)))return!1;const n=e.auction_coverage;if(!b(n)||!g(n,["capture_status","issues"]))return!1;const s=$(n.issues,16);if(!s||!s.every(l=>w(l,de)))return!1;let i=-1;for(const l of s){const f=de.indexOf(l);if(f<=i)return!1;i=f}const o=$(e.server_auctions,16);if(!o)return!1;const c=$(e.slot_correlations,128);if(!c)return!1;for(const l of c)if(!b(l)||o.some(f=>b(f)&&f.source==="auction_api"&&f.diagnostic_auction_id===l.diagnostic_auction_id))return!1;const a=s.some(l=>["evidence_projection_failed","evidence_transport_failed","evidence_validation_failed","record_evicted"].includes(l)),p=o.length>0?a?"partial":"complete":a?"unavailable":"not_observed";return n.capture_status===p&&$e(e,10)!==void 0}function _e(e,t,r){try{const n=M(e);return n!==void 0&&ee(t)===t&&L(r)&&me(n.value,t,r)}catch{return!1}}function _t(e,t,r){try{const n=M(e,11);if(!n)return!1;const s=n.value;return b(s)&&g(s,["stored_at_ms","report"])&&L(r)&&S(s.stored_at_ms)&&s.stored_at_ms-r<=6e4&&r-s.stored_at_ms<=9e5&&_e(s.report,t,s.stored_at_ms)}catch{return!1}}function bt(e){switch(e.requestPath){case"prebid_refresh":case"publisher_refresh":return"Browser refresh observed; winner not determined";case"competing":case"unattributed":return"Multiple or unknown delivery paths";case"trusted_server_direct":return"Trusted Server request path observed";default:return"Request path unavailable"}}const vt={initial_navigation_ssat:"Initial-page server auction (SSAT)",spa_page_bids:"Trusted Server page-refresh auction",auction_api:"Trusted Server auction API"};function gt(e,t,r){const n=pe(e,t,r);if(!n)return;const s=n.gpt_diagnostics.slots.flatMap(c=>c.requests.map(a=>({runtimeSlotNumber:c.runtimeSlotNumber,cycle:a}))),i=n.server_auctions.flatMap(c=>c.slots.map(a=>({id:c.diagnostic_auction_id,slot:a}))),o=n.server_auctions.map(c=>{const a=c.diagnostic_auction_id,p=n.server_auctions.filter(f=>f.diagnostic_auction_id===a).length===1,l=c.slots.map(f=>{const m=Object.freeze({serverSlot:f,correlation:"unknown"});if(!p||c.source==="auction_api"||i.filter(k=>k.id===a&&k.slot.slot_ref===f.slot_ref).length!==1)return m;const v=n.slot_correlations.filter(k=>k.diagnostic_auction_id===a&&k.slot_ref===f.slot_ref);if(v.length!==1)return m;const y=v[0];if(n.slot_correlations.filter(k=>k.runtime_slot_number===y.runtime_slot_number&&k.request_number===y.request_number).length!==1)return m;const u=s.filter(k=>k.runtimeSlotNumber===y.runtime_slot_number&&k.cycle.requestNumber===y.request_number);if(u.length!==1||u[0].cycle.trustedServerAuctionId!==a)return m;const _=u[0].cycle,A=_.isEmpty===!1&&_.renderAtMs!==void 0&&_.trustedServerCreativeResponseAtMs!==void 0&&_.delivery==="trusted_server_response_sent";return Object.freeze({serverSlot:f,correlation:"matched",runtimeSlotNumber:y.runtime_slot_number,requestNumber:y.request_number,cycle:_,pathLabel:bt(_),creativeLabel:A?"Trusted Server creative rendered":"Participation unconfirmed"})});return Object.freeze({evidence:c,sourceLabel:vt[c.source],relativeMilestonesLabel:"Unavailable in v1",providerScopeLabel:"Auction-wide provider status; per-slot no-bid reason unavailable",slots:Object.freeze(l)})});return Object.freeze({auctions:Object.freeze(o)})}const be="trusted-server-trace-v1.json";function te(e,t,r){const n=pe(e,t,r);return n?JSON.stringify(n,null,2):void 0}function ht(e,t,r){const n=te(e,t,r);if(n===void 0)return{status:"invalid_report"};let s;try{s=URL.createObjectURL(new Blob([n],{type:"application/json"}))}catch{return{status:"failed"}}let i;try{return i=document.createElement("a"),i.href=s,i.download=be,document.body.append(i),i.click(),{status:"downloaded"}}catch{return{status:"failed"}}finally{try{i?.remove()}catch{}window.setTimeout(()=>URL.revokeObjectURL(s),1e3)}}async function yt(e,t,r){const n=te(e,t,r);if(n===void 0)return{status:"invalid_report"};try{return typeof navigator.clipboard?.writeText!="function"?{status:"unsupported"}:(await navigator.clipboard.writeText(n),{status:"copied"})}catch{return{status:"failed"}}}async function St(e,t,r){const n=te(e,t,r);if(n===void 0)return{status:"invalid_report"};try{if(typeof navigator.canShare!="function"||typeof navigator.share!="function")return{status:"unsupported"};const i={files:[new File([n],be,{type:"application/json"})]};return navigator.canShare(i)?(await navigator.share(i),{status:"shared"}):{status:"unsupported"}}catch{return{status:"failed"}}}async function Tt(e){return ve(e,!1)}async function wt(){return ve("end",!0)}async function ve(e,t){const r={mutation:"failed",observation:"not_attempted",confirmed:!1};if(e!=="enable"&&e!=="end")return r;let n="failed";try{(await fetch(`/_ts/trace/${e}`,{method:"POST",credentials:"same-origin",cache:"no-store",headers:{"X-TS-Trace-Action":e}})).ok&&(n="requested")}catch{}if(n==="failed"&&!t)return r;const s={mutation:n,observation:"failed",confirmed:!1};try{const i=await fetch("/_ts/trace/state",{method:"GET",credentials:"same-origin",cache:"no-store"});if(!i.ok)return s;const o=await i.json();if(o===null||typeof o!="object"||Array.isArray(o))return s;const c=Object.keys(o);if(c.length!==1||c[0]!=="observed_active")return s;const a=Object.getOwnPropertyDescriptor(o,"observed_active");if(!a||typeof a.value!="boolean")return s;const p=a.value;return{mutation:n,observation:p?"active":"inactive",confirmed:n==="requested"&&p===(e==="enable")}}catch{return s}}const Rt="Return to the affected page, reload once, reproduce the problem, then select View trace results.",kt="Reopen the affected article on the exact same hostname and in this same tab, then reload once.",ge="Tracing is on — cookie observed by server",ne="Tracing is off — no valid diagnostics session observed";function Y(e){switch(e.state){case"absent":return"Not present in this request";case"present_valid":return"Valid shape observed";case"present_invalid":return"Invalid shape observed";case"duplicate":return"Multiple values observed";case"unavailable":return e.detail==="runtime_header_ambiguous"?"Unavailable — runtime-visible cookies could not be reliably inspected":"Unavailable — runtime-visible cookie inspection failed"}}function q(e,t,r,n){const s=e.createElement("dt");s.textContent=r;const i=e.createElement("dd");i.textContent=n,t.append(s,i)}function Nt(e){const t=e.getElementById("trace-request-context"),r=e.getElementById("trace-network-facts"),n=e.getElementById("trace-cookie-facts");if(!t||!r||!n)return;let s;try{s=JSON.parse(t.textContent??"")}catch{s=void 0}if(t.textContent="",r.replaceChildren(),n.replaceChildren(),!oe(s)){q(e,r,"Request facts","Unavailable"),q(e,n,"Cookie health","Unavailable");return}const i=s.network;q(e,r,"Approximate network identifier",i.masked_client_ip??"Unavailable"),q(e,r,"Country",i.country??"Unavailable"),q(e,r,"Region",i.region??"Unavailable"),q(e,r,"ASN",i.asn===void 0?"Unavailable":String(i.asn)),q(e,r,"HTTP version",i.http_version??"Unavailable"),q(e,r,"TLS protocol",i.tls_protocol??"Unavailable"),q(e,r,"TLS cipher",i.tls_cipher??"Unavailable"),q(e,r,"Edge hostname",i.edge_hostname??"Unavailable"),q(e,r,"Edge region",i.edge_region??"Unavailable"),q(e,r,"Edge POP",i.edge_pop??"Unavailable"),q(e,n,"Edge Cookie",Y(s.cookies.ts_ec)),q(e,n,"External IDs",Y(s.cookies.ts_eids)),q(e,n,"Tester",Y(s.cookies.ts_tester)),q(e,n,"Diagnostics session",Y(s.cookies.diagnostics_session))}function Et(e=document){Nt(e);const t=e.getElementById("trace-session-state"),r=e.getElementById("trace-status"),n=e.getElementById("trace-enable"),s=e.getElementById("trace-end"),i=e.getElementById("trace-back");if(!t||!r||!(n instanceof HTMLButtonElement)||!(s instanceof HTMLButtonElement)||!(i instanceof HTMLButtonElement))return()=>{};t.textContent=t.dataset.observedActive==="true"?ge:t.dataset.observedActive==="false"?ne:"Tracing state unconfirmed";let o=!1,c=!1;const a=async m=>{if(!(c||o)){o=!0,n.disabled=s.disabled=!0,r.textContent=m==="enable"?"Verifying activation…":"Verifying deactivation…";try{const v=await Tt(m);if(c)return;t.textContent=v.observation==="active"?ge:v.observation==="inactive"?ne:"Tracing state unconfirmed",v.confirmed?(r.textContent=m==="enable"?Rt:ne,i.classList.toggle("primary",m==="enable")):r.textContent=`${m==="enable"?"Activation":"Deactivation"} unconfirmed. Try again.`}finally{c||(n.disabled=s.disabled=!1),o=!1}}},p=()=>{a("enable")},l=()=>{a("end")},f=()=>{c||(window.history.length>1?window.history.back():r.textContent=kt)};return n.addEventListener("click",p),s.addEventListener("click",l),i.addEventListener("click",f),()=>{c=!0,n.removeEventListener("click",p),s.removeEventListener("click",l),i.removeEventListener("click",f)}}const re="trusted-server.trace.report.v1";function he(e){return e??window.sessionStorage}function Ct(e,t,r){const n=M(e,11)?.value;return b(n)?ct(n.report,t,r)??"invalid_report":"invalid_report"}function qt(e,t,r){let n,s;try{n=he(r),s=n.getItem(re)}catch{return{status:"unavailable"}}if(s===null)return{status:"absent"};let i,o;try{typeof s=="string"&&new TextEncoder().encode(s).length<=512*1024&&(i=JSON.parse(s),o=at(i,e,t))}catch{}if(o)return{status:"ready",value:o};const c=Ct(i,e,t);try{n.removeItem(re)}catch{}return{status:"rejected",reason:c}}function At(e){try{return he(e).removeItem(re),{status:"deleted"}}catch{return{status:"unavailable"}}}function T(e){return{no_bid:"No bid returned",no_candidate:"No candidate",selected:"Candidate selected",selected_unrenderable:"Selected candidate could not be rendered",trusted_server_direct:"Trusted Server request path observed",prebid_refresh:"Browser refresh observed; winner not determined",publisher_refresh:"Browser refresh observed; winner not determined",competing:"Multiple or unknown delivery paths",unattributed:"Multiple or unknown delivery paths",trusted_server_response_sent:"Trusted Server creative response sent",trusted_server_selected:"Trusted Server candidate selected; render unconfirmed",candidate_unconfirmed:"Candidate unconfirmed",not_observed:"Not observed",unknown:"Unknown",unavailable:"Unavailable"}[e]??e.replace(/([a-z])([A-Z])/g,"$1 $2").replace(/_/g," ").replace(/^./,r=>r.toUpperCase())}function Ot(e){return e===void 0?"Unavailable":typeof e=="boolean"?e?"Yes":"No":typeof e=="number"?String(e):typeof e=="string"?e:"Unavailable"}function J(e){return e.state==="absent"?"Not present in this request":e.state==="present_valid"?"Valid shape observed":e.state==="duplicate"?"Multiple values observed":e.state==="present_invalid"?e.detail==="oversized"?"Invalid shape — too long":e.detail==="unsupported_value"?"Invalid shape — unsupported value":"Invalid shape observed":e.detail==="runtime_header_ambiguous"?"Unavailable — runtime-visible cookies could not be reliably inspected":e.detail==="header_too_large"?"Unavailable — the visible cookie header was too large":"Unavailable — the visible cookie header was not valid text"}function d(e,t,r){const n=e.createElement(t);return r!==void 0&&(n.textContent=r),n}function U(e,t,r,n){const s=d(e,"section");return n&&(s.id=n),s.append(d(e,"h2",r)),t.append(s),s}function R(e,t,r){const n=d(e,"dl"),s=d(e,"dl");let i=0;for(const[o,c]of r){const a=Ot(c),p=a==="Unavailable"?s:n;p===s&&(i+=1),p.append(d(e,"dt",o),d(e,"dd",a))}if(n.childElementCount&&t.append(n),i){const o=d(e,"details");o.className="trace-unavailable",o.append(d(e,"summary",i===1?"1 field unavailable":`${i} fields unavailable`),s),t.append(o)}}function B(e){return e===void 0?"Unavailable":e.length===0?"Not observed":e.map(([t,r])=>`${t} × ${r}`).join(", ")}function O(e){return e===void 0?"Unavailable":`${Number(e.toFixed(1))} ms`}function se(e,t,r,n){const s=d(e,"details");s.className="trace-section",n&&(s.id=n);const i=d(e,"summary");return i.append(d(e,"h2",r)),s.append(i),t.append(s),s}function ie(e){const t=/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(e);return t?`${t[1]} ${t[2]} UTC`:e}const xt={masked_client_ip:"Approximate network identifier",country:"Country",region:"Region",asn:"ASN",http_version:"HTTP version",tls_protocol:"TLS protocol",tls_cipher:"TLS cipher",edge_hostname:"Edge hostname",edge_region:"Edge region",edge_pop:"Edge POP"},$t={requestedAtMs:"Requested (browser clock)",responseAtMs:"Response received (browser clock)",renderAtMs:"Rendered (browser clock)",loadAtMs:"Loaded (browser clock)",viewableAtMs:"Viewable (browser clock)",isBackfill:"Backfill observed",slotContentChanged:"Slot content changed",incompleteSequence:"Incomplete sequence",responseClass:"GPT response class",requestIntentId:"Browser request intent number",opportunityToRequestMs:"Opportunity to request",replacedRequestNumber:"Replaced request number",previousRenderToRequestMs:"Previous render to request",creativeChanged:"Creative changed",loadObservedBeforeRender:"Load observed before render",trustedServerOpportunity:"Trusted Server candidate opportunity",trustedServerCreativeRequestAtMs:"Creative bridge request (browser clock)",trustedServerCreativeResponseAtMs:"Creative bridge response (browser clock)",delivery:"Creative delivery observation"};function Mt(e,t,r,n,s){const i=d(e,"details");i.className="trace-request",i.append(d(e,"summary",`Request ${n.requestNumber}`));const o=(s?.auctions??[]).flatMap(a=>a.slots).filter(a=>a.correlation==="matched"&&a.runtimeSlotNumber===r&&a.requestNumber===n.requestNumber);R(e,i,[["Correlation",o.length===1?"Matched server slot":"Correlation unknown"],["Creative participation",o.length===1?o[0].creativeLabel:"Participation unconfirmed"],["GPT fill observation",n.isEmpty===void 0?"Unknown":n.isEmpty?"Empty":"Filled"],["Browser request path",n.requestPath===void 0?"Unavailable":T(n.requestPath)],["Requested sizes",B(n.requestedSlotSizes)],["Rendered size",n.size?B([n.size]):"Unavailable"],["Observed CSS box size",n.observedSlotSize?B([n.observedSlotSize]):"Unavailable"]]);const c=Object.entries($t).map(([a,p])=>{const l=n[a];return[p,typeof l=="string"?T(l):a.endsWith("Ms")?O(l):l]});R(e,i,c),R(e,i,[["Request to response",O(n.durations.requestToResponseMs)],["Response to render",O(n.durations.responseToRenderMs)],["Request to render",O(n.durations.requestToRenderMs)],["Render to load",O(n.durations.renderToLoadMs)],["Render to viewable",O(n.durations.renderToViewableMs)],["Creative bridge failures",n.trustedServerCreativeFailures===void 0?"Unavailable":n.trustedServerCreativeFailures.length===0?"Not observed":n.trustedServerCreativeFailures.map(T).join(", ")]]),t.append(i)}function Lt(e,t,r,n){const s=U(e,t,"Server auctions","trace-report-auctions");s.append(d(e,"p","Returned bid counts may overlap between slots and must not be summed as unique bids.")),r.server_auctions.length||s.append(d(e,"p","Not observed. This does not mean no server auction ran."));for(const[i,o]of(n?.auctions??[]).entries()){const c=d(e,"details");c.className="trace-auction",c.open=!0,c.append(d(e,"summary",`Auction ${i+1}: ${o.sourceLabel}`)),c.append(d(e,"p","Produced by Trusted Server; copied through an untrusted browser snapshot")),R(e,c,[["Terminal outcome",T(o.evidence.terminal_status)],["Terminal reason",o.evidence.terminal_reason===void 0?"Unavailable":T(o.evidence.terminal_reason)],["Server auction-local elapsed time",O(o.evidence.total_time_ms)],["Request-relative milestones",o.relativeMilestonesLabel]]),c.append(d(e,"p",o.providerScopeLabel));for(const a of o.evidence.provider_calls)R(e,c,[[`Provider call ${a.provider_number}`,T(a.role)],["Call outcome",T(a.status)],["Provider-local elapsed time",O(a.response_time_ms)],["Returned bid count",a.returned_bid_count]]);o.evidence.provider_calls.length||c.append(d(e,"p","Provider calls: Not observed"));for(const a of o.slots){const p=d(e,"div");p.className="trace-server-slot",p.append(d(e,"h3",`Server slot ${a.serverSlot.slot_number}`)),R(e,p,[["Requested sizes",B(a.serverSlot.requested_sizes)],["Returned bid count",a.serverSlot.returned_bid_count],["Candidate",T(a.serverSlot.candidate)],["Selected creative size",a.serverSlot.selected_creative_size?B([a.serverSlot.selected_creative_size]):"Unavailable"],["Correlation",a.correlation==="matched"?`Matched GPT slot ${a.runtimeSlotNumber}, request ${a.requestNumber}`:"Correlation unknown"],["Browser request path",a.pathLabel??"Unknown"],["Creative participation",a.creativeLabel??"Participation unconfirmed"]]),c.append(p)}R(e,c,[["Provider calls omitted",o.evidence.truncation.omitted_provider_calls],["Server slots omitted",o.evidence.truncation.omitted_slots],["Nested values omitted",o.evidence.truncation.omitted_nested_values]]),s.append(c)}}function It(e,t,r){const n=se(e,t,"Publisher request","trace-report-request");n.append(d(e,"p","Produced by Trusted Server; copied through an untrusted browser snapshot"),d(e,"p","These facts describe the traced publisher document. Masked identifiers are approximate and may still identify a network.")),R(e,n,[["Document request captured at",ie(r.request_context.captured_at)],...Object.entries(xt).map(([i,o])=>[o,r.request_context.network[i]])]);const s=se(e,t,"Cookie health","trace-report-cookies");s.append(d(e,"p","Produced by Trusted Server; copied through an untrusted browser snapshot. Only cookie shape visible in this request is inspected; values and browser attributes are excluded.")),R(e,s,[["Edge Cookie",J(r.request_context.cookies.ts_ec)],["External IDs",J(r.request_context.cookies.ts_eids)],["Tester",J(r.request_context.cookies.ts_tester)],["Diagnostics session",J(r.request_context.cookies.diagnostics_session)]])}function jt(e,t,r){const n=se(e,t,"Coverage and ambiguity","trace-report-coverage");R(e,n,[["Server capture",T(r.auction_coverage.capture_status)],["Capture and interpretation limits",r.auction_coverage.issues.length?r.auction_coverage.issues.map(T).join(", "):"None recorded"],["Correlation sidecars retained",r.slot_correlations.length]]);for(const[s,i]of Object.entries(r.gpt_diagnostics.coverage))R(e,n,[[T(s),`${i.observed} observed; ${i.matched} matched; ${i.unmatched} unmatched; ${i.ambiguous} ambiguous`]]);for(const[s,i]of Object.entries(r.truncation))R(e,n,[[T(s),i]]);for(const[s,i]of Object.entries(r.gpt_diagnostics.metadata))R(e,n,[[`GPT ${T(s)}`,i]]);for(const s of r.gpt_diagnostics.callbackIssues)R(e,n,[["Callback issue",T(s.kind)],["GPT slot",s.runtimeSlotNumber],["Browser clock",O(s.timestampMs)],["Disposition",T(s.disposition)],["Reason",T(s.reason)]]);for(const s of r.gpt_diagnostics.attributionIssues??[])R(e,n,[["Creative attribution issue",T(s.reason)],["GPT slot",s.runtimeSlotNumber],["Browser clock",O(s.timestampMs)]]);for(const[s,i]of r.slot_correlations.entries()){const o=d(e,"details");o.append(d(e,"summary",`Correlation record ${s+1}`)),o.append(d(e,"p","Browser observed correlation. These opaque references permit a join only when unique and consistent; duplicate or conflicting records remain unknown.")),R(e,o,[["Auction reference",i.diagnostic_auction_id],["Slot reference",i.slot_ref],["GPT slot number",i.runtime_slot_number],["GPT request number",i.request_number]]),n.append(o)}}const ye=20;function z(e,t,r){return`${e} ${e===1?t:r}`}function Pt(e){return e.some(t=>t.isEmpty===!1)?"filled":e.some(t=>t.isEmpty===!0)?"empty":"unknown"}function Se(e){return e.find(t=>t.isEmpty===!1)??e[e.length-1]}function Ut(e){if(!e)return[];const{durations:t}=e,r=[["response","response",t.requestToResponseMs],["render","render",t.responseToRenderMs],t.renderToViewableMs!==void 0?["viewable","until viewable",t.renderToViewableMs]:["load","until loaded",t.renderToLoadMs]],n=[];for(const[i,o,c]of r)typeof c=="number"&&Number.isFinite(c)&&c>=0&&n.push([i,o,c]);const s=n.reduce((i,[,,o])=>i+o,0);return n.map(([i,o,c])=>({kind:i,label:o,ms:c,weight:s>0?Math.min(ye,Math.max(1,Math.round(c/s*ye))):1}))}function Bt(e,t){const r=[];return e==="empty"&&r.push("GPT returned no ad"),e==="unknown"&&r.push("No fill observed"),t.some(n=>n.incompleteSequence)&&r.push("GPT event sequence incomplete"),t.some(n=>(n.trustedServerCreativeFailures?.length??0)>0)&&r.push("Creative bridge failure observed"),r}function zt(e,t){return(t?.auctions??[]).flatMap((n,s)=>n.slots.filter(i=>i.correlation==="matched"&&i.runtimeSlotNumber===e).map(i=>({auctionNumber:s+1,sourceLabel:n.sourceLabel,serverSlotNumber:i.serverSlot.slot_number,candidate:i.serverSlot.candidate})))[0]}function Dt(e,t){const r=e.gpt_diagnostics.slots.map(u=>{const _=Pt(u.requests),A=Se(u.requests);return{runtimeSlotNumber:u.runtimeSlotNumber,fill:_,backfill:A?.isBackfill===!0,size:A?.size,requestCount:u.requests.length,timingRequestNumber:A?.requestNumber,timing:Ut(A),link:zt(u.runtimeSlotNumber,t),attention:Bt(_,u.requests)}}),n=e.server_auctions,s=n.flatMap(u=>u.provider_calls),i=s.filter(u=>u.status==="error").length,o=n.filter(u=>u.terminal_status==="completed").length,c=s.filter(u=>u.role==="bidder").reduce((u,_)=>u+_.returned_bid_count,0),a=n.flatMap(u=>u.slots).filter(u=>u.candidate==="selected").length,p=u=>r.filter(_=>_.fill===u).length,l=p("filled"),f=r.filter(u=>u.link).length,m=r.length?`${l} of ${z(r.length,"ad slot","ad slots")} filled${n.length?` · ${z(c,"Trusted Server bid","Trusted Server bids")}`:""}`:"No GPT ad slots observed",v=[];if(!n.length)v.push("No server auction was captured for this page.");else if(o===n.length)v.push(n.length===1?"The server auction completed.":n.length===2?"Both server auctions completed.":`All ${n.length} server auctions completed.`);else{const u=new Map;for(const _ of n){if(_.terminal_status==="completed")continue;const A=_.terminal_status.replace(/_/g," "),k=_.terminal_reason?`${A} (${_.terminal_reason.replace(/_/g," ")})`:A;u.set(k,(u.get(k)??0)+1)}v.push(`${o} of ${n.length} server auctions completed; ${[...u].map(([_,A])=>`${A} ${_}`).join(", ")}.`)}n.length&&v.push(c?`${z(c,"bid was","bids were")} returned to Trusted Server.`:"No bids were returned to Trusted Server."),a&&v.push(`Trusted Server selected a candidate for ${z(a,"server slot","server slots")}.`),i&&v.push(`${i} of ${z(s.length,"provider call","provider calls")} failed.`);const y=new Map;for(const u of e.gpt_diagnostics.slots){const _=Se(u.requests);_?.isEmpty===!1&&_.responseClass&&y.set(_.responseClass,(y.get(_.responseClass)??0)+1)}return y.size&&v.push(`GPT classified the filled ads as ${[...y].map(([u,_])=>`${_} ${u.replace(/_/g," ")}`).join(" and ")}.`),n.length&&r.length&&f0||r.some(u=>u.attention.length>0),stats:[{value:n.length,label:n.length===1?"server auction":"server auctions"},{value:i,label:i===1?"provider error":"provider errors"},{value:f,label:f===1?"slot linked to TS":"slots linked to TS"}],fills:["filled","empty","unknown"].map(u=>({fill:u,count:p(u)})).filter(u=>u.count>0),slots:r}}const Ft={filled:"Filled",empty:"Empty",unknown:"Fill unknown"},Vt={filled:"filled",empty:"empty",unknown:"fill unknown"};function Te(e,t,r){const n=d(e,"span",r);return n.className=`trace-chip trace-chip-${t}`,n}function Gt(e,t,r){const n=U(e,t,"What happened","trace-report-summary"),s=d(e,"div");s.className=`trace-verdict ${r.needsAttention?"is-attention":"is-clear"}`;const i=d(e,"p",r.headline);i.className="trace-headline",s.append(i);for(const l of r.reading)s.append(d(e,"p",l));const o=d(e,"ul");o.className="trace-stats";for(const l of r.stats){const f=d(e,"li"),m=d(e,"span",String(l.value));m.className="trace-stat-value";const v=d(e,"span",l.label);v.className="trace-stat-label",f.append(m,v),o.append(f)}if(s.append(o),r.fills.length){const l=d(e,"p");l.className="trace-chips";for(const f of r.fills)l.append(Te(e,f.fill,`${f.count} ${Vt[f.fill]}`));s.append(l)}n.append(s);const c=r.slots.filter(l=>l.attention.length>0);if(!c.length)return;const a=U(e,t,"Needs attention","trace-report-attention"),p=d(e,"ul");p.className="trace-attention";for(const l of c){const f=d(e,"li"),m=d(e,"a",`Slot ${l.runtimeSlotNumber}`);m.href=`#trace-slot-${l.runtimeSlotNumber}`,f.append(m,d(e,"span",` — ${l.attention.join("; ")}`)),p.append(f)}a.append(p)}function Kt(e,t,r){if(!r.timing.length){t.append(d(e,"p","Request timing unavailable"));return}const n=r.timing.map(o=>`${o.label} ${O(o.ms)}`),s=d(e,"div");s.className="trace-timing",s.setAttribute("role","img"),s.setAttribute("aria-label",`Request ${r.timingRequestNumber??1} timing: ${n.join(", ")}`);for(const o of r.timing){const c=d(e,"span");c.className=`trace-segment trace-segment-${o.kind} trace-weight-${o.weight}`,s.append(c)}const i=d(e,"ul");i.className="trace-legend",i.setAttribute("aria-hidden","true");for(const o of r.timing){const c=d(e,"li",`${o.label} ${O(o.ms)}`);c.className=`trace-legend-${o.kind}`,i.append(c)}t.append(s,i)}function Yt(e,t,r,n,s){const i=U(e,t,"Ad slots","trace-report-slots");i.append(d(e,"p","Browser observed. Server auction → GPT request/response → creative render/load/viewability are separate observations. A filled slot does not identify an auction winner.")),R(e,i,[["Browser snapshot captured at",ie(r.gpt_diagnostics.capturedAt)]]),r.gpt_diagnostics.slots.length||i.append(d(e,"p","Not observed"));for(const[o,c]of r.gpt_diagnostics.slots.entries()){const a=n.slots[o],p=d(e,"article");p.className="trace-slot",p.id=`trace-slot-${c.runtimeSlotNumber}`;const l=d(e,"header");l.append(d(e,"h3",a.size?`Slot ${c.runtimeSlotNumber} · ${B([a.size])}`:`Slot ${c.runtimeSlotNumber}`),Te(e,a.fill,a.backfill&&a.fill==="filled"?"Filled · backfill":Ft[a.fill])),p.append(l),Kt(e,p,a);const f=d(e,"p",a.link?`Linked to auction ${a.link.auctionNumber} (${a.link.sourceLabel}) · server slot ${a.link.serverSlotNumber} · ${T(a.link.candidate)}`:"Not linked to a server auction");f.className=a.link?"trace-link":"trace-link is-unlinked",p.append(f),a.requestCount>1&&p.append(d(e,"p",`${a.requestCount} requests; timing shows request ${a.timingRequestNumber}.`));const m=d(e,"details");m.className="trace-slot-details",m.append(d(e,"summary",`All details for slot ${c.runtimeSlotNumber}`)),R(e,m,[["Binding",T(c.binding.status)],["Binding reason",c.binding.reason===void 0?"Unavailable":T(c.binding.reason)],["Current visibility percentage",c.currentVisibilityPercentage],["Maximum visibility percentage",c.maximumVisibilityPercentage]]),c.requests.length||m.append(d(e,"p","Requests: Not observed"));for(const v of c.requests)Mt(e,m,c.runtimeSlotNumber,v,s);p.append(m),i.append(p)}}function Jt(e,t,r,n){const s=d(e,"article");s.id="trace-report";const i=d(e,"p","Browser-carried, unverified diagnostic data");i.className="trace-notice";const o=d(e,"header");o.className="trace-report-header";const c=d(e,"p",`Captured ${ie(t.captured_at)} · ${t.gpt_diagnostics.page.origin} · ${we(t.gpt_diagnostics.slots.length,"ad slot","ad slots")} · ${we(t.server_auctions.length,"server auction","server auctions")}`);c.className="trace-meta",o.append(i,d(e,"h1","Trusted Server trace results"),c,d(e,"p","This browser snapshot helps troubleshoot rendering. It is not proof of a server event, identity, or security incident.")),s.append(o);const a=gt(t,r,n),p=Dt(t,a);Gt(e,s,p),Yt(e,s,t,p,a);const l=d(e,"p","More detail");return l.className="trace-more",s.append(l),Lt(e,s,t,a),It(e,s,t),jt(e,s,t),s}function we(e,t,r){return`${e} ${e===1?t:r}`}function Re(e=document,t={}){const r=e.querySelector("main");if(!r)return{destroy(){}};const n=Et(e),s=t.origin??window.location.origin;let i,o=!1;const c=[],a=(h,N,qe)=>{const x=d(e,"button",N);x.type="button";const Ae=()=>{o||qe()};return x.addEventListener("click",Ae),c.push(()=>x.removeEventListener("click",Ae)),h.append(x),x},p=()=>{o=!0,i=void 0,n();for(const h of c)h()};let l;try{l=qt(s,(t.now??Date.now)(),t.storage)}catch{l={status:"unavailable"}}if(l.status!=="ready"){const h=d(e,"p",l.status==="absent"?"No saved report. Enable tracing, return to the affected page, reload once, reproduce the problem, then select View trace results.":"The saved report is unavailable, expired, or unsupported. Return to the affected page in this same tab and exact hostname, reload once, reproduce the problem, then select View trace results.");return h.id="trace-report-notice",r.prepend(h),{destroy:p}}i=l.value;const f=d(e,"details");f.id="trace-viewer-setup",f.append(d(e,"summary","Setup request and tracing controls"));for(const h of Array.from(r.children))f.append(h);const m=Jt(e,i.report,s,i.stored_at_ms);r.append(m);const v=U(e,m,"Export");v.append(d(e,"p","Copy, Download and Share use the same public report JSON. The selected app receives this JSON when you choose Share. Nothing is uploaded by this viewer."));const y=d(e,"div");y.className="controls",v.append(y);const u=d(e,"p");u.id="trace-export-status",u.setAttribute("role","status"),u.setAttribute("aria-live","polite"),v.append(u);let _=!1;const A=async h=>{if(!i||o||_)return;_=!0;const N=i;try{const x=await(h==="Copy"?t.copy??yt:h==="Share"?t.share??St:t.download??ht)(N.report,s,N.stored_at_ms);if(o||i!==N)return;u.textContent=x.status==="copied"?"Copied JSON.":x.status==="downloaded"?"Download started; completion is managed by your browser.":x.status==="shared"?"The share request completed.":h==="Share"&&x.status==="unsupported"?"File sharing is unavailable. Use Copy or Download.":`${h} could not be completed. Your report remains available; retry or choose another export.`}catch{!o&&i===N&&(u.textContent=`${h} could not be completed. Your report remains available.`)}finally{_=!1}};for(const h of["Copy","Download","Share"])a(y,h,()=>{A(h)});const k=U(e,r,"Report cleanup"),G=d(e,"div");G.className="controls",k.append(G);const j=d(e,"p","A local report is saved in this tab.");j.id="trace-cleanup-local-status",j.setAttribute("role","status"),j.setAttribute("aria-live","polite"),j.tabIndex=-1,k.append(j);const P=d(e,"p","Server tracing state has not been changed by this report visit.");P.id="trace-cleanup-server-status",P.setAttribute("role","status"),P.setAttribute("aria-live","polite"),P.tabIndex=-1,k.append(P);const ke=()=>{let h;try{h=At(t.storage)}catch{h={status:"unavailable"}}if(!o)if(h.status==="deleted"){const N=e.activeElement===Ce||m.contains(e.activeElement);i=void 0,m.remove(),Ce.hidden=!0,j.textContent="Local report deleted from this tab.",N&&j.focus()}else j.textContent="Local report deletion failed. The report remains displayed; retry deletion."};let H=!1;const Ne=async()=>{if(o||H)return;const h=e.activeElement===D;H=!0,Ee.disabled=D.disabled=!0,P.textContent="Requesting tracing end and checking the next request…";let N;try{N=await wt()}catch{N={mutation:"failed",observation:"failed",confirmed:!1}}o||(P.textContent=N.confirmed?"Tracing is off — no valid diagnostics session observed.":N.observation==="inactive"?"End tracing unconfirmed. No valid diagnostics session was observed; tracing may remain active. Retry end tracing.":N.observation==="active"?"End tracing unconfirmed — a valid session is still observed. Tracing may remain active. Retry end tracing.":"End tracing unconfirmed. Tracing may remain active. Retry end tracing.",D.hidden=N.confirmed,Ee.disabled=D.disabled=!1,N.confirmed&&h&&(e.activeElement===D||e.activeElement===e.body)&&P.focus(),H=!1)},Ee=a(G,"Clear report and end tracing",()=>{if(H)return;let h=!1;try{h=(t.confirm??(N=>window.confirm(N)))("Delete the report from this tab and request tracing end?")}catch{}!h||o||(ke(),o||Ne())}),Ce=a(G,"Delete local report",ke),D=a(G,"Retry end tracing",()=>{Ne()});return D.hidden=!0,r.append(f),{destroy:p}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>Re(),{once:!0}):Re()})(); diff --git a/crates/trusted-server-js/src/lib.rs b/crates/trusted-server-js/src/lib.rs index 8acb8ba2b..800ad5a77 100644 --- a/crates/trusted-server-js/src/lib.rs +++ b/crates/trusted-server-js/src/lib.rs @@ -4,6 +4,7 @@ )] pub mod bundle; +pub mod trace_assets; // Build-script helpers, compiled here only so their unit tests run with the crate's. #[cfg(test)] diff --git a/crates/trusted-server-js/src/trace_assets.rs b/crates/trusted-server-js/src/trace_assets.rs new file mode 100644 index 000000000..b75a06b43 --- /dev/null +++ b/crates/trusted-server-js/src/trace_assets.rs @@ -0,0 +1,70 @@ +//! Immutable, independently embedded assets for the full-document trace page. + +/// Verified asset bytes and their byte-derived SHA-256 digest. +/// +/// Publisher integration bundle discovery does not include these assets. +#[derive(Clone, Copy, Debug)] +pub struct TraceAsset { + /// Exact pathname of the versioned asset. + pub path: &'static str, + /// Immutable bytes verified against the committed manifest during the build. + pub bytes: &'static [u8], + /// Lowercase hexadecimal SHA-256 digest of [`Self::bytes`]. + pub sha256: &'static str, +} + +include!(concat!(env!("OUT_DIR"), "/trace_assets.rs")); + +/// Look up an asset only by its exact versioned pathname. +/// +/// # Examples +/// +/// ``` +/// use trusted_server_js::trace_assets::trace_asset; +/// assert!(trace_asset("/_ts/trace/assets/v1.js").is_some()); +/// assert!(trace_asset("/_ts/trace/assets/v1.js?data=example").is_none()); +/// ``` +#[must_use] +pub fn trace_asset(path: &str) -> Option { + TRACE_ASSETS + .iter() + .find(|asset| asset.path == path) + .copied() +} + +#[cfg(test)] +mod tests { + use sha2::{Digest as _, Sha256}; + + use super::*; + + #[test] + fn exact_trace_asset_lookup_matches_verified_bytes() { + for path in ["/_ts/trace/assets/v1.js", "/_ts/trace/assets/v1.css"] { + let asset = trace_asset(path).expect("should embed the fixed versioned trace asset"); + assert_eq!( + asset.path, path, + "should retain the exact public asset path" + ); + assert_eq!( + hex::encode(Sha256::digest(asset.bytes)), + asset.sha256, + "should embed only bytes matching the manifest digest" + ); + } + for path in [ + "/_ts/trace/assets/v2.js", + "/_ts/trace/assets/v1.js?data=example", + "/_ts/trace/assets/../v1.js", + ] { + assert!( + trace_asset(path).is_none(), + "should reject every unregistered asset spelling" + ); + } + assert!( + !crate::all_module_ids().contains(&"trace"), + "should keep trace assets outside the publisher integration pipeline" + ); + } +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 422085179..d008920bb 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -165,6 +165,10 @@ export default withMermaid( text: 'GPT Runtime Diagnostics', link: '/guide/integrations/gpt-diagnostics', }, + { + text: 'GPT Diagnostics Label Dictionary', + link: '/guide/integrations/gpt-diagnostics-dictionary', + }, { text: 'Lockr', link: '/guide/integrations/lockr' }, { text: 'Next.js', link: '/guide/integrations/nextjs' }, { text: 'Osano', link: '/guide/integrations/osano' }, diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index f6c8f72d3..99059adba 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -169,8 +169,9 @@ curl -i "https://edge.example.com/_ts/clear-tester" Browser and programmatic auction endpoint. It accepts the Trusted Server ad-unit request shape and returns an OpenRTB response. Creative markup follows the -independent `[auction].sanitize_creatives` and `[auction].rewrite_creatives` -settings; sanitization is opt-in and rewriting is enabled by default. +independent `[auction].sanitize_creatives`, `[auction].rewrite_creatives` and +`[auction].rewrite_clicks` settings; sanitization is opt-in, asset rewriting is +enabled by default, and click wrapping follows `rewrite_creatives` unless set. Configured provider IDs appear in response metadata and provider responses. Consumers that previously matched the literal provider name `prebid` must use @@ -567,6 +568,7 @@ curl -X POST https://edge.example.com/first-party/sign \ - `403 Forbidden`: The target has a valid host that does not match a non-empty `proxy.allowed_domains` list - `413 Payload Too Large`: The POST body exceeds 64 KiB +- `502 Bad Gateway`: The target host matches `rewrite.exclude_domains` or is outside a non-empty `rewrite.include_domains` - Shared `5xx`: Malformed JSON or an invalid/unsupported target under the current proxy-error mapping diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index edbc38471..74376dd6e 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -161,7 +161,7 @@ sequenceDiagram Note right of Client: Fragment-bound nonce and one-time acknowledgement
No allow-same-origin on the outer frame else Ordinary creative Client->>Client: Inject winning creative
Render iframe
Load creative resources - Note right of Client: Default: first-party proxy/click URLs
rewrite_creatives=false: accepted external URLs remain direct + Note right of Client: Default: first-party proxy/click URLs
rewrite_creatives=false: asset URLs direct; clicks follow rewrite_clicks end deactivate Client end @@ -195,7 +195,7 @@ AuctionOrchestrator.run_auction() Convert OrchestrationResult → OpenRTB 2.x Response │ ├─[sanitize_creatives=true] Strip executable markup - ├─[rewrite_creatives=true] Rewrite URLs and inject creative TSJS + ├─[rewrite_creatives or rewrite_clicks] Rewrite assets and/or links, inject creative TSJS ├─ Add ext.orchestrator metadata └─ Set consent and optional EID response headers ``` @@ -638,8 +638,11 @@ HTML rewriter (`lol_html`) that converts eligible external resource and click URLs to signed first-party paths, adds `data-tsclick`, rewrites inline CSS `url(...)` values, removes bidder-supplied `` elements, and injects the unified creative TSJS runtime exactly once, whether or not the bidder supplied a -`` element. In every mode, a creative -larger than the 1 MiB per-creative cap is rejected and its `adm` is dropped. +`` element. `rewrite_clicks` (unset by default, following +`rewrite_creatives`) controls click-through link wrapping separately; see +[Creative Processing](/guide/creative-processing#assets-and-clicks). In every +mode, a creative larger than the 1 MiB per-creative cap is rejected and its +`adm` is dropped. ```toml [auction] @@ -702,6 +705,7 @@ Each proxied URL includes a `tstoken` HMAC signature for tamper protection. See enabled = true sanitize_creatives = false # Opt-in; blanks script-based creatives when enabled rewrite_creatives = true +# rewrite_clicks = true # Unset follows rewrite_creatives timeout_ms = 2000 mediator = "adserver_mock" @@ -835,7 +839,9 @@ The typed `ts config validate`, `ts config diff`, and `ts config push` flows can override existing scalar leaves. The pinned EdgeZero loader does not create missing leaves or replace arrays, tables, maps, or rules. Existing configs must add `rewrite_creatives = true` and `sanitize_creatives = false` before relying on -those scalar overrides. Edit and re-push TOML for other values. Provider map +those scalar overrides. `rewrite_clicks` is unset by default; add +`rewrite_clicks = true` or `false` under `[auction]` before relying on +`TRUSTED_SERVER__AUCTION__REWRITE_CLICKS`. Edit and re-push TOML for other values. Provider map keys preserve hyphens, so `pbs-main` uses the `PBS-MAIN` segment and needs `env` shell syntax: @@ -850,9 +856,11 @@ env 'TRUSTED_SERVER__AUCTION__ENABLED=true' \ ``` Before rolling back to a binary that does not know a creative-processing field, -remove that field's non-default value (`rewrite_creatives = false` or -`sanitize_creatives = true`), push the default-compatible blob, and then roll -back. See [Configuration](/guide/configuration#auction-configuration) for the +remove that field's non-default value (`rewrite_creatives = false`, +`sanitize_creatives = true`, or any explicit `rewrite_clicks`), push the default-compatible blob, and then roll +back. Older binaries tie click wrapping to `rewrite_creatives`, so rolling back +also changes link wrapping when `rewrite_clicks` was set. See +[Configuration](/guide/configuration#auction-configuration) for the complete migration, upgrade-sequencing, and rollback guidance. ## Floor Prices diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 8b851bd82..9466e1f3d 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -215,31 +215,61 @@ deploy. Adapter passthrough arguments must now follow a `--` separator; unknown flags before `--` (including the renamed-away `--stage`) are rejected at parse time rather than forwarded. This is a change: passthrough args previously worked without the separator, so existing runbooks and CI jobs that pass -adapter flags directly need the `--` added: +adapter flags directly need the `--` added. Trusted Server declares Config, KV, +and Secret Stores, so EdgeZero treats every Fastly deploy as managed and +requires a verified application release root via `--application-release`; a +bare `ts deploy --adapter fastly` without a release is accepted only for +store-free applications. The CLI loads `edgezero.toml` from the working +directory unless `EDGEZERO_MANIFEST` names another file, and it rejects a +manifest outside the release root, so select the release's own manifest. + +Managed deployment also requires a nonempty `FASTLY_API_TOKEN` in the process +environment; signing in with `ts auth login` or a Fastly CLI profile alone +does not supply the token used by the adapter's direct API calls. Export the +token before running: ```bash -ts deploy --adapter fastly --service-id --staging -ts deploy --adapter fastly -- --comment "release" -``` - -A staged deploy only redirects the staged version's config selector at the -`_staging` key — it does not copy the production config blob -there. Push the staged config before probing the staged version: +EDGEZERO_MANIFEST="/edgezero.toml" \ + ts deploy --adapter fastly --service-id --application-release "" --staging +EDGEZERO_MANIFEST="/edgezero.toml" \ + ts deploy --adapter fastly --service-id --application-release "" -- --comment "release" +``` + +`` is an extracted immutable application release. Build the +application CLI and Fastly package, then use EdgeZero's +`package-application-release-fastly` action to assemble the release archive. +The `deploy-fastly` action consumes that prebuilt archive, verifies and extracts +it, selects the manifest, and supplies the release root. See EdgeZero's GitHub +Actions deployment guide (`docs/guide/deploy-github-actions.md` in the EdgeZero +repository) for the producer and consumer workflow. + +A staged deploy selects the physical Config Store from the staging environment +and links it to the staged version under the logical store ID. The staged +runtime reads the `` key from that store. It does not copy +the production config blob there. Push the staged config before probing the +staged version: ```bash -ts config push --adapter fastly --staging -ts config diff --adapter fastly --staging -``` - -The staged version resolves its app-config key through the version-linked -`edgezero_runtime_env` store. After `ts config push --staging`, the staged -binary reads `_staging` while the active production version -continues to read the production key. +EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME= \ + ts config push --adapter fastly --staging +EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME= \ + ts config diff --adapter fastly --staging +``` + +The config key is fixed on Fastly: production, staging, and local Viceroy all +read ``. `--staging` does not choose a different store or +key: the physical store is whatever `EDGEZERO__STORES__CONFIG____NAME` +resolves to in the shell or deploy job that runs the push, falling back to the +logical ID when it is unset. If production and staging select the same +physical Config Store, a staged push overwrites the production entry and +production instances load it on their next start. Give staging its own +physical Config Store and set its selector explicitly when pushing, as the +commands above do. `--staging` on `config push` / `config diff` writes and compares the -`_staging` key in the same store. It is mutually exclusive -with `--key`: the staging key is derived from the store's logical id, so an -explicit key would be written where nothing reads it. +`` key in the physical store selected by the current +environment. It is mutually exclusive with `--key`, and on Fastly any `--key` +other than the logical store ID is rejected for every target, staged or not. Inspect and verify deployments with the deploy lifecycle commands. All three are Fastly-only — the axum, cloudflare, and spin adapters reject them: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 81d936ec2..91eb8ecf5 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -55,7 +55,9 @@ publisher, trusted-client-IP, EC, handler, Tinybird, DataDome, and S3 fields: - `publisher.proxy_secret` - `trusted_client_ip.shared_secret`, when trusted client-IP forwarding is configured +- `trusted_forwarder.shared_secret`, when public-origin forwarding is configured - `ec.passphrase` +- `ec.identity_handoff.keys[*].secret`, for each provisioned handoff signing key - `ec.partners[*].api_token`, when inbound identify or batch sync is used - `ec.partners[*].ts_pull_token`, when pull sync is enabled - `handlers[*].password` @@ -69,10 +71,8 @@ resolved only while an instance builds runtime settings. An adapter can map the logical ID to a different physical name. For example, Fastly commonly maps `trusted_server_secrets` to physical store `ts_secrets`. -Two accepted secret-shaped fields are deliberately different: +One accepted secret-shaped field is deliberately different: -- `trusted_client_ip.shared_secret` is an inline value in the app-config blob; - it is redacted by debug formatting but is not resolved from a secret store. - `tinybird.access_token_secret` is deprecated input. It is accepted for migration, then discarded and omitted from serialized config; use `tinybird.auction_token_secret` as the store key name instead. @@ -99,6 +99,7 @@ serialization, runtime, and secret axes for every exceptional field: | `DataDomeProtectionTestBypassConfig.credential_secret_name` | canonical | canonical | serialized | active | store resolved | | `DataDomeProtectionTestBypassConfig.credential_secret_store` | deprecated | canonical | skipped | normalized away | none | | `Ec.passphrase` | canonical | canonical | serialized | active | store resolved | +| `IdentityHandoffKey.secret` | canonical | canonical | serialized | active when provided | store resolved | | `EcPartner.api_token` | canonical | canonical | serialized | active | store resolved | | `EcPartner.ts_pull_token` | canonical | canonical | serialized | active | store resolved | | `Handler.password` | canonical | canonical | serialized | active | store resolved | @@ -209,7 +210,9 @@ fail and the service will return its startup-error response. | `[response_headers]` | Headers added to Trusted Server responses | | `[rewrite]` | First-party URL rewrite exclusions | | `[tester_cookie]` | Optional tester-cookie endpoints | -| `[tinybird]` | Direct Tinybird auction telemetry | +| `[tinybird]` | Auction and access telemetry transport | +| `[observability]` | Server-Timing header emission | +| `[trusted_forwarder]` | Authenticated browser-facing host and scheme forwarding | | `[trusted_client_ip]` | Authenticated front-door client-IP forwarding | ## Example: Production Setup @@ -489,6 +492,53 @@ address instead of the immediate fronting edge node's address. Only the Fastly adapter honours this section; the Cloudflare, Spin, and Axum adapters validate it but keep using their own runtime client address. +### `[trusted_forwarder]` + +Trusted Server can accept the browser-facing authority and scheme from an +explicitly authenticated forwarder. This preserves the public origin when the +upstream leg uses plain HTTP or sends a different Host. Leave the section absent +for direct traffic. Unauthenticated forwarding headers never establish a public +origin. + +```toml +[trusted_forwarder] +auth_header = "x-ts-forwarder-auth" +shared_secret = "trusted_forwarder_shared_secret" +``` + +Both fields are required. `shared_secret` names a key in the logical +`trusted_server_secrets` store. Generate a cryptographically random token, such +as 32 random bytes encoded as hex; the stored value must contain at least 32 +ASCII graphic bytes. Length validation does not establish entropy. The +credential is redacted in diagnostics; temporary fixed-size digests are compared +in constant time and are never stored or exposed. Configure the forwarder to send +the same actual value in the header named by `auth_header`. For `ts dev proxy`, +use `auth_header = "x-ts-forwarder-auth"` and pass the local token file through +`--forwarder-secret-file`; see its +[authenticated forwarding setup](./ts-dev-proxy.md#authenticated-forwarding). + +The forwarder must replace client-supplied `X-Forwarded-Host` and +`X-Forwarded-Proto` with the actual browser-facing authority and HTTP/HTTPS +scheme. The authority includes a non-default browser port. Exactly one field of +each kind and one matching authentication field are required. Lists, ambiguous +or malformed values, and hosts outside `publisher.domain` or its subdomains +cannot establish trust. Missing or invalid authentication falls back to the +runtime transport origin. Hostname case and default ports canonicalize for +comparison. + +The validated public origin supplies URL rewriting and trace Origin checks. +The actual ingress URI, Host, TLS metadata, target provenance and cookie-header +fidelity remain transport facts. Trace actions still require an exact browser +Origin, same-origin Fetch Metadata, the action control, no query and an empty +body; forwarding does not expand identify CORS or relax cookie policy. + +Forwarding credentials and raw forwarding fields are removed before routing, +including trace preflight returns. Authentication names cannot overlap +forwarding sources, internal controls or configured trusted-client-IP fields. +Omitted configuration stays omitted from serialized blobs. Deploy a compatible +server before enabling this section, and remove it before rolling back to an +older server. + ### `[trusted_client_ip]` | Field | Type | Required | Description | @@ -646,6 +696,7 @@ be at least 32 bytes. Keep it stable to preserve EC identifier continuity. | `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | | `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | | `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | +| `identity_handoff` | Table | No | Authenticated short-lived capture handoff, disabled by default | | `partners` | Array | No | Static partner registry entries | ::: tip Partner keying @@ -657,6 +708,27 @@ the partner calls the inbound identify or batch-sync APIs. A partner without `api_token` remains available for source-domain lookup, bidstream EIDs, and outbound pull sync, but cannot authenticate to those inbound APIs. +Optional `identity_owner` selects one registered integration whose capture +capability claims the source. Deploy validation, config push and startup reject +missing or non-claiming owners and owned sources with legacy pull enabled. +For owned sources, `api_token` still permits read-only `identify`; `batch-sync` +returns 403 before accessing storage. Ownership also excludes the source from +legacy pull dispatch, completeness and marker fingerprints. Disable capture +without removing ownership when stopping acquisition. Changing owners requires +an operator migration and old-invocation drain, not automatic fallback. + +Provider expiry, writer and opaque revision are optional schema-v1 KV fields. +Known-expired IDs are omitted from auctions and `identify` without cleanup +writes. Unowned legacy pull remains fill-missing-only even for expired records. +Drain metadata-unaware readers and writers before enabling managed capture. +`/auction` body EIDs enrich existing consenting rows conservatively; the retired +`ts-eids` cookie and EID diagnostic response headers are no longer identity +transport. The optional `ts-eids-s` cookie is an authenticated server-capture +handoff only. It does not restore `ts-eids` or collect browser IDs. Inbound +diagnostic headers remain stripped. See the +[Prebid transport migration](./integrations/prebid.md) and +[Lockr capture gates](./integrations/lockr.md). + **Example**: ```toml @@ -672,6 +744,61 @@ bidstream_enabled = true # ts_pull_token = "partner_ts_pull_token" # required when pull sync is enabled ``` +### `[ec.identity_handoff]` + +The authenticated `ts-eids-s` capture handoff is an explicit publisher opt-in. +It defaults off with no keys. Keep it off until capture, consent, source +ownership, deployment compatibility, synthetic checks, and live acceptance have +all passed for the publisher route. + +```toml +[ec.identity_handoff] +enabled = false +active_key_id = "handoff-2026-10" + +[[ec.identity_handoff.keys]] +id = "handoff-2026-10" +secret = "identity_handoff_2026_10" +``` + +`secret` is a key name in `trusted_server_secrets`. Its resolved value must be +URL-safe unpadded base64 for exactly 32 random bytes. Use a dedicated value, not +`ec.passphrase` or another application credential. Key IDs must be unique, +non-empty ASCII alphanumeric, `_`, or `-`, with at most 32 characters. Configure +no more than four keys. When `enabled = true`, `active_key_id` must name one of +them. + +Generate a value locally, then write it directly to the mapped secret store: + +```bash +openssl rand 32 | openssl base64 -A | tr '+/' '-_' | tr -d '=' +``` + +Trusted Server resolves every provided key at startup, even while handoff is +disabled, and fails startup if a value is missing, malformed, or does not decode +to exactly 32 bytes. This lets operators provision and check keys before opting +in. Store changes are startup-scoped. + +For rotation, add the new key and secret while retaining the old key. Push and +deploy that verification set first. After every instance can read both keys, +change `active_key_id` to the new key and push again. Remove the old key only +after old invocations have drained and at least the five-minute handoff lifetime +has elapsed since all instances began issuing with the new key. The active key +signs new envelopes; retained keys verify envelopes created before the switch. + +The cookie value has a fixed five-minute maximum lifetime and a 2,048-byte +encoded-value limit. Provider expiry may shorten the lifetime. The handoff is +eligible only for an absent registered source on an existing live, consenting +EC root. Current consent, source ownership, bidstream policy, the integration's +capture switch, and the shared handoff switch all still apply. Existing, +expired, invalidated, or conflicting stored records win. + +Persistence retry is best-effort. A later qualifying request may stage one +bounded fill-absent attempt after the response is sent on Fastly. There is no +durable queue, recursive retry, root creation, existing-source refresh, or +first-auction guarantee. Keep rollout and live acceptance gated as described in +the [Lockr integration guide](./integrations/lockr.md). + **Environment Override**: ```bash @@ -781,6 +908,75 @@ Edit TOML, validate, and push again for those changes. Custom headers may be overwritten by application logic. Standard headers (`Content-Type`, `Content-Length`) are controlled by the application. ::: +### Origin URLs in proxied response headers + +Trusted Server rewrites publisher-origin URLs in page bodies to the serving +host. It applies the same mapping to URL-bearing headers on every proxied +publisher response, so the browser stays on the serving host. No configuration +is required. + +| Header | Behavior | +| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `Location`, `Content-Location` | An absolute or protocol-relative URL on the origin is mapped to the serving host. Relative URLs, other hosts, and other ports are unchanged. | +| `Refresh` | The URL after the delay (`0; url=...`) is mapped the same way. | +| `Link` | Each `<...>` target and quoted `imagesrcset` candidate on the origin is mapped; `rel`, `as`, and other parameters are kept. | +| `Content-Security-Policy`,
`Content-Security-Policy-Report-Only` | Every source naming the origin gains a serving-host source beside it. Origin sources are kept; `report-uri` and `report-to` are unchanged. | + +For example, with the origin `origin.example.com` served at `www.example.com`: + +```http +Location: https://origin.example.com/landing.html +Content-Security-Policy: img-src https://origin.example.com data: +``` + +is delivered as: + +```http +Location: https://www.example.com/landing.html +Content-Security-Policy: img-src https://origin.example.com https://www.example.com data: +``` + +Two exceptions prevent a redirect loop. Rewriting either target would send the +browser back to the same URL, and the origin would redirect again: + +- When the origin redirects to the URL being requested but under a different + scheme from `publisher.origin_url` (for example, an `http://` origin forcing + HTTPS), the `Location` or `Refresh` target is left unchanged. +- When the origin answers a `GET` or `HEAD` with a `Location` to the URL being + requested and sets no cookie, the target is left unchanged. The origin is + redirecting because of something Trusted Server sends unchanged on every + retry, such as the `Host` from `publisher.origin_host_header_override`. + +Other same-scheme redirects to the current URL, such as after a form POST or +one that sets a cookie, are rewritten as usual, as is a `Refresh` reload. + +`Set-Cookie` headers are not rewritten. If the origin sets a cookie with a +`Domain` attribute that does not cover the serving host (for example, +`Domain=origin.example.com` served at `www.example.com`), the browser rejects +it. A redirect to the current URL that relies on that cookie then repeats until +the browser reports too many redirects. Have the origin set host-only cookies, +or a `Domain` shared by both hosts. + +Each response keeps its own per-page values. A `[response_headers]` entry for +the same header still replaces the rewritten value. + +Only response headers are covered. A `` or +`` tag in the page body keeps its +origin URLs, so the browser can still navigate to the origin or block rewritten +resources. Deliver these as headers on proxied pages. + +::: warning CSP and injected scripts +Host rewriting only adds serving-host sources. It does not authorize inline +content Trusted Server inserts without a nonce, or the `/static/tsjs=` bundle +when the policy allows neither `'self'` nor the serving host. Check +`script-src` (or `default-src`) on pages with a restrictive policy. + +A serving-host source without a path also allows Trusted Server's own routes +on that host, including `/first-party/proxy`, which serves any target that +`/first-party/sign` signs. A policy that limited scripts to the origin now +allows those too. Set `proxy.allowed_domains` to bound what the proxy serves. +::: + ## Request Signing Configuration for Ed25519 request signing and JWKS management. @@ -992,13 +1188,14 @@ before pushing the config. ## URL Rewrite Configuration -Control which domains are excluded from first-party rewriting. +Control which hosts first-party rewriting covers. ### `[rewrite]` -| Field | Type | Required | Description | -| ----------------- | ------------- | ---------------- | ------------------------- | -| `exclude_domains` | Array[String] | No (default: []) | Domains to skip rewriting | +| Field | Type | Required | Description | +| ----------------- | ------------- | ---------------- | --------------------------------------------------------------------------------------------------- | +| `exclude_domains` | Array[String] | No (default: []) | Hosts never rewritten, for assets and click-through links | +| `include_domains` | Array[String] | No (default: []) | When non-empty, the only hosts whose asset URLs are proxied. Does not apply to click-through links. | **Example**: @@ -1009,12 +1206,14 @@ exclude_domains = [ "first-party.publisher.com", # Exact match "localhost", # Development ] +include_domains = ["assets.example.com", "*.img.example.com"] ``` **Environment Override**: -EdgeZero v0.0.4 cannot replace this array or address its elements by index. Edit -`exclude_domains` in TOML, then validate and push the file again. +EdgeZero v0.0.4 cannot replace these arrays or address their elements by index. +Edit `exclude_domains` and `include_domains` in TOML, then validate and push the +file again. ### Pattern Matching @@ -1043,6 +1242,49 @@ Matches: - ❌ `www.api.example.com` - ❌ `api.example.com.evil.com` +**Normalization and precedence**: + +- Both lists are trimmed and lowercased at load, so matching is case-insensitive. +- `exclude_domains` wins when a host matches both lists. +- Each `include_domains` entry must be able to equal a URL host. After + lowercasing, an entry must be one of: + - a host name of ASCII letters, digits, `-` and `.`, optionally prefixed with + `*.`, with no leading or trailing `.` and no empty label; + - an IPv4 address in dotted-quad form, such as `192.0.2.1`; + - an IPv6 address in brackets, written the way URL hosts render it, such as + `[2001:db8::1]` or `[::ffff:c000:201]` (not `[::ffff:192.0.2.1]`). +- Settings load and `ts config validate` reject any other `include_domains` + entry: an empty entry, a bare `*`, a `*` other than a leading `*.`, a scheme + (`https://`), a port, a path, spaces, an unbracketed IPv6 address, or any + entry a URL parser would rewrite or reject, such as an invalid `xn--` label. + Non-ASCII (internationalized) names are rejected too; write them in punycode, + such as `xn--bcher-kva.example`. +- Empty and bare `*` entries in `exclude_domains` are dropped with a warning, + because they never match a host. Other `exclude_domains` entries that can + never match a host (for example `https://cdn.example.com` or + `cdn.example.com:443`) are kept but logged with a warning at load; fix them, + or their hosts stay proxied. + +::: tip +When both `include_domains` and `proxy.allowed_domains` are set, keep every +`include_domains` host inside `proxy.allowed_domains`. Otherwise a listed asset +is rewritten to a proxy URL that then fails with `403`. +::: + +::: warning Upgrade sequencing and rollback for `include_domains` +An empty `include_domains` is omitted from stored JSON. A non-empty list is +serialized, and older binaries reject a blob that carries it because `[rewrite]` +uses `deny_unknown_fields`. + +**Upgrading:** deploy the binary **first**, then add `include_domains` to +`trusted-server.toml`, run `ts config validate`, and push. + +**Rolling back:** remove `include_domains` from the TOML, run +`ts config validate`, push the resulting blob, and only then roll back the +binary. Rolling back the binary also restores case-sensitive `exclude_domains` +matching. +::: + ### Use Cases **Trusted Partners**: @@ -1606,9 +1848,18 @@ See [GPT](/guide/integrations/gpt). **Section**: `[integrations.gpt_diagnostics]` -The only field is `enabled`, a Boolean that defaults to `false`. When enabled, -the standalone diagnostics tag is available, but individual browser sessions -still require the activation flow in [GPT diagnostics](/guide/integrations/gpt-diagnostics). +Both fields default to `false`: + +| Field | Type | Contract | +| -------------------- | ------- | ---------------------------------------------------------------- | +| `enabled` | Boolean | Make the GPT diagnostics module available for activated sessions | +| `trace_page_enabled` | Boolean | Enable the mobile trace endpoint; requires `enabled = true` | + +Individual browser sessions still require the activation flow in +[GPT diagnostics](/guide/integrations/gpt-diagnostics). The mobile endpoint does +not activate tracing on a GET. Before enabling it, accept the documented +same-origin visibility and privacy implications of cookie health, masked network +facts and browser-carried auction evidence. ### JS Asset Proxy Integration @@ -1936,15 +2187,16 @@ provider or bidder route. ### `[auction]` -| Field | Type | Default | Description | -| ---------------------- | ------- | ------------------ | -------------------------------------------------------------- | -| `enabled` | Boolean | `false` | Enable the auction orchestrator | -| `sanitize_creatives` | Boolean | `false` | Strip executable markup from winning-bid `adm` before delivery | -| `rewrite_creatives` | Boolean | `true` | Rewrite winning-bid `adm` through first-party endpoints | -| `timeout_ms` | Integer | `2000` | Logical auction budget in milliseconds | -| `mediator` | String | `None` | Optional separate `adserver_mock` mediator | -| `creative_store` | String | `"creative_store"` | Deprecated; creatives are delivered inline | -| `allowed_context_keys` | Array | `[]` | Request context keys admitted into the auction | +| Field | Type | Default | Description | +| ---------------------- | ------- | ------------------ | ------------------------------------------------------------------------------ | +| `enabled` | Boolean | `false` | Enable the auction orchestrator | +| `sanitize_creatives` | Boolean | `false` | Strip executable markup from winning-bid `adm` before delivery | +| `rewrite_creatives` | Boolean | `true` | Rewrite winning-bid asset URLs through first-party endpoints | +| `rewrite_clicks` | Boolean | unset | Wrap creative links in `/first-party/click`; unset follows `rewrite_creatives` | +| `timeout_ms` | Integer | `2000` | Logical auction budget in milliseconds | +| `mediator` | String | `None` | Optional separate `adserver_mock` mediator | +| `creative_store` | String | `"creative_store"` | Deprecated; creatives are delivered inline | +| `allowed_context_keys` | Array | `[]` | Request context keys admitted into the auction | Creative markup delivered by `POST /auction` and the publisher SSAT/page-bids path is processed by two independent passes. With `sanitize_creatives = true` @@ -1952,23 +2204,29 @@ path is processed by two independent passes. With `sanitize_creatives = true` and event handlers) is stripped together with its inner content. This blanks script-based creatives, so enable it only when creatives render in a context that shares the publisher's origin. With `rewrite_creatives = true` (the -default), eligible absolute or protocol-relative resource and click URLs not -excluded by rewrite configuration are converted to signed first-party -endpoints, and any bidder-supplied `` element is removed. The -`POST /auction` path emits root-relative endpoints and injects the creative TSJS -runtime exactly once, whether or not the bidder supplied a ``, since bare +default), eligible absolute or protocol-relative asset URLs not excluded by +rewrite configuration are converted to signed `/first-party/proxy` endpoints. +`rewrite_clicks` controls click-through links (``, ``) +separately: when it is unset, it follows `rewrite_creatives`, so existing +configs keep their current behavior. Whenever either setting is on, a bidder-supplied `` element +loses its `href`; it keeps a `target` and is removed when it has none. The `POST /auction` path emits +root-relative endpoints and, when either setting is on, injects the creative +TSJS runtime exactly once, whether or not the bidder supplied a ``, since bare fragments are the common `adm` shape. The foreign-origin SSAT renderer emits -absolute endpoints and does not inject that bundle. With both disabled, `adm` -ships exactly as the bidder returned it, except that a creative larger than the +absolute endpoints and does not inject that bundle. With all three disabled, +`adm` ships exactly as the bidder returned it, except that a creative larger than the 1 MiB per-creative cap is rejected in every mode and its `adm` is dropped. -Accepted external URLs are not host allowlisted by the sanitizer. Neither -setting affects HTML or CSS fetched through `/first-party/proxy`. See +Accepted external URLs are not host allowlisted by the sanitizer. +`rewrite_creatives` and `sanitize_creatives` do not affect HTML or CSS fetched +through `/first-party/proxy`. An explicitly set `rewrite_clicks` does apply to +links in proxied HTML; unset, proxied HTML keeps wrapping links. See [Creative Processing](/guide/creative-processing#auction-rewrite-control). ::: warning Existing configs, upgrade sequencing, and rollback Default values are omitted from stored JSON; non-default values -(`sanitize_creatives = true`, `rewrite_creatives = false`) are serialized, and -older `AuctionConfig` schemas reject unknown fields. +(`sanitize_creatives = true`, `rewrite_creatives = false`, and any explicit +`rewrite_clicks`) are serialized, and older `AuctionConfig` schemas reject +unknown fields. **Upgrading:** binaries that predate `sanitize_creatives` reject a blob that carries it, so in a rolling deployment upgrade the binary **first**, then push @@ -1982,14 +2240,20 @@ on new code, while an explicit `true` fails startup on old code. **Rolling back:** before reverting to a binary that does not know a field, remove that field's non-default value (and any environment override), run `ts config validate`, push the resulting default-compatible blob, and only then -roll back the binary. +roll back the binary. Older binaries tie click wrapping to `rewrite_creatives` +in both directions: rolling back from `rewrite_creatives = true` with +`rewrite_clicks = false` turns click wrapping back on, and rolling back from +`rewrite_creatives = false` with `rewrite_clicks = true` turns it off. **Environment overlays:** The pinned EdgeZero loader cannot create missing TOML leaves. Existing configs must add **both** leaves under `[auction]` (`rewrite_creatives` and `sanitize_creatives`) before `TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES` / `TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES` can take effect. An override for a -missing leaf is silently ignored. +missing leaf is silently ignored. `rewrite_clicks` is unset by default and so +cannot appear as a TOML leaf until you set it; add `rewrite_clicks = true` or +`false` under `[auction]` before relying on +`TRUSTED_SERVER__AUCTION__REWRITE_CLICKS`. ::: ### Provider map @@ -2177,8 +2441,9 @@ finishes local decision and delivery. An auction can therefore exceed its configured wall-clock timeout. Creative sanitization is opt-in. `sanitize_creatives = true` strips executable -markup before delivery. `rewrite_creatives = false` skips first-party URL -rewriting and creative TSJS injection. See +markup before delivery. `rewrite_creatives = false` leaves asset URLs +direct; links still follow `rewrite_clicks`, and creative TSJS is still +injected when `rewrite_clicks = true`. See [Creative Processing](/guide/creative-processing#auction-rewrite-control). **Environment overrides** replace map leaves that already exist in TOML: @@ -2825,131 +3090,195 @@ EdgeZero entry point. The former `edgezero_enabled` and `edgezero_rollout_pct` canary keys are no longer read. `[stores.config].default` in `edgezero.toml` supplies the logical config store -ID and default blob key, currently `trusted_server_config`. Fastly has no -process environment. Its entry point reads service-scoped overrides from the -`edgezero_runtime_env` Config Store before opening the app-config store: +ID, currently `trusted_server_config`. The Fastly runtime opens the store by +that logical ID and reads the entry under the same key on every target. Store +selection is a deploy-time input: a managed `ts deploy --adapter fastly` links +the physical store named by +`EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME` to the service version +under the logical ID. No selector is read from a Config Store at runtime, and +on Fastly the CLI rejects any `--key` or `__KEY` selector other than the +logical ID. + +Keep the `__NAME` selector set in the shell or deploy job for every push, so +`ts config push` writes into the same physical store the deployment links. +Isolate staging by selecting a different physical store. See +[Secret Stores](/guide/fastly#secret-stores) and the +[CLI lifecycle commands](/guide/cli#lifecycle-commands) for the full deploy and +staging flow. -```mermaid -flowchart TD - A[Manifest default store ID] --> B[Resolve store name and blob key] - C[Service-scoped entries in edgezero_runtime_env] --> B - B --> D[Open the resolved resource-link name] - D --> E[Read the selected blob key from the linked physical store] -``` +### Local development -For this logical ID, the runtime selectors are: +The repository's Viceroy configuration uses the logical app-config store ID and +key. Clear any production key override for the local push: -```text -EDGEZERO__SERVICES____STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME -EDGEZERO__SERVICES____STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY +```bash +env -u EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY \ + ts config push --adapter fastly --local ``` -The runtime ignores unscoped entries. Missing or blank selectors fall back to -the logical ID. A resource link must exist under the resolved name, not always -under `trusted_server_config`. +`--local` writes under `[local_server.config_stores.trusted_server_config]` in +the tracked `fastly.toml`, the logical store ID, regardless of any `__NAME` +selector. Review the generated diff and do not commit deployment-specific +app-config entries. Credentials belong in secret stores; the app-config blob +contains their key references. -### Initial setup with a service-specific store +### Rollback -Fastly store names are account-level. Choose a physical name that is not used -by another service. The default physical name is safe only if the service owns -that store exclusively. +Rollback to the legacy entry point is no longer controlled by runtime config +keys. Use the normal deployment rollback path to restore a pre-cleanup service +version if that is required. -Create the Fastly service and an editable service version before provisioning -non-default mappings. Select its ID through top-level `service_id` in -`fastly.toml` or `FASTLY_SERVICE_ID`. If both are set, they must agree. Do not -reuse the checked-in service ID for your deployment. Without a service ID, -provisioning rejects non-default mappings before creating resources. +## Observability and Access Telemetry Configuration -The following example is for initial setup before the service receives traffic. -Replace the service ID and choose your own physical store name: +Settings for the `Server-Timing` response header and the sampled +access-telemetry sink. Both are off by default and are independent switches: +enabling one does not enable the other. -```bash -export FASTLY_SERVICE_ID="" -export EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=example_config +### `[observability]` -ts provision --adapter fastly --dry-run -ts provision --adapter fastly -``` +| Field | Type | Required | Default | Description | +| ----------------------- | -------- | -------- | ------- | --------------------------------------------------------------------------------------------------------- | +| `server_timing_enabled` | Boolean | No | `false` | Append request-phase timings to the `Server-Timing` response header | +| `route_sections` | String[] | No | `[]` | Section names kept as publisher route templates (`/{section}/*`) in access telemetry; empty collapses all | -Provisioning creates the stores and persists the selected name in the -service-scoped `edgezero_runtime_env` entry. Keep all intended store-name -overrides set when provisioning, including any [secret-store mapping](/guide/fastly#secret-stores). -Provisioning reconciles mappings for all declared stores, so omitting a previous -override can remove it. +**Purpose**: Surfaces per-phase request timing (`ts-total` plus recorded +phases such as `ts-appbuild`, `ts-filter`, `ts-geo`, `ts-kv`, `ts-origin`, and +`ts-template-cache`) as a standard `Server-Timing` header, in milliseconds +with one decimal place. An unrecorded phase is omitted from the header +rather than rendered as zero. -For an existing service, Fastly does not reapply `[setup]` entries. Follow the -provisioner's resource-link instructions. Both the app-config store and the -runtime-env store must be linked to the same editable version. For this example: +**Emission is conservative**: the header is appended only on responses that +are conclusively private, meaning `Cache-Control` contains `private` or +`no-store`. A response that is heuristically cacheable, carries a bare +`max-age`, or has no cache header at all never receives the header, because a +shared-cache object would otherwise replay one request's timings for its +entire stored lifetime. The long-lived, shared-cacheable `tsjs` asset route is +the concrete case this excludes. The header is appended, never inserted, so +an origin-supplied `Server-Timing` value and any entries the fronting +delivery layer adds are preserved alongside the TS entries. -```bash -fastly resource-link create --service-id "$FASTLY_SERVICE_ID" --version latest --autoclone \ - --resource-id --name example_config -fastly resource-link create --service-id "$FASTLY_SERVICE_ID" --version latest --autoclone \ - --resource-id --name edgezero_runtime_env +The Axum adapter applies the same private-response rule at its own terminal +point before serializing the response, and emits the header only; it does not +send access-telemetry rows. -ts config push --adapter fastly --dry-run -ts config push --adapter fastly -fastly compute publish --service-id "$FASTLY_SERVICE_ID" --version latest -``` - -Look up each store ID by its name before linking. Confirm the push dry run names -`example_config`, not the account-level default. Publish the application to the -linked version only after seeding its config store; a missing or invalid blob -makes application startup fail closed. Do not activate a new service's empty -version before uploading the application. If your deployment separates upload -from activation, activate the prepared version with -`fastly service-version activate --service-id "$FASTLY_SERVICE_ID" --version ` -only after both the code and config are ready. - -Keep the `__NAME` override in your deployment environment for **every subsequent -push**. The CLI reads its process environment, not the service's persisted -runtime mapping. Omitting the override can write to the wrong physical store. -Reject empty values in deployment scripts rather than relying on the fallback. - -For a live service, changing entries in its active `edgezero_runtime_env` store -changes runtime selection immediately, independently of service-version -activation. Do not use the initial-setup sequence to migrate a live mapping. -Prepare and seed the destination and make its resource link available to the -active version before switching the selector, or use an isolated staged runtime -configuration. - -An existing deployment may instead link a service-specific physical store under -the logical name `trusted_server_config`, with no runtime `__NAME` override. -That alias works, but the CLI still needs the physical-name override on every -push. Do not add a runtime override unless a link under the newly selected name -also exists. - -### Selecting another blob key - -A normal push writes at the logical store ID. A runtime `__KEY` override does -not change that write destination. To select another production key, first push -with `ts config push --adapter fastly --key `, then set the matching -service-scoped `__KEY` entry in `edgezero_runtime_env`. Changing that entry affects -the active service immediately. Do not point the production selector at a -staging key; staged deployments need their own runtime-env store. +**Example**: -### Local development +```toml +[observability] +server_timing_enabled = true +``` + +::: tip The Axum dev server reads this flag once at startup +Unlike the Fastly adapter, which reads settings per request, the Axum dev +server bakes `server_timing_enabled` into its service when it starts. +Flipping the flag there requires a restart to take effect. +::: + +::: warning Client-visible latency disclosure +The `Server-Timing` header is sent to every client on eligible responses, +not only to operators: browsers expose the values to same-origin JavaScript +via `PerformanceResourceTiming.serverTiming`, and any caller can read the +raw header. Enabling it publishes measured per-phase server latency, +including KV read timing on the public identity endpoints (`ts-kv`) and +origin/cache behaviour on publisher pages (`ts-origin`, +`ts-template-cache`). This is standard `Server-Timing` practice and the +values are durations only, but treat the flag as a diagnostic aid to enable +deliberately, not a general always-on toggle, unless disclosing those +timings to all clients is acceptable for the deployment. +::: -The repository's Viceroy configuration uses the default logical app-config name -and key. Clear production overrides for the local push: +**Environment Override**: ```bash -env -u EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME \ - -u EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY \ - ts config push --adapter fastly --local +TRUSTED_SERVER__OBSERVABILITY__SERVER_TIMING_ENABLED=true ``` -`--local` writes under `[local_server.config_stores.]` in the -tracked `fastly.toml`. If you customize Viceroy's service-scoped runtime selectors, -keep that local name and the pushed key aligned with them. Review the generated -diff and do not commit deployment-specific app-config entries. Credentials belong -in secret stores; the app-config blob contains their key references. +::: tip Present-but-false by default +`server_timing_enabled` ships as `false` in the base operator config rather +than being left out, even though `false` is also its default. The +environment-variable overlay can only override a leaf that already exists in +the parsed TOML; it cannot create a missing one. Keeping the leaf present lets +`TRUSTED_SERVER__OBSERVABILITY__SERVER_TIMING_ENABLED` take effect without an +extra edit to add the table first. +::: -### Rollback +### `[tinybird]` access telemetry keys + +`[tinybird]` configures a shared Events API transport (`enabled`, `api_host`, +`secret_store`, and per-sink dataset and token fields) used by two +independent emitters: auction telemetry (`auction_dataset`, +`auction_token_secret`) and access telemetry. The keys below cover the +access-telemetry sink and the shared enable flags. + +| Field | Type | Required | Default | Description | +| --------------------- | ------- | ------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------- | +| `enabled` | Boolean | Yes, when `access_enabled` | `false` | Master switch for the shared Tinybird transport (host, store, credentials) | +| `auction_enabled` | Boolean | No | `true` | Independently gates auction telemetry emission, decoupled from access telemetry | +| `access_enabled` | Boolean | No | `false` | Enables the sampled access-telemetry row sent after each response is delivered | +| `access_dataset` | String | Yes, when `access_enabled` | `access_logs_raw` | Access-log Events API datasource name | +| `access_token_secret` | String | Yes, when `access_enabled` | `tinybird_access_append_token` | Secret Store key holding the access APPEND token | +| `max_body_bytes` | Integer | No | `1048576` | Maximum NDJSON request body size; must be at least 1024 | +| `access_sample_rate` | Float | Yes (`> 0.0`), when `access_enabled` | `0.0` | Fraction (`0.0`-`1.0`) of requests to emit an access-telemetry row for | + +**Purpose**: `access_enabled` and `auction_enabled` gate the two Tinybird +sinks separately so that turning on one does not silently turn on (or leave +off) the other; a settings test locks this decoupling in both directions. +Setting `access_enabled = true` with `access_sample_rate = 0.0` is rejected at +config load as an armed-but-silent configuration; use `access_enabled` itself +to turn the sink off, not the sample rate. Enabling `access_enabled` also +requires the shared transport fields (`enabled`, non-empty `api_host`, +`secret_store`, `access_dataset`, `access_token_secret`, and a +`max_body_bytes` of at least 1024) to already be set. -Rollback to the legacy entry point is no longer controlled by runtime config -keys. Use the normal deployment rollback path to restore a pre-cleanup service -version if that is required. +**Example**: + +```toml +[tinybird] +enabled = true +api_host = "api.tinybird.example.com" +secret_store = "ts_secrets" +auction_enabled = true + +# Access-log telemetry, decoupled from auction emission. +access_enabled = true +access_dataset = "access_logs_raw" +access_token_secret = "tinybird_access_append_token" +access_sample_rate = 0.05 +max_body_bytes = 1048576 +``` + +**Environment Override**: + +```bash +TRUSTED_SERVER__TINYBIRD__ACCESS_ENABLED=true +TRUSTED_SERVER__TINYBIRD__ACCESS_SAMPLE_RATE=0.05 +TRUSTED_SERVER__TINYBIRD__AUCTION_ENABLED=true +``` + +A sampled request emits one access-telemetry row to `access_dataset` after +the response has already been delivered to the client, so ingest never delays +the response the reader sees. + +### Deploy and rollback ordering + +::: warning Push a compatibility config before rolling back +The compatibility boundary is uneven. The top-level `Settings` schema uses +`deny_unknown_fields`, so an older binary rejects a config carrying the +`[observability]` table. The nested `[tinybird]` table does not: an older +binary accepts unknown keys there, rejects `access_enabled = true` through +validation, ignores `auction_enabled` entirely, and reads `enabled = true` +as "auction telemetry on". + +**Deploying**: upgrade the binary first, then push a config containing the +new fields second. Never push a config with these fields while a +pre-observability binary can still receive it. + +**Rolling back**: push a compatibility config first, then roll the binary +back. The compatibility config removes the `[observability]` table, sets +`access_enabled = false`, and, for a deployment that only used the access +sink, sets `enabled = false` as well; otherwise the older binary would +interpret the leftover `enabled = true` as enabling auction telemetry. +::: ## Validation diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index 5edf64c9e..89132e816 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -47,8 +47,8 @@ publisher's first-party domain. This provides: The creative rewriters are invoked by independent delivery paths: -1. **Auction `adm`**: Winning-bid HTML returned by `POST /auction` is optionally sanitized (`[auction].sanitize_creatives`, opt-in, default `false`) and rewritten (`[auction].rewrite_creatives`, default `true`). -2. **First-party proxy**: Non-streaming `text/html` and `text/css` responses fetched through `/first-party/proxy` are rewritten independently of the auction setting. +1. **Auction `adm`**: Winning-bid HTML returned by `POST /auction` is optionally sanitized (`[auction].sanitize_creatives`, opt-in, default `false`) and rewritten (`[auction].rewrite_creatives`, default `true`). Click-through links are wrapped according to `[auction].rewrite_clicks`, which follows `rewrite_creatives` when unset. +2. **First-party proxy**: Non-streaming `text/html` and `text/css` responses fetched through `/first-party/proxy` are rewritten independently of the auction setting. An explicitly set `[auction].rewrite_clicks` also applies to links in proxied HTML. 3. **Integration processing**: Publisher HTML integrations use their own registration and configuration controls. ::: info Streaming Mode @@ -57,26 +57,28 @@ When `with_streaming()` is enabled in `ProxyRequestConfig`, proxied HTML/CSS pro ## Auction Rewrite Control -Two independent auction settings control the processing applied to winning-bid -`adm` returned by `POST /auction` and delivered through the publisher -SSAT/page-bids path. Sanitization is opt-in (default `false`); rewriting is -enabled by default: +Three auction settings control the processing applied to winning-bid `adm` +returned by `POST /auction` and delivered through the publisher SSAT/page-bids +path. Sanitization is opt-in (default `false`) and asset rewriting is enabled +by default. Click wrapping is controlled by `rewrite_clicks`, which follows +`rewrite_creatives` when unset; see [Assets and clicks](#assets-and-clicks). ```toml [auction] sanitize_creatives = false rewrite_creatives = true +# rewrite_clicks unset: follows rewrite_creatives ``` Regardless of mode, a creative larger than the 1 MiB per-creative cap is rejected and its `adm` is dropped. -| `sanitize_creatives` | `rewrite_creatives` | Auction winning-bid `adm` behavior | -| -------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `false` (default) | `false` | Deliver the creative exactly as the bidder returned it (subject to the size cap). | -| `true` | `false` | Strip executable markup (`script`/`object`/`embed`/`form`, event handlers) with its inner content, then deliver without rewriting. Sanitizer-accepted external resource, click, and inline CSS URLs remain direct. | -| `false` | `true` (default) | Rewrite eligible resource/CSS and click URLs in the raw bidder markup to signed first-party endpoints, removing any bidder `` element. Executable markup is preserved. | -| `true` | `true` | Sanitize first, then rewrite. `POST /auction` emits root-relative endpoints and injects creative TSJS exactly once, whether or not the bidder supplied a ``; SSAT/page-bids emits absolute endpoints for its foreign-origin renderer and does not inject the bundle. | +| `sanitize_creatives` | `rewrite_creatives` | Auction winning-bid `adm` behavior | +| -------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `false` (default) | `false` | Asset URLs stay direct. With `rewrite_clicks` unset or `false`, deliver the creative exactly as the bidder returned it (subject to the size cap); with `rewrite_clicks = true`, wrap links as described in [Assets and clicks](#assets-and-clicks). | +| `true` | `false` | Strip executable markup (`script`/`object`/`embed`/`form`, event handlers) with its inner content, then deliver without asset rewriting. Sanitizer-accepted external resource and inline CSS URLs remain direct; links follow `rewrite_clicks` and stay direct when it is unset or `false`. | +| `false` | `true` (default) | Rewrite eligible resource/CSS URLs in the raw bidder markup to signed first-party endpoints, dropping any bidder `` URL. Links are wrapped unless `rewrite_clicks = false`. Executable markup is preserved. | +| `true` | `true` | Sanitize first, then rewrite. `POST /auction` emits root-relative endpoints and injects creative TSJS exactly once, whether or not the bidder supplied a ``; SSAT/page-bids emits absolute endpoints for its foreign-origin renderer and does not inject the bundle. | ::: warning Sanitization blanks script-based creatives Sanitization removes `script`/`object`/`embed`/`form` and similar elements @@ -90,6 +92,45 @@ accepted HTTP(S) URLs may cause the browser to contact external creative hosts directly. ::: +### Assets and clicks + +`rewrite_creatives` governs asset URLs. `rewrite_clicks` governs click-through +links and follows `rewrite_creatives` when unset: + +```toml +[auction] +rewrite_creatives = true +# Unset: follows rewrite_creatives. Set false to keep landing links direct. +# rewrite_clicks = false +``` + +| Assets (`rewrite_creatives`) | Clicks (`rewrite_clicks`) | Asset URLs | Links | `` | TSJS on `POST /auction` | +| ---------------------------- | ------------------------- | -------------------- | ------------------------------------- | -------------- | ----------------------- | +| `true` | `true` or unset | `/first-party/proxy` | `/first-party/click` + `data-tsclick` | `href` dropped | injected | +| `true` | `false` | `/first-party/proxy` | direct | `href` dropped | injected | +| `false` | `true` | direct | `/first-party/click` + `data-tsclick` | `href` dropped | injected | +| `false` | `false` or unset | direct | direct | kept | not injected | + +SSAT/page-bids follows the same table with absolute URLs and never injects TSJS. +`exclude_domains` applies to both assets and links. + +The client-side `tsCreativeConfig.clickGuard` flag is separate. `rewrite_clicks` +decides whether the server emits signed click URLs; `clickGuard` decides whether +the creative runtime re-signs them after creative script changes their query +parameters. With `rewrite_clicks` off the guard still installs its listeners but never +acts, because no link carries `data-tsclick`. + +With `rewrite_creatives = false` and `rewrite_clicks = true`, `POST /auction` +still injects TSJS, but this does not reopen asset proxying in practice: +`/auction` creatives render in a sandbox without `allow-same-origin`, so the +render guard skips `/first-party/sign` and script-inserted assets load direct. +`/first-party/sign` itself is not gated by `rewrite_creatives`. + +Whenever either switch is on, a bidder `` loses its `href` and keeps +`target`; a `` without `target` is removed. With `rewrite_creatives = +false` and `rewrite_clicks = true`, relative asset URLs therefore no longer +resolve against the bidder base, so bidders should use absolute asset URLs. + ::: info Runtime protections inside the sandboxed creative iframe Creatives rendered by Trusted Server's own path run in a sandboxed iframe **without** `allow-same-origin`, i.e. an opaque origin. The injected creative @@ -307,15 +348,18 @@ If the iframe content itself contains HTML, it will be processed recursively. Ea **Rewrite Mode**: Uses `/first-party/click` for direct redirects +**Controlled by**: `[auction].rewrite_clicks` (follows `rewrite_creatives` when unset) + **Example**: ```html -Buy Now +Buy Now Buy Now ``` @@ -651,14 +695,16 @@ exclude_domains = [ ### Pattern Matching -**Wildcard Patterns**: `*` matches any subdomain +Matching is case-insensitive. Entries are trimmed and lowercased at load. + +**Wildcard Patterns**: `*.` matches the base domain and any subdomain ``` Pattern: *.cdn.example.com Matches: ✅ assets.cdn.example.com ✅ images.cdn.example.com - ❌ cdn.example.com (no subdomain) + ✅ cdn.example.com (base domain) ❌ cdn.example.com.evil.com (different domain) ``` @@ -698,6 +744,33 @@ exclude_domains = ["assets.publisher.com"] Skip resources already on your domain. +## Include Domains + +Limit asset rewriting to hosts you list: + +```toml +[rewrite] +include_domains = ["assets.example.com", "*.img.example.com"] +``` + +- When `include_domains` is empty (the default), every eligible asset URL is + proxied. +- When it is non-empty, only asset URLs whose host matches an entry are + rewritten to `/first-party/proxy`. Other assets keep their original URL, load + directly, and do not receive the EC ID that proxied fetches append. +- `exclude_domains` wins when a host matches both lists. +- Click-through links (``, ``) are not affected. Whether + they are wrapped in `/first-party/click` depends only on `rewrite_clicks` and + `exclude_domains`. +- HTML and CSS fetched through `/first-party/proxy` go through the same rewrite + pass and follow the same list. +- `/first-party/sign` declines off-list hosts with a non-`403` error, so the + creative runtime loads them directly. + +Patterns use the same syntax as `exclude_domains`. Entries that can never +equal a URL host, such as ones with a scheme, port, path or non-ASCII name, fail +validation; see [Pattern Matching](/guide/configuration#pattern-matching). + ## Integration Hooks ### Attribute Rewriters diff --git a/docs/guide/edgezero.md b/docs/guide/edgezero.md index ed9c4f2f2..2107f95f5 100644 --- a/docs/guide/edgezero.md +++ b/docs/guide/edgezero.md @@ -16,8 +16,10 @@ version operations use the adapter commands declared in `edgezero.toml`. Configuration is startup state, not a live control plane. A successful push does not change a running instance until the target's restart or deployment path loads that value. Use `--staging` only with the corresponding staging -deployment flow: it writes `LOGICAL_ID_staging` in the same physical store, -while production continues to read the ordinary logical key. +deployment flow. On Fastly the entry key is always the logical store ID, so +staging isolation comes from selecting a different physical store with +`EDGEZERO__STORES__CONFIG____NAME`; see +[the CLI guide](/guide/cli#lifecycle-commands). ## Logical and physical stores diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index fa6428054..6cf1e5e38 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -271,6 +271,16 @@ Used for storing public configuration (e.g., public keys, key metadata): fastly config-store create --name jwks_store ``` +Trusted Server's app config lives under logical store ID +`trusted_server_config`. Select its physical store in each deployment +environment, the same way as the secret store below. Production and staging +isolate their app config by selecting different physical stores, because the +config entry key is the logical ID on every target: + +```bash +export EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME= +``` + ### Secret Stores Trusted Server keeps static app-config credentials under logical store ID @@ -281,33 +291,37 @@ runtime-managed store. Create or select the service before provisioning a non-default mapping. Set its ID in `fastly.toml` or `FASTLY_SERVICE_ID`; if both are set, they must agree. Provisioning rejects non-default mappings without a service ID. Set the physical -mapping before provisioning, alongside any config-store or KV-store overrides: +mapping in the selected deployment environment before provisioning and +deploying, alongside any config-store or KV-store overrides: ```bash export EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME=ts_secrets ts provision --adapter fastly ``` -Provisioning creates or reuses the physical store and persists this runtime -mapping in Fastly Config Store `edgezero_runtime_env`, scoped to the current -Fastly service: - -```text -EDGEZERO__SERVICES____STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME=ts_secrets -``` - -The runtime ignores legacy unscoped entries. The Fastly service must link both -`ts_secrets` and `edgezero_runtime_env` to the active service version. The -custom streaming entry point reads the service-scoped mapping before loading -app config, so every startup and reload resolves static credentials from -`ts_secrets` while the portable manifest continues to declare -`trusted_server_secrets`. - -The same runtime mapping mechanism applies to app-config stores. See -[Fastly runtime config stores](/guide/configuration#fastly-runtime-config-store) -for the initial provisioning and linking sequence, subsequent CLI pushes, and -precautions when changing a live mapping. A process-environment override alone -does not configure the Fastly runtime. +Provisioning creates or reuses the physical store. A managed deployment reads +the selector from its deployment environment and links the selected physical +store to the target service version under the logical ID +`trusted_server_secrets`. The runtime opens the store by that logical ID; no +selector is stored in a Config Store and no service ID appears in the variable +name. + +Each GitHub Environment or deploy process uses the same canonical names and may +select different physical resources. Production and staging can therefore run +identical package bytes against different stores. A staged deploy links the +staging environment's selected stores into only the staged version. Selected +resources must already exist before deployment. + +The custom streaming entry point opens `trusted_server_secrets` before loading +app config, so every startup and reload resolves static credentials from the +physical store linked under that ID while the portable manifest continues to +declare `trusted_server_secrets`. Local Viceroy configuration exposes the store +under the logical ID directly; see `[local_server.secret_stores]` in +`fastly.toml`. + +The app-config store is selected the same way, through +`EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME` in the deployment +environment. No runtime mapping is stored in a Config Store. Create the separate request-signing store when that feature is enabled: @@ -315,8 +329,56 @@ Create the separate request-signing store when that feature is enabled: fastly secret-store create --name signing_keys ``` -Do not copy the same app credential store under a second hardcoded -`trusted_server_secrets` Fastly link. Configure the mapping instead. +The deployment creates the `trusted_server_secrets` link itself from the +selected physical store. Do not create that link by hand. + +### Upgrading from EdgeZero v0.0.8 + +Services deployed before this change link the physical store directly, such as +`ts_secrets`, together with an `edgezero_runtime_env` Config Store. This binary +opens `trusted_server_secrets` instead, and only a managed deployment creates +that link. A plain `fastly compute publish` clones the active version's links, +so it activates a version whose link is missing: app config fails to load and +every publisher route returns 500 while `/health` keeps answering 200. + +When upgrading an existing service: + +- Deploy only through the managed path, so the `trusted_server_secrets` link + exists before activation: run + `ts deploy --adapter fastly --application-release ` with + `EDGEZERO_MANIFEST` set to the release's `edgezero.toml`, or use EdgeZero's + `deploy-fastly` action. +- Move each physical-name selector into the deploy environment before the + first managed deploy. Copy every service-scoped + `EDGEZERO__SERVICES____STORES______NAME` entry from + `edgezero_runtime_env` to the unscoped + `EDGEZERO__STORES______NAME` variable. An unset selector falls + back to the logical ID, so the deploy links whichever account-level store + carries that name, which can belong to another service. +- Select a KV store for logical ID `trusted_server_kv`. The managed deploy + links every declared store, and this one is usually absent on Fastly. Set + `EDGEZERO__STORES__KV__TRUSTED_SERVER_KV__NAME` to an existing KV store or + run `ts provision --adapter fastly`; otherwise the deploy stops with + "selected Fastly KV store `trusted_server_kv` does not exist". +- Re-push staging app config under the logical key into the staging Config + Store, with `EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME` set to + that store. Entries previously pushed under `_staging` are + ignored. Do not rerun an existing `ts config push --adapter fastly --staging` + runbook unchanged: `--staging` no longer selects a separate key, so without + the `__NAME` selector it writes the production entry in the logical-ID store, + and production instances load it on their next start. +- Remove any `EDGEZERO__STORES__CONFIG____KEY` selectors. On Fastly the + CLI rejects any config key other than the logical store ID, so a leftover + selector makes `ts config push` and `ts deploy` fail until it is removed. +- Delete the stale `edgezero_runtime_env` link and any `*_staging` config + entries only after no active, staged, or rollback version depends on them. + A version-linked selector on an older staged version still reads + `_staging`, so removing the entry while that version is + deployed makes its publisher routes return 500. This release no longer + reads that store. If you set the optional `TS__SANDBOX__*` reuse bounds + there, move them into the app-config store under the logical ID + `trusted_server_config`, without the `EDGEZERO__SERVICES____` + prefix. ## Create EC KV Store @@ -340,7 +402,8 @@ passphrase = "ec_passphrase" ec_store = "ec_identity_store" ``` -Store the high-entropy passphrase under that key in `ts_secrets`. The resolved +Store the high-entropy passphrase under that key in the physical store +selected for `trusted_server_secrets`. The resolved value, rather than the key name, must contain at least 32 characters. Pipe the value over standard input so it never appears in the process argument list: @@ -378,7 +441,8 @@ Run the repository smoke from a clean shell: The script creates an isolated application config, applies its publisher-origin overrides, and runs strict validation. It then executes `ts config push --adapter fastly --local`, adds all three required entries to -`[local_server.secret_stores.ts_secrets]`, and starts `fastly compute serve` +`[local_server.secret_stores.trusted_server_secrets]`, and starts +`fastly compute serve` through Viceroy. The required keys are `handler_password`, `publisher_proxy_secret`, and `ec_passphrase`. diff --git a/docs/guide/first-party-proxy.md b/docs/guide/first-party-proxy.md index 69d4ed7c6..1e0f782fd 100644 --- a/docs/guide/first-party-proxy.md +++ b/docs/guide/first-party-proxy.md @@ -170,6 +170,8 @@ POST /first-party/sign | `href` | Complete signed proxy URL ready to use | | `base` | Original base URL (without query parameters) | +A host excluded by `rewrite.exclude_domains`, or outside a non-empty `rewrite.include_domains`, returns `502`. The creative runtime treats it as a non-policy failure and loads the URL directly. These checks run before `proxy.allowed_domains`. + A valid host outside a non-empty allowlist returns `403 Forbidden`. The creative runtime treats this response as a policy rejection and does not assign the attempted raw image or iframe URL. Network failures, malformed success responses, and non-403 errors keep the existing direct-load fallback. An empty allowlist permits every valid host. This blocked-assignment behavior applies when the runtime intercepts a `src` setter or `setAttribute` call before native assignment. Markup inserted through `innerHTML` or `document.write` may begin a native load before the `MutationObserver` requests a signature. A later `403` does not retract that existing attribute. @@ -506,7 +508,7 @@ allowed_domains = [ ::: -### URL Rewrite Exclusions +### URL Rewrite Host Lists Exclude specific domains from rewriting: @@ -520,6 +522,17 @@ exclude_domains = [ URLs matching these patterns will NOT be rewritten to `/first-party/proxy`. +Limit asset rewriting to listed hosts: + +```toml +[rewrite] +include_domains = ["assets.example.com", "*.img.example.com"] +``` + +When `include_domains` is non-empty, only asset URLs on matching hosts are +rewritten. `exclude_domains` wins when a host matches both lists, and +click-through links ignore `include_domains`. + ### Streaming vs Buffered Control whether responses are streamed or buffered: diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index d83b0cc86..a1fa12e4b 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -207,12 +207,26 @@ Restart or redeploy instances after secret rotation. See ## Deploy to Fastly +Trusted Server declares Config, KV, and Secret Stores, so deployment must go +through EdgeZero's managed path. That path links each selected physical store +to the service version under its logical ID; `fastly compute publish` clones +the active version's links instead and activates a version whose logical links +are missing. + ```bash -TRUSTED_SERVER__GIT_VERSION=v1.3.0 fastly compute publish +EDGEZERO_MANIFEST="/edgezero.toml" \ + ts deploy --adapter fastly --service-id --application-release "" ``` -The build compiles `TRUSTED_SERVER__GIT_VERSION` into the binary and reports it -in the `x-ts-version` response header. The Fastly service version is in +See [the Fastly guide](/guide/fastly#upgrading-from-edgezero-v0-0-8) when +upgrading a service that was deployed before logical store links, and +[the CLI guide](/guide/cli#lifecycle-commands) for where `` +comes from. + +Set `TRUSTED_SERVER__GIT_VERSION` when building the Fastly package that goes +into the release; `ts deploy` deploys the prebuilt release and does not rebuild +it. The build compiles the value into the binary and reports it in the +`x-ts-version` response header. The Fastly service version is in `x-ts-fastly-version`. Without the variable, a build reports the checked-out tag, else the branch, else the first 6 characters of the commit, and a build outside a git checkout omits the header. The value is public on every diff --git a/docs/guide/integrations/gpt-diagnostics-dictionary.md b/docs/guide/integrations/gpt-diagnostics-dictionary.md new file mode 100644 index 000000000..6a487a65a --- /dev/null +++ b/docs/guide/integrations/gpt-diagnostics-dictionary.md @@ -0,0 +1,167 @@ +# GPT Diagnostics Label Dictionary + +This dictionary defines the exact fixed labels and dynamic label prefixes in the GPT Runtime Diagnostics panel and badges. Text after a prefix such as `Server auction winner:` is the bounded observed value. The console reports observations; it does not identify the creative ultimately served unless a listed evidence source explicitly establishes that fact. Browser times use `performance.now()` and server times use the server request's `RequestTimings` clock. The clocks are never subtracted. + +See [GPT Runtime Diagnostics](./gpt-diagnostics.md) for activation and operational details. + +## Identity and controls + +| Label | Badge | Source | Meaning and limits | +| ----------------------------- | ------------ | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `Ad #N` | `Ad #N` | `runtimeSlotNumber` | Stable number assigned to a GPT slot object in this page. It is not a creative ID. | +| `Request #M` | `Request #M` | `requestNumber` | Monotonic request cycle for that slot, including refreshes. It does not imply fill. | +| `GPT-reported creative` | — | `adManager.creativeId` or `sourceAgnosticCreativeId` | GAM's callback identifier. It is separate from Ad/Request identity and does not identify a demand source. | +| `Filter` | — | panel state | Limits displayed rows. Activating a badge resets a hiding filter to `All`. | +| `Export JSON` | — | `gptDiagnostics.export()` | Downloads the allowlisted V1 snapshot. It issues no ad request. | +| `Label dictionary` | — | documentation link | Opens this keyboard-accessible help page. | +| `How to read this evidence` | — | inline help | Expands a concise explanation that winner, candidate, render, and timing observations have separate meanings and clocks. | +| `Locate on page` | — | exact unique DOM binding | Scrolls only on activation and briefly draws a diagnostics-layer highlight. It never changes publisher attributes, classes, or styles. | +| `Collapse`, `Expand`, `Close` | — | panel state | Presentation controls only. `show()` can reopen a closed panel. | +| `Request history` | — | retained cycles | Earlier retained requests. At most ten cycles are retained per slot. An evicted selection is reported as no longer retained. | +| `Technical details` | — | allowlisted snapshot fields | Expands correlation IDs, coverage, failures, and other non-summary evidence. | + +The per-request panel groups are `Summary`, `Auction evidence`, `Delivery evidence`, `Timing`, and `Size and visibility`. Summary prefixes are `GPT result:` and `Observed auction path:`. Technical prefixes include `Ad unit`, `Request intent:`, `Trusted Server auction:`, and `Prebid auction:`. A fact appears in one detailed group; Technical details does not repeat those grouped facts. + +Panel status is `GPT observed` or `Waiting for GPT`. Filter values are `All`, `Visible`, `Filled`, `Empty`, `Pending/Incomplete`, and `Unbound/Ambiguous`. Empty results are `No GPT slots observed yet.` or `No slots match.` The retained-cycle labels are `Initial request` and `Refresh N`; an evicted selection is announced as `Ad #N, Request #M is no longer retained.` The overview counts use the prefixes `slots`, `callback issues`, and `attribution issues`. Callback coverage uses the suffixes `observed`, `matched`, `unmatched`, and `ambiguous`. + +## GPT lifecycle and delivery + +| Label | Badge | Raw value / source | Meaning and what it does not prove | +| -------------------------------------------------------------------- | ------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Waiting for request` | — | no request cycle | GPT has not emitted `slotRequested` for the slot. | +| `Requesting` | `Pending` | `slotRequested` | A GPT request callback was observed. Elapsed time alone does not mean failure. | +| `Response received` | — | `slotResponseReceived` | GPT emitted its response callback. It does not prove fill. | +| `GPT result: Filled` / `Filled` | `Filled` | `slotRenderEnded.isEmpty === false` | GPT reported non-empty. It does not prove which bidder or pixels were served. | +| `GPT result: Empty` / `Empty` | `Empty` | `slotRenderEnded.isEmpty === true` | GPT explicitly reported empty. | +| `GPT result: Rendered (fill unknown)` / `Rendered (fill unknown)` | `Rendered (fill unknown)` | `slotRenderEnded` without `isEmpty` | Render callback observed; fill remains unavailable. | +| `Creative markup sent; execution not confirmed` | `TS response sent` | `delivery=trusted_server_response_sent` | The bridge posted markup to PUC. It does not prove execution, visibility, or final served source. | +| `Server bid selected by the creative bridge; response not confirmed` | `TS selected` | `delivery=trusted_server_selected` | A matched PUC request selected the server bid; no successful response post was observed. | +| `Server bid available; selection not confirmed` | `TS unconfirmed` | `delivery=candidate_unconfirmed` | A server candidate existed but no matched selection appeared in the observation window. | +| `Waiting for Trusted Server creative evidence` | `TS candidate (pending)` | `delivery=pending` | The five-second positive-evidence window is open. | +| `No direct Trusted Server candidate` | `No TS candidate` | `delivery=no_candidate` | `adInit` explicitly found no direct candidate for this request. | +| `Delivery status unknown — required evidence was not observed` | `Delivery unknown` | `delivery=unknown` | Evidence exists but cannot establish a more specific delivery state. | +| `Delivery evidence: Not applicable` | — | `delivery=not_applicable` | No positive bridge evidence exists and a delivery conclusion does not apply before render or for an explicitly empty result. | +| `Delivery evidence: Not observed` | — | missing delivery state | No delivery state was captured. | +| `Served bidder not confirmed` | — | a non-empty GPT render without served proof | Server winner, targeting candidate, `bidWon`, and GPT render facts remain separate; none alone confirms the final served creative. This label is not shown for pending or empty requests. | +| `GPT slot onload observed` | — | `slotOnload` | GPT emitted onload. It is not pixel-level or demand-source proof. | +| `GPT impressionViewable observed` | — | `impressionViewable` | GPT emitted its viewability callback. | +| `Incomplete sequence` | `Incomplete sequence` | `incompleteSequence` | An observed callback proves a missing or invalid predecessor. Time alone never sets it. | + +## Auction evidence + +| Label | Badge | Raw value / source | Meaning and limits | +| ----------------------------------- | ---------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SSAT: initial-page server auction` | `SSAT` | `auctionType=ssat` | Explicit completed server-auction evidence from the initial page request. | +| `TS auction: SPA server auction` | `TS auction` | `auctionType=trusted_server` | Explicit completed `/_ts/page-bids` server-auction evidence. | +| `Client-side Prebid auction` | `Prebid auction` | `auctionType=client_side`, exact Prebid callback correlation | A completed Prebid attempt was correlated to this slot and next GPT request. A refresh wrapper call alone does not establish it. | +| `Multiple auction paths observed` | `Multiple paths` | `auctionType=competing` | Completed server and Prebid auction evidence both exist for the request. This does not prove a race, overwrite, or winner. | +| `Auction not observed` | no auction badge | missing or malformed explicit facts | Diagnostics has no qualifying completed-auction evidence. It never defaults to SSAT. | +| `Server auction winner` | — | compatibility `auctionWinner`, server `hb_bidder` | Winner selected by the server auction. It is not the final served bidder. | +| `Server bid price bucket:` | — | server `hb_pb` | Already-bucketed price; raw CPM is never exported. A supplied currency follows the bucket value; an absent currency adds no suffix. | +| `Prebid targeting candidate:` | — | `prebidAuction.targetingCandidate` after exact `setTargetingForGPTAsync` boundary | Candidate targeting observed on the exact GPT slot for the exact Prebid callback auction ID. It is not a final win. | +| `Prebid candidate price bucket:` | — | candidate `hb_pb` | Bucketed targeting value for the candidate; it is not a winning-price claim. Diagnostics do not infer or read a Prebid currency targeting key. | +| `Prebid bidWon observation:` | — | `prebidAuction.win`, documented `bidWon` payload | A bounded event joined by exact auction ID, ad-unit code, slot object, navigation generation, and retention window. It remains distinct from GPT render and served-source proof. | +| `Prebid win price bucket:` | — | `bidWon.adserverTargeting.hb_pb` | Bucketed value observed on the correlated `bidWon` event; it is not proof of the creative GAM served. Diagnostics do not infer a currency. | + +Bidder names are limited to 128 UTF-8 bytes, numeric bucket strings to 64 bytes, currencies to three ASCII letters, and auction IDs to 256 UTF-8 bytes. Prebid supplies its own auction ID through `bidsBackHandler`; diagnostics does not override Prebid auction identity. Duplicate, ambiguous, expired, late, prior-navigation, and malformed observations are rejected. Only an active diagnostics recorder installs the `bidWon` listener or retains candidate/win state, which is bounded to 128 pending attempts and 30 seconds. No raw CPM, creative markup, targeting dump, or losing bids are retained. + +First-party GPT and Prebid integrations never populate `currency`. The optional field +accepts currency supplied through the internal `gptDiagnosticsRecorder` channel; that +channel is not a supported operator API. Diagnostics does not infer a currency or read +`hb_cur`, and missing currency adds no suffix to the price bucket. + +`Auction not observed` also covers the Prebid watchdog fallback: targeting can be +applied even when Prebid never invokes `bidsBackHandler`, so no completed-auction ID +is available to correlate with the GPT request. Targeting alone is not proof of a +completed auction. + +## Request paths and opportunities + +| Label | Raw value | Meaning | +| -------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `Request path: Trusted Server direct` | `trusted_server_direct` | The direct `adInit` route preceded the request. Route evidence alone is not auction evidence. | +| `Request path: Prebid refresh` | `prebid_refresh` | The installed wrapper delegated the GPT refresh. Timeout and throw fallbacks retain this route label but do not claim a completed auction. | +| `Request path: Publisher refresh` | `publisher_refresh` | Publisher refresh boundary observed. | +| `Request path: Multiple paths observed` | `competing` | More than one route marker preceded the request; competition is possible but unproven. | +| `Request path: Not observed` | `unattributed` | No eligible route marker was consumed. | +| `Server bid available; creative source present` | `renderable_candidate` | Bid targeting plus ad ID and inline markup or complete cache coordinates were present. | +| `Server bid available; creative source incomplete` | `unrenderable_candidate` | Bid targeting existed but the bridge lacked a complete render source. | +| `Direct opportunity: No candidate` | `no_candidate` | `adInit` explicitly observed no direct bid targeting. | +| `Direct opportunity: Not observed` | missing opportunity | No bounded direct-opportunity evidence was captured. It is not negative demand-source evidence. | +| `Request intent:` | `requestIntentId` | Opaque local correlation sequence, not an auction or user ID. | +| `Trusted Server auction:` | `trustedServerAuctionId` | Opaque per-auction correlation token, not a GAM key or visitor identifier. | +| `Prebid auction:` | `prebidAuction.auctionId` | Opaque Prebid-supplied auction correlation token. Diagnostics does not create or replace it. | + +Path markers live for five seconds, are consumed once, and are keyed by GPT slot object identity. + +## Timing + +All values are milliseconds. Missing timing that should apply is `Unavailable`, never zero. Server timing is `Not applicable` when no completed server auction was observed. A displayed zero is a valid immediate observation. The three server rows below are rendered with their T0 anchor substituted: `Initial document request T0` for a navigation-origin auction and `SPA page-bids request T0` for an SPA-origin one. Both offsets use their adapter's request clock, not a handler-local or browser clock. + +| Label | Origin and boundaries | Raw field | +| ---------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------ | +| ` → auction dispatched` | Server request `RequestTimings` T0 to successful `dispatch_auction` outcome | `serverAuctionTimings.auctionDispatchedMs` | +| ` → auction collected` | Same T0 to completion of `collect_dispatched_auction` | `auctionResolvedMs` | +| ` → bids ready` | Same T0 to winning-bid map commit | `auctionCommittedMs` | +| `Auction collection wait` | Actual duration blocked in collect; placement is `pre-header` or `in stream` | `auctionWaitMs`, `auctionWaitPlacement` | +| `Opportunity → request` | Browser `performance.now()`: recorder observation to matched GPT `slotRequested` | `opportunityToRequestMs` | +| `GAM request → response` | Browser `slotRequested` to `slotResponseReceived` | `durations.requestToResponseMs` | +| `GAM response → render` | Browser `slotResponseReceived` to `slotRenderEnded` | `responseToRenderMs` | +| `GAM request → render` | Browser `slotRequested` to `slotRenderEnded` | `requestToRenderMs` | +| `Render → load` | Browser `slotRenderEnded` to `slotOnload` | `renderToLoadMs` | +| `Render → viewable` | Browser `slotRenderEnded` to `impressionViewable` | `renderToViewableMs` | +| `Replaced rendered request` | Earlier browser render callback to later request callback | `previousRenderToRequestMs` | + +Server offsets are not browser timestamps. `auctionResolvedMs` means collection completed (including timeout handling), not that a network byte arrived at that exact instant. + +## Size, visibility, binding, and GAM fields + +| Label | Raw field / source | Meaning and limits | +| ------------------------------------------------------------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `Requested sizes` | `requestedSlotSizes` | Configured sizes supplied to GPT; ordinary sizes such as 300×250 remain visible. Missing data is shown as `Requested sizes: Not observed`. | +| `GPT-reported size` | `size` | Exact `slotRenderEnded.size`. A 1×1 placeholder is shown as `GPT-reported size: placeholder hidden` and retained unchanged in V1 JSON. | +| `Size filled` / `Measured outer slot size` | `observedSlotSize` | CSS outer box of the exact uniquely bound slot after fill. Missing data is shown as `Size filled: Not observed · Measured outer slot size`. | +| `GPT visibility` | current/maximum visibility percentage | Values from GPT visibility callbacks; absence is `GPT visibility: Not observed`. | +| `Binding: Bound` / `Bound` | `binding.status=bound` | Exactly one connected publisher element matched. | +| `Binding: unbound` / `Unbound` | `binding.status=unbound` | No safe element binding; reason is shown. | +| `Binding: ambiguous` / `Ambiguous binding` | `binding.status=ambiguous` | Duplicate ID or GPT-slot evidence prevented a safe choice. No badge or locate action is invented. | +| `Outside viewport` | binding geometry | The exact element does not intersect the viewport; it remains in the panel. | +| `Ad Manager response class: empty/backfill/reservation/unclassified non-empty` | `responseClass` | Source-neutral classification derived from GPT render facts. | +| `Ad Manager reported …` | `adManager` identifiers | GAM-reported line item, order, advertiser, creative, yield-group, and company IDs. They do not identify the demand source. | +| `Ad Manager fields: Not observed` | missing `adManager` | No allowlisted GAM identifier was captured. | +| `Ad Manager response class: Not observed` | missing `responseClass` | GPT did not provide enough render evidence to classify the response. | +| `Backfill yes/no` | `isBackfill` | GPT's callback value. | +| `Slot content changed yes/no` | `slotContentChanged` | GPT's callback value; not proof pixels changed. | +| `Creative changed/unchanged` | retained GAM creative IDs | Comparison only when both cycles supplied an ID. | + +The unprefixed `Bound`, `Unbound`, and `Ambiguous binding` variants appear in Technical details only when a slot has no request cycle. Once request groups exist, binding appears only under Size and visibility. + +Binding reasons are `missing_slot_element_id`, `missing_element`, `duplicate_dom_id`, `dom_uniqueness_unverifiable`, and `duplicate_gpt_slot_id`. Other exact technical fact prefixes are `Replaced rendered request`, `Creative changed`, `Creative unchanged`, `Trusted Server creative request observed at`, and `Trusted Server markup response sent at`. + +## Failures, attribution, coverage, and retention + +Creative bridge labels are `Creative bridge failure: missing render source`, `Creative bridge failure: cache fetch failed`, `Creative bridge failure: invalid cache payload`, and `Creative bridge failure: response post failed`. They map directly to `missing_render_source`, `cache_fetch_failed`, `invalid_cache_payload`, and `response_post_failed`; they describe only the observed bridge step and contain no URL, markup, payload, or stack trace. + +Attribution issue labels map to `creative_request_without_slot`, `creative_request_without_cycle`, `creative_request_ambiguous_cycle`, `creative_request_on_empty_cycle`, `creative_attempt_capacity`, `creative_attempt_unknown`, `creative_attempt_expired`, and `creative_attempt_evicted`. Callback coverage separately counts `observed`, `matched`, `unmatched`, and `ambiguous` for `slotRequested`, `slotResponseReceived`, `slotRenderEnded`, `slotOnload`, `impressionViewable`, and `slotVisibilityChanged`. + +The V1 export remains additive and compatible: optional Prebid evidence and optional currency fields are new; existing fields keep their meanings. Bounds are 64 slots, ten request cycles per slot, 128 callback issues, 128 attribution issues, 64 direct associations, 16 requested sizes, and 128 creative attempts. Metadata reports dropped callbacks/issues and evicted slots/cycles. + +## Missing-value vocabulary + +- **Not observed**: the relevant callback or explicit evidence was not captured. +- **Unavailable**: the value cannot be calculated or safely retained. +- **Not applicable**: the fact does not apply, for example delivery conclusions before render or for an explicitly empty result. +- **Unknown**: evidence exists but is insufficient to select a more specific state. + +## Acronyms + +- **TS**: Trusted Server. +- **SSAT**: server-side ad targeting on the initial page request. +- **SPA**: single-page application. +- **GPT**: Google Publisher Tag. +- **GAM**: Google Ad Manager. +- **Prebid**: the client-side header bidding library observed here. +- **PBS**: Prebid Server. +- **PUC**: Prebid Universal Creative. +- **CPM**: cost per thousand impressions; diagnostics retains only a bucketed targeting value, never raw CPM. +- **T0**: the start of one server request's `RequestTimings` clock. diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index af6f2e324..40a042163 100644 --- a/docs/guide/integrations/gpt-diagnostics.md +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -9,9 +9,8 @@ GPT Runtime Diagnostics is an opt-in browser console for documented Google Publisher Tag (GPT) lifecycle callbacks and Trusted Server integration evidence. -It groups observations into per-slot request cycles, shows timings and source-neutral -GAM facts, binds slots to exact DOM elements, and downloads the same allowlisted data -as versioned JSON. +It groups observations into per-slot request cycles, shows auction and GPT timings, +links each accessible page badge to the same stable `Ad #N` and `Request #M` in the side panel, binds slots to exact DOM elements, and downloads the same allowlisted data as versioned JSON. See the complete [label dictionary](./gpt-diagnostics-dictionary.md) for every operator-facing term. The console reports positive observations, not inferred ownership. A filled result means only that GPT emitted `slotRenderEnded` with `isEmpty === false`. A Trusted @@ -45,8 +44,8 @@ and non-storeable. ### Auction correlation token -Enabling the integration has one further server-side effect, beyond module -availability, that does not depend on browser activation. For each server-side auction +The existing `hb_auction_id` publication has one further server-side effect, beyond module +availability, that does not depend on browser activation. With mobile tracing inactive, for each server-side auction that produced winning bids, Trusted Server mints a fresh correlation token and publishes it as `hb_auction_id` on each winning bid in `window.tsjs.bids`: @@ -67,6 +66,11 @@ ts-auc-2f8c1d5a4b7e4c0f9a3d6b1e8c5f2a7d documents with no active console session, because the console reads it from the same page bid state the GPT integration already consumes. +Active mobile tracing instead mints its opaque identity before dispatch and reuses +that token in deliverable auction evidence and GPT opportunity markers. Zero-bid, +skipped and failed auctions can carry evidence independently of winning bids. +Disabling mobile tracing retains the existing console-only publication behavior. + ## Activate or Deactivate a Browser Session Open a page with one of these exact, case-sensitive query directives: @@ -94,14 +98,190 @@ An exact directive establishes or clears the host-only, `Secure`, `HttpOnly`, `SameSite=Lax` `__Host-ts-console` session cookie. The server removes every reserved `ts_console` pair before origin, cookie, or auction handling, and the response removes the directive from the visible URL while preserving the path, unrelated query pairs, -and fragment. Activation applies to the same origin across tabs until the browser -session ends or an exact deactivation directive clears it. +and fragment. Explicit activation lasts 30 minutes; ordinary requests do not +extend that lifetime. The host-only cookie is shared across tabs on that hostname +and does not isolate different ports. An exact deactivation directive clears it. Duplicate directives, unrecognized values, and duplicate activation cookies fail closed for the current response. Active and directive-bearing HTML responses use `Cache-Control: private, no-store` and omit surrogate cache headers. The cookie is never forwarded to the publisher origin and is unrelated to `ts-tester`. +## Mobile Trace Page + +The mobile report is a separate opt-in surface. Its deployment option defaults +to `false` and requires the diagnostics integration: + +```toml +[integrations.gpt_diagnostics] +enabled = true +trace_page_enabled = true +``` + +Before enabling it, the operator must accept that same-origin scripts can read +the projected health of four owned cookies, opaque auction outcomes, a masked +network identifier and available coarse network metadata. A masked identifier +can still identify a network. The `HttpOnly` cookie value remains inaccessible +to scripts; the report deliberately exposes only validated shape and observation +categories. No cookie values, exact publisher path, provider identity, creative +markup, bid price or external user identifier belongs in a trace report. + +Open `/_ts/trace` on the exact publisher hostname and in the same browser tab. +The initial visit is read-only and describes its own **Setup request**, not an +earlier ad failure. Tap **Enable tracing**. A successful POST requests the cookie +change; a separate state request must observe a valid session before the page +says tracing is on. Return to the affected page, explicitly reload it once, +reproduce the issue, then tap **View trace results** in the TS Console. A history +Back or restored page alone does not prove a new capture. If history is not useful, +reopen the article on that same hostname in that tab and reload once. + +The publisher action validates one combined report and saves it before navigating +in the same tab. If storage alone fails, it stays on the publisher page and offers +an explicit download of that same valid report. A failed capture does not offer a +GPT-only export as an equivalent fallback. The viewer keeps traced-document facts +separate from its setup request. Server-auction and browser/GPT clocks remain +separate; candidate selection or a filled slot does not establish an auction winner. + +Slot returned-bid counts include records returned by bidder and mediator calls, +including mediator echoes. They are observations across stages, not unique bids; +do not sum slot counts to infer a unique bid total. + +### Storage, export and cleanup + +One versioned `sessionStorage` key holds at most 512 KiB of compact UTF-8 data. +A new explicit capture replaces it. Reports expire after 15 minutes, but tabs with +an opener and browser session restoration may copy or retain the entry. Same-origin +scripts and service workers can read or replace it. The viewer labels it +**Browser-carried, unverified diagnostic data**; it is troubleshooting information, +not cryptographic proof, identity evidence or security evidence. + +**Copy**, **Download** and file **Share** use the same validated public report JSON. +The selected share app receives that file. The viewer does not upload a report or +substitute URL sharing when file sharing is unavailable. Native download or share +initiation does not prove that a recipient saved the file. + +**Delete local report** removes only this tab's owned report key. After confirmation, +**Clear report and end tracing** attempts local deletion, the end POST and a separate +state check independently. Offline requests cannot prevent local deletion. A failed +deletion retains its own retry; unconfirmed server end retains a separate retry and +may leave tracing active. An inactive state means no valid session was observed in +that request; it does not prove that every cookie is absent. Exported files and +copies in other tabs or apps are not removed by local cleanup. + +### Deployment and runtime boundary + +Use a suitable same-origin deployment where the browser accepts the unchanged +`Secure`, `HttpOnly`, host-only `SameSite=Lax` cookie. Its explicit activation lifetime +is 1800 seconds; ordinary requests do not refresh it. Do not weaken cookie attributes +or infer HTTPS from an untrusted forwarding header to make a test pass. + +On deployed Fastly, Cloudflare and Spin staging services, verify HTTPS Enable returns a +successful response, a separate state request observes the session, publisher +reload captures evidence, and End followed by another state request observes +inactivity. Local plain-HTTP runtime tests do not establish this HTTPS behavior. + +Enable and End require the browser's canonical Origin to agree with the +effective public origin. By default that origin comes from the runtime's +trusted transport metadata. A TLS-terminating proxy that forwards HTTPS as +HTTP, or replaces the public authority with an internal host, can therefore +cause an intentional `403`, including on Spin and Axum. + +For that arrangement, explicitly configure an +[authenticated forwarder](../configuration.md#trusted-forwarder) and provision +its shared secret in the secret store. The proxy must supply one authenticated +`X-Forwarded-Host` and `X-Forwarded-Proto` pair bounded to the configured publisher +domain or a subdomain. This supports an HTTPS browser using Axum's plain-HTTP +listener without changing the received transport evidence. The request URI and +Host must still agree with that evidence; forwarding cannot upgrade cookie or +header fidelity. Unauthenticated `Forwarded` or `X-Forwarded-*` fields do not +grant trust. Verify Enable, state and End through the actual proxy with the +unchanged Secure cookie attributes before accepting the deployment. + +Publisher activation and context use injected inline scripts without an attached +CSP nonce. A publisher nonce/hash policy that does not authorize those scripts +can prevent diagnostics activation and capture. Check the literal activation gate +and actual capture under the deployed publisher CSP; do not weaken that policy +or the trace viewer's separate CSP to make the check pass. + +A pre-existing session cookie cannot be retroactively assigned this lifetime by +the server. End tracing and explicitly enable it again to adopt the bounded cookie. +Expiry or disabling the deployment flag does not unload an already-running page; +reload documents after rollback. Subsequent gated server requests stop producing +new evidence immediately when the deployment option is disabled. + +Ordered Basic-auth handlers remain first-match-wins. They match the literal path +visible after runtime conversion, before trace feature flags or method checks. +Protect the shell and its fixed assets consistently if operator authentication is +required. Without a matching handler, this opt-in surface is available to users of +that publisher deployment. The script and stylesheet are fixed same-origin versioned +assets. Dynamic endpoints and evidence-bearing responses are `private, no-store`; +successful assets are publicly immutable only when unprotected. + +The boundary distinguishes runtime rejection before invocation, adapter +bootstrap/conversion failure, and successfully converted application-visible +handling. Fastly, Cloudflare or Spin can normalize a path or change visible header +representation before Trusted Server sees it. A path normalized outside the trace +namespace follows ordinary health/routing behavior. A path normalized into a literal +trace path receives full trace authentication and handling. Aliases still visible +encoded after conversion are reserved and rejected under authentication for their literal visible path; decoding does not +grant different authentication coverage. Original wire-target reconstruction is not +a release prerequisite or a source of authentication decisions. + +The visible Cookie header is capped at 16384 bytes. Invalid visible text or size +prevents health inspection. When runtime fidelity cannot guarantee preservation, +replacement characters, or commas that border a Trusted Server cookie name and +could be folded field boundaries, conservatively make all four cookie rows +unavailable and suppress capture. Commas inside unrelated cookie values, such as +JSON-valued sign-in or consent cookies, do not block tracing; marker-free headers +with unknown fidelity can still activate tracing. This can produce false negatives rather than +guessing a session. End remains available for ambiguous cookie observations. + +Locally handled authentication, routing and control failures use bounded +400/401/403/404/405/413 responses. A runtime rejection has no fabricated local trace +response. Adapters may buffer a request body before the hook runs; the hook supplies +no wall-clock timeout, memory-allocation guarantee or earlier raw-header access. +Unsupported metadata and request-relative server milestones display **Unavailable**. +There is no historical recovery or server-side report store. + +### Release acceptance + +Keep `trace_page_enabled` off until the required automated checks in the +[implementation plan](https://github.com/IABTechLab/trusted-server/blob/750ba8dc470aa37f3eb62512cc2b5a025bab8f9d/docs/superpowers/plans/2026-10-05-mobile-ad-render-trace-implementation-plan.md) +pass. The browser checks must exercise the complete setup, real cookie observation, +publisher reload, capture, viewer, export and end flow on each supported adapter. +Real bidder transport and creative rendering require their separate live checks; +an auction-disabled browser fixture does not establish those behaviors. + +Before production rollout, also complete the following checks on real iOS Safari +and Android Chrome. Desktop Chromium emulation does not establish native mobile +behavior: + +- Reproduce from the same hostname and tab using the displayed return/reload + instructions. Verify browser Back alone is not described as a fresh capture. +- Check 320 CSS px layout, text zoom, safe-area placement, readable status messages + and usable primary controls without horizontal page scrolling. +- Download and share the JSON through the native browser/app picker. Compare the + saved file with the copied report, and verify cancellation or rejection retains + the visible report and its other export actions. +- Verify local deletion independently of an offline or failed end request, then + reconnect and use the separate end/state retry. +- Check viewer reload, tab/opener copies, expiry and any browser session restoration + behavior being supported. Record session-restoration checks separately from + automated storage fixtures. + +The linked plan is the immutable implementation baseline. Record device/browser +versions and new acceptance results in the current rollout evidence, referencing +that baseline and the exact deployed revision. +Until these checks are complete, physical mobile acceptance remains pending. + +### Maintaining versioned assets + +The committed trace asset manifest covers source, compiler dependency locks and +build options. After changing those inputs, rebuild and review the JS/CSS and +refresh the source digest. Unchanged bytes keep their existing asset URL and +digest. Once an asset set is published, changed bytes require a new versioned +URL; the current feature's v1 set remains unpublished until release acceptance. + ## What the Console Shows The panel opens expanded after document startup and provides filters for All, @@ -109,8 +289,12 @@ Visible, Filled, Empty, Pending/Incomplete, and Unbound/Ambiguous slots. Each request cycle can show: +- The same stable `Ad #N` and `Request #M` used by the request's page badge. - The observed request path, request-intent ID, and direct Trusted Server opportunity. +- Explicit auction classification: initial-page server, SPA server, completed client-side Prebid, multiple observed paths, or not observed. - Opaque Trusted Server auction-ID correlation and opportunity-to-request latency when available. +- Source-scoped server-auction winner, Prebid targeting candidate, and documented Prebid win observations. None is presented as the final served creative. +- Server-measured auction dispatch, resolution, commit, and wait timing when available. - Observed replacement of an earlier retained filled render, including GPT creative-ID transitions. - Requesting, Response received, Filled, Empty, or Rendered (fill unknown) GPT lifecycle state. @@ -118,9 +302,11 @@ Each request cycle can show: - Safe, deduplicated creative-bridge failure categories. - Source-neutral GAM response class and identifiers reported by GPT. - GPT slot-onload, impression-viewable, and visibility observations. -- Non-negative request-to-response, response-to-render, render-to-load, and +- Non-negative GAM request-to-response, GAM response-to-render, render-to-load, and render-to-viewable durations. -- GPT-reported rendered size, a separately labelled observed outer slot box when safely bound, backfill, and slot-content-change facts. +- GPT-reported rendered size, a separately labelled `Size filled` outer-slot measurement + when safely bound, backfill, and slot-content-change facts. GPT's ubiquitous `1×1` + placeholder stays in the JSON evidence but is hidden from the panel and badge. - Current DOM binding status and viewport intersection. Elapsed time alone never changes a pending GPT request to Incomplete. Incomplete @@ -129,6 +315,67 @@ step. When `slotRenderEnded` omits `isEmpty`, the result stays Rendered (fill un and `responseClass` remains absent. `unclassified_non_empty` requires an explicit `isEmpty === false` observation. +## Auction Classification and Timing + +Auction labels describe the path observed for that GPT request cycle: + +| Label | Meaning | +| --------------------------------- | ---------------------------------------------------------------------------------------------------- | +| SSAT: initial-page server auction | Explicit completed initial-document server-auction evidence populated the direct request. | +| TS auction: SPA server auction | Explicit completed `/_ts/page-bids` server-auction evidence populated the direct request. | +| Client-side Prebid auction | A completed Prebid attempt was joined by exact auction ID, ad-unit code, GPT slot, and next request. | +| Multiple auction paths observed | Server and completed client-side evidence were both observed. This does not prove a race or winner. | +| Auction not observed | Explicit completed-auction evidence was absent or malformed. It never defaults to SSAT. | + +Publisher refresh evidence does not by itself establish another auction. It remains +visible in the request-path classification but does not turn an SSAT, TS auction, or +client-side auction label into `Multiple auction paths observed`. Publisher-only refreshes and +unattributed requests have no auction label because the available evidence does not +establish an auction implementation. + +For synthetic refresh auctions, diagnostics correlates the auction ID supplied by +Prebid's own `bidsBackHandler`; it does not provide or override Prebid auction IDs. The +`bidWon` listener, targeting reads, and bounded correlation state are installed only +when an active diagnostics recorder exists, so an inactive console does not change +Prebid-visible behavior. + +Server auction timing and browser GPT timing use separate clocks and are never +subtracted from each other. Both initial SSAT and SPA TS auction offsets use the +adapter's server-request clock. Initial offsets belong to the document request, while +SPA offsets belong to the later `/_ts/page-bids` request. Diagnostics retain that +request origin separately from the aggregate auction classification, so a request marked +`competing` still labels SPA offsets from the SPA page-bids request T0. Neither server +clock is the browser's navigation clock. The server facts are: + +- `auctionDispatchedMs`: bid dispatch offset from that timing origin. +- `auctionResolvedMs`: offset when auction collection completed, including timeout handling. +- `auctionCommittedMs`: offset when winning bids were available to page state. +- `auctionWaitMs`: actual time blocked in auction collection. +- `auctionWaitPlacement`: whether that wait occurred before response headers or while + the document response was streaming. + +The SPA page-bids response includes these server timings only for an activated +console session and a successfully dispatched auction. Failures before any provider +dispatch do not emit timing evidence. The activation cookie is still removed before +auction and publisher processing; the server retains only the request-scoped activation +decision needed to gate this diagnostics field. + +Browser timings have different boundaries. **GAM request → response** starts at GPT's +`slotRequested` callback and ends at `slotResponseReceived`. **GAM response → render** +starts at `slotResponseReceived` and ends at `slotRenderEnded`. “Render” therefore +means that GPT emitted its render-ended callback; it does not prove inner-iframe pixels +executed or became visible. + +```mermaid +flowchart TB + A[Server timing origin] --> B[Auction dispatched] + B --> C[Auction resolved] + C --> D[Bids committed to page state] + E[GPT slotRequested] --> F[GPT slotResponseReceived] + F --> G[GPT slotRenderEnded] + G --> H[GPT slotOnload / impressionViewable] +``` + ## Request Paths Request-path labels describe integration paths observed immediately before one GPT @@ -162,8 +409,10 @@ context even when the delegated refresh throws, and the Prebid wrapper restores exact prior value. Diagnostics never suppresses or changes a GPT request. For a direct observation, the optional opaque auction ID is retained only after -trimming to a non-empty value no longer than 256 UTF-8 bytes. No auction payload, -targeting map, bid price, markup, network body, or stack trace is exported. The +trimming to a non-empty value no longer than 256 UTF-8 bytes. Winning bidder names are +limited to 128 UTF-8 bytes, and numeric price-bucket strings to 64 bytes. The console +exports only those winning-bid fields and the bounded timing object—not a raw auction +payload, targeting map, exact unbucketed CPM, markup, network body, or stack trace. The reported opportunity-to-request duration is browser-observed only and is omitted for invalid or negative timing. @@ -181,8 +430,8 @@ For the direct path, `adInit` records one opportunity: | `unrenderable_candidate` | Bid targeting was applied, but the current bridge lacked the complete ID/render-source combination needed to serve markup. | | `no_candidate` | `adInit` explicitly observed no direct Trusted Server bid targeting for that configured slot. | -An absent opportunity is displayed as unknown. It must not be converted into a -negative demand-source conclusion. +An absent opportunity is displayed as `Not observed`. It must not be converted into +a negative demand-source conclusion. ## Trusted Server Evidence Ladder @@ -202,15 +451,15 @@ that acknowledgement is outside the zero-publisher-change design. The derived `delivery` value uses these evidence-safe meanings: -| Delivery state | Panel wording | -| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `trusted_server_response_sent` | Trusted Server selected; markup response sent to PUC | -| `trusted_server_selected` | Trusted Server selected; no markup response confirmed | -| `candidate_unconfirmed` | Trusted Server candidate unconfirmed — another GAM result or a creative/bridge failure is possible | -| `no_candidate` | adInit observed no direct Trusted Server candidate for this request | -| `unknown` | Delivery status unknown — required GPT or direct-candidate evidence was not observed | -| `pending` | Waiting for Trusted Server creative evidence | -| `not_applicable` | No delivery conclusion is displayed before render or for an explicitly empty result, provided no Trusted Server creative evidence was stamped on the cycle. | +| Delivery state | Panel wording | +| ------------------------------ | ------------------------------------------------------------------ | +| `trusted_server_response_sent` | Creative markup sent; execution not confirmed | +| `trusted_server_selected` | Server bid selected by the creative bridge; response not confirmed | +| `candidate_unconfirmed` | Server bid available; selection not confirmed | +| `no_candidate` | No direct Trusted Server candidate | +| `unknown` | Delivery status unknown — required evidence was not observed | +| `pending` | Waiting for Trusted Server creative evidence | +| `not_applicable` | Delivery evidence: Not applicable | For an explicit non-empty candidate, diagnostics wait five seconds from `slotRenderEnded` for positive creative evidence. If no matched PUC request arrives, @@ -309,11 +558,12 @@ A concise viewport badge appears only when a slot: - Has a unique, connected exact binding. - Has a non-zero rectangle intersecting the viewport. -A badge summarizes the slot's most recent request cycle: the GPT result (Filled, Empty, -Rendered (fill unknown), or Pending), a short delivery label, a `Competing paths` -marker when the request path is `competing`, the rendered size, and the request-to- -response, response-to-render, and render-to-viewable durations that are available. It -adds `Incomplete sequence` when a callback proved a missing or invalid earlier step. +A badge starts with the slot's stable `Ad #N`, matching the side-panel heading. It then +summarizes the most recent request cycle: the GPT result (Filled, Empty, Rendered (fill +unknown), or Pending), auction type, a short delivery label, a `Competing paths` marker +when the request path is `competing`, sizes, and the GAM request-to-response, GAM +response-to-render, and render-to-viewable durations that are available. It adds +`Incomplete sequence` when a callback proved a missing or invalid earlier step. Badge delivery labels are the same derived states the panel and export report, shortened to fit: @@ -354,10 +604,11 @@ becomes unbound or ambiguous, the last successful sample remains as request-cycl evidence while the binding status reports the current DOM state. It is displayed separately from `size`, which remains the exact GPT-reported `slotRenderEnded.size` fill-size fact. The panel labels the three separate facts as requested slot sizes, -GPT-reported fill size, and observed outer slot box; the badge abbreviates them as -`Req`, `Fill`, and `Box`. The observed box may differ from GPT's reported size (for -example, a flexible APS creative can report `1×1` while its allocated outer slot box -is larger). The measurement describes publisher-page layout, not universal internal +GPT-reported fill size, and `Size filled`; the badge abbreviates the first two as `Req` +and `Fill` and uses `Size filled` for the observed outer box. The observed box may +differ from GPT's reported size. A flexible APS creative, for example, can report GPT's +uninformative `1×1` placeholder while its allocated outer slot box is larger; the UI +suppresses that `1×1` text but the export retains the original `size: [1, 1]` evidence. The measurement describes publisher-page layout, not universal internal creative-pixel dimensions. A collapsed or hidden bound element can report `0×0`, which records the page layout state rather than an invalid measurement. Empty, unbound, missing, or ambiguous slots do not report an observed box; delayed measurements from an older cycle @@ -368,14 +619,17 @@ content or altering the APS sandbox, so this field cannot prove the inner creati pixels. Badges and the panel live in a closed Shadow DOM. Diagnostics do not add attributes, -classes, or inline styles to publisher slot elements. +classes, or inline styles to publisher slot elements. Each badge is an interactive button +positioned over the slot's top-left area. While diagnostics are active, the badge intercepts +pointer input within its own bounds instead of passing that input to the creative. ## Presentation Lifecycle - **Collapse** reduces the panel while preserving capture. - **Close** dismisses the presentation for the current document. - External removal by hydration or DOM reconciliation triggers a debounced remount. -- Live re-renders preserve open request-history disclosures and panel scroll position. +- Live re-renders preserve open request-history, help, and technical disclosures plus panel scroll position. +- Selecting an earlier request from a badge opens its history once. A later user collapse remains closed across live updates. - Explicit Close or `hide()` prevents remount until `show()` is called. - Capture continues while the panel is hidden. @@ -410,17 +664,26 @@ unsubscribe() | `hide()` | Dismisses presentation without stopping capture | | `show()` | Clears dismissal and remounts presentation without resetting data | -## V1 Export, Storage, and Privacy +## GPT-only V1 Export, Observation Memory, and Privacy -The allowlisted export contains: +The existing GPT-only `snapshot()` and `export()` model contains: - `version: 1` and an ISO `capturedAt` timestamp. - Current page origin and pathname, excluding query parameters and fragments. - Retained slots, binding facts, visibility, and request cycles. - `requestedSlotSizes` when Trusted Server supplied configured formats for that exact request, plus GPT-reported fill `size` and an optional observed outer `observedSlotSize`. -- Request path, request intent ID, opportunity, creative-progress timestamps, and - safe failure enums. +- Request path, auction type, request intent ID, opportunity, creative-progress + timestamps, and safe failure enums. +- The bounded winning bidder and bucketed price plus server auction timing fields and + their `navigation` or `spa_auction` origin when direct auction evidence was observed. +- Completed client-side Prebid evidence (`prebidAuction`): Prebid's opaque `auctionId`, + plus bounded bidder and bucketed price fields in `targetingCandidate` and `win`, + when a completed attempt and any documented `bidWon` observation were correlated + to that exact request. Neither candidate nor win evidence proves the final served bidder. +- Optional `currency` on server winner, Prebid candidate, and Prebid win evidence, + when supplied through the internal recorder. Values are normalized to three uppercase + ASCII letters; first-party integrations do not populate this field. - The per-auction diagnostics token (`trustedServerAuctionId`) and the opportunity-to-request duration, when a direct opportunity was observed. - Replacement facts for a re-rendered slot: `replacedRequestNumber`, @@ -431,9 +694,11 @@ The allowlisted export contains: - Separate callback issues, attribution issues, coverage counters, and retention counters. -It does not contain raw targeting, bid IDs, bid prices, bidder identity, creative -markup, cache URLs, cache payloads, cache or bridge error details, cookies, user -identifiers, query strings, or URL fragments. The exported `trustedServerAuctionId` +It does not contain raw targeting, bid IDs, exact unbucketed bid prices, losing bid +lists, creative markup, cache URLs, cache payloads, cache or bridge error details, +cookies, user identifiers, query strings, or URL fragments. It does contain the server +winner and the Prebid candidate and win bidder names with their bucketed `hb_pb` values +as described above, even when the final served bidder is unconfirmed. The exported `trustedServerAuctionId` is the `hb_auction_id` value described in [Auction correlation token](#auction-correlation-token): minted fresh for each server-side auction, not derived from the Edge Cookie ID or any other visitor @@ -441,12 +706,22 @@ identifier, and never repeated across auctions, so it cannot be joined back to a visitor. Diagnostics retain it only after trimming to a non-empty value of at most 256 UTF-8 bytes. -Captured records are memory-only. Diagnostics do not add an upload, diagnostics +The exported `prebidAuction.auctionId` is supplied by Prebid for auction correlation, +not visitor identity. Diagnostics does not generate or replace it and retains only +non-empty values of at most 256 UTF-8 bytes. Bidder names are limited to 128 UTF-8 +bytes and numeric price bucket strings to 64 bytes. These bounds limit retained data; +they do not establish how a publisher's Prebid configuration generated its auction ID. + +The GPT-only observation store remains memory-only. Its capture and `export()` do not add an upload, diagnostics network request, `localStorage`, `sessionStorage`, IndexedDB, or other persistence. `export()` creates only the user-requested local JSON download; it sends nothing to a server. The `__Host-ts-console` session cookie contains only the activation bit and is inaccessible to JavaScript. +The explicit mobile handoff separately writes the bounded combined trace report +to `sessionStorage`. Its stricter GPT projection excludes the exact pathname, +GAM identifiers and `previousCreativeId`, as described under [Mobile Trace Page](#mobile-trace-page). + ## Timing and Retention Bounds - Direct, Prebid, and publisher request-path markers: five seconds, one-shot. @@ -463,8 +738,9 @@ inaccessible to JavaScript. - Retained attribution issues: 128. The least-recently-active slot is evicted when the slot bound is exceeded. An evicted -GPT slot can re-enter retention only after a future `slotRequested`; request numbers -remain monotonic. The oldest request cycle or issue is removed at its own bound. +GPT slot can re-enter retention only after a future `slotRequested`; its `Ad #N` identity +and request numbers remain stable and monotonic for the console lifetime. The oldest +request cycle or issue is removed at its own bound. Export metadata reports `evictedSlots`, `evictedRequestCycles`, `droppedCallbacks`, and `droppedAttributionIssues`. diff --git a/docs/guide/integrations/lockr.md b/docs/guide/integrations/lockr.md index ef9e57b0f..e2805e968 100644 --- a/docs/guide/integrations/lockr.md +++ b/docs/guide/integrations/lockr.md @@ -1,7 +1,7 @@ # Lockr Integration **Category**: Identity -**Status**: Production +**Status**: Proxy production; identity capture and handoff rollout gated **Type**: Identity Management & Privacy Vault ## Overview @@ -22,18 +22,17 @@ Lockr is an identity resolution and privacy platform that helps publishers manag ## How It Works -``` -┌──────────────────────────────────────────────────┐ -│ User Visit │ -│ ↓ │ -│ Trusted Server generates EC ID │ -│ ↓ │ -│ Lockr Integration maps to Lockr Identity │ -│ ↓ │ -│ Identity synchronized (with consent) │ -│ ↓ │ -│ Lockr ID added to bid requests (user.ext.eids) │ -└──────────────────────────────────────────────────┘ +```mermaid +flowchart TD + A[Browser loads first-party Lockr SDK] --> B[SDK calls first-party API proxy] + B --> C[Lockr returns provider tokens] + C --> D[Capture validates ID5, consent and source ownership] + D --> E[Original response sent with optional authenticated handoff] + E --> F[Eligible EC record updated after send on Fastly] + E --> G[Later live-root request validates handoff] + G --> H[Absent source reaches auction and stages bounded retry] + F --> I[Later auctions read usable IDs from KV] + H --> I ``` ## Configuration @@ -41,41 +40,63 @@ Lockr is an identity resolution and privacy platform that helps publishers manag Add Lockr configuration to `trusted-server.toml`: ```toml +[ec.identity_handoff] +# Separate opt-in. Leave off until the handoff rollout and live gates pass. +enabled = false +active_key_id = "handoff-2026-10" + +[[ec.identity_handoff.keys]] +id = "handoff-2026-10" +secret = "identity_handoff_2026_10" + [integrations.lockr] enabled = true -api_endpoint = "https://api.lockr.io" -organization_id = "your-org-id" -project_id = "your-project-id" +app_id = "example-app-id" +# Leave capture off until provider, consent, ownership and rollout gates pass. +capture_identity = false +capture_global_withdrawal = false + +[[ec.partners]] +name = "ID5" +source_domain = "id5-sync.com" +openrtb_atype = 1 +bidstream_enabled = true +identity_owner = "lockr" ``` ### Configuration Options -| Field | Type | Required | Description | -| ----------------- | ------- | -------- | --------------------------------------------- | -| `enabled` | boolean | No | Enable/disable integration (default: `false`) | -| `api_endpoint` | string | Yes | Lockr API endpoint URL | -| `organization_id` | string | Yes | Your Lockr organization ID | -| `project_id` | string | Yes | Your Lockr project ID | +| Field | Type | Required | Description | +| --------------------------- | ------- | -------- | ------------------------------------------------------------------------------ | +| `enabled` | boolean | No | Enable/disable the integration (default: `true` when configured) | +| `app_id` | string | Yes | Lockr application ID | +| `capture_identity` | boolean | No | Token-capture kill switch (default: `true`; set `false` for rollout) | +| `capture_global_withdrawal` | boolean | No | Approved global EC withdrawal on application-success revoke (default: `false`) | +| `api_endpoint` | URL | No | Override the Lockr API origin | +| `sdk_url` | URL | No | Override the SDK URL | +| `cache_ttl_seconds` | integer | No | SDK response cache lifetime in seconds (default: 3600) | +| `rewrite_sdk` | boolean | No | Rewrite matching SDK URLs to the first-party route (default: `true`) | +| `origin_override` | URL | No | Origin header override for local or approved domains | + +`rewrite_sdk_host` is deprecated and ignored. An absent Lockr configuration +does not register the integration. `[ec.identity_handoff]` is shared EC +configuration, not a Lockr option. Its keys come from +`trusted_server_secrets`; see the [configuration reference](../configuration) +for provisioning and rotation. ### Environment Variables ```bash TRUSTED_SERVER__INTEGRATIONS__LOCKR__ENABLED=true -TRUSTED_SERVER__INTEGRATIONS__LOCKR__API_ENDPOINT=https://api.lockr.io -TRUSTED_SERVER__INTEGRATIONS__LOCKR__ORGANIZATION_ID=your-org-id -TRUSTED_SERVER__INTEGRATIONS__LOCKR__PROJECT_ID=your-project-id +TRUSTED_SERVER__INTEGRATIONS__LOCKR__APP_ID=example-app-id +TRUSTED_SERVER__INTEGRATIONS__LOCKR__CAPTURE_IDENTITY=false ``` ## Features ### Identity Synchronization -Lockr integration automatically syncs Trusted Server EC IDs with Lockr's identity vault: - -1. User visits site (Trusted Server generates EC ID) -2. If GDPR consent granted, sync with Lockr -3. Lockr returns unified identity -4. Identity available for bid requests and analytics +The SDK requests identities from Lockr through the first-party proxy. Trusted Server does not call Lockr to resolve identifiers independently. With approved capture enabled, valid ID5 tokens enrich an existing consenting EC record after the proxy response has been sent on Fastly. Later auctions can use that ID once the KV write is visible. Neither the first auction nor a failed post-send write has a guaranteed repair. ### Privacy Vault @@ -88,27 +109,9 @@ User data is stored securely in Lockr's privacy vault: ### Extended ID (EID) Support -Lockr identities are injected into OpenRTB bid requests as Extended Identifiers: - -```json -{ - "user": { - "ext": { - "eids": [ - { - "source": "lockr.io", - "uids": [ - { - "id": "lockr-user-id-123", - "atype": 1 - } - ] - } - ] - } - } -} -``` +A later server-side auction can include a usable registered ID5 record as an `id5-sync.com` EID with the registry's `atype=1`. When the persisted record's writer is `lockr`, Trusted Server reconstructs `inserter: "Lockr-For-Publishers"`, `matcher: ""` and `mm: 2`. These fixed values require no extra KV fields and remain available if new capture is disabled. Source or configured ownership alone does not establish Lockr origin; browser, legacy and unknown-writer records are not labeled as Lockr. + +Explicit browser-supplied provenance retains current-request precedence. If the browser adds a different UID for the same source, the merged EID omits reconstructed provenance to avoid attributing every UID to Lockr. Trusted Server does not store arbitrary provenance, provider `ext` or extra UIDs in KV. Other provider token mappings are not enabled by the ID5 parser. ## Use Cases @@ -126,7 +129,7 @@ Lockr identities are injected into OpenRTB bid requests as Extended Identifiers: **Solution**: Lockr's identity graph links devices to a unified user profile. -**Benefit**: Better audience targeting and frequency capping across devices. +**Benefit**: Provider-side linking may support cross-device recognition. Trusted Server does not link EC IDs across browsers or devices through capture. ### 3. Consent-Based Data Sharing @@ -150,55 +153,87 @@ The Lockr integration is implemented in [crates/trusted-server-core/src/integrat **ID Mapping**: -- Maps Trusted Server EC ID → Lockr unified ID -- Cached for performance -- Respects consent status +- The only supported capture mapping is Lockr `id5id` to registered `id5-sync.com` with `atype=1`. +- The EC identity graph retains the source, UID and lifecycle metadata, including known provider expiry and the acquisition writer. Later auctions reconstruct fixed Lockr ID5 provenance only for a usable Lockr-written record. KV does not store arbitrary provenance, provider `ext` or additional UIDs. +- Publisher SDK storage remains the SDK's responsibility. Capture never restores the browser-written `ts-eids` cookie. The separately enabled shared handoff may add authenticated `ts-eids-s`. **Consent Validation**: -- Checks consent signals before syncing -- Integrates with CMP (e.g., Didomi) -- Respects user withdrawal of consent +- Request consent and populated body `consentString`, `gppString` and `ccpaString` pass shared acquisition policy. Malformed populated signals fail closed. +- Body signals can restrict but cannot override denied request consent, including opt-out and GPC. +- An explicitly approved successful revoke is separate from acquisition gating. -## Best Practices +### Capture and rollout -### 1. Consent First +Capture inspects only exact POST `page-view`, `generate-tokens`, `refresh-tokens` and `sync-no-hem-ids` routes under `/publisher/app/v2/identityLockr/`. It decodes URL-encoded `advertising_token.universal_uid` from `aimTokens`, verifies an associated `ids.id5id.eid` when present, and converts `identity_expires` milliseconds to Unix seconds. Unknown provider keys, mismatched source or UID, expired tokens and malformed responses do not change stored identity. SDK, settings and unlisted API requests stay on the original proxy path. The proxy still strips publisher `Cookie` and `Authorization` headers. -Always validate consent before initiating Lockr sync: +Original and decoded capture responses have separate 64 KiB limits. Inspection failure leaves upstream response bytes and headers intact. With capture enabled, Fastly sends the SDK response before shared capture persistence against the existing consenting EC record. Missing, unreadable or tombstoned roots cannot be created or revived by acquisition. There is no native resolver, first-auction guarantee or durable retry queue. -```rust -if !has_gdpr_consent() { - return; // Skip Lockr sync -} -``` +### Authenticated capture handoff -### 2. Cache Lockr IDs +The optional shared handoff can add `ts-eids-s` to a validated capture response. +It defaults off independently of Lockr capture. Enabling it requires both +`ec.identity_handoff.enabled = true` and `integrations.lockr.capture_identity = +true`, plus an active dedicated signing key. Do not reuse `ec.passphrase`. Keep +the handoff off until key management, source ownership, consent behavior, +metadata-compatible deployment, synthetic verification, and live acceptance +have passed for the publisher route. -Cache Lockr identity mappings to reduce API calls: +`ts-eids-s` has a maximum five-minute lifetime and a 2,048-byte encoded-value +budget. It binds the publisher, full EC ID, capture writer, source, UID, expiry, +and original handoff deadline in an authenticated envelope. `Secure`, +`HttpOnly`, and `SameSite=Lax` cookie flags do not replace signature checking. +Provider expiry may shorten the deadline, and reads or retries never extend it. -- TTL: 24 hours recommended -- Invalidate on consent withdrawal -- Use KV store for persistence +A later request can use an eligible ID5 handoff only when the registered source +is absent from an existing live, consenting EC root. Current Lockr capture, +source ownership, consent, expiry, and bidstream gates are checked again. Any +existing record wins, including an expired, invalidated, or conflicting record. +The handoff never creates or recovers a root, replaces a UID, extends stored +expiry, or accelerates refresh of an existing source. -### 3. Monitor Sync Rate +Eligibility uses the existing request-local KV snapshot. A stale live read can +still miss a newer record or withdrawal at another POP; the handoff does not add +strong reads or instant cross-POP revocation. Current request consent still +applies. Replay uses generation-checked writes and cannot overwrite that newer +record or revive its tombstone. -Track Lockr sync success/failure rates: +The same later request may stage one bounded fill-absent persistence attempt. +Fastly runs it after sending the response. This retry is best-effort and needs a +qualifying request before the original deadline. It has no durable queue or +recursive provider call. A successful after-send write cannot clear the cookie +already sent; a subsequent request reconciles and clears acknowledged entries. -- Alert on elevated failures -- Monitor API latency -- Track consent decline impact +This feature does not restore the retired browser-written `ts-eids` cookie. +`ts-eids-s` contains validated server capture only. It does not ingest late +browser EIDs or restore the old diagnostic headers. Cookie-bearing responses +are private/no-store, and auction-provider Cookie forwarding strips the handoff. +Only Fastly's admitted EC/KV requests support issuance and post-send replay; +other adapters keep the feature inactive. -### 4. Test Identity Flow +`capture_identity` defaults to `true` but must be explicitly set to `false` during rollout until sanitized provider fixtures, consent requirements, source ownership, metadata-aware binary drain and an approved publisher test route have been verified. Disabling capture keeps the registered ID5 source claim and already-stored metadata. Do not roll back to a metadata-unaware binary after managed writes without an approved compatibility procedure. Reject conflicting legacy push or pull writers before assigning `identity_owner = "lockr"`. -Validate end-to-end identity flow: +`capture_global_withdrawal` remains `false` until Lockr confirms that a 2xx `revoke-consent` response with `"success": true` means global withdrawal for this EC identity. Only then may operators opt in: the normal finalizer expires `ts-ec` and the existing-key-only tombstone prevents later enrichment. Empty token arrays, network failures and other responses never imply withdrawal. Overlapping SDK calls have no provider sequence contract; CAS follows observation order. -```bash -# 1. Generate EC ID -curl https://edge.example.com/ +Use synthetic or sanitized source/count/status evidence for an approved test route. Verify unchanged proxy response, delayed KV read-back of UID/source/expiry/writer/revision without exporting values, later auction source presence, same-UID expiry extension, expired omission and confirmed opt-out/revoke. For handoff acceptance, also hold back KV visibility, verify absent-source auction delivery only after cookie installation, inject a failed post-send write, observe later best-effort repair and acknowledgement cleanup, then test expiry, consent denial, root read failure, EC rotation, ownership changes and key rotation. These are rollout gates, not completed acceptance. No live IDs, consent strings, credentials or query URLs belong in test records. -# 2. Verify Lockr sync (check logs) -# 3. Validate EID in bid request -``` +## Best Practices + +### 1. Consent First + +The publisher should gate SDK activity according to its consent policy. Trusted Server also checks EC consent and any populated request-body signals before ID5 capture. A denied token acquisition does not block an approved explicit revocation. + +### 2. Cache Lockr IDs + +The SDK response can be cached for the configured `cache_ttl_seconds`. Trusted Server stores accepted provider IDs in the EC identity graph with known provider expiry where supplied; expiry is not an instruction to refresh or call Lockr. Consent withdrawal tombstones the existing EC row. + +### 3. Monitor Sync Rate + +Track the proxy success rate and source-only counts for accepted or skipped capture, without logging IDs, consent strings, credentials or provider query URLs. Monitor post-send KV failures separately from the SDK's response status. + +### 4. Test Identity Flow + +On an approved synthetic test route, first confirm the SDK request and original response. After post-send persistence becomes visible, inspect the EC row without exporting identifiers and check for the registered EID source in a later auction. A no-token SDK response must not create an identity. ## Troubleshooting @@ -211,10 +246,10 @@ curl https://edge.example.com/ **Solutions**: -- Verify `organization_id` and `project_id` are correct -- Check Lockr API endpoint is reachable -- Ensure GDPR consent is granted -- Review Lockr API credentials +- Verify `app_id`, the SDK URL and the API endpoint configuration +- Check that the first-party proxy reaches the configured Lockr API origin +- Check publisher consent gating and whether `capture_identity` is still disabled for rollout +- Review upstream status codes without logging response tokens or request query URLs ### Missing EID in Bid Requests @@ -224,40 +259,36 @@ curl https://edge.example.com/ **Solutions**: -- Verify OpenRTB request builder includes `user.ext.eids` -- Check integration is registered in IntegrationRegistry -- Ensure `contribute_eids()` method is implemented +- Confirm `id5-sync.com` is registered with `identity_owner = "lockr"` and bidstream enabled +- Confirm the response contained a valid, unexpired ID5 token with allowed consent +- Wait for the Fastly post-send KV update, then inspect the EC row and a later auction +- Check whether the EC ID rotated, the record expired, or withdrawal tombstoned its row ## Performance -### Typical Latency +### Response timing -- Identity sync: 50-100ms -- Cached lookup: <5ms -- API timeout: 500ms (configurable) +The SDK call still waits for the upstream Lockr API response. Fastly runs eligible KV capture updates after sending that response; later auctions see a UID only after the write is visible. The SDK route uses `cache_ttl_seconds` for its own response. No separate Lockr fetch loop or refresh timeout is configured by capture. ### Optimization Tips -- Enable caching for repeat visitors -- Batch sync operations when possible -- Use async/non-blocking sync -- Monitor API quota usage +- Set the SDK cache lifetime to suit the publisher's deployment +- Monitor upstream proxy latency and capture persistence separately +- Avoid assuming an ID will be present in the first auction ## Security Considerations ### API Credentials -- Store Lockr credentials in secret management system -- Rotate credentials periodically -- Never commit credentials to git -- Use environment variables in production +- Keep provider configuration and any credentials in approved secret management +- Never commit credentials to git or forward publisher `Authorization` and `Cookie` headers to Lockr +- Do not log provider query URLs, body tokens, UID values or consent strings ### Data Privacy -- Only sync with explicit user consent -- Implement right to erasure -- Audit data access logs -- Encrypt data in transit (HTTPS) +- Apply publisher consent policy before SDK activity and shared acquisition consent policy before KV writes +- Handle right-to-erasure requests through the established EC deletion process, not a token response +- Use HTTPS and record status/count evidence without exporting private identifiers ## Next Steps diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 18db20e23..ebdd4179d 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -849,12 +849,9 @@ page or auction. When available, the opaque value follows the standard path: 2. The current `/auction` request includes that entry. 3. Trusted Server merges and consent-gates it, then forwards it to Prebid Server as `user.ext.eids`. -4. After the response, the same `/auction` request ingests the body entry into - an EC/KV partner configured with `source_domain = "liveramp.com"`, when that - partner UID is not already stored. -5. The browser also persists the opaque value in the bounded `ts-eids` cookie, - which lets requests without an EID body, such as `GET /_ts/page-bids` and - page navigations, ingest it. +4. Response finalization conservatively stores the body UID on an existing, + consenting EC row for a partner configured with `source_domain = "liveramp.com"`. +5. Later auctions resolve the stored ID from KV. No EID cookie is used. Trusted Server treats the RampID envelope as an opaque string. Do not log, decode, publish, or dimension metrics by the value. Source names, counts, @@ -922,8 +919,8 @@ domain, booleans, source names, counts, and status codes: recording its value. 5. Confirm a controlled Prebid Server request contains that source in `user.ext.eids`. -6. Confirm a later request ingests the source into the configured - `liveramp.com` EC partner. +6. Confirm the body submission persists the source into the existing + `liveramp.com` EC partner, then confirm a later auction resolves it from KV. 7. Repeat with denied consent and confirm the envelope endpoint is not called, `idl_env` is not written, and no LiveRamp EID is forwarded. 8. Repeat on an unapproved origin and confirm identity resolution degrades @@ -934,48 +931,68 @@ path. LiveRamp ATS Direct audience segments, including `_lr_atsDirect` storage and GAM or Prebid segment activation, require a separate integration and are not passed by this implementation. -## Identity Forwarding +## Identity forwarding -Trusted Server uses a **hybrid EID forwarding model** for Prebid-routed auctions: +Prebid.js sends current EIDs from `pbjs.getUserIdsAsEids()` in the `/auction` +body. Trusted Server merges them with usable registered IDs from the current +EC/KV row, applies consent gating, and forwards `user.ext.eids` to Prebid Server. -1. **Current-request EIDs from Prebid.js** are read from `pbjs.getUserIdsAsEids()` in the browser and sent in the `/auction` request body. -2. **Server-side EIDs from the EC/KV identity graph** are resolved on the edge from the current EC ID. -3. Trusted Server **merges and deduplicates** both sets before calling Prebid Server. -4. The merged result is forwarded downstream as `user.ext.eids` in the OpenRTB request. -5. After the response, Trusted Server adds matched partner UIDs that are missing from the EC identity graph, reading the `ts-eids` cookie, then the `/auction` request-body EIDs, then the `sharedId` cookie. Because the body carries the untrimmed EID set, `/auction` ingestion is not limited by the size-capped `ts-eids` cookie. +Registered body sources are staged before provider dispatch. Response finalization +combines them with the separate `sharedId` cookie in one conservative mutation, +using the auction's KV snapshot. No bid and handled provider-error responses still +finalize these updates. Browser input fills absent sources only. It cannot replace +stored IDs, forge lifecycle metadata, create a root, recover a missing root, or +revive a withdrawal tombstone. Unknown sources remain current-request-only. -The `ts-eids` cookie still matters for requests without an EID body, such as `GET /_ts/page-bids` and page navigations, where it is the source for both EID fallback and identity-graph ingestion. Identity-graph EID writes follow the same consent rule as bidstream forwarding: under GDPR, TCF Purpose 1 and Purpose 4 must both be consented. - -This means Prebid auctions get same-request transparency for browser-resolved IDs without giving up the durability of the server-managed EC identity graph. +This persistence requires EC KV support. Fastly supports it; adapters without an +EC identity graph can forward permitted body EIDs but do not persist them. +Publisher and SPA page-bid auctions use KV IDs only. An ID arriving late needs +another `/auction` body submission; there is no page-exit beacon or cookie repair. ### Identity flow ```mermaid -sequenceDiagram - participant B as Browser / Prebid.js - participant T as Trusted Server /auction - participant K as EC + KV identity graph - participant P as Prebid Server - - B->>B: User ID modules resolve EIDs - B->>T: POST /auction\n(adUnits + current-request eids) - T->>K: Resolve EC-backed source-domain IDs - K-->>T: KV-derived EIDs - T->>T: Merge + dedupe client + KV EIDs - T->>T: Apply consent gating - T->>P: OpenRTB request\nuser.ext.eids = merged set - P-->>T: OpenRTB bid response - T-->>B: Auction response - T->>K: Ingest body eids + ts-eids/sharedId cookies\n(TCF Purpose 1 + 4 under GDPR) +flowchart TD + A[Prebid User ID modules resolve EIDs] --> B[POST auction with current body EIDs] + B --> C[Stage registered UID-only updates on EC context] + C --> D[Read current EC row and merge usable KV EIDs] + D --> E[Consent-gated OpenRTB request to Prebid Server] + E --> F[Bid, no-bid, or handled error response] + F --> G[Finalize body plus sharedId on existing consenting root] + G --> H[Send response] + G --> I[Later auctions read stored IDs from KV] ``` +### Cookie and diagnostic retirement + +New servers ignore `ts-eids`, including writes by cached older bundles. No TS EID +cookie replaces it. The transition JS bundle expires the old host-only `Path=/` +cookie once installed. Remove that cleanup only after writing bundles have aged +past both the old cookie's one-day max-age and the deployment cache lifetime. +The server does not emit unconditional cleanup `Set-Cookie` headers. + +`x-ts-eids` and `x-ts-eids-truncated` are no longer emitted and remain stripped +from inbound requests. Check external diagnostic consumers before rollout. +`/_ts/admin/eids` remains authenticated and returns a local `410 Gone` with +`Cache-Control: no-store`, never publisher fallback. Use `/_ts/admin/ec` to inspect +persisted state on adapters with EC KV support. EC and consent headers remain. + +Deploy metadata-preserving readers and writers everywhere and drain old invocations +before activating body writer/revision metadata. Old binaries can erase metadata +or ignore expiry; rolling back to them after activation is unsafe. A passing test +on a new binary is not deployment-drain evidence. + ### Merge and deduplication rules - Client-request EIDs and KV-resolved EIDs are merged by `source` - UIDs are deduplicated by `source + id` - If the same UID appears in both places, it is sent only once downstream - Distinct UIDs under the same source are preserved -- Consent gating is applied to the **merged** set before forwarding +- Supplied `inserter`, `matcher`, and integer `mm` remain current-request provenance, + including empty `matcher` and zero `mm`; they are not stored in KV +- UID `ext` remains current-request-only; existing UID merge precedence is unchanged +- Known-expired KV IDs are omitted, including an identical body UID for that source +- Consent gating is applied to the merged set before forwarding ### What reaches Prebid Server @@ -988,7 +1005,7 @@ The downstream Prebid Server request includes: In practice, this gives operators both: - **same-request identity transparency** for Prebid User ID Module output, and -- **future-request continuity** through cookie ingestion and KV-backed partner resolution. +- future-request continuity through body persistence and KV-backed partner resolution. ## Endpoints diff --git a/docs/guide/integrations/sourcepoint.md b/docs/guide/integrations/sourcepoint.md index 7b9ce9435..d103ba3ec 100644 --- a/docs/guide/integrations/sourcepoint.md +++ b/docs/guide/integrations/sourcepoint.md @@ -38,7 +38,7 @@ The Sourcepoint browser module is now opt-in through `[integrations.sourcepoint] | `rewrite_sdk` | boolean | `true` | Rewrite matching Sourcepoint URLs in HTML | | `cdn_origin` | string | `https://cdn.privacy-mgmt.com` | Sourcepoint CDN origin | | `auth_cookie_name` | string or `null` | `null` | Optional custom Sourcepoint `authCookie` name to forward upstream alongside built-in cookies. Names must be 1-64 characters and contain only letters, numbers, `_`, or `-` | -| `cache_ttl_seconds` | integer | `3600` | Cache TTL applied to successful CDN responses when the origin omits cache headers | +| `cache_ttl_seconds` | integer | `3600` | Cache TTL for successful CDN responses without upstream cache headers; always applied to rewritten static JavaScript unless the response sets cookies | ## Endpoints @@ -60,6 +60,21 @@ When `rewrite_sdk = true`, Trusted Server rewrites matching Sourcepoint URLs in If a publisher uses a Content Security Policy, `script-src` must allow the first-party Trusted Server host after rewriting. A policy that only allows `https://cdn.privacy-mgmt.com` can block the rewritten first-party script URLs. +## Response body rewriting + +With `rewrite_sdk = true`, successful `GET` responses with status 200 and a JavaScript or HTML content type are eligible for body rewriting when `Content-Encoding` is absent or explicitly `identity`. JavaScript CDN URLs and privacy-manager HTML root-relative `src` / `href` assets are routed through the first-party proxy. + +The input body limit is 5 MiB: + +- Bodies at or below the limit are rewritten, including chunked or HTTP/2 responses without `Content-Length`. +- A declared `Content-Length` above the limit skips rewriting and leaves the body unchanged. +- If collection exceeds the limit, Trusted Server stops reading and returns `502 Bad Gateway`. This also applies when `Content-Length` understates the size. The partial body is discarded, not returned to the browser. +- Compressed responses, unsupported or malformed encoding headers, other content types, disabled rewriting, and ineligible methods or statuses do not collect the body for rewriting. Their bytes and encoding headers pass through unchanged. + +On Fastly, upstream responses remain streaming until the bounded collector reads them. It retains at most 5 MiB of input plus the current transport chunk, with additional bounded allocations for rewriting. Non-rewritten responses remain streaming. Adapters without streaming support still buffer upstream responses before this check; this limit is not an adapter-level memory guarantee on Axum, Cloudflare, or Spin. + +Likely JavaScript and HTML paths, including `/mms/v2/get_site_data`, request `Accept-Encoding: identity`. Rewritten bodies discard the upstream `Content-Length` and `Content-Encoding`, and remove `Accept-Encoding` from `Vary`. Non-UTF-8 bodies pass through unchanged after bounded collection. + ## Runtime Config Rewriting Trusted Server also injects a `window._sp_` property trap that rewrites known Sourcepoint URL-bearing config fields (`baseEndpoint`, `mmsDomain`, `wrapperAPIOrigin`, `cmpOrigin`, and `metricUrl`). If Sourcepoint introduces another URL-bearing field and third-party `cdn.privacy-mgmt.com` requests remain after enabling this integration, report the missed field so it can be added to the trap. @@ -74,6 +89,10 @@ Trusted Server forwards only Sourcepoint's documented cookie names upstream, plu Responses that include `Set-Cookie` are forced to `Cache-Control: private, no-store` so cookie-bearing Sourcepoint traffic is never marked as publicly cacheable content by the proxy. +Rewritten JavaScript on `.js`, `.mjs`, and `/unified/` paths is treated as a static bundle and receives `public, max-age=`, replacing any upstream `Cache-Control`, unless the response sets cookies. + +Other rewritten JavaScript, including `/mms/v2/get_site_data`, keeps upstream `Cache-Control`. When upstream omits it, these responses use `private, max-age=0` if Sourcepoint cookies were forwarded, or `public, max-age=` otherwise. Rewritten HTML uses the same policy. + ## Notes - This version scopes the integration to `cdn.privacy-mgmt.com`. Additional Sourcepoint domains (e.g., `geo.privacymanager.io`) can be added later if publishers require them. diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index ebd449bb9..9abf07917 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -62,11 +62,10 @@ ts dev proxy \ `--rewrite-host` sends the upstream `Host: ` so upstreams that reject the default `Host: ` — most dev and staging services, which aren't configured -for the production hostname — still serve a page. Trade-off: against a real -Trusted Server adapter, first-party URLs then render on the upstream host, not the -production domain. To keep them on the production domain, point at an upstream that -accepts `Host: ` and omit `--rewrite-host` — see -[Host header behavior](#host-header-behavior). +for the production hostname — still serve a page. To keep first-party URLs and +trace authorization on the browser's production origin, configure authenticated +forwarding on the Trusted Server upstream and supply `--forwarder-secret-file`. +See [authenticated forwarding](#authenticated-forwarding). Run `ts dev proxy --help` to list every flag. @@ -85,7 +84,7 @@ invocation with explicit options but no complete rewrite rule fails with a `no rewrite rule` error before touching system proxy state. Connection options — `--rewrite-host`, `--basic-auth`/`--basic-auth-file`, -`--insecure`, and `--upstream-plaintext` — apply to every mapping, not per-rule. +`--forwarder-secret-file`, `--insecure`, and `--upstream-plaintext` — apply to every mapping, not per-rule. ### Explicit rule and browser launch @@ -286,13 +285,22 @@ certificate is imported until the subject check succeeds. ## Host header behavior -The proxy always sends `X-Forwarded-Host: ` (the production hostname) — the -standard "original host" header for a forward proxy. Trusted Server core anchors -all HTML/URL rewriting to it (it prefers `X-Forwarded-Host`, then `Host`), so -**first-party URLs stay on the production domain regardless of the `Host` header — -as long as the upstream preserves `X-Forwarded-Host`**. That decouples routing -(`Host`) from the first-party host. (Real Trusted Server adapters strip inbound -`X-Forwarded-Host`; the caveat below covers what that means with `--rewrite-host`.) +The proxy sends the validated inbound browser authority as `X-Forwarded-Host`, +including any explicit browser port, and `X-Forwarded-Proto: https` because the +browser leg uses TLS. Trusted Server accepts these fields for public URLs and +trace Origin checks only when the upstream configures +[`[trusted_forwarder]`](./configuration.md#trusted-forwarder) and the proxy is +given the matching token through `--forwarder-secret-file`. The forwarded +hostname must also match `publisher.domain` or one of its subdomains. The upstream +routing `Host` remains a separate choice. Every adapter strips raw `Forwarded`, +`X-Forwarded-Host` and `X-Forwarded-Proto` fields before routing; unauthenticated +fields do not influence the public origin. + +Without authenticated forwarding, first-party URLs use the upstream's runtime or +transport host and scheme. An Axum upstream reached over plain HTTP emits +`http://` first-party URLs even though the browser-to-proxy connection uses HTTPS. +Configuring `[trusted_forwarder]` alone does not change that; the proxy must also +supply the matching credential. By default `Host: ` too. Fastly routes by SNI (`= TO`) and passes `Host` through unchanged, so `Host: ` reaches the upstream — but the upstream still @@ -312,40 +320,95 @@ ts dev proxy \ ``` The proxy dials `192.0.2.10` while the SNI stays `ts.example-publisher.com` and -`X-Forwarded-Host` stays `www.example-publisher.com` — so TS rewrites first-party -URLs onto the production domain. This keeps the tool self-contained — no -`/etc/hosts` edit. (Pointing `--to` at a bare IP instead would make the SNI an IP, +the default `Host` stays `www.example-publisher.com`. The upstream must accept +that production Host; `X-Forwarded-Host` alone does not establish trust for URL +rewriting. With authenticated forwarding configured, Trusted Server uses the +browser's production origin for first-party URLs. This keeps the tool +self-contained — no `/etc/hosts` edit. (Pointing `--to` at a bare IP instead would make the SNI an IP, which sends no SNI extension at all, so a host-routed endpoint serves its default vhost.) Add `--insecure` if the endpoint serves a certificate that doesn't match the hostname. **Sending `Host: TO`.** If your upstream routes or validates on its _own_ hostname (e.g. a Fastly Deliver service that rejects an unconfigured `Host`), pass -`--rewrite-host` to send `Host: `. The proxy still stamps -`X-Forwarded-Host: `, so first-party URL rewriting stays anchored to `FROM` -**as long as the upstream preserves that header**. - -> **Caveat with real Trusted Server adapters.** The Fastly and Spin adapter -> request paths strip inbound `X-Forwarded-Host` before routing, so with -> `--rewrite-host` a real Trusted Server upstream falls back to `Host` (`TO`) and -> emits first-party URLs on `TO`, not `FROM`. Even so, `--rewrite-host` is the -> right choice for most upstreams — dev and staging services rarely have the -> production hostname configured and would reject the plain `Host: `. Drop -> it only when the upstream is configured to accept `Host: ` and you -> specifically need first-party URLs anchored to `FROM` — the `--resolve` example -> above is exactly that case. +`--rewrite-host` to send `Host: `. The proxy preserves every browser `Origin` +value on all routes, including trace Enable/End, publisher requests, and vendor +integration requests. Trusted Server validates the browser Origin against the +authenticated public origin when forwarding is configured. + +> **Trusted Server upstreams.** Fastly, Axum, Cloudflare and Spin all use their +> runtime or transport origin without authenticated forwarding. With +> `--rewrite-host`, this commonly means first-party URLs use `TO`. Use the option +> when the upstream requires its own Host for routing or validation. Omit it when +> the upstream accepts `Host: ` and you need the production hostname; the +> scheme still follows the upstream transport. The TLS SNI is always the `TO` host either way: -| Form | `Host` header | `X-Forwarded-Host` | TLS SNI | -| ---------------- | ------------- | ------------------ | --------- | -| _(omitted)_ | `FROM` | `FROM` | `TO` host | -| `--rewrite-host` | `TO` host | `FROM` | `TO` host | +| Form | `Host` header | `X-Forwarded-Host` | TLS SNI | +| ---------------- | ------------- | -------------------------------- | --------- | +| _(omitted)_ | `FROM` | Browser authority, port included | `TO` host | +| `--rewrite-host` | `TO` host | Browser authority, port included | `TO` host | **Port handling:** with `--rewrite-host` and a non-default `TO` port (e.g. `localhost:3000`), the port is included in the `Host` header but never in the SNI (a bare hostname; a port in SNI is invalid). +## Authenticated forwarding + +This feature requires a Trusted Server upstream with authenticated forwarder +support enabled. Provision the same secret in the server's trusted-forwarder +configuration and in a local file, using the authentication header +`x-ts-forwarder-auth`. The publisher hostname must be allowed by the server's +publisher domain configuration. The server consumes the credential before +routing or forwarding to publishers and vendors. + +Configure the upstream application with this optional section: + +```toml +[trusted_forwarder] +auth_header = "x-ts-forwarder-auth" +shared_secret = "trusted_forwarder_shared_secret" +``` + +`shared_secret` names a server secret-store key; provision that key with the +actual token bytes from the local file, excluding its line terminator. The publisher domain or its subdomains must +cover every mapped browser hostname. Omitting the section keeps the server's +default transport-origin behavior. + +Generate a cryptographically random local token file, provision its token in the server secret store, +and pass only the file path to the proxy: + +```bash +umask 077 +openssl rand -hex 32 > ./forwarder-token.txt + +ts dev proxy \ + --map www.publisher.example.com=trusted-server.example.com \ + --rewrite-host \ + --forwarder-secret-file ./forwarder-token.txt \ + --launch chrome +``` + +The file must contain at least 32 ASCII graphic bytes on one line, with an +optional single LF or CRLF terminator. Spaces, blank or extra lines, and non-ASCII +bytes are rejected. It is read once at startup. The token is redacted in debug +output and marked sensitive in the HTTP header value. + +The proxy removes incoming `x-ts-forwarder-auth` fields on mapped MITM requests, +including trailer declarations and streamed trailer fields, even when no +credential is configured. With the option enabled it stamps the +validated token after hop-by-hop sanitation, alongside the actual browser +`X-Forwarded-Host` and `X-Forwarded-Proto: https`. A missing, duplicate, malformed, +or conflicting Host/absolute-URI authority receives `400`; the proxy never +signs a guessed CONNECT host. Each request must match its own mapping. +Unmatched blind tunnels and plain HTTP forwarding keep their existing behavior. + +Without the server opt-in, forwarded metadata does not establish a trusted +public origin. In particular, rewriting Host can change the origin Trusted Server +uses for public URLs and can make browser trace actions fail Origin validation. +Keep the credential file outside version control. + ## Non-loopback listen The proxy binds `127.0.0.1:18080` by default. A non-loopback `--listen` is @@ -360,7 +423,8 @@ ts dev proxy \ Even with `--allow-non-loopback`, unmatched `CONNECT` authorities are refused (`403`) rather than blind-tunneled, so the proxy cannot act as an open CONNECT -proxy on the LAN. +proxy on the LAN. Injected forwarding credentials and Basic auth require a +loopback listener, even with `--allow-non-loopback`. ## All options diff --git a/docs/superpowers/plans/2026-08-24-request-phase-timing.md b/docs/superpowers/plans/2026-08-24-request-phase-timing.md new file mode 100644 index 000000000..1b25b70d1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-request-phase-timing.md @@ -0,0 +1,1040 @@ +# Request Phase Timing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every application response attributes its own server time by phase via a +Server-Timing header and a sampled Tinybird access-telemetry row. + +**Architecture:** A core `RequestTimings` handle (Arc-shared, infallible recording) +collects phase spans always-on; the Fastly adapter freezes and emits at +`send_edgezero_response` immediately before `into_parts()`; a post-send emitter ships +one NDJSON row to the Tinybird Events API with a bounded, 2xx-validated await. + +**Tech Stack:** Rust 2024, `edgezero` HTTP types, Fastly Compute (wasm32-wasip1, +Viceroy tests), Axum (native tests), Tinybird Events API. + +**Spec:** `docs/superpowers/specs/2026-08-24-request-phase-timing-design.md`: the +plan argues from the spec; executors read both. Spec section numbers are cited per +task. + +## Global Constraints + +- Errors use `error-stack` (`Report`); errors defined with + `derive_more::Display`; never thiserror, never anyhow (except the Spin entry point). +- No `unwrap()` in production code; `expect("should ...")` only. Assertion messages + `"should ..."`. Tests use Arrange-Act-Assert. +- No inline comments; comments on their own line above the code. +- Functions never exceed 7 arguments; use a struct instead (this bit + `ec_finalize_response` in review; the timings handle travels inside existing state). +- No local imports inside functions; `use super::*` only in `#[cfg(test)]`. +- Only example/fictional data in tests and docs (`example.com` domains). +- Recording is infallible: saturating math, lock failure drops the sample, no panics + (spec 5, 13). +- Vendor identity never appears in emitted surfaces: the filter span is `ts-filter` + (spec 3). +- Test commands: `cargo test-axum` (native, fast inner loop), `cargo test-fastly` + (Viceroy) for adapter tasks. Before PR handoff: the full CI gate list in + `CLAUDE.md`. +- Commit style: sentence case, imperative, no prefixes, no trailers. + +## File Structure + +| File | Responsibility | +| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/request_timing.rs` (new) | `Phase`, `AuctionWaitPlacement`, `RequestTimings`, `PhaseSpan`, `TimingSnapshot`, header rendering | +| `crates/trusted-server-core/src/access_telemetry.rs` (new) | `RouteClass`, `publisher_route_template`, `AccessTelemetrySnapshot`, `AccessEventRow` NDJSON | +| `crates/trusted-server-core/src/geo.rs` (modify) | `GeoLookupState` response-extension type | +| `crates/trusted-server-core/src/settings.rs` (modify) | `ObservabilitySettings`, tinybird flag decoupling, access validation | +| `crates/trusted-server-core/src/publisher.rs` (modify) | `ts-origin`, `ts-template-cache`, auction-wait spans | +| `crates/trusted-server-core/src/ec/kv.rs` (modify) | `ts-kv` at the graph abstraction | +| `crates/trusted-server-adapter-fastly/src/main.rs` (modify) | T0, appbuild span, freeze point, `DeliveryOutcome`, post-send emission ordering | +| `crates/trusted-server-adapter-fastly/src/app.rs` (modify) | filter span, geo span + `GeoLookupState` attach, route class assignment | +| `crates/trusted-server-adapter-fastly/src/middleware.rs` (modify) | finalize consumes `GeoLookupState` | +| `crates/trusted-server-adapter-fastly/src/tinybird.rs` (modify) | access sink with confirmed delivery | +| `crates/trusted-server-adapter-axum/src/` (modify) | terminal freeze layer, header emission | +| `tinybird/datasources/access_logs_raw.datasource` (modify) | phase-column schema, non-null sorting key | +| `trusted-server.example.toml` (modify) | `[observability]`, tinybird keys | + +Out of scope for this plan: the Grafana dashboard JSON (separate telemetry repo, +spec 11) and Cloudflare/Spin emission wiring (spec non-goal). + +--- + +### Task 1: Core `RequestTimings` + +**Files:** + +- Create: `crates/trusted-server-core/src/request_timing.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` (add `pub mod request_timing;`) +- Test: same file, `#[cfg(test)]` + +**Interfaces:** + +- Consumes: nothing (leaf module; `std::time`, `std::sync`). +- Produces (later tasks rely on these exact names): + - `pub enum Phase { AppBuild, Filter, Geo, EcKv, Origin, TemplateCacheLookup, AuctionWait, Stream }` + - `pub enum AuctionWaitPlacement { PreHeader, InStream }` + - `#[derive(Clone)] pub struct RequestTimings` with: + - `pub fn new() -> Self` + - `pub fn record(&self, phase: Phase, dur: Duration)` (saturating accumulate) + - `pub fn record_auction_wait(&self, placement: AuctionWaitPlacement, dur: Duration)` + - `pub fn span(&self, phase: Phase) -> PhaseSpan` (records on drop) + - `pub fn mark_headers_ready(&self)` (first call wins) + - `pub fn mark_request_elapsed(&self)` (first call wins) + - `pub fn set_resp_bytes(&self, bytes: u64)` + - `pub fn server_timing_value(&self) -> Option` + - `pub fn snapshot(&self) -> TimingSnapshot` + - `pub struct TimingSnapshot { pub time_elapsed_ms: Option, pub request_elapsed_ms: Option, pub appbuild_ms: Option, pub filter_ms: Option, pub geo_ms: Option, pub kv_ms: Option, pub origin_ms: Option, pub template_cache_ms: Option, pub auction_wait_ms: Option, pub stream_ms: Option, pub auction_wait_placement: Option, pub resp_bytes: Option }` + +- [ ] **Step 1: Write the failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_omits_unrecorded_phases_and_orders_total_first() { + let timings = RequestTimings::new(); + timings.record(Phase::Filter, Duration::from_micros(9_100)); + timings.mark_headers_ready(); + let value = timings + .server_timing_value() + .expect("should render after mark_headers_ready"); + assert!( + value.starts_with("ts-total;dur="), + "should lead with ts-total: {value}" + ); + assert!(value.contains("ts-filter;dur=9.1"), "should render one decimal: {value}"); + assert!(!value.contains("ts-geo"), "should omit unrecorded phases: {value}"); + } + + #[test] + fn render_returns_none_before_headers_ready() { + let timings = RequestTimings::new(); + timings.record(Phase::Geo, Duration::from_millis(1)); + assert!(timings.server_timing_value().is_none(), "should require the snapshot"); + } + + #[test] + fn repeated_phases_accumulate_saturating() { + let timings = RequestTimings::new(); + timings.record(Phase::Geo, Duration::from_millis(2)); + timings.record(Phase::Geo, Duration::from_millis(3)); + timings.mark_headers_ready(); + let snapshot = timings.snapshot(); + assert_eq!(snapshot.geo_ms, Some(5), "should accumulate repeats"); + } + + #[test] + fn mark_headers_ready_is_first_call_wins() { + let timings = RequestTimings::new(); + timings.mark_headers_ready(); + let first = timings.snapshot().time_elapsed_ms; + std::thread::sleep(Duration::from_millis(5)); + timings.mark_headers_ready(); + assert_eq!(timings.snapshot().time_elapsed_ms, first, "should not restamp"); + } + + #[test] + fn span_guard_records_on_drop() { + let timings = RequestTimings::new(); + { + let _span = timings.span(Phase::Origin); + std::thread::sleep(Duration::from_millis(2)); + } + timings.mark_headers_ready(); + assert!( + timings.snapshot().origin_ms.expect("should record on drop") >= 1, + "should measure elapsed span time" + ); + } + + #[test] + fn auction_wait_records_placement() { + let timings = RequestTimings::new(); + timings.record_auction_wait(AuctionWaitPlacement::PreHeader, Duration::from_millis(40)); + let snapshot = timings.snapshot(); + assert_eq!(snapshot.auction_wait_ms, Some(40), "should record wait"); + assert_eq!( + snapshot.auction_wait_placement, + Some(AuctionWaitPlacement::PreHeader), + "should record placement" + ); + } + + #[test] + fn rendered_names_never_include_vendor_terms() { + let timings = RequestTimings::new(); + for phase in [Phase::AppBuild, Phase::Filter, Phase::Geo, Phase::EcKv, Phase::Origin, Phase::TemplateCacheLookup] { + timings.record(phase, Duration::from_millis(1)); + } + timings.mark_headers_ready(); + let value = timings.server_timing_value().expect("should render"); + assert!(!value.to_ascii_lowercase().contains("datadome"), "should mask vendors"); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-axum -p trusted-server-core request_timing` +Expected: compile FAIL, module does not exist. + +- [ ] **Step 3: Implement** + +```rust +//! Per-request phase timing collection and Server-Timing rendering. +//! +//! Collection is always-on and infallible: saturating math, lock failure +//! drops the sample, no panics. See the design spec +//! `docs/superpowers/specs/2026-08-24-request-phase-timing-design.md`. + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const PHASE_COUNT: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + AppBuild, + Filter, + Geo, + EcKv, + Origin, + TemplateCacheLookup, + AuctionWait, + Stream, +} + +impl Phase { + fn index(self) -> usize { /* match self -> 0..=7 */ } + + /// Header entry name; row-only phases return None. + fn header_name(self) -> Option<&'static str> { + match self { + Self::AppBuild => Some("ts-appbuild"), + Self::Filter => Some("ts-filter"), + Self::Geo => Some("ts-geo"), + Self::EcKv => Some("ts-kv"), + Self::Origin => Some("ts-origin"), + Self::TemplateCacheLookup => Some("ts-template-cache"), + Self::AuctionWait | Self::Stream => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuctionWaitPlacement { + PreHeader, + InStream, +} + +struct Inner { + t0: Instant, + phases: [Option; PHASE_COUNT], + headers_ready_total: Option, + request_elapsed: Option, + auction_wait_placement: Option, + resp_bytes: Option, +} + +#[derive(Clone)] +pub struct RequestTimings(Arc>); +``` + +Implementation notes (all bodies in this task, none deferred): + +- Every method takes `if let Ok(mut inner) = self.0.try_lock()` and silently + returns otherwise: contention and poison both drop the sample instead of waiting, + per the infallibility constraint. +- `record` accumulates with `saturating_add` semantics + (`Some(existing.saturating_add(dur))`). +- `mark_headers_ready` and `mark_request_elapsed` write `t0.elapsed()` only when the + slot is `None`. +- `server_timing_value` returns `None` unless `headers_ready_total` is set; renders + `ts-total` first from the stored snapshot, then the six header phases in enum order + with `{:.1}` millisecond formatting (`dur.as_secs_f64() * 1000.0`). +- `PhaseSpan { timings: RequestTimings, phase: Phase, started: Instant }`; `Drop` + calls `record(self.phase, self.started.elapsed())`. +- `TimingSnapshot` converts each `Duration` with + `u32::try_from(dur.as_millis()).unwrap_or(u32::MAX)`. +- `impl Default for RequestTimings` delegates to `new()`. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-axum -p trusted-server-core request_timing` +Expected: all 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/request_timing.rs crates/trusted-server-core/src/lib.rs +git commit -m "Add RequestTimings phase collection and Server-Timing rendering" +``` + +--- + +### Task 2: Settings: `[observability]`, tinybird decoupling, access validation + +**Files:** + +- Modify: `crates/trusted-server-core/src/settings.rs` +- Modify: `trusted-server.example.toml` + +**Interfaces:** + +- Produces: + - `pub struct ObservabilitySettings { pub server_timing_enabled: bool }` as + `settings.observability`, `#[serde(default)]` on the field and + `#[serde(skip_serializing_if = "ObservabilitySettings::is_default")]`. + - `TinybirdSettings.auction_enabled: bool` (`#[serde(default = "default_true")]`). + - `prepare_runtime` validation: `access_enabled` requires `enabled`, non-empty + `api_host`, `secret_store`, `access_dataset`, `access_token_secret`, + `max_body_bytes > 0`, and `access_sample_rate > 0.0`. + +- [ ] **Step 1: Write the failing tests** (in `settings.rs` tests module) + +```rust +#[test] +fn observability_defaults_off_and_serializes_away() { + let settings = create_test_settings(); + assert!(!settings.observability.server_timing_enabled, "should default off"); + let toml = toml::to_string(&settings).expect("should serialize settings"); + assert!( + !toml.contains("[observability]"), + "should omit the default table so a prior binary can parse the config" + ); +} + +#[test] +fn access_enabled_requires_positive_sample_rate() { + // access_enabled = true with access_sample_rate = 0 is armed-but-silent: an error. + let err = settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\naccess_enabled = true\naccess_sample_rate = 0.0\n", + ) + .expect_err("should reject armed-but-silent access telemetry"); + assert!(format!("{err:?}").contains("access_sample_rate"), "should name the field"); +} + +#[test] +fn access_and_auction_emission_are_independent() { + let settings = settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_enabled = false\naccess_enabled = true\naccess_sample_rate = 1.0\n", + ) + .expect("should accept access without auction"); + assert!(!settings.tinybird.auction_enabled, "should disable auction emission"); + assert!(settings.tinybird.access_enabled, "should enable access emission"); +} + +#[test] +fn auction_enabled_defaults_true_for_existing_configs() { + let settings = settings_from_toml_with("[tinybird]\nenabled = true\napi_host = \"api.example.com\"\n") + .expect("should parse a pre-decoupling config"); + assert!(settings.tinybird.auction_enabled, "should preserve current behavior"); +} +``` + +Also REPLACE the existing rejection test +(`tinybird_access_enabled_is_rejected_until_emitter_is_wired`, `settings.rs:4123`) +with a wiring test asserting a fully-specified access config is accepted. + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-axum -p trusted-server-core observability access_enabled auction_enabled` +Expected: compile FAIL (`observability` field missing). + +- [ ] **Step 3: Implement** + +- Add `ObservabilitySettings` (derive `Debug, Clone, Default, PartialEq, Deserialize, +Serialize`, `#[serde(deny_unknown_fields)]`), with + `fn is_default(&self) -> bool { *self == Self::default() }`. +- Add the `observability` field to `Settings` with the serde attributes above. +- Add `auction_enabled` to `TinybirdSettings` with `default_true()`; update + `Default for TinybirdSettings`. +- Extend `TinybirdSettings::prepare_runtime` with the access validation matrix; error + messages name the failing field (`"tinybird.access_sample_rate must be > 0 when +access_enabled"` and so on). +- `trusted-server.example.toml`: add a commented `[observability]` block with + `server_timing_enabled = false` present-but-false and the env-override note (the + overlay cannot create a missing leaf), plus `auction_enabled`/access keys in the + tinybird section comments. +- Gate the auction sink: in `crates/trusted-server-adapter-fastly/src/app.rs`, + `auction_sink_from_settings` condition becomes + `settings.tinybird.enabled && settings.tinybird.auction_enabled`. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-axum -p trusted-server-core` then `cargo test-fastly` (the sink gate +touches the Fastly adapter). +Expected: PASS, including the replaced wiring test. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/settings.rs trusted-server.example.toml crates/trusted-server-adapter-fastly/src/tinybird.rs crates/trusted-server-adapter-fastly/src/app.rs +git commit -m "Add observability settings and decouple tinybird access and auction emission" +``` + +--- + +### Task 3: Fastly freeze point, header emission, `DeliveryOutcome` + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` (entry T0, appbuild span, + `send_edgezero_response`) +- Test: `crates/trusted-server-adapter-fastly/src/app.rs` tests module (route-level + tests run under Viceroy) + +**Interfaces:** + +- Consumes: `RequestTimings`, `Phase` (Task 1); + `trusted_server_core::cache_policy::cache_control_headers_are_private_or_no_store`. +- Produces: + - `RequestTimings` inserted into request extensions at dispatch + (`core_req.extensions_mut().insert(timings.clone())`), alongside the existing + `config_store`/`device_signals`/`client_info` inserts. + - `send_edgezero_response(response, effects, timings) -> DeliveryOutcome` where + `pub(crate) struct DeliveryOutcome { pub bytes: u64, pub result: DeliveryResult }` + and `pub(crate) enum DeliveryResult { Complete, Error }` (streaming partial + detection lands in Task 6). + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn server_timing_emitted_on_private_response_when_enabled() { + // Arrange: settings with observability.server_timing_enabled = true; publisher + // route fixture whose response is Cache-Control: private, no-store. + // Act: dispatch through the full adapter path. + // Assert: + let header = response_header(&response, "server-timing").expect("should emit header"); + assert!(header.contains("ts-total;dur="), "should carry the stored total"); + assert_eq!( + header.matches("ts-total").count(), 1, + "should emit exactly one TS-owned metric set" + ); +} + +#[test] +fn server_timing_absent_when_flag_off() { /* same fixture, flag false: no ts-total */ } + +#[test] +fn server_timing_absent_on_cacheable_responses() { + // tsjs route (public, max-age=31536000, immutable) and a bare max-age=60 response: + // both must carry no ts-total even with the flag on. +} + +#[test] +fn preexisting_server_timing_values_survive() { + // Fixture response already carrying Server-Timing: upstream;dur=1 stays present + // alongside the appended TS set. +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-fastly server_timing` +Expected: FAIL, no header emitted. + +- [ ] **Step 3: Implement** + +In `edgezero_main` (`main.rs`): + +```rust +let timings = RequestTimings::new(); +{ + let _appbuild = timings.span(Phase::AppBuild); + // existing: open_trusted_server_config_store() + build_app_with_state() +} +``` + +Move the config-store open inside the span scope. Insert `timings.clone()` into +request extensions before dispatch. Thread the handle into both send sites and the +error paths by value (it is a cheap clone). + +In `send_edgezero_response`, immediately before `response.into_parts()`: + +```rust +timings.mark_headers_ready(); +let conclusively_private = + cache_control_headers_are_private_or_no_store(response.headers()); +if settings_enabled_server_timing && conclusively_private { + if let Some(value) = timings.server_timing_value() { + match HeaderValue::from_str(&value) { + Ok(header_value) => { + response.headers_mut().append(header::SERVER_TIMING, header_value); + } + Err(error) => log::warn!("skipping server-timing header: {error}"), + } + } +} +``` + +`settings_enabled_server_timing` arrives inside a small +`SendContext { timings: RequestTimings, server_timing_enabled: bool }` so the +function stays at or under seven parameters. Return `DeliveryOutcome` with per-mode +semantics: buffered bodies capture the byte count from the body length before +`send_to_client()` (which returns no delivery result) and report complete-on-return; +the streaming branch gains a counting writer in Task 6. Existing callers ignore the +outcome in this task (Task 8 consumes it). + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-fastly` and `cargo clippy-fastly` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/main.rs crates/trusted-server-adapter-fastly/src/app.rs +git commit -m "Emit Server-Timing at the send freeze point on conclusively private responses" +``` + +--- + +### Task 4: Filter span and geo span with `GeoLookupState` dedupe + +**Files:** + +- Modify: `crates/trusted-server-core/src/geo.rs` (add `GeoLookupState`) +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` + (`run_pre_route_filters` wrapper, `build_ec_request_state` geo span + state attach) +- Modify: `crates/trusted-server-adapter-fastly/src/middleware.rs` and `main.rs` + (`resolve_geo_for_response` consumes carried state) + +**Interfaces:** + +- Consumes: `RequestTimings` from request extensions (Task 3). +- Produces: + - `pub enum GeoLookupState { NotAttempted, Attempted, Resolved(GeoInfo) }` in + `trusted_server_core::geo`, attached as a response extension on every exit path + that attempted a lookup (including the asset fallback). + - `resolve_geo_for_response` gains the carried state as input: live lookup only on + `NotAttempted`; `Attempted` is never retried; fallback lookups are wrapped in + `timings.span(Phase::Geo)`. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn finalize_reuses_request_phase_geo_without_second_lookup() { + // Counting geo stub: dispatch a publisher route; assert lookup count == 1 and + // x-geo-country still set on the response. +} + +#[test] +fn failed_lookup_is_not_retried() { + // Stub returns None once; assert GeoLookupState::Attempted carried and the + // finalize path performs zero further lookups. +} + +#[test] +fn asset_fallback_carries_geo_state_without_ec_finalize_state() { + // Asset route: response extension holds GeoLookupState, EcFinalizeState absent. +} + +#[test] +fn filter_span_recorded_when_request_filter_runs() { + // Registry fixture with a test request filter; assert snapshot().filter_ms is Some. +} + +#[test] +fn geo_lookup_skipped_for_unauthorized_responses() { + // Existing 401 rule preserved: no lookup, state NotAttempted. +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-fastly geo_ filter_span` +Expected: FAIL (lookup count 2; no `GeoLookupState`). + +- [ ] **Step 3: Implement** + +- `GeoLookupState` derives `Debug, Clone`; store in response extensions from the + dispatch layer right after `build_ec_request_state` resolves (or fails) its lookup. +- Wrap the `build_ec_request_state` lookup and any finalize fallback lookup in + `timings.span(Phase::Geo)` (accumulating slot handles the repeat case). +- Wrap `run_pre_route_filters` (`app.rs:751`) in `timings.span(Phase::Filter)`, + recording only when at least one filter is registered (skip the span when the + registry has no request filters, so the header omits `ts-filter` on unconfigured + deployments). +- `resolve_geo_for_response(response, carried: &GeoLookupState, client_ip, lookup)` + keeps the 401 short-circuit first, then matches the carried state. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-fastly` and `cargo test-axum` +Expected: PASS including untouched existing geo header tests. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/geo.rs crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-adapter-fastly/src/middleware.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Record filter and geo spans and dedupe the per-request geo lookup" +``` + +--- + +### Task 5: Core spans: origin, template cache, KV abstraction + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` (origin send ~4496, template + cache lookup ~4391 on current main) +- Modify: `crates/trusted-server-core/src/ec/kv.rs` (graph-level `ts-kv`) +- Test: `publisher.rs` and `ec/kv.rs` test modules + +**Interfaces:** + +- Consumes: `RequestTimings` read from request extensions inside + `handle_publisher_request`; `KvIdentityGraph` gains + `pub fn with_timings(self, timings: RequestTimings) -> Self` (builder-style, + optional field), set where the graph is constructed in `main.rs`. +- Produces: `origin_ms`, `template_cache_ms`, `kv_ms` populated in snapshots. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn origin_span_covers_the_publisher_fetch() { + // Stubbed origin with a small injected delay; assert snapshot().origin_ms is Some. +} + +#[test] +fn template_cache_span_recorded_only_when_lookup_runs() { + // Inline mode fixture: template_cache_ms None. Shared-mode eligible fixture: + // template_cache_ms Some. +} + +#[test] +fn kv_span_accumulates_across_graph_operations() { + // Stub KV recording two operations through a TimedKvStore-wrapped graph; assert + // kv_ms Some and covers both (accumulated, not last-write). +} + +#[test] +fn consent_store_reads_are_timed_and_pull_sync_is_not() { + // Consent read through the decorated RuntimeServices store: kv_ms Some. + // Pull-sync graph built from the untimed store: records nothing. +} + +#[test] +fn ec_finalize_kv_lands_before_freeze() { + // Adapter-level (test-fastly): EC-enabled fixture with eids cookies; assert the + // emitted header contains ts-kv, proving the freeze point sits after finalize. +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-axum -p trusted-server-core origin_span template_cache_span kv_span` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +- `handle_publisher_request` reads the handle: + `let timings = req.extensions().get::().cloned().unwrap_or_default();` + (a defaulted handle records into nothing that ever renders, keeping non-adapter + tests unchanged). +- Origin: `let origin_span = timings.span(Phase::Origin);` immediately before + `services.http_client().send(platform_request).await`; `drop(origin_span)` when the + response headers are available (directly after the `match` arm binds the response). +- Template cache: same guard pattern around + `services.template_cache().lookup_or_reserve(key).await`. +- KV: add `TimedKvStore` (new type in `crates/trusted-server-core/src/platform/`), + a decorator implementing `PlatformKvStore` that wraps `Arc` + plus a `RequestTimings` handle and records `Phase::EcKv` around every trait + method. Every request-path `KvIdentityGraph` construction site (request setup, + identify, admin lookup, batch sync, finalization) receives the timed store; + consent-store access through `RuntimeServices` uses the same decorator; pull-sync + constructs its graph from the untimed store explicitly (add a test asserting the + pull-sync store records nothing). `ec_finalize_response` keeps seven arguments: + the handle rides inside the store the graph already receives. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-axum` then `cargo test-fastly` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-core/src/ec/kv.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Record origin, template cache, and KV phase spans in core" +``` + +--- + +### Task 6: Body-phase capture: stream, auction wait placement, bytes + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` (seam wait + buffered wait) +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` (stream drive timing, + `DeliveryOutcome.bytes`) +- Test: `publisher.rs` tests + adapter tests + +**Interfaces:** + +- Consumes: `record_auction_wait` (Task 1), `DeliveryOutcome` (Task 3). +- Produces: `stream_ms`, `auction_wait_ms` + placement, `resp_bytes`, + `mark_request_elapsed()` called by the adapter immediately after the stream drive + returns. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn streaming_seam_wait_records_in_stream_placement() { + // Streaming fixture with a delayed auction: placement InStream, and + // stream_ms >= auction_wait_ms. +} + +#[test] +fn buffered_template_miss_records_pre_header_placement() { + // Shared-template authorized miss (buffered finalizer): placement PreHeader; the + // wait is recorded even though headers had not committed. +} + +#[test] +fn delivery_outcome_reports_bytes_and_request_elapsed_set() { + // Adapter: after send, snapshot has resp_bytes Some(body_len) and + // request_elapsed_ms Some; request_elapsed excludes post-send emitter time by + // construction (asserted by ordering test in Task 8). +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-fastly seam_wait buffered_template delivery_outcome` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +- Streaming path: around the `collect_stream_auction(...)` await inside the body + stream, measure with `Instant::now()` and call + `timings.record_auction_wait(AuctionWaitPlacement::InStream, waited)`. The handle + reaches the stream closure through `OwnedProcessResponseParams`/assembly params (it + is `Clone`; add a field). +- Buffered path (`buffer_publisher_response_async` and the shared-template miss + finalizer): same measurement with `AuctionWaitPlacement::PreHeader`. +- Adapter stream drive: wrap the `block_on(stream_asset_body(...))` region with a + counting writer that tallies bytes and observes truncation/error, record + `Phase::Stream` with the elapsed drive time, populate `DeliveryOutcome` with + bytes and Complete/Partial/Error, call `timings.set_resp_bytes(bytes)` and + `timings.mark_request_elapsed()` immediately after the drive returns, before + anything else post-send. Buffered responses keep the Task 3 complete-on-return + semantics; `body_mode` distinguishes the regimes in the row. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-fastly` and `cargo test-axum` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Capture stream duration, auction wait placement, and response bytes" +``` + +--- + +### Task 7: `AccessTelemetrySnapshot`, route class, route template + +**Files:** + +- Create: `crates/trusted-server-core/src/access_telemetry.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` (RouteMetadata attach at + handler wrappers), `main.rs` (snapshot build at freeze point), + `crates/trusted-server-core/src/publisher.rs` (typed template-cache state + extension) + +**Interfaces:** + +- Consumes: `TimingSnapshot` (Task 1), `GeoLookupState` (Task 4). +- Produces: + - `pub enum RouteClass { PublisherHtml, Tsjs, IntegrationProxy, Ec, AuctionApi, Other }` + with `pub fn as_str(&self) -> &'static str` (snake_case values from the spec). + - `pub fn publisher_route_template(path: &str) -> String`: `/` plus first segment + filtered to `[a-z0-9_-]`, truncated to 32 chars, plus `/*` when deeper; empty or + disallowed first segments render `/other/*`. + - `pub struct AccessTelemetrySnapshot { pub method: String, pub status: u16, pub route_class: RouteClass, pub route_template: String, pub publisher_domain: String, pub env: String, pub service_id: String, pub pop: String, pub ts_version: String, pub country: String, pub template_cache_state: String, pub body_mode: &'static str, pub sample_rate: f64 }` + - `pub fn access_event_row(snapshot: &AccessTelemetrySnapshot, timings: &TimingSnapshot, event_ts_epoch_ms: u64) -> String` (one NDJSON line). + +- [ ] **Step 1: Write the failing tests** (adversarial, per spec 9) + +```rust +#[test] +fn admin_ec_route_template_never_contains_the_identifier() { + // Named-route template comes from the route table: "/_ts/admin/ec/{id}". + // Assert a row built for that route never contains a 64-hex EC id fixture. +} + +#[test] +fn publisher_paths_normalize_to_coarse_templates() { + assert_eq!(publisher_route_template("/news/some-article-slug"), "/news/*"); + assert_eq!(publisher_route_template("/"), "/"); + assert_eq!( + publisher_route_template("/user@example.com/profile"), + "/other/*", + "should reject non-allowlisted characters" + ); + assert_eq!( + publisher_route_template(&format!("/{}", "a".repeat(500))), + format!("/{}", "a".repeat(32)), + "should bound segment length" + ); + assert_eq!(publisher_route_template("/search terms here"), "/other/*"); +} + +#[test] +fn row_serializes_nulls_for_missing_phases() { + // Sparse TimingSnapshot: absent phases serialize as JSON null, dimension fields + // never null (unknown sentinel). +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-axum -p trusted-server-core access_telemetry route_template` +Expected: compile FAIL. + +- [ ] **Step 3: Implement** + +Row serialization via `serde_json::json!` mapping spec section 9 column names exactly +(`time_elapsed_ms`, `appbuild_ms`, ..., `auction_wait_placement` as +`pre_header|in_stream|none`). `pop`/`service_id` read from Fastly env +(`FASTLY_SERVICE_ID`, `FASTLY_POP`), defaulting `"unknown"`; `env` derived by the +adapter from `FASTLY_IS_STAGING` (the `x-ts-env` input), never from `Settings`. +Route identity travels as a typed `RouteMetadata` response extension +(`pub struct RouteMetadata { pub route_class: RouteClass, pub route_template: String }` +in `access_telemetry.rs`): each named-route handler wrapper attaches its matched +route-table pattern verbatim, and the fallback and tsjs handlers attach their class +plus the coarse template; the freeze point consumes the extension (no `RouteClass` +column in `NAMED_ROUTES`, no reconstruction from a handler enum). Also in this task: +make `TemplateCacheResponseState` a typed response extension in `publisher.rs`, set +at every point that writes `x-ts-template-cache` so header and extension cannot +drift; the row reads the extension. The snapshot is built (when access +telemetry is enabled) in +`send_edgezero_response` right after `mark_headers_ready()` and returned inside +`DeliveryOutcome` (add field `pub snapshot: AccessTelemetrySnapshot`). + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-axum` and `cargo test-fastly` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/access_telemetry.rs crates/trusted-server-core/src/lib.rs crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Add access telemetry snapshot, route classes, and coarse route templates" +``` + +--- + +### Task 8: Access sink with confirmed delivery + post-send ordering + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/tinybird.rs` (access sink) +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` (post-send ordering) + +**Interfaces:** + +- Consumes: `AccessTelemetrySnapshot` + `access_event_row` (Task 7), settings flags + (Task 2), `DeliveryOutcome` (Tasks 3/6). +- Produces: `pub(crate) async fn emit_access_event(client: &FastlyPlatformHttpClient, target: &TinybirdEventsTarget, row: String) -> Result<(), Report>`, + sending via the adapter's stateless platform client (the blocking variant, + post-delivery), checking `response.status().is_success()`, warning with status + otherwise. The transport context is adapter-owned and route-independent (target + derived from settings once at entry), so asset, admin, and error responses emit + without `RuntimeServices` or `EcFinalizeState`. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn access_emitter_posts_ndjson_and_validates_2xx() { + // RecordingHttpClient returning 202: assert URI is /v0/events?name=access_logs_raw, + // body is the row, Authorization bearer from the secret stub. +} + +#[test] +fn access_emitter_warns_and_drops_on_non_2xx() { + // RecordingHttpClient returning 422: emit returns Err naming the status; no retry + // request recorded (exactly one request seen). +} + +#[test] +fn sampled_out_requests_emit_nothing() { + // access_sample_rate stub decision false: RecordingHttpClient sees zero requests. +} + +#[test] +fn post_send_order_is_elapsed_then_pull_sync_then_telemetry() { + // Instrumented stubs record call order; assert request_elapsed snapshot precedes + // pull-sync dispatch which precedes the telemetry send. +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-fastly access_emitter post_send_order` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +- Reuse `TinybirdEventsTarget` with a second constructor + `from_access_config(config: TinybirdSettings)` using `access_dataset` and + `access_token_secret`. +- Sampling decision: `fn sampled_in(rate: f64, entropy: u64) -> bool` where entropy is + derived from the event timestamp nanos XOR a per-request counter (no `rand` + dependency; document that uniformity is approximate and sufficient). +- `main.rs` post-send, in order: `timings.mark_request_elapsed()` (already placed in + Task 6), existing pull-sync dispatch unchanged, then when + `settings.tinybird.enabled && settings.tinybird.access_enabled` and sampled in: + build the row from `outcome.snapshot` + `timings.snapshot()`, call + `emit_access_event`, log one warning on `Err`. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-fastly` and `cargo clippy-fastly` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/tinybird.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Emit confirmed access telemetry rows after pull-sync post-send" +``` + +--- + +### Task 9: Tinybird datasource schema + +**Files:** + +- Modify: `tinybird/datasources/access_logs_raw.datasource` + +**Interfaces:** + +- Consumes: column names exactly as serialized by `access_event_row` (Task 7). +- Produces: the deployed schema contract for the dashboard (separate repo). + +- [ ] **Step 1: Rewrite the schema** per spec section 9: keep + `event_ts DateTime64(3)`, `method`, `status UInt16`, `time_elapsed_ms UInt32`, + `sample_rate Float64` + 30-day TTL; add the columns from spec 9 with + dimension columns non-nullable `LowCardinality(String)` and phase columns + `Nullable(UInt32)`; drop `path` and `cache_state`; set + `ENGINE_SORTING_KEY "toDate(event_ts), service_id, publisher_domain, env, route_class, pop, status"` + (`event_date` was dropped for the sorting-key expression; see spec section 9). + +- [ ] **Step 2: Validate** with the tinybird toolchain if available locally + (`tb check` / project tests under `tinybird/tests`); otherwise assert the file + parses by review and rely on rollout step 4's remote verification. Add a fixture row + in `tinybird/fixtures` matching `access_event_row` output. + +- [ ] **Step 3: Commit** + +```bash +git add tinybird/datasources/access_logs_raw.datasource tinybird/fixtures +git commit -m "Extend access_logs_raw with phase columns and a non-null sorting key" +``` + +--- + +### Task 10: Axum adapter emission + +**Files:** + +- Modify: `crates/trusted-server-adapter-axum/src/` (terminal layer at the response + serialization boundary; locate the equivalent of the Fastly send path) +- Test: axum adapter tests (`cargo test-axum`) + +**Interfaces:** + +- Consumes: `RequestTimings`, header emission helper. Extract the emission block from + Task 3 into a shared core helper so both adapters call one function: + `pub fn append_server_timing_if_private(response: &mut Response, timings: &RequestTimings, enabled: bool)` + in `request_timing.rs` (move the Fastly inline logic here and re-point Task 3's call + site). +- Produces: Axum responses carry the header under the same conservative predicate; + `ts-appbuild` absent by construction (state built at startup); router-generated + 404/405 covered by the terminal layer; `/health` excluded by route match. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn axum_emits_header_on_private_response() { /* flag on, private response: ts-total present, ts-appbuild absent */ } + +#[test] +fn axum_404_carries_header_when_private() { /* router-generated 404 passes through the terminal layer */ } + +#[test] +fn axum_health_is_excluded() { /* /health: no ts-total */ } +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-axum axum_emits axum_404 axum_health` +Expected: FAIL. + +- [ ] **Step 3: Implement** an outer service wrapper around the `RouterService` + inside `AxumDevServer` (not router middleware, which router-generated 404/405 + responses bypass and which returns before body serialization): create + `RequestTimings::new()` per request in the wrapper, insert into request + extensions, and on the wrapper's response side call `mark_headers_ready()` + + `append_server_timing_if_private(...)`, skipping the `/health` path by match. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-axum` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-axum/src crates/trusted-server-core/src/request_timing.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Emit Server-Timing from the Axum terminal layer with adapter-specific semantics" +``` + +--- + +### Task 11: Full gate, docs, and PR + +- [ ] **Step 1: Docs.** Add a short operator section to `docs/guide/configuration.md`: + the `[observability]` flag, the tinybird access keys, the deploy/rollback ordering + from spec section 12 (binary first, config second; config first on rollback), and + the conservative emission rule. Run `cd docs && npm run format`. + +- [ ] **Step 2: Full CI gate list** from `CLAUDE.md`: + `cargo fmt --all -- --check`; all six clippy aliases; `test-fastly`, `test-axum`, + `test-cloudflare`, `test-spin`; the integration-tests parity suite; JS build/test + and formats. Cloudflare/Spin compile the new core modules (collection only), which + is exactly what the non-goal requires. + +- [ ] **Step 3: Commit docs, push the branch, open the implementation PR** referencing + the spec PR #1069 and issue #1068, with the rollout section of the spec quoted as + the deployment checklist (staging pass-through + MISS/HIT replay before production + flag-on). + +--- + +## Self-Review + +- Spec coverage: sections 5 (Task 1), 12 (Task 2), 7 (Tasks 3, 10), 8/8a (Tasks 4, + 10), 6 (Tasks 4-6), 9 (Tasks 7, 9), 10 (Task 8), 13 (Tasks 1, 3, 8), 14 (test + steps throughout), 15 steps 1-4 (Task 11 + deployment checklist). Section 11 + (dashboard) is explicitly out of scope for this repo's plan. +- Type consistency: `RequestTimings`/`TimingSnapshot`/`RouteClass`/ + `AccessTelemetrySnapshot`/`DeliveryOutcome` names and signatures match across + Tasks 1, 3, 6, 7, 8, 10. +- Known intentional deferral: `DeliveryResult::Partial` detection is named in Task 3 + and wired when the stream drive reports bytes in Task 6; no other deferrals. diff --git a/docs/superpowers/plans/2026-08-26-auction-timeline-offsets.md b/docs/superpowers/plans/2026-08-26-auction-timeline-offsets.md new file mode 100644 index 000000000..6a2aec751 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-auction-timeline-offsets.md @@ -0,0 +1,83 @@ +# Auction Timeline Offsets Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Record three T0-anchored auction milestones (dispatched, resolved, committed) plus the auction id on `RequestTimings`, and emit them as four additive columns on the `access_logs_raw` row. + +**Architecture:** Follows spec section 18. All state lives in the existing `RequestTimings` inner (same `try_lock`/first-call-wins/saturating model as `mark_headers_ready`); the row builder reads the values from `TimingSnapshot`, so no new emission path and no adapter changes. + +**Tech Stack:** Rust (core crate only), Tinybird datasource and fixture files. + +**Spec:** `docs/superpowers/specs/2026-08-24-request-phase-timing-design.md` section 18. + +## Global Constraints + +- Marks are first-call-wins; `try_lock` only; a contended lock drops the sample, a poisoned lock is recovered (matching every other write on the collector). +- A null offset means "this milestone was not reached", not "no auction ran". `auction_id` is what separates the two: null id means no auction was attempted; a non-null id with a null dispatch offset means attempted but nothing sent; a non-null dispatch offset with a null resolve offset means dispatched and never collected. +- The id is stamped where the observation is built, not on dispatch, so skipped and dispatch-failed auctions stay joinable to the `auction_events_raw` rows they emit. +- Column names: `auction_dispatched_ms`, `auction_resolved_ms`, `auction_committed_ms`, `auction_id`; JSONPaths `json:$.`; FORWARD_QUERY extended in the same order. +- `auction_id` is `Nullable(UUID)`, matching `auction_events_raw.auction_id` so the join needs no cast and no sentinel. +- All three `AuctionSource` variants are instrumented: `InitialNavigation`, `SpaNavigation` (`/_ts/page-bids`), and `AuctionApi` (`POST /auction`). +- No header emission and no config surface. Changes are confined to `trusted-server-core`, `tinybird/`, and the spec and plan documents. + +--- + +### Task 1: RequestTimings marks and snapshot fields + +**Files:** + +- Modify: `crates/trusted-server-core/src/request_timing.rs` + +**Interfaces:** + +- Produces: `set_auction_id(&self, auction_id: Uuid)`, `mark_auction_dispatched(&self)`, `mark_auction_resolved(&self)`, `mark_auction_committed(&self)`; `TimingSnapshot { auction_dispatched_ms, auction_resolved_ms, auction_committed_ms: Option, auction_id: Option, .. }` + +- [x] Add `auction_dispatched`, `auction_resolved`, `auction_committed: Option` and `auction_id: Option` to `Inner`; initialize `None`. +- [x] Add `set_auction_id` plus the three no-arg mark methods, first-call-wins on their own field, storing `inner.t0.elapsed()`. +- [x] Recover a poisoned lock in all four, matching the other lock-taking methods on the collector. +- [x] Map all four into `TimingSnapshot` via `duration_ms`; `Uuid` is `Copy`, so the id needs no clone. +- [x] Tests: first-call-wins per mark, proved by sleeping between the two calls and asserting the value is unchanged; the id survives a request with no dispatch mark; unmarked snapshot yields all `None`. +- [x] `cargo test-fastly request_timing`, commit. + +### Task 2: Auction call sites + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/auction/endpoints.rs` + +**Interfaces:** + +- Consumes: Task 1 methods; `observation.auction_id` (`AuctionObservationContext`), in scope wherever an observation is built. + +- [x] Navigation path: `set_auction_id` at observation construction; `mark_auction_dispatched()` in the `DispatchAuctionOutcome::Dispatched` arm; `mark_auction_resolved()` after both `record_auction_wait` calls; `mark_auction_committed()` after both `write_bids_to_state` calls. +- [x] `/_ts/page-bids`: pull the collector off the request extensions; `set_auction_id` at observation construction; dispatch before `run_auction`, resolve on both its `Ok` and `Err` arms, commit after the bid map is built. +- [x] `POST /auction`: pull the collector off `parts.extensions` (the request is consumed by `into_parts` before the auction runs); same bracket around `run_auction`, commit once the OpenRTB response is converted. +- [x] Tests: extend the two existing collect-site tests to assert the resolve and commit offsets alongside `auction_wait_ms`. +- [x] `cargo test-fastly`, commit. + +### Task 3: Row columns, datasource, fixture + +**Files:** + +- Modify: `crates/trusted-server-core/src/access_telemetry.rs` +- Modify: `tinybird/datasources/access_logs_raw.datasource` +- Modify: `tinybird/fixtures/access_logs_raw.ndjson` + +- [x] `access_event_row`: add the three offset keys and `auction_id`, all nullable, after the existing phase keys. +- [x] Extend `row_serializes_nulls_for_missing_phases` (including `auction_id` in the nullable-column loop) and `row_serializes_recorded_phases_as_numbers` for the new keys. +- [x] Datasource: four schema columns with JSONPaths (`Nullable(UInt32)` ×3, `Nullable(UUID)`), appended at the end of SCHEMA and FORWARD_QUERY so existing column order stays stable. +- [x] Fixture: extend the existing row with a full timeline reusing the `auction_events_raw` fixture's UUID so the pair demonstrates the join, and add a second row covering the no-auction case. +- [x] Full gates: fmt, clippy (all six), test-fastly/axum/cloudflare/spin, parity. Commit. + +### Task 4: Spec amendment + +**Files:** + +- Modify: `docs/superpowers/specs/2026-08-24-request-phase-timing-design.md` + +- [x] Section 18: mark table including `set_auction_id`, the per-source instrumentation table, the null-semantics table, and the `Nullable(UUID)` rationale. +- [x] Section 18: correct the interpretation ladder so `time_elapsed_ms` is not shown last, since on a streamed body the headers commit before the seam collect. +- [x] Section 18: state the `R - D` versus `total_time_ms` reconciliation caveat and give the join query. +- [x] Section 9: add the four columns so the canonical column list the row builder's doc comment points at stays complete. +- [x] Docs format (`cd docs && npm run format`). diff --git a/docs/superpowers/plans/2026-09-16-edgezero-fastly-store-selectors.md b/docs/superpowers/plans/2026-09-16-edgezero-fastly-store-selectors.md new file mode 100644 index 000000000..7eff8e023 --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-edgezero-fastly-store-selectors.md @@ -0,0 +1,333 @@ +# EdgeZero Fastly Store Selector Alignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore Trusted Server staging deployments by consuming EdgeZero PR 381's canonical Fastly store-selector behavior and removing the superseded service-scoped configuration. + +**Architecture:** Pin every EdgeZero workspace dependency to the upstream PR branch so the CLI and adapters share one revision. Keep Trusted Server's strict runtime secret resolution, while moving the Fastly entry point, local Viceroy configuration, its regression test, and operator guidance to the logical-ID store model: EdgeZero links each physical store selected by a canonical `EDGEZERO__STORES__...` deployment selector under its logical ID, and the runtime opens stores by that ID. + +**Tech Stack:** Rust 1.95, Cargo Git dependencies, Fastly Compute/Viceroy TOML configuration, Markdown documentation, GitHub CLI. + +--- + +## File Map + +- `Cargo.toml`: select the EdgeZero PR 381 branch for all EdgeZero crates. +- `Cargo.lock`: lock all EdgeZero packages to the branch revision. +- `crates/trusted-server-adapter-fastly/src/main.rs`, `crates/trusted-server-adapter-fastly/src/app.rs`: open stores and the config entry by logical ID instead of reading `edgezero_runtime_env`. +- `fastly.toml`: expose the local Viceroy secret store under its logical ID and drop the runtime selector store. +- `crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml`, `scripts/template-cache-local-test.sh`: keep generated Viceroy configurations aligned with the root Fastly configuration. +- `crates/trusted-server-integration-tests/tests/common/config.rs`: regress the logical-ID store exposure in both Viceroy configurations. +- `docs/guide/fastly.md`: explain deploy-time canonical selector handling and physical store linking. +- `docs/guide/cli.md`: explain staging Config Store selection under the restored EdgeZero deployment model. +- `docs/superpowers/specs/2026-09-16-edgezero-fastly-store-selectors-design.md`: approved design, already committed. +- `docs/superpowers/plans/2026-09-16-edgezero-fastly-store-selectors.md`: this execution plan. + +### Task 1: Create the Tracking Issue + +**Files:** + +- No repository files. + +- [ ] **Step 1: Draft the issue** + +Use a bug title such as `Align Fastly staging store selectors with EdgeZero PR 381` and include: + +- the observed `publisher.proxy_secret` resolution failure; +- the impact that staging cannot build application state; +- the root cause that EdgeZero `v0.0.8` reads service-scoped selectors while the selected GitHub Environment supplies canonical selectors; +- the expected canonical key `EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME=ts_secrets`; +- the dependency on `https://github.com/stackpop/edgezero/pull/381`; +- acceptance criteria covering the branch dependency, Viceroy fixtures, documentation, and validation. + +- [ ] **Step 2: Create the issue and record its number** + +Run: + +```bash +gh issue create --repo IABTechLab/trusted-server --title "Align Fastly staging store selectors with EdgeZero PR 381" --label bug --body-file +``` + +Expected: GitHub prints the new issue URL. Save its issue number for the pull request body. + +### Task 2: Expose Viceroy Stores Under Their Logical IDs + +**Files:** + +- Modify: `crates/trusted-server-integration-tests/tests/common/config.rs:48` +- Modify: `fastly.toml:56` +- Modify: `crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml:69` +- Modify: `scripts/template-cache-local-test.sh:372` + +- [ ] **Step 1: Change the regression test first** + +Replace `local_fastly_secret_store_mapping_is_service_scoped` with +`local_fastly_secret_store_is_exposed_under_its_logical_id`. Assert that both +parsed configurations define `[[local_server.secret_stores.trusted_server_secrets]]` +and define no `edgezero_runtime_env` Config Store. Point the starter secret +reference test at the logical ID as well. + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run from `crates/trusted-server-integration-tests` using the host target: + +```bash +cargo test --test integration local_fastly --target aarch64-apple-darwin +``` + +Expected: FAIL because both TOML files still name the store `ts_secrets` and +still define `edgezero_runtime_env`. + +- [ ] **Step 3: Update the Viceroy configurations** + +In `fastly.toml`, the Viceroy template, and the template-cache script rename +`[[local_server.secret_stores.ts_secrets]]` to +`[[local_server.secret_stores.trusted_server_secrets]]`. Remove the +`[local_server.config_stores.edgezero_runtime_env]` table and its contents from +`fastly.toml` and the template. Adjust the nearby root `fastly.toml` comment to +say Viceroy exposes the store under its logical ID. + +- [ ] **Step 4: Run the focused test and verify it passes** + +Run the same focused `cargo test` command. + +Expected: PASS. + +- [ ] **Step 5: Commit the regression and fixtures** + +```bash +git add fastly.toml scripts/template-cache-local-test.sh crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml crates/trusted-server-integration-tests/tests/common/config.rs +git commit -m "Expose Viceroy stores under their logical IDs" +``` + +### Task 3: Consume the EdgeZero PR Branch + +**Files:** + +- Modify: `Cargo.toml:59` +- Modify: `Cargo.lock` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs:92` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs:158` + +- [ ] **Step 1: Select the upstream branch consistently** + +Replace `tag = "v0.0.8"` on all six `edgezero-*` workspace dependencies with: + +```toml +branch = "fix/fastly-environment-store-selectors" +``` + +- [ ] **Step 2: Refresh the lockfile through a targeted CLI check** + +Run: + +```bash +cargo check --package trusted-server-cli --target aarch64-apple-darwin +``` + +Expected: Cargo fetches the branch, locks every EdgeZero package to the same Git +revision, and the Trusted Server CLI compiles. If upstream API changes cause a +compiler failure, make only the call-site changes required by that error and add +a focused parsing or behavior test before the production edit. + +The branch head removes `edgezero_adapter_fastly::runtime_env_config`. Replace +`RuntimeStoreConfig::from_env` with `RuntimeStoreConfig::logical()`: open +`trusted_server_config` and `trusted_server_secrets` by logical ID and read +the config entry under `trusted_server_config` for every target. Cover the +logical IDs and key with a unit test in `app.rs`. + +- [ ] **Step 3: Verify the lockfile revision is consistent** + +Run: + +```bash +rg -n -A3 'name = "edgezero-' Cargo.lock +``` + +Expected: all Git-sourced EdgeZero packages use the branch URL and the same +commit fragment; no EdgeZero entry remains on `tag=v0.0.8`. + +- [ ] **Step 4: Run the CLI test suite** + +Run: + +```bash +./scripts/test-cli.sh +``` + +Expected: all non-ignored tests pass. + +- [ ] **Step 5: Commit the dependency update** + +```bash +git add Cargo.toml Cargo.lock crates/trusted-server-adapter-fastly crates/trusted-server-cli +git commit -m "Consume canonical EdgeZero deployment selectors" +``` + +Only stage `crates/trusted-server-cli` if compiler-driven compatibility changes +were necessary. + +### Task 4: Align Operator Documentation + +**Files:** + +- Modify: `docs/guide/fastly.md:264` +- Modify: `docs/guide/cli.md:115` + +- [ ] **Step 1: Update Fastly store-selector guidance** + +Keep the canonical export example. Replace the statements that provisioning +persists a service-scoped key and that unscoped keys are ignored with the PR 381 +contract: + +- provisioning creates/reuses the physical store; +- a managed deployment reads canonical selectors from its selected environment + and links each selected physical store to the target version under its + logical ID; +- the runtime opens stores by logical ID; nothing is stored in + `edgezero_runtime_env`; +- staging links its selected physical stores into only the staged version; +- canonical environment-variable names contain no service ID; +- the selected physical resources must already exist. + +- [ ] **Step 2: Update staging CLI guidance** + +Clarify that `ts config push --staging` writes the logical key into the +physical Config Store selected by the staging environment, and that the staged +binary reads the same `` key there. State that production and +staging may select the same or different physical stores. + +- [ ] **Step 3: Check for stale service-scoped guidance** + +Run: + +```bash +rg -n 'EDGEZERO__SERVICES__|service-scoped mapping|ignored unscoped|edgezero_runtime_env' fastly.toml docs crates/trusted-server-integration-tests --glob '!docs/superpowers/**' +``` + +Expected: no matches outside archived design material. + +- [ ] **Step 4: Format documentation** + +Run: + +```bash +cd docs && npm run format +``` + +Expected: formatter exits successfully without unrelated changes. + +- [ ] **Step 5: Commit documentation** + +```bash +git add docs/guide/fastly.md docs/guide/cli.md +git commit -m "Document deploy-time Fastly store selection" +``` + +### Task 5: Verify the Complete Change + +**Files:** + +- Verify all modified files. + +- [ ] **Step 1: Run formatting checks** + +```bash +cargo fmt --all -- --check +cd docs && npm run format +``` + +Expected: both commands exit successfully. + +- [ ] **Step 2: Run target-matched Rust checks** + +```bash +cargo check-fastly +cargo check-axum +cargo check-cloudflare +cargo check-spin +``` + +Expected: all adapters compile with the selected EdgeZero branch. + +- [ ] **Step 3: Run target-matched tests** + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +./scripts/test-cli.sh +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test integration local_fastly +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +Expected: all tests pass. Run socket-binding tests with loopback access if the +sandbox blocks them. + +- [ ] **Step 4: Run target-matched Clippy** + +```bash +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +Expected: every command exits successfully with warnings denied by the aliases. + +- [ ] **Step 5: Run JavaScript and documentation gates** + +```bash +(cd crates/trusted-server-js/lib && node build-all.mjs && npx vitest run && npm run format) +(cd docs && npm run format) +``` + +Expected: the JavaScript build and tests pass, and both formatters exit +successfully without unrelated changes. + +- [ ] **Step 6: Review the final diff** + +```bash +git diff origin/main...HEAD --check +git diff origin/main...HEAD --stat +git status --short +``` + +Expected: no whitespace errors, only planned files changed, and the worktree is +clean after final commits. + +### Task 6: Publish the Pull Request + +**Files:** + +- No additional repository files. + +- [ ] **Step 1: Push the isolated branch** + +```bash +git push -u origin fix/align-edgezero-pr-381 +``` + +- [ ] **Step 2: Draft the PR from the repository template** + +Use a title such as `Align Fastly staging store selectors with EdgeZero PR 381`. +Lead with the staging failure and resulting canonical store resolution. Include: + +- the EdgeZero branch dependency and upstream PR link; +- the canonical Viceroy fixtures and regression test; +- documentation changes; +- exact validation results; +- `Closes #`; +- a note that the branch pin will be replaced by the release tag after upstream + merges. + +- [ ] **Step 3: Create the pull request** + +```bash +gh pr create --repo IABTechLab/trusted-server --base main --head fix/align-edgezero-pr-381 --title "Align Fastly staging store selectors with EdgeZero PR 381" --body-file +``` + +Expected: GitHub prints the new pull request URL. diff --git a/docs/superpowers/plans/2026-10-01-docs-proposal-automation.md b/docs/superpowers/plans/2026-10-01-docs-proposal-automation.md new file mode 100644 index 000000000..4a61e3b74 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-docs-proposal-automation.md @@ -0,0 +1,1506 @@ +# Documentation Proposal Automation Implementation Plan + +> **Status: Superseded.** This is the original two-job plan, kept for history. +> Review moved the docs gates into the read-only `validate` job, replaced +> `--force` pushes and `gh pr close --delete-branch` with leased pushes, dropped +> `shell(git grep:*)` and the npm cache, and added base tracking. Do not execute +> it; the design spec and `scripts/docs-proposal/` are the source of truth. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** After a code merge to `main`, run GitHub Copilot CLI to propose +documentation edits, and open one pull request per merge whose hunks each carry +a review comment citing the merged source and a suggestion that reverts the +hunk. + +**Architecture:** A two-job workflow. `propose` (read-only token plus +`copilot-requests: write`) runs Copilot and uploads a patch, rationale, and +per-hunk evidence. `publish` (write token, no Copilot) applies the patch, +enforces a path allowlist, runs the docs gates, pushes `docs/auto/`, +opens or edits the pull request, and posts a review built deterministically +from the diff by a dependency-free Node script. + +**Tech Stack:** GitHub Actions, Copilot CLI (`@github/copilot@1.0.90`), Bash, +Node 24 (`node:test`, no npm dependencies), `gh` CLI, VitePress docs gates. + +**Spec:** `docs/superpowers/specs/2026-10-01-docs-proposal-automation-design.md` + +## Global Constraints + +- Copilot may only change `docs/guide/**` and `docs/index.md`; `publish.sh` + enforces it with `^docs/(guide/.+|index\.md)$`. +- Branch name: `docs/auto/`. +- One pull request per merge SHA; a closed or merged proposal is never + reopened or recreated. +- Empty diff: no pull request; an open proposal for that SHA is closed. +- Docs gates before any push: `npm ci`, `npm run lint`, `npm run format`, + `npm run build` in `docs/`. +- Copilot never holds a token with `contents: write` or `pull-requests: write`; + checkout in `propose` uses `persist-credentials: false`. +- Action references match the repository's existing tags (`actions/checkout@v4`, + `actions/setup-node@v4`, `actions/upload-artifact@v4`, + `actions/download-artifact@v4`). Workflow steps are single-line; logic lives + in `scripts/docs-proposal/`. +- Merge SHAs are full 40-character lowercase hex and must be reachable from + `origin/main`. +- Comments above code, never inline. Fictional `example.com` values only. +- Commit messages: sentence case, imperative, no prefixes; `git commit -S --signoff`. + +## File Map + +| File | Responsibility | +| -------------------------------------------------- | ------------------------------------------------------------ | +| `scripts/docs-proposal/hunks.mjs` | Parse a unified diff, list hunks with ids, build the review | +| `scripts/docs-proposal/hunks.test.mjs` | `node:test` coverage for `hunks.mjs` | +| `scripts/docs-proposal/lib.sh` | Shared Bash helpers: SHA validation, branch, allowlist, body | +| `scripts/docs-proposal/prompt.md` | Copilot instructions for proposing edits | +| `scripts/docs-proposal/evidence-prompt.md` | Copilot instructions for per-hunk evidence | +| `scripts/docs-proposal/propose.sh` | Job `propose` entry point | +| `scripts/docs-proposal/publish.sh` | Job `publish` entry point | +| `scripts/docs-proposal/test.sh` | Bash tests for `lib.sh` and `publish.sh` with stubbed `gh` | +| `.github/workflows/docs-proposal.yml` | The proposal workflow | +| `.github/workflows/format.yml` | Add a job that lints and tests the proposal scripts | +| `scripts/README.md`, `.gitignore`, the design spec | Prerequisites, ignored work dir, small spec corrections | + +--- + +### Task 1: Diff parser and hunk listing + +**Files:** + +- Create: `scripts/docs-proposal/hunks.mjs` +- Test: `scripts/docs-proposal/hunks.test.mjs` + +**Interfaces:** + +- Produces: + - `parseDiff(text: string): File[]` where + `File = { path: string, status: "modified"|"added"|"deleted", binary: boolean, hunks: Hunk[] }`, + `Hunk = { lines: Line[] }`, + `Line = { type: " "|"-"|"+", text: string, oldNo: number|null, newNo: number|null }`. + - `collectHunks(files: File[]): { id: string, path: string, status: string, hunk: Hunk }[]` + (binary files skipped; ids are `#<8 hex>`, with `-2`, `-3` … on collision). + - `listHunks(files: File[]): { id, path, status, removed: string[], added: string[] }[]`. + - CLI: `node hunks.mjs list < diff` prints `listHunks` JSON. + +- [ ] **Step 1: Write the failing tests** + +```js +// scripts/docs-proposal/hunks.test.mjs +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { collectHunks, listHunks, parseDiff } from './hunks.mjs' + +const MODIFIED = [ + 'diff --git a/docs/guide/cli.md b/docs/guide/cli.md', + 'index 1111111..2222222 100644', + '--- a/docs/guide/cli.md', + '+++ b/docs/guide/cli.md', + '@@ -10,4 +10,4 @@ heading', + ' keep one', + '-old flag', + '+new flag', + ' keep two', + '', +].join('\n') + +test('parseDiff numbers context, removed, and added lines', () => { + const [file] = parseDiff(MODIFIED) + assert.equal(file.path, 'docs/guide/cli.md', 'should take the b/ path') + assert.equal(file.status, 'modified', 'should default to modified') + assert.deepEqual( + file.hunks[0].lines, + [ + { type: ' ', text: 'keep one', oldNo: 10, newNo: 10 }, + { type: '-', text: 'old flag', oldNo: 11, newNo: null }, + { type: '+', text: 'new flag', oldNo: null, newNo: 11 }, + { type: ' ', text: 'keep two', oldNo: 12, newNo: 12 }, + ], + 'should track old and new line numbers' + ) +}) + +test('parseDiff records added, deleted, and binary files', () => { + const diff = [ + 'diff --git a/docs/guide/new.md b/docs/guide/new.md', + 'new file mode 100644', + '--- /dev/null', + '+++ b/docs/guide/new.md', + '@@ -0,0 +1 @@', + '+hello', + 'diff --git a/docs/guide/gone.md b/docs/guide/gone.md', + 'deleted file mode 100644', + '--- a/docs/guide/gone.md', + '+++ /dev/null', + '@@ -1 +0,0 @@', + '-bye', + 'diff --git a/docs/guide/logo.png b/docs/guide/logo.png', + 'Binary files a/docs/guide/logo.png and b/docs/guide/logo.png differ', + '', + ].join('\n') + const files = parseDiff(diff) + assert.deepEqual( + files.map(({ path, status, binary }) => ({ path, status, binary })), + [ + { path: 'docs/guide/new.md', status: 'added', binary: false }, + { path: 'docs/guide/gone.md', status: 'deleted', binary: false }, + { path: 'docs/guide/logo.png', status: 'modified', binary: true }, + ], + 'should classify each file' + ) + assert.deepEqual( + files[0].hunks[0].lines, + [{ type: '+', text: 'hello', oldNo: null, newNo: 1 }], + 'should start a new file at line 1' + ) +}) + +test('parseDiff skips no-newline markers', () => { + const diff = MODIFIED.replace( + ' keep two\n', + ' keep two\n\\ No newline at end of file\n' + ) + const [file] = parseDiff(diff) + assert.equal(file.hunks[0].lines.length, 4, 'should ignore the marker line') +}) + +test('collectHunks gives stable ids and skips binary files', () => { + const first = collectHunks(parseDiff(MODIFIED)) + const second = collectHunks( + parseDiff(MODIFIED.replace('@@ -10,4 +10,4', '@@ -20,4 +20,4')) + ) + assert.equal(first.length, 1, 'should collect the single hunk') + assert.match( + first[0].id, + /^docs\/guide\/cli\.md#[0-9a-f]{8}$/, + 'should use path#hash' + ) + assert.equal(first[0].id, second[0].id, 'should not depend on line numbers') +}) + +test('collectHunks suffixes duplicate ids', () => { + const twice = MODIFIED + MODIFIED.split('\n').slice(4).join('\n') + const ids = collectHunks(parseDiff(twice)).map((entry) => entry.id) + assert.equal(ids.length, 2, 'should collect both hunks') + assert.equal(ids[1], `${ids[0]}-2`, 'should suffix the repeated id') +}) + +test('listHunks exposes removed and added text', () => { + const [entry] = listHunks(parseDiff(MODIFIED)) + assert.deepEqual(entry.removed, ['old flag'], 'should list removed lines') + assert.deepEqual(entry.added, ['new flag'], 'should list added lines') + assert.equal(entry.status, 'modified', 'should carry the file status') +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test scripts/docs-proposal/hunks.test.mjs` +Expected: FAIL with `Cannot find module` for `hunks.mjs`. + +- [ ] **Step 3: Write the implementation** + +```js +// scripts/docs-proposal/hunks.mjs +// Parses a unified documentation diff into hunks and builds the per-hunk +// review that cites Copilot evidence and offers a suggestion reverting each +// hunk. Dependency-free so it runs on any runner with Node. +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const FILE_HEADER = /^diff --git a\/(.+) b\/(.+)$/ +const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/ + +export function parseDiff(text) { + const files = [] + let file = null + let hunk = null + let oldNo = 0 + let newNo = 0 + for (const raw of text.split('\n')) { + const fileHeader = FILE_HEADER.exec(raw) + if (fileHeader) { + file = { + path: fileHeader[2], + status: 'modified', + binary: false, + hunks: [], + } + files.push(file) + hunk = null + continue + } + if (!file) { + continue + } + const hunkHeader = HUNK_HEADER.exec(raw) + if (hunkHeader) { + oldNo = Number(hunkHeader[1]) + newNo = Number(hunkHeader[2]) + hunk = { lines: [] } + file.hunks.push(hunk) + continue + } + if (!hunk) { + if (raw.startsWith('new file mode')) { + file.status = 'added' + } else if (raw.startsWith('deleted file mode')) { + file.status = 'deleted' + } else if (raw.startsWith('Binary files ')) { + file.binary = true + } + continue + } + const type = raw[0] + const lineText = raw.slice(1) + if (type === ' ') { + hunk.lines.push({ type, text: lineText, oldNo: oldNo++, newNo: newNo++ }) + } else if (type === '-') { + hunk.lines.push({ type, text: lineText, oldNo: oldNo++, newNo: null }) + } else if (type === '+') { + hunk.lines.push({ type, text: lineText, oldNo: null, newNo: newNo++ }) + } + } + return files +} + +function hashHunk(path, hunk) { + const hash = createHash('sha256') + hash.update(path) + for (const line of hunk.lines) { + if (line.type !== ' ') { + hash.update(`\n${line.type}${line.text}`) + } + } + return `${path}#${hash.digest('hex').slice(0, 8)}` +} + +export function collectHunks(files) { + const seen = new Map() + const entries = [] + for (const file of files) { + if (file.binary) { + continue + } + for (const hunk of file.hunks) { + const base = hashHunk(file.path, hunk) + const count = (seen.get(base) ?? 0) + 1 + seen.set(base, count) + const id = count === 1 ? base : `${base}-${count}` + entries.push({ id, path: file.path, status: file.status, hunk }) + } + } + return entries +} + +export function listHunks(files) { + return collectHunks(files).map(({ id, path, status, hunk }) => ({ + id, + path, + status, + removed: hunk.lines + .filter((line) => line.type === '-') + .map((line) => line.text), + added: hunk.lines + .filter((line) => line.type === '+') + .map((line) => line.text), + })) +} + +function main([command]) { + const files = parseDiff(readFileSync(0, 'utf8')) + if (command === 'list') { + process.stdout.write(`${JSON.stringify(listHunks(files), null, 2)}\n`) + return + } + process.stderr.write( + 'usage: hunks.mjs list | review \n' + ) + process.exitCode = 2 +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test scripts/docs-proposal/hunks.test.mjs` +Expected: PASS, 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/docs-proposal/hunks.mjs scripts/docs-proposal/hunks.test.mjs +git commit -S --signoff -m "Add documentation diff hunk parser" +``` + +--- + +### Task 2: Per-hunk review builder + +**Files:** + +- Modify: `scripts/docs-proposal/hunks.mjs` +- Test: `scripts/docs-proposal/hunks.test.mjs` + +**Interfaces:** + +- Consumes: `parseDiff`, `collectHunks` from Task 1. +- Produces: + - `revertSuggestion(hunk: Hunk): { startLine: number, line: number, body: string[] } | null`. + - `indexEvidence(raw: unknown): Map`. + - `buildReview(files: File[], evidence: Map, commitId: string): { commit_id, event: "COMMENT", body: string, comments: object[] }`. + - CLI: `node hunks.mjs review < diff` prints the + review payload for `POST /repos/{owner}/{repo}/pulls/{number}/reviews`. A + missing or malformed evidence file is treated as no evidence. + +- [ ] **Step 1: Write the failing tests** (append to `hunks.test.mjs`, and + extend the import to + `import { buildReview, collectHunks, indexEvidence, listHunks, parseDiff, revertSuggestion } from "./hunks.mjs";`) + +`````js +function hunkOf(lines, oldStart = 1, newStart = 1) { + const diff = [ + 'diff --git a/docs/guide/a.md b/docs/guide/a.md', + '--- a/docs/guide/a.md', + '+++ b/docs/guide/a.md', + `@@ -${oldStart} +${newStart} @@`, + ...lines, + '', + ].join('\n') + return parseDiff(diff)[0].hunks[0] +} + +test('revertSuggestion restores replaced lines', () => { + const suggestion = revertSuggestion(hunkOf([' a', '-b', '+B', '+B2', ' c'])) + assert.deepEqual( + suggestion, + { startLine: 2, line: 3, body: ['b'] }, + 'should span the new lines and restore the old one' + ) +}) + +test('revertSuggestion deletes pure insertions', () => { + const suggestion = revertSuggestion(hunkOf([' a', '+new', ' c'])) + assert.deepEqual( + suggestion, + { startLine: 2, line: 2, body: [] }, + 'should suggest an empty body' + ) +}) + +test('revertSuggestion spans several change groups with their context', () => { + const suggestion = revertSuggestion(hunkOf(['-a', '+A', ' mid', '-c', '+C'])) + assert.deepEqual( + suggestion, + { startLine: 1, line: 3, body: ['a', 'mid', 'c'] }, + 'should keep the middle context line' + ) +}) + +test('revertSuggestion anchors pure deletions on the previous context line', () => { + const suggestion = revertSuggestion(hunkOf([' a', '-gone', ' c'], 4, 4)) + assert.deepEqual( + suggestion, + { startLine: 4, line: 4, body: ['a', 'gone'] }, + 'should restore the deleted line after its anchor' + ) +}) + +test('revertSuggestion anchors leading deletions on the next context line', () => { + const suggestion = revertSuggestion(hunkOf(['-gone', ' a'])) + assert.deepEqual( + suggestion, + { startLine: 1, line: 1, body: ['gone', 'a'] }, + 'should restore the deleted line before its anchor' + ) +}) + +test('revertSuggestion returns null without an anchor', () => { + assert.equal( + revertSuggestion(hunkOf(['-only'])), + null, + 'should not invent an anchor' + ) +}) + +test('indexEvidence keeps valid entries and sanitizes text', () => { + const evidence = indexEvidence({ + hunks: [ + { + id: 'x#1', + source: 'crates/a.rs:4', + reason: 'Adds `--flag`\nfor @team', + }, + { id: 7, source: 'ignored' }, + { id: 'x#2' }, + 'junk', + ], + }) + assert.deepEqual( + [...evidence], + [['x#1', { source: 'crates/a.rs:4', reason: 'Adds --flag for @​team' }]], + 'should drop invalid entries and strip backticks, newlines, and mentions' + ) + assert.equal(indexEvidence(null).size, 0, 'should accept missing evidence') +}) + +test('buildReview cites evidence and suggests reverts', () => { + const files = parseDiff( + [ + 'diff --git a/docs/guide/a.md b/docs/guide/a.md', + '--- a/docs/guide/a.md', + '+++ b/docs/guide/a.md', + '@@ -1,3 +1,3 @@', + ' a', + '-uses ```fence```', + '+B', + ' c', + '@@ -10 +10,2 @@', + ' j', + '+k', + '', + ].join('\n') + ) + const [first, second] = collectHunks(files) + const evidence = indexEvidence({ + hunks: [ + { id: first.id, source: 'crates/a.rs:9', reason: 'Renames the flag.' }, + ], + }) + const review = buildReview(files, evidence, 'abc123') + + assert.equal(review.commit_id, 'abc123', 'should pin the reviewed commit') + assert.equal(review.event, 'COMMENT', 'should not approve or request changes') + assert.equal(review.comments.length, 2, 'should comment on every hunk') + assert.deepEqual( + review.comments[0], + { + path: 'docs/guide/a.md', + line: 2, + side: 'RIGHT', + body: [ + '**Source:** `crates/a.rs:9` — Renames the flag.', + '', + 'Apply this suggestion to revert the hunk.', + '', + '````suggestion', + 'uses ```fence```', + '````', + ].join('\n'), + }, + 'should escalate the fence and omit start_line for one line' + ) + assert.match( + review.comments[1].body, + /none cited/, + 'should flag hunks without evidence' + ) + assert.match( + review.comments[1].body, + /```suggestion\n```$/, + 'should suggest deleting an insertion' + ) + assert.equal(second.id, collectHunks(files)[1].id, 'should be deterministic') +}) + +test('buildReview lists binary and unanchored changes in the body', () => { + const files = parseDiff( + [ + 'diff --git a/docs/guide/gone.md b/docs/guide/gone.md', + 'deleted file mode 100644', + '@@ -1 +0,0 @@', + '-bye', + 'diff --git a/docs/guide/logo.png b/docs/guide/logo.png', + 'Binary files a/docs/guide/logo.png and b/docs/guide/logo.png differ', + '', + ].join('\n') + ) + const review = buildReview(files, new Map(), 'abc123') + assert.equal(review.comments.length, 0, 'should not post line comments') + assert.match( + review.body, + /`docs\/guide\/gone\.md`: file deleted/, + 'should note the deletion' + ) + assert.match( + review.body, + /`docs\/guide\/logo\.png`: binary change/, + 'should note the binary file' + ) +}) + +test('buildReview adds start_line for multi-line spans', () => { + const files = parseDiff( + [ + 'diff --git a/docs/index.md b/docs/index.md', + '@@ -1 +1,2 @@', + '-a', + '+b', + '+c', + '', + ].join('\n') + ) + const [comment] = buildReview(files, new Map(), 'abc123').comments + assert.equal(comment.start_line, 1, 'should start at the first new line') + assert.equal( + comment.start_side, + 'RIGHT', + 'should anchor the start on the new side' + ) + assert.equal(comment.line, 2, 'should end at the last new line') +}) +````` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test scripts/docs-proposal/hunks.test.mjs` +Expected: FAIL with `does not provide an export named 'buildReview'`. + +- [ ] **Step 3: Implement** — add to `hunks.mjs` after `listHunks`: + +```js +const MAX_EVIDENCE_LENGTH = 300 + +const REVIEW_INTRO = + 'Copilot evidence for each proposed hunk. Leave a hunk to accept it, or apply its suggestion to revert it. Verify every cited source before merging.' + +export function revertSuggestion(hunk) { + const { lines } = hunk + const first = lines.findIndex((line) => line.type !== ' ') + const last = lines.findLastIndex((line) => line.type !== ' ') + if (first === -1) { + return null + } + const span = lines.slice(first, last + 1) + const newSide = span.filter((line) => line.type !== '-') + const oldText = span + .filter((line) => line.type !== '+') + .map((line) => line.text) + if (newSide.length > 0) { + return { + startLine: newSide[0].newNo, + line: newSide.at(-1).newNo, + body: oldText, + } + } + const before = lines[first - 1] + if (before?.type === ' ') { + return { + startLine: before.newNo, + line: before.newNo, + body: [before.text, ...oldText], + } + } + const after = lines[last + 1] + if (after?.type === ' ') { + return { + startLine: after.newNo, + line: after.newNo, + body: [...oldText, after.text], + } + } + return null +} + +function sanitize(value) { + if (typeof value !== 'string') { + return '' + } + return value + .replace(/[`\r\n]+/g, ' ') + .replace(/@/g, '@​') + .replace(/\s+/g, ' ') + .trim() + .slice(0, MAX_EVIDENCE_LENGTH) +} + +export function indexEvidence(raw) { + const evidence = new Map() + const items = Array.isArray(raw?.hunks) ? raw.hunks : [] + for (const item of items) { + if (typeof item?.id !== 'string') { + continue + } + const source = sanitize(item.source) + const reason = sanitize(item.reason) + if (source || reason) { + evidence.set(item.id, { source, reason }) + } + } + return evidence +} + +function fenceFor(body) { + const longestRun = Math.max( + 0, + ...body.map((text) => + Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length)) + ) + ) + return '`'.repeat(Math.max(3, longestRun + 1)) +} + +function commentBody(cited, body) { + const source = cited?.source + ? `\`${cited.source}\`` + : 'none cited — verify this hunk against the merged code.' + const reason = cited?.reason ? ` — ${cited.reason}` : '' + const fence = fenceFor(body) + const suggestion = + body.length > 0 + ? `${fence}suggestion\n${body.join('\n')}\n${fence}` + : `${fence}suggestion\n${fence}` + return [ + `**Source:** ${source}${reason}`, + '', + 'Apply this suggestion to revert the hunk.', + '', + suggestion, + ].join('\n') +} + +export function buildReview(files, evidence, commitId) { + const comments = [] + const notes = [] + for (const file of files) { + if (file.binary) { + notes.push( + `- \`${file.path}\`: binary change — review the file directly.` + ) + } + } + for (const { id, path, status, hunk } of collectHunks(files)) { + const suggestion = revertSuggestion(hunk) + if (!suggestion) { + const reason = + status === 'deleted' ? 'file deleted' : 'no line to anchor a revert' + notes.push(`- \`${path}\`: ${reason} — revert it manually if needed.`) + continue + } + const comment = { + path, + line: suggestion.line, + side: 'RIGHT', + body: commentBody(evidence.get(id), suggestion.body), + } + if (suggestion.startLine !== suggestion.line) { + comment.start_line = suggestion.startLine + comment.start_side = 'RIGHT' + } + comments.push(comment) + } + const body = + notes.length > 0 + ? `${REVIEW_INTRO}\n\nChanges without a suggestion:\n\n${notes.join('\n')}` + : REVIEW_INTRO + return { commit_id: commitId, event: 'COMMENT', body, comments } +} + +function readEvidence(path) { + try { + return indexEvidence(JSON.parse(readFileSync(path, 'utf8'))) + } catch (error) { + process.stderr.write( + `Ignoring unreadable evidence at ${path}: ${error.message}\n` + ) + return new Map() + } +} +``` + +Note: the `comment` object key order in the test is `path, line, side, body`; +`deepEqual` ignores key order, so the construction order above is fine. + +Then extend `main`: + +```js +function main([command, evidencePath, commitId]) { + const files = parseDiff(readFileSync(0, 'utf8')) + if (command === 'list') { + process.stdout.write(`${JSON.stringify(listHunks(files), null, 2)}\n`) + return + } + if (command === 'review' && evidencePath && commitId) { + const review = buildReview(files, readEvidence(evidencePath), commitId) + process.stdout.write(`${JSON.stringify(review, null, 2)}\n`) + return + } + process.stderr.write( + 'usage: hunks.mjs list | review \n' + ) + process.exitCode = 2 +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test scripts/docs-proposal/hunks.test.mjs` +Expected: PASS, 16 tests. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/docs-proposal/hunks.mjs scripts/docs-proposal/hunks.test.mjs +git commit -S --signoff -m "Build per-hunk revert reviews for documentation proposals" +``` + +--- + +### Task 3: Shared helpers, prompts, and `propose.sh` + +**Files:** + +- Create: `scripts/docs-proposal/lib.sh`, `scripts/docs-proposal/prompt.md`, + `scripts/docs-proposal/evidence-prompt.md`, `scripts/docs-proposal/propose.sh` +- Create: `scripts/docs-proposal/test.sh` (lib tests only in this task) +- Modify: `.gitignore` (add `/.docs-proposal/`) + +**Interfaces:** + +- Consumes: `node hunks.mjs list` from Task 1. +- Produces (sourced by `publish.sh` in Task 4): + - `docs_proposal_require_sha `: exits 1 unless full hex SHA reachable from `origin/main`. + - `docs_proposal_branch ` → prints `docs/auto/`. + - `docs_proposal_disallowed_paths` (stdin: paths) → prints bad paths, returns 1 if any. + - `docs_proposal_pr_body ` → prints body. + - `propose.sh ` writes `/proposal.patch` (possibly empty), + `/rationale.md`, `/evidence.json`. + +- [ ] **Step 1: Write the failing lib tests** + +```bash +#!/usr/bin/env bash +# Tests the documentation proposal helpers and publish flow with stubbed gh +# and npm against a throwaway git repository. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/docs-proposal/lib.sh +source "$here/lib.sh" + +failures=0 + +assert_eq() { + if [ "$1" != "$2" ]; then + printf 'FAIL: %s\n expected: %s\n actual: %s\n' "$3" "$2" "$1" + failures=$((failures + 1)) + fi +} + +assert_contains() { + if [[ "$1" != *"$2"* ]]; then + printf 'FAIL: %s\n missing: %s\n' "$3" "$2" + failures=$((failures + 1)) + fi +} + +sha=0123456789abcdef0123456789abcdef01234567 + +assert_eq "$(docs_proposal_branch "$sha")" "docs/auto/0123456789ab" "should derive the branch from the SHA" + +bad="$(printf 'docs/guide/a.md\ndocs/index.md\ndocs/superpowers/x.md\nREADME.md\n' | docs_proposal_disallowed_paths || true)" +assert_eq "$bad" $'docs/superpowers/x.md\nREADME.md' "should print only paths outside the allowlist" + +if printf 'docs/guide/a.md\ndocs/guide/integrations/b.md\n' | docs_proposal_disallowed_paths >/dev/null; then + status=0 +else + status=1 +fi +assert_eq "$status" 0 "should accept guide and nested guide paths" + +rationale="$(mktemp)" +printf -- '- docs/guide/cli.md: documents the new flag.\n' > "$rationale" +body="$(docs_proposal_pr_body "$sha" "Add example flag" 42 "$rationale")" +assert_contains "$body" "" "should embed the SHA marker" +assert_contains "$body" "merged change $sha (#42): Add example flag" "should link the merge and its PR" +assert_contains "$body" "documents the new flag" "should include the rationale" +: > "$rationale" +body="$(docs_proposal_pr_body "$sha" "Add example flag" "" "$rationale")" +assert_contains "$body" "merged change $sha: Add example flag" "should omit a missing origin PR" +assert_contains "$body" "did not provide a rationale" "should note an empty rationale" +rm -f "$rationale" + +if (docs_proposal_require_sha "not-a-sha") 2>/dev/null; then + status=0 +else + status=1 +fi +assert_eq "$status" 1 "should reject a malformed SHA" + +# PUBLISH_TESTS + +if [ "$failures" -gt 0 ]; then + printf '%d failure(s)\n' "$failures" + exit 1 +fi +printf 'All documentation proposal script tests passed.\n' +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `chmod +x scripts/docs-proposal/test.sh && scripts/docs-proposal/test.sh` +Expected: FAIL — `lib.sh: No such file or directory`. + +- [ ] **Step 3: Write `lib.sh`** + +```bash +# shellcheck shell=bash +# Shared helpers for the documentation proposal scripts. Source this file; +# do not execute it. + +DOCS_PROPOSAL_ALLOWED_RE='^docs/(guide/.+|index\.md)$' +DOCS_PROPOSAL_MAX_RATIONALE_BYTES=20000 + +# Exits unless the argument is a full commit SHA reachable from origin/main. +docs_proposal_require_sha() { + local sha="$1" + if ! [[ "$sha" =~ ^[0-9a-f]{40}$ ]]; then + printf '::error::Expected a full 40-character commit SHA, got %s\n' "$sha" >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "$sha" origin/main 2>/dev/null; then + printf '::error::Commit %s is not reachable from origin/main\n' "$sha" >&2 + exit 1 + fi +} + +docs_proposal_branch() { + printf 'docs/auto/%s\n' "${1:0:12}" +} + +# Reads changed paths on stdin, prints those outside the allowlist, and +# returns 1 when any were found. +docs_proposal_disallowed_paths() { + local path + local found=0 + while IFS= read -r path; do + if [ -n "$path" ] && ! [[ "$path" =~ $DOCS_PROPOSAL_ALLOWED_RE ]]; then + printf '%s\n' "$path" + found=1 + fi + done + return "$found" +} + +docs_proposal_pr_body() { + local sha="$1" + local subject="$2" + local origin_pr="$3" + local rationale="$4" + local origin="" + if [ -n "$origin_pr" ]; then + origin=" (#$origin_pr)" + fi + printf '\n' "$sha" + printf 'Proposed documentation update for merged change %s%s: %s\n\n' "$sha" "$origin" "$subject" + printf 'Generated by GitHub Copilot CLI in the docs-proposal workflow. A maintainer must verify every hunk against the merged code before merging. Each hunk has a review comment citing its source and a suggestion that reverts it.\n\n' + printf '## Rationale\n\n' + if [ -s "$rationale" ]; then + head -c "$DOCS_PROPOSAL_MAX_RATIONALE_BYTES" "$rationale" + printf '\n' + else + printf '_Copilot did not provide a rationale._\n' + fi +} +``` + +- [ ] **Step 4: Run lib tests to verify they pass** + +Run: `scripts/docs-proposal/test.sh` +Expected: `All documentation proposal script tests passed.` + +- [ ] **Step 5: Write the prompts** + +`scripts/docs-proposal/prompt.md`: + +```markdown +# Documentation proposal + +You are updating the public Trusted Server documentation after a commit merged +to `main`. The merged commit and the table mapping implementation sources to +documentation pages appear at the end of this file. + +## Task + +1. Inspect the merged change with `git show --stat ` and + `git show `. +2. Decide whether it changes behavior that `docs/index.md` or a page under + `docs/guide/` describes: configuration fields and defaults, routes, adapter + support, integrations, auction behavior, CLI commands and flags, browser + bundles, or verification commands. Use the Sources of truth table to find + the affected pages. +3. If no reader-facing documentation is affected, change nothing and write + `.docs-proposal/rationale.md` containing one sentence explaining why. +4. Otherwise edit only the affected pages, then write + `.docs-proposal/rationale.md` with one bullet per edited page naming the + page, what changed, and the merged source file that justifies it. + +## Rules + +- Describe behavior the merged code ships. Do not document intent, roadmap + items, or unmerged work. +- Read the merged source before writing. Every statement you add must be + verifiable in the code at this commit. +- Make the smallest edit that keeps the page accurate. Do not restyle, + reorder, or rewrite unaffected text. +- Use only fictional values: `example.com` domains and placeholder secrets. + Never copy real domains, customer names, credentials, or service IDs. +- Keep existing links and anchors working, and link to other pages with + relative paths. +- Do not edit files outside `docs/guide/`, `docs/index.md`, and + `.docs-proposal/`. Do not commit, push, or create branches. +``` + +`scripts/docs-proposal/evidence-prompt.md`: + +````markdown +# Documentation proposal evidence + +The hunks at the end of this file are proposed edits to the Trusted Server +documentation for the merged commit named there. For each hunk, find the merged +source that justifies it. + +Write `.docs-proposal/evidence.json` with exactly this shape, and change no +other file: + +```json +{ + "hunks": [ + { "id": "", "source": ":", "reason": "" } + ] +} +``` + +- Copy `id` exactly from the hunk list. +- `source` is a repository-relative path and line at the merged commit that + shows the documented behavior. +- `reason` is one sentence a reviewer can check against that source. +- If no source justifies a hunk, omit it. The reviewer will be told the hunk + has no cited source. +```` + +- [ ] **Step 6: Write `propose.sh`** + +````bash +#!/usr/bin/env bash +# Asks Copilot CLI to propose documentation edits for one merged commit. +# +# Usage: scripts/docs-proposal/propose.sh +# Writes proposal.patch (empty when nothing changes), rationale.md, and +# evidence.json to , which must be inside the repository. +set -euo pipefail + +sha="$1" +work_dir="$2" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(git rev-parse --show-toplevel)" +spec="$root/docs/superpowers/specs/2026-08-19-documentation-refresh-design.md" +# shellcheck source=scripts/docs-proposal/lib.sh +source "$here/lib.sh" + +cd "$root" +docs_proposal_require_sha "$sha" +mkdir -p "$work_dir" +work_dir="$(cd "$work_dir" && pwd)" +relative_dir="${work_dir#"$root"/}" + +run_copilot() { + copilot -p "Read $relative_dir/$1 and follow its instructions exactly." \ + --no-ask-user \ + --allow-tool=write \ + --allow-tool='shell(git show:*)' \ + --allow-tool='shell(git diff:*)' \ + --allow-tool='shell(git log:*)' \ + --allow-tool='shell(git grep:*)' \ + --deny-tool='shell(git push)' \ + --deny-tool='shell(git commit)' +} + +{ + cat "$here/prompt.md" + printf '\n## Merged change\n\n- Commit: %s\n- Subject: %s\n\n' "$sha" "$(git log -1 --format=%s "$sha")" + printf '## Sources of truth\n\n' + awk '/^## Sources of truth/ { found = 1; next } /^## / { found = 0 } found' "$spec" +} > "$work_dir/prompt.md" +run_copilot prompt.md + +# Normalize Copilot's edits before hunks are identified so ids stay valid +# after the publish job's format check. +(cd docs && npm ci && npm run format:write) + +git add -A -- docs +git diff --cached --no-renames --binary "$sha" -- docs > "$work_dir/proposal.patch" +touch "$work_dir/rationale.md" +if [ ! -s "$work_dir/proposal.patch" ]; then + printf '{}\n' > "$work_dir/evidence.json" + printf 'No documentation changes proposed for %s.\n' "$sha" + exit 0 +fi + +{ + cat "$here/evidence-prompt.md" + printf '\n## Merged change\n\n- Commit: %s\n\n## Hunks\n\n```json\n' "$sha" + git diff --cached --no-renames "$sha" -- docs | node "$here/hunks.mjs" list + printf '```\n' +} > "$work_dir/evidence-prompt.md" +run_copilot evidence-prompt.md +if [ ! -f "$work_dir/evidence.json" ]; then + printf '{}\n' > "$work_dir/evidence.json" +fi +```` + +- [ ] **Step 7: Ignore the work dir, lint, and commit** + +Append `/.docs-proposal/` to `.gitignore` under a comment +`# Documentation proposal workflow scratch space`. + +Run: `chmod +x scripts/docs-proposal/propose.sh && shellcheck scripts/docs-proposal/*.sh` (skip locally if +`shellcheck` is not installed; CI runs it in Task 5). +Run: `scripts/docs-proposal/test.sh` — Expected: pass. + +```bash +git add .gitignore scripts/docs-proposal/lib.sh scripts/docs-proposal/prompt.md scripts/docs-proposal/evidence-prompt.md scripts/docs-proposal/propose.sh scripts/docs-proposal/test.sh +git commit -S --signoff -m "Add Copilot documentation proposal script" +``` + +--- + +### Task 4: `publish.sh` with stubbed integration tests + +**Files:** + +- Create: `scripts/docs-proposal/publish.sh` +- Modify: `scripts/docs-proposal/test.sh` (replace the `# PUBLISH_TESTS` line) + +**Interfaces:** + +- Consumes: `lib.sh` (Task 3), `node hunks.mjs review` (Task 2), files from `propose.sh`. +- Produces: `publish.sh `; env `GH_TOKEN`. `gh` calls used: + - `gh pr list --head --state all --json number,state --jq '.[0] | select(.) | "\(.number) \(.state)"'` + - `gh pr close --comment --delete-branch` + - `gh api repos/{owner}/{repo}/commits//pulls --jq '.[0].number // empty'` + - `gh pr create --base main --head --title --body-file ` (prints URL) + - `gh pr edit --title --body-file ` + - `gh api --method POST repos/{owner}/{repo}/pulls//reviews --input ` + +- [ ] **Step 1: Write the failing publish tests** — replace `# PUBLISH_TESTS` + in `test.sh` with: + +```bash +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=Example GIT_AUTHOR_EMAIL=dev@example.com +export GIT_COMMITTER_NAME=Example GIT_COMMITTER_EMAIL=dev@example.com + +mkdir -p "$tmp/bin" +cat > "$tmp/bin/gh" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$STUB_LOG" +case "$1 $2" in + "pr list") printf '%s' "${STUB_PR:-}" ;; + "pr create") printf 'https://github.com/example/repo/pull/7\n' ;; + "api repos/{owner}/{repo}/commits/"*) printf '%s' "${STUB_ORIGIN_PR:-}" ;; + "api --method") + for arg in "$@"; do last="$arg"; done + cp "$last" "$STUB_REVIEW" + ;; +esac +STUB +printf '#!/usr/bin/env bash\nprintf "npm %%s\\n" "$*" >> "$STUB_LOG"\n' > "$tmp/bin/npm" +chmod +x "$tmp/bin/gh" "$tmp/bin/npm" +export PATH="$tmp/bin:$PATH" STUB_LOG="$tmp/gh.log" STUB_REVIEW="$tmp/review.json" + +git init -q --bare -b main "$tmp/origin.git" +git clone -q "$tmp/origin.git" "$tmp/work" 2>/dev/null +cd "$tmp/work" +mkdir -p docs/guide +printf 'line one\nline two\nline three\n' > docs/guide/cli.md +git add docs && git commit -q -m "Seed docs" && git push -q origin HEAD:main +merge_sha="$(git rev-parse HEAD)" +branch="$(docs_proposal_branch "$merge_sha")" + +# Each publish run leaves HEAD on its proposal commit, so every patch starts +# from a clean checkout of the merge commit. +make_patch() { + git reset -q --hard "$merge_sha" && git clean -qfd + rm -rf "$tmp/proposal" && mkdir -p "$tmp/proposal" + if [ -n "$1" ]; then + mkdir -p "$(dirname "$1")" && printf 'proposed\n' >> "$1" + git add -A && git diff --cached --binary "$merge_sha" > "$tmp/proposal/proposal.patch" + git reset -q --hard "$merge_sha" && git clean -qfd + else + : > "$tmp/proposal/proposal.patch" + fi + printf -- '- docs/guide/cli.md: example rationale.\n' > "$tmp/proposal/rationale.md" + printf '{}\n' > "$tmp/proposal/evidence.json" +} + +run_publish() { + : > "$STUB_LOG" + rm -f "$STUB_REVIEW" + if "$here/publish.sh" "$merge_sha" "$tmp/proposal" > "$tmp/out.log" 2>&1; then + printf '0' + else + printf '1' + fi +} + +remote_has_branch() { + if git ls-remote --exit-code -q origin "refs/heads/$branch" >/dev/null; then printf 'yes'; else printf 'no'; fi +} + +make_patch "" +assert_eq "$(STUB_PR="" run_publish)" 0 "should succeed on an empty proposal" +assert_eq "$(grep -c 'pr create\|pr close' "$STUB_LOG" || true)" 0 "should not open or close a PR for an empty diff" + +make_patch "" +assert_eq "$(STUB_PR="7 OPEN" run_publish)" 0 "should succeed when closing a stale proposal" +assert_contains "$(cat "$STUB_LOG")" "pr close 7" "should close the open proposal for an empty diff" + +make_patch docs/superpowers/notes.md +assert_eq "$(STUB_PR="" run_publish)" 1 "should fail for a disallowed path" +assert_contains "$(cat "$tmp/out.log")" "docs/superpowers/notes.md" "should name the disallowed path" +assert_eq "$(remote_has_branch)" no "should not push a disallowed proposal" + +make_patch docs/guide/cli.md +assert_eq "$(STUB_PR="" STUB_ORIGIN_PR=42 run_publish)" 0 "should publish a valid proposal" +assert_eq "$(remote_has_branch)" yes "should push the proposal branch" +assert_contains "$(cat "$STUB_LOG")" "pr create --base main --head $branch" "should open a PR from the proposal branch" +assert_contains "$(cat "$STUB_LOG")" "npm run build" "should run the docs build" +assert_contains "$(cat "$STUB_REVIEW")" '"path": "docs/guide/cli.md"' "should post a review for the hunk" + +assert_eq "$(STUB_PR="7 OPEN" run_publish)" 0 "should succeed on an unchanged retry" +assert_eq "$(grep -c 'pr edit\|pr create\|reviews' "$STUB_LOG" || true)" 0 "should not re-post an unchanged proposal" + +make_patch docs/index.md +assert_eq "$(STUB_PR="7 OPEN" run_publish)" 0 "should update a changed proposal" +assert_contains "$(cat "$STUB_LOG")" "pr edit 7" "should edit the existing PR" +assert_contains "$(cat "$STUB_REVIEW")" '"path": "docs/index.md"' "should review the new hunk" + +assert_eq "$(STUB_PR="7 CLOSED" run_publish)" 0 "should succeed for a closed proposal" +assert_eq "$(grep -c 'pr edit\|pr create' "$STUB_LOG" || true)" 0 "should never reopen or recreate a closed proposal" + +cd "$here" +``` + +Note: `make_patch docs/index.md` creates `docs/index.md` (an added file) so the +"changed proposal" case diffs a different tree than the pushed branch. + +- [ ] **Step 2: Run to verify it fails** + +Run: `scripts/docs-proposal/test.sh` +Expected: FAIL lines such as `should succeed on an empty proposal` because +`publish.sh` does not exist. + +- [ ] **Step 3: Write `publish.sh`** + +```bash +#!/usr/bin/env bash +# Validates a documentation proposal and opens or updates its pull request. +# +# Usage: scripts/docs-proposal/publish.sh +# holds proposal.patch, rationale.md, and evidence.json from +# propose.sh. Requires GH_TOKEN with contents and pull-requests write access. +set -euo pipefail + +sha="$1" +work_dir="$(cd "$2" && pwd)" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(git rev-parse --show-toplevel)" +# shellcheck source=scripts/docs-proposal/lib.sh +source "$here/lib.sh" + +cd "$root" +docs_proposal_require_sha "$sha" +branch="$(docs_proposal_branch "$sha")" + +existing="$(gh pr list --head "$branch" --state all --json number,state --jq '.[0] | select(.) | "\(.number) \(.state)"')" +pr_number="${existing%% *}" +pr_state="${existing#* }" +if [ -n "$existing" ] && [ "$pr_state" != "OPEN" ]; then + printf 'Proposal #%s for %s is %s; not reopening it.\n' "$pr_number" "$sha" "$pr_state" + exit 0 +fi + +if [ ! -s "$work_dir/proposal.patch" ]; then + printf 'No documentation changes proposed for %s.\n' "$sha" + if [ -n "$existing" ]; then + gh pr close "$pr_number" --delete-branch \ + --comment "A rerun for $sha proposed no documentation changes." + fi + exit 0 +fi + +git switch --quiet --detach "$sha" +git apply --index "$work_dir/proposal.patch" +if ! disallowed="$(git diff --cached --name-only --no-renames "$sha" | docs_proposal_disallowed_paths)"; then + printf '::error::Proposal changes paths outside docs/guide/** and docs/index.md:\n%s\n' "$disallowed" + exit 1 +fi + +(cd docs && npm ci && npm run lint && npm run format && npm run build) + +git -c user.name='github-actions[bot]' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + commit --quiet --no-verify \ + -m "Propose documentation updates for ${sha:0:12}" \ + -m "Generated by the docs-proposal workflow from $sha." +head_sha="$(git rev-parse HEAD)" + +remote_tree="" +if git fetch --quiet origin "refs/heads/$branch" 2>/dev/null; then + remote_tree="$(git rev-parse 'FETCH_HEAD^{tree}')" +fi +if [ -n "$existing" ] && [ "$remote_tree" = "$(git rev-parse 'HEAD^{tree}')" ]; then + printf 'Proposal #%s for %s is unchanged.\n' "$pr_number" "$sha" + exit 0 +fi +git push --quiet --force origin "HEAD:refs/heads/$branch" + +subject="$(git log -1 --format=%s "$sha")" +origin_pr="$(gh api "repos/{owner}/{repo}/commits/$sha/pulls" --jq '.[0].number // empty' || true)" +title="$(printf 'Update documentation for %s' "$subject" | cut -c1-200)" +docs_proposal_pr_body "$sha" "$subject" "$origin_pr" "$work_dir/rationale.md" > "$work_dir/pr-body.md" +if [ -n "$existing" ]; then + gh pr edit "$pr_number" --title "$title" --body-file "$work_dir/pr-body.md" +else + pr_url="$(gh pr create --base main --head "$branch" --title "$title" --body-file "$work_dir/pr-body.md")" + pr_number="${pr_url##*/}" +fi + +git diff --no-renames "$sha" "$head_sha" -- docs \ + | node "$here/hunks.mjs" review "$work_dir/evidence.json" "$head_sha" > "$work_dir/review.json" +gh api --method POST "repos/{owner}/{repo}/pulls/$pr_number/reviews" --input "$work_dir/review.json" > /dev/null +printf 'Published proposal #%s for %s.\n' "$pr_number" "$sha" +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `chmod +x scripts/docs-proposal/publish.sh && scripts/docs-proposal/test.sh` +Expected: `All documentation proposal script tests passed.` + +- [ ] **Step 5: Commit** + +```bash +git add scripts/docs-proposal/publish.sh scripts/docs-proposal/test.sh +git commit -S --signoff -m "Publish documentation proposals as per-merge pull requests" +``` + +--- + +### Task 5: Workflow, CI coverage, and operator docs + +**Files:** + +- Create: `.github/workflows/docs-proposal.yml` +- Modify: `.github/workflows/format.yml` (new job), `scripts/README.md` (table rows + prerequisites), + `docs/superpowers/specs/2026-10-01-docs-proposal-automation-design.md` (corrections below) + +**Interfaces:** + +- Consumes: `propose.sh .docs-proposal`, `publish.sh .docs-proposal`. + +- [ ] **Step 1: Write `docs-proposal.yml`** + +```yaml +name: Documentation proposal + +# Proposes documentation edits with Copilot CLI after code merges to main and +# opens one reviewable pull request per merge. See +# docs/superpowers/specs/2026-10-01-docs-proposal-automation-design.md. +on: + push: + branches: [main] + paths: + - 'crates/**' + - 'trusted-server.example.toml' + - 'edgezero.toml' + - 'fastly.toml' + - '.cargo/config.toml' + - 'scripts/**' + - '!scripts/docs-proposal/**' + workflow_dispatch: + inputs: + sha: + description: Full SHA of a commit on main to propose documentation for + required: true + type: string + +permissions: {} + +concurrency: + group: docs-proposal + cancel-in-progress: false + +env: + MERGE_SHA: ${{ inputs.sha || github.sha }} + +jobs: + propose: + runs-on: ubuntu-latest + permissions: + contents: read + copilot-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ env.MERGE_SHA }} + fetch-depth: 0 + persist-credentials: false + + - name: Retrieve Node.js version + id: node-version + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + cache: 'npm' + cache-dependency-path: docs/package-lock.json + + - name: Install Copilot CLI + run: npm install -g @github/copilot@1.0.90 + + - name: Propose documentation edits + env: + GITHUB_TOKEN: ${{ github.token }} + run: scripts/docs-proposal/propose.sh "$MERGE_SHA" .docs-proposal + + - name: Upload proposal + uses: actions/upload-artifact@v4 + with: + name: docs-proposal + path: .docs-proposal/ + include-hidden-files: true + if-no-files-found: error + + publish: + needs: propose + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ env.MERGE_SHA }} + fetch-depth: 0 + + - name: Retrieve Node.js version + id: node-version + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + cache: 'npm' + cache-dependency-path: docs/package-lock.json + + - name: Download proposal + uses: actions/download-artifact@v4 + with: + name: docs-proposal + path: .docs-proposal + + - name: Publish proposal pull request + env: + GH_TOKEN: ${{ github.token }} + run: scripts/docs-proposal/publish.sh "$MERGE_SHA" .docs-proposal +``` + +- [ ] **Step 2: Add the CI job to `format.yml`** (after `format-docs`) + +```yaml +docs-proposal-scripts: + name: docs-proposal scripts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Node.js version + id: node-version + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + + - name: Run shellcheck + run: shellcheck scripts/docs-proposal/*.sh + + - name: Test hunk review builder + run: node --test scripts/docs-proposal/hunks.test.mjs + + - name: Test proposal scripts + run: scripts/docs-proposal/test.sh +``` + +- [ ] **Step 3: Document in `scripts/README.md`** — add table rows: + +```markdown +| `docs-proposal/propose.sh` | Merge SHA on `main`, work dir; Copilot CLI, Node, `npm` | Runs Copilot CLI to edit `docs/guide/**` or `docs/index.md`, formats docs, and writes a patch, rationale, and evidence into the work dir. Leaves the docs edits staged. | +| `docs-proposal/publish.sh` | Merge SHA, work dir from `propose.sh`; `gh` with write `GH_TOKEN` | Applies the patch, runs docs gates, force-pushes `docs/auto/`, opens or edits its PR, and posts a per-hunk review. Never writes `main`. | +| `docs-proposal/test.sh` | Git, Node | Tests the helpers and publish flow against a temporary repository with stubbed `gh` and `npm`; removes it on exit. | +``` + +Then append after the table: + +```markdown +The `Documentation proposal` workflow runs both proposal scripts after code +merges to `main`. It needs the organization Copilot policy "Allow use of +Copilot CLI billed to the organization" and the repository setting "Allow +GitHub Actions to create and approve pull requests". Pull requests opened with +`GITHUB_TOKEN` do not trigger other workflows, so the publish job runs the docs +gates itself; push to the proposal branch to run regular CI. Retry a merge with +the workflow's manual dispatch and its full commit SHA. +``` + +Run `cd docs && npx prettier --write ../scripts/README.md` only if the docs +Prettier config covers it; otherwise align the table columns by hand. + +- [ ] **Step 4: Correct the spec** in + `docs/superpowers/specs/2026-10-01-docs-proposal-automation-design.md`: + +- Per-hunk table, last row: anchor "Listed in the review body", suggestion "None". +- Trigger: add that `scripts/docs-proposal/**` is excluded and that dispatch + requires a full SHA reachable from `origin/main`. +- `publish` step 1: a closed or merged proposal for the SHA is left alone. +- Testing: CI runs the scripts' tests in a new `format.yml` job. + +Run: `cd docs && npx prettier --write superpowers/specs/2026-10-01-docs-proposal-automation-design.md superpowers/plans/2026-10-01-docs-proposal-automation.md` + +- [ ] **Step 5: Validate and commit** + +Run: `actionlint .github/workflows/docs-proposal.yml .github/workflows/format.yml` if +installed, else `python3 -c "import yaml,sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]" .github/workflows/docs-proposal.yml .github/workflows/format.yml`. +Expected: no errors. + +```bash +git add .github/workflows/docs-proposal.yml .github/workflows/format.yml scripts/README.md docs/superpowers +git commit -S --signoff -m "Add documentation proposal workflow" -m "Closes #1105." +``` + +--- + +### Task 6: Verification + +- [ ] `node --test scripts/docs-proposal/hunks.test.mjs` — all pass. +- [ ] `scripts/docs-proposal/test.sh` — all pass. +- [ ] `cd docs && npm run lint && npm run format && npm run build` — pass. +- [ ] `cargo fmt --all -- --check` — unaffected, pass. +- [ ] After merge: dispatch `Documentation proposal` with a recent merge SHA + that changed a CLI flag; confirm a `docs/auto/` PR with per-hunk + review comments, and that a second dispatch with the same SHA does not + re-post. diff --git a/docs/superpowers/plans/2026-10-05-mobile-ad-render-trace-implementation-plan.md b/docs/superpowers/plans/2026-10-05-mobile-ad-render-trace-implementation-plan.md new file mode 100644 index 000000000..5160341a4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-mobile-ad-render-trace-implementation-plan.md @@ -0,0 +1,1245 @@ +# Mobile Ad Rendering Trace Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use @superpowers:subagent-driven-development or @superpowers:executing-plans to implement this plan task by task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the approved mobile trace journey from deliberate activation through real publisher reproduction to a bounded, redacted, browser-local report. + +**Architecture:** Core owns early authenticated trace responses, request facts, and live auction evidence. The existing GPT recorder supplies browser observations and exact binding sidecars; a trace-owned browser projection supplies the same model to the mobile viewer and every export. Implement the four boundaries in spec section 17 as phases within this single plan, with optional network enrichment outside the v1 release gate. + +**Tech Stack:** Rust 1.95.0/edition 2024, EdgeZero, Fastly Compute, Axum, Cloudflare Workers, Spin, TypeScript, the existing Vite/esbuild pipeline, Vitest, Prebid 10.26.0, Playwright, and native DOM/Web APIs. + +**Runtime-boundary amendment:** This plan tracks the approved v1 amendment in +spec sections 8/9.2/16. Classify application-visible paths; inspect frozen +runtime-visible cookies with explicit ambiguity suppression; record runtime +rejection and adapter conversion failure separately. Original-target recovery +and original-wire reconstruction are not v1 prerequisites. Existing auth, +same-origin actions and response privacy remain required. Keep this one plan. + +--- + +## Source and execution boundary + +- Approved source: [Mobile ad-rendering trace design](../specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md), especially sections 5, 8, 9, 12–17. +- Approval reference: [PR #1107](https://github.com/IABTechLab/trusted-server/pull/1107). +- Planning baseline: `20f4a0cc0139eac842d1d6ff1410a1af6ee8eba8` on `spec/mobile-ad-render-trace-endpoint`. +- The original spec and runtime-boundary amendment are approved. The user authorized implementation on 2026-10-05 after independent document review. +- Keep this branch and checkout. Do not create a worktree or branch. Implement incrementally against this single plan, recording verification and independent review evidence. Publish only the complete v1 feature after its release gates. +- Existing contributor rules apply to implementation: `error-stack`, `derive_more`, no local imports, no sensitive fixture data, documented public APIs, and sentence-case imperative commit messages. +- Proposed new filenames and function names below are implementation decisions, not claims that those symbols already exist. Locate existing code by symbol; line numbers will move. + +## Execution order and dependency map + +This is one implementation plan for one approved spec. The user requested a single plan; the four implementation boundaries in spec section 17 remain phases here rather than separate documents or mandatory separate PRs. All mandatory work stays on the existing branch and ships as one complete default-off v1 feature. Intermediate commits are review checkpoints, not independently published setup releases. + +| Phase | Tasks | Deliverable | Dependency | +| --------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| 1. Route/privacy foundation | F0–F8 | Local authenticated application-visible routes on all four adapters, setup/session lifecycle, cookie/network facts and private publisher context | Reviewed immutable EdgeZero hook/metadata pin and runtime-boundary acceptance | +| 2. Live auction evidence | E1–E7 | Bounded server records through all transports, API unit tokens and exact SSAT/SPA recorder sidecars | Foundation gates, immutable request facts and private response policy | +| 3. Browser handoff/viewer | V1–V7 | Strict combined report, deterministic bounds, same-tab handoff, mobile presentation and equivalent local exports | Foundation plus live collector/public GPT export | +| 4. Optional enrichment | N1–N3 | SDK-verified optional protocol/POP/ASN facts only | Complete v1; not a release gate | + +Implementation may progress on pure F1–F3/F5–F6 and E1/V1 contract work while F0 is unavailable, but no adapter-ordering success may be claimed without F0. After each task, run its focused verification; checkpoint the completed phase against the acceptance matrix. Keep full release gates at the final handoff, and run the full repository gates before any earlier external PR handoff required by AGENTS.md. + +No trace assets exist at the planning baseline. Build and evolve one unpublished v1 JS/CSS set through phases 1–3, keeping the committed digest/byte fixtures synchronized in each implementation commit. Freeze that complete set at first publication after V7; later changed bytes require a new asset-set URL/digest. Do not create a v2 set merely because the setup and viewer were implemented in different tasks. + +## Staff review of implementation choices + +Reuse the four adapter entry paths and existing authentication/cache mechanisms. Add focused trace modules rather than splitting the large publisher/orchestrator files or replacing the existing GPT attribution engine. The server projection reads live observations, never telemetry rows. The viewer imports the public GPT export contract, never the private store. + +Pinned EdgeZero v0.0.8 (`567964158e4f8bd0d52321b9801de44966422e1b`) selects a method/path before middleware. Its concrete RouterService runner seams therefore require the upstream pre-dispatch hook and a reviewed immutable dependency pin. The EdgeZero feature branch now supplies that API, honest metadata and converter fixes. Verify method preservation for requests the runtime accepts, classify the SDK/runtime-visible pathname, and prove required cookie/control semantics at this boundary. Do not require an original-target SDK accessor or impossible original-wire reconstruction for v1. Fastly paths normalized outside trace retain their existing health/JA4 behavior; paths exposed within trace must be intercepted. Cloudflare's converter must preserve exposed extension tokens even though its tested wire parser rejects them before invocation. A finite method list or Fastly-only bypass does not satisfy application-visible adapter parity. + +## Implementation invariants + +1. Authenticate every reserved trace request before setup facts, body inspection, or mutation, including disabled routes and malformed paths; preserve first-match-wins handlers. +2. Trace dispatch creates no ordinary event/EC state, invokes no configured filters, performs no auction/EID processing, and makes no publisher or telemetry request. +3. Freeze runtime-visible CookieHealth before `gpt_diagnostics::prepare_request` sanitizes headers; retain no raw values. Base capture requires the flag plus exactly one valid inspectable session, with ambiguity suppressing it; document capture additionally requires the effective diagnostics navigation decision. +4. Preserve console-only diagnostics when trace is off. Query activation deliberately adopts the shared 1800-second lifetime even when trace is disabled. +5. Gate every new token, extension, listener, mapping, sidecar, and handoff action. TSJS requires the literal `window.__tsjs_trace_active === true`; the server independently evaluates each request. +6. Keep the three clocks and three evidence layers separate. Correlation failure never downgrades complete server capture. `/auction` has no GPT sidecars in v1. +7. Strictly allowlist both projection and ingestion. No page path, cookie value, identity, full IP, fingerprint, provider name, price, targeting, creative identity/payload, or internal request ID enters a trace artifact or trace-specific log. +8. Reuse terminal private/no-store protection, including Fastly's final guard after late effects. Only successful unprotected fixed assets can be public immutable. +9. Asset URLs are byte contracts. Freeze the complete v1 set at first publication; any later changed bytes require new URLs/digests while retaining published bytes/digests and exact routes. +10. All trace failures fail open for advertising. No diagnostic failure changes ordinary ad acceptance, ordering, targeting, rendering, or status. + +## Acceptance evidence matrix + +| Spec criterion | Owning work | Required evidence | +| --------------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1: off by default, ordinary traffic unchanged | Foundation F1/F4/F7; auction E2/E5/E6; viewer V5 | Config validation, inactive gates, before/after bid fixtures, rollback reload | +| 2–3: deliberate mobile setup and reproduction | Foundation F2–F6 | Auth/method/path matrix, same-origin controls, GET read-only, observed-state verification, history/reload instructions | +| 4: real publisher capture | Foundation F7; auction E1–E6 | Traced reload, SSAT/SPA/API transports, unchanged ads | +| 5: bounded same-tab snapshot | Viewer V1–V3/V5 | Strict validation, 512 KiB UTF-8 wrapper, deterministic truncation/floor rejection, successful write before navigation | +| 6–7: separated evidence and equivalent export | Viewer V4/V6/V7 | Semantic labels, exact joins, downloaded/copied/shared report equality | +| 8: public-safe fields only | All phases | Distinct forbidden-data sentinels across HTML, transport, storage, logs, copy/share/export; hostile stored keys rejected | +| 9: private/no-store | Foundation F4/F7; auction E3/E4; viewer V7 | Hostile operator overrides, final Fastly effects, ESI/shared-template absence | +| 10: honest degradation | Auction E5–E7; viewer V2–V7 | Coverage truth table, transport/projection/storage failures, optional API absence | +| 11: mobile/accessibility | Foundation F6; viewer V6/V7 | 320 CSS px, 44 px controls, keyboard/focus/aria-live, iOS Safari and Android Chrome | +| 12–13: exact schemas, provenance, hostile storage | Viewer V1/V4/V6/V7 | Source-member coverage/type gate, all unknown versions/keys rejected, CSP/XSS/depth/DOM-bound checks | +| 14: independent cleanup and verification | Foundation F5/F6; viewer V3/V6/V7 | Offline end, failed deletion, mismatch and retry with separate statuses | +| 15: server entry point and honest joins | Auction E1–E7; viewer V4/V6/V7 | Zero-bid/failure records, SSAT/SPA exact joins, API independent, no inferred client winner | +| 16: explicit runtime boundary and adapter parity | Foundation F0/F2–F5/F8; viewer V7 | Separate pre-invocation rejection/conversion outcomes, visible path/auth/method tests, complete browser journey on all four adapters with suitable origins | +| 17: conservative Cookie ambiguity and public detail | Foundation F3/F4/F6; viewer V1–V3/V6/V7 | Per-Cookie fidelity-axis fixtures, cap/UTF-8/ambiguity precedence, inactive capture, exact reason/state validation and storage/export equality | + +## Shared verification commands + +Run focused red/green tests in each task. A red run must fail for the intended behavior, not because a dependency is missing. Name new Rust regression functions/modules with the task filter shown in its command and assert each focused run actually selects tests; a zero-test run is not verification. Keep tests and the minimum implementation together in each planned commit. Use @superpowers:test-driven-development and @superpowers:verification-before-completion during execution. No implementation or runtime test pass is claimed by this plan. + +Before the final implementation/PR handoff, run the complete repository gate list from the root unless a working directory is shown. Earlier internal task/phase checkpoints use focused tests and affected-target checks; if an earlier checkpoint is submitted as its own external PR, run the complete gates at that handoff as AGENTS.md requires: + +```bash +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo clippy-cli +cargo clippy-codegen +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +./scripts/test-cli.sh +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +npm --prefix crates/trusted-server-js/lib run build +npm --prefix crates/trusted-server-js/lib run test +npm --prefix crates/trusted-server-js/lib run format +npm --prefix docs run format +docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md" +git diff --check +``` + +Expected: every command exits 0; all suites have zero unexpected failures. Install locked npm dependencies with `npm ci` in the respective package only when missing. Use the pinned Rust/Node/runtime tools. Rust adapter aliases already select the proper targets; bare workspace `cargo test` is incorrect. + +For runtime/build changes, also run `cargo build-fastly`, `cargo build-axum`, `cargo build-cloudflare`, and `cargo build --package trusted-server-adapter-spin --target wasm32-wasip1 --features spin --release`. Verify changed public Rust documentation with `cargo doc -p trusted-server-core --no-deps --all-features --target wasm32-wasip1`; do not run the incompatible workspace all-feature command. + +Browser changes additionally run `./scripts/integration-tests-browser.sh`, which builds artifacts, generates Viceroy config, builds the Docker fixtures, and executes Next.js and WordPress Playwright suites. It requires Docker, Viceroy, the WASM target, and Playwright Chromium. Manual mobile checks are required release evidence; an unavailable phone or Docker daemon is an explicitly pending check, never a pass. + +## Execution checklist + +### Execution evidence (2026-10-06) + +Entries below record successive checkpoints. Later verification supersedes an +earlier checkpoint's pending status; the final status table records remaining +release work. + +- User approved the amended contract and authorized implementation on the + existing branch. +- F0: EdgeZero independently reviewed and published as PR #403, closing Task + issue #402 through its PR-creator workflow. Trusted Server pins immutable + revision `499d5c93d597f01b5e0497e332af82f2aa633277`. All four adapter check + aliases pass. Independent probes using the resolved consumer versions pass + 47 hook/converter tests and all 54 freshly rebuilt raw-wire cases, with zero + differences from committed observations. All reported upstream GitHub checks + pass. Project-board association is pending token project permissions. + Full F0 acceptance still requires F4 integration and V7 supported-origin + browser workflows; converter evidence alone does not close that gate. +- F1: Default-off config, dependency validation on both startup paths and shared + 1800-second cookie policy implemented. Red tests demonstrated the missing + dependency validation and literal missing cookie lifetime before implementation. + `cargo test-fastly trace_config` selects two passing core tests; + `cargo test-fastly gpt_diagnostics` selects fourteen passing core tests. + Independent spec and quality reviews approved after corrections. + Combined F1/F2 target-matched Fastly clippy passes; endpoint/query sequence + proof follows F5. +- F2: Classifier, authentication and response preflight implemented. Spec review + approved. Quality review identified combined separator/dot namespace erasure; + a behavioral red regression and fix now pass all twelve route tests, with + independent re-review approved. Twenty-seven existing auth tests pass. + Successful shell/state/action/asset behavior depends on F3/F5/F6; no complete + endpoint or runtime acceptance is claimed yet. +- F3: Runtime-visible cookie scanner and frozen request context implemented; + ten cookie and seven context tests pass, with independent spec and quality + approvals. Focused clippy, documentation build and formatting pass. +- F5: Deliberate action and frozen state handlers implemented. Fifteen focused + action tests and forty-six combined trace tests pass. Both independent reviews + approved; clippy, documentation build and formatting pass. Production callers + and adapter body parity remain part of F4 integration. +- F6: Session helper, setup controls, strict request-context validator, escaped + Rust shell, separate versioned assets and build verification are implemented + and independently approved for their current unit scopes. Eighty-five browser + foundation/asset tests and two Rust shell tests pass. Five compiled build-script + probes verify missing/stale assets, stale source, unknown manifest fields and + valid skipped builds. Complete report viewer and browser workflows remain pending. +- E1 browser contract slice: exact opaque tokens, bounded evidence and sidecar + validators, exclusive transport types and immutable owned ingestion implemented. + Independent review caught and corrected type exclusivity and caller-controlled + array-method gaps; both re-reviews approved. One hundred and three focused tests + pass; combined browser foundation and evidence tests total one hundred and + eighty-eight. Scoped strict TypeScript and ESLint checks pass. The Rust contract + also received independent spec and quality approvals: twenty-six focused native + and Viceroy tests, nine documentation examples, all six adapter Clippy aliases + and native core lint pass. Strict map/string decoding and required-fact validation + before truncating tails are pinned by regressions. +- E2 private live carries received independent spec approval and architectural + review. Forty-seven focused native and Viceroy tests pass, covering actual + provider/mediator launch order, empty-plan versus split-dispatch semantics, + checked tail counts, duplicate accepted instances and telemetry-independent + cancellation. All four adapter suites, six adapter Clippy gates, formatting and + committed assets pass; docs compile with the existing thirty-one warnings. + Response slot-token adoption, final delivery disposition and public transports + remain E3/E4 work. +- V1 public report contract is implemented and independently approved for its + pure validation/type scope. Fifty-nine behavioral validation tests and source + member classification/type tests pass. Validation and byte measurement consume + the same owned descriptor snapshot; changing getters, serialization hooks, + extra fields and unsupported schemas cannot enter accepted reports. The scoped + TypeScript gate exposes only four pre-existing duplicate `w`/`h` declarations + in `core/types.ts`, reproduced against the planning baseline; an in-memory + future-source-field probe correctly breaks the exhaustive classification map. +- V2 projection and report builder are implemented, with separate spec and + quality approvals. Seventeen projection and fifteen report tests pass, including + checked omission overflow, stable capture-clock observations, dangling sidecar + pruning, deterministic removal order and protected slot floors. The populated + 512 KiB fixture includes multibyte facts and exact six-counter/survivor checks. + Collector integration and mobile capture responsiveness remain pending. +- V3 storage and export primitives are implemented and independently approved. + Seventeen storage and eight export tests pass, covering exact origin/expiry, + serialized UTF-8 bounds, safe integer capture time, unavailable storage, + identical public-report formatting and independent deferred download cleanup. + Actual branch trace tests now total three hundred and six across thirteen + files. User action wiring, handoff recovery and browser acceptance remain pending. +- F4 integration received independent spec and quality approvals. The actual + Fastly, Cloudflare and Spin runtime boundary suite passes all three selected + tests, including each runtime's 198-case flag/auth matrix, normalized-in and + normalized-out paths, encoded aliases, terminal headers and distinct runtime + rejection outcomes. Native Axum parity and all six adapter lint gates pass. + This closes the scoped hook/terminal integration; F0 browser acceptance still + requires V7. No pre-invocation rejection is credited with an application response. +- The E1 owned-ingress correction, E5 pure collector and V4 pure correlation + model received independent spec and quality approvals. Public validators now + snapshot own data before validation, and collector imports do not pull in the + report/GPT runtime graph. Actual trace tests total 336 across fifteen files. + Live caller, sidecar and viewer integration remain separate tasks. +- F7 publisher bootstrap received independent spec and quality approvals. Six actual Viceroy + tests cover the gate matrix, HTML-only injection, safe head ordering, failure + privacy, ESI exclusion and distinct visitors using a warm template cache. An + emitted-script Node test proves hostile-string roundtrip and deep freezing of + all context containers. All four adapter suites and all six adapter lint gates + pass; documentation retains the same 31 pre-existing warnings. +- E5 direct API wiring and V5 handoff received independent spec and quality + approvals and are copied into the branch. Direct evidence is consumed before ordinary + bids; supplied unreadable namespaces record a bounded validation issue. Handoff + saves one validated report before same-tab navigation, keeps an explicit download + after storage failure, and stops on destruction from callbacks. The actual branch + passes 587 tests across thirty trace/asset/core/GPT files after rebuilding and + refreshing draft source digests. Prebid hooks, live SSAT/SPA sidecars and V7 + browser acceptance remain separate tasks. +- V6 viewer and independent cleanup received independent spec and quality approvals + and are copied into the branch. Sixty-one focused tests pass, including twelve local-delete + × end-request × state-observation combinations, offline requests, independent + retries, local deletion during a pending end request and listener destruction. + Report facts and setup facts remain separate; exports use the same immutable + public model. After rebuilding and refreshing draft assets, the actual branch + passes 658 tests across thirty-seven selected trace/asset/core/GPT files. + An independently reviewed browser-carried fixture passes the served viewer in + actual Chromium against each publisher framework: 320 px layout, 44 px controls, + text-only hostile content, real keyboard Clipboard and Blob download with equal + report-only JSON, and independent local/server cleanup. This proves viewer + behavior under its CSP; it does not substitute for live auction capture/handoff, + physical devices or file-sharing acceptance. +- F8 uses two dedicated trace configurations alongside the unchanged default-off + browser baseline, with explicit owned runtime cleanup in local and CI runners. + Two process-cleanup regressions pass. Seven actual Chromium foundation tests + pass against each of Next.js and WordPress with a freshly rebuilt Fastly artifact: + explicit session activation/end, authenticated routes/assets, served asset digests, + default-off behavior, cross-site mutation rejection and history/reload activation. + Publisher reload exposed origin-304 reuse when the ad stack was disabled. Two + meaningful failing regressions preceded a fix that strips validators/ranges for + diagnostics document changes and rejects unexpected origin 304 responses with a + private 502. Both regressions and eighteen existing cache-policy tests now pass. + Browser TypeScript checks pass with the existing shared Node type definitions. + Operator guide additions received scoped spec approval; full report workflows, + other adapter browser workflows and physical mobile acceptance remain pending. +- E6 browser binding and the related real-snapshot V2 correction received + independent spec and quality approvals and are copied into the branch. Accepted + SSAT/SPA batches bind exact slot objects; sidecars emit only for the consumed + opportunity and its actual GPT cycle. Zero-bid opportunities reuse the validated + auction token, conflicting markers remain unknown, and oversized delivery lists + keep server evidence without prefix joins. Optional own-data undefined members + in the existing GPT snapshot are omitted during projection; other schema checks + remain strict. Actual bootstrap, bundle, fallback and SPA snapshots pass report + construction and exact correlation. The branch passes 990 selected tests across + forty-four files, including committed asset verification. Live Rust transport + wiring remains E3/E4 work. +- E7 Prebid transport is copied into the branch after independent spec and + quality approvals. Review exposed sequential publisher bid-ID reuse; the + corrected private association pairs the original bid ID with the actual SDK + bidder-request ID. Late old timeout/error hooks cannot consume a newer request; + ambiguous or missing associations still retain exact readable responses. + Bounds, expiry and page lifecycle cleanup preserve ordinary bidding. Eleven + actual registered SDK artifact tests pass; the branch passes 852 selected tests + across twenty-seven files, including asset verification. The measured external + Prebid shim is 52,030 characters with a narrowly adjusted 52,500 guard; strict + source and changed-test checks introduce no diagnostics against the existing + baseline. +- V7's local bidder fixture reaches the real Rust HTTP client through test-only + Viceroy aliases derived from the production backend naming policy. Thirteen + generator regressions pass, including rejection of non-loopback overrides and + bounded timeout enumeration; helper Clippy passes. The fixture returns selected, + empty and failed responses, counts actual invocations without retaining bodies, + and rejects malformed/null controls with fixed 400 responses. A meaningful + failing HTTP regression preceded that correction. Nine Chromium checks pass + with the enabled Next.js configuration and eight shared checks pass against + WordPress. A form-navigation race was corrected by waiting for the actual + rejected navigation to commit. The live zero-bid journey proves two actual + bidder calls before reaching its expected missing-transport failure; it is + still incomplete until E4 delivers the live envelope and final assertions run. +- E3's typed `/auction` transport received independent spec and architecture + approvals. The raw extension visitor tracks accepted conversion occurrences + without changing public ad models or acceptance. Review caught oversized + optional numeric values discarding unrelated valid references; a meaningful + failing regression preceded borrowed per-unit isolation. Eleven focused cases + pass on native and Fastly, all four full adapter suites and six adapter lint + gates pass after the correction, and documentation/format/asset checks pass + with the same pre-existing documentation warnings. API responses preserve + ordinary ad content and add private evidence only under the frozen request gate. + Publisher SSAT/SPA transport remains E4 work. +- V7's all-four browser harness reuses the Rust runtime launchers and owns its + browser subprocess. Its source received independent review, TypeScript, + discovery and baseline-skip checks pass, and a real hanging Chromium probe + proves the owned Node/worker signal cleanup closes the observed detached + browser processes locally. The normal browser workflows remain unexecuted + pending fresh artifacts; this is not evidence of full adapter journey success. +- E4's request-scoped SSAT and both SPA transports received independent spec + and architecture approvals. The document tail reuses the pre-dispatch auction + identity and exact slot references, projects actual delivered dispositions, + and keeps templates/ESI free of private transport. Skipped and empty auctions + remain observable when the tail is reached; an unreached tail invents no + evidence. Two meaningful failing regressions preceded implementation; six + focused native and six focused Fastly cases pass. All four adapter suites, + six adapter lint gates, formatting and asset checks pass. Final lint corrections + reuse the ordinary inactive wrapper and replace a serializer panic with the + exclusive unavailable marker and a fixed log message. Fresh production + artifacts and complete live browser assertions remain pending. +- The integration workflow now gives raw runtime and normal browser trace + acceptance a dedicated job with explicit Chromium, Wrangler and Spin + prerequisites. Ordinary integration runs exclude these opt-in families. + Independent review and six actual Bash 3.2 argument cases verify default, + positive-filter and negative-skip behavior. The prepared CI job has not run + remotely; its source is not credited as runtime acceptance evidence. +- Independent full mandatory-phase source/spec and architecture reviews found + no actionable findings. Seventeen shared Chromium checks pass against the + reviewed foundation artifact, including real CSP blocking, opener-cloned + storage separation and simulated expiry, rejected report removal, blocked + deletion with retained export, and offline cleanup followed by actual server + retry/observation. These carried-report fixtures are not credited as live + auction capture or a real browser restart. Three additional reviewed live + fixtures cover a real failed-provider SSAT response and unchanged core API + creatives after only the optional trace transport is removed or made invalid; + their fresh-artifact execution remains pending. +- Final fresh production artifacts build on all four platforms. The complete + repository browser runner passes 49 Next.js and 28 WordPress checks, with only + framework-selection and separate-runtime skips. Actual SSAT zero-bid and + failed-provider evidence, selected creative delivery through the real PUC + bridge, both core API outcomes, the pinned real Prebid caller, both SPA aliases, + and unchanged advertising after altered optional trace transport all pass. + The first live handoff failure exposed a test locator that could not reach the + intentionally closed diagnostics shadow root. A reviewed Chromium-only helper + now finds the exact accessible button and sends a real pointer click; production + shadow behavior and asset bytes are unchanged. The failing case and complete + suites then pass. +- Fresh normal browser workflows pass separately on Axum, Cloudflare, Fastly and + Spin: real Secure/HttpOnly cookie storage, separate observed state, publisher + reload/capture, same-tab handoff, equivalent clipboard/download JSON, independent + local deletion, server end and subsequent state verification. Auctions are + deliberately disabled in this cross-runtime fixture; live bidder and creative + transport proof comes from the dedicated Next.js/Viceroy suite above. Fresh raw + boundary suites also pass against Cloudflare, Fastly and Spin, keeping runtime + rejection separate from converted-path/header behavior. These are local runtime + results, not deployed-platform or physical-device acceptance. +- Remaining repository gates pass: CLI and codegen lint, host CLI tests including + actual Chrome fixtures, 21 cross-adapter parity cases, integration lint, JS build, + pinned external Prebid build, 1,733 Vitest tests in 69 files with no type errors, + JS/docs/Markdown format and browser TypeScript checks. Final current-source full + adapter suites pass after the last E4 lint corrections: Fastly core 3,011, + adapter 199, JS crate 4, OpenRTB 21 and doctests 23; Axum 44, Cloudflare 54 and + Spin 89, with only existing ignored cases. Generator tests pass 13/0; core + target-matched documentation and format checks pass with the same 31 existing + documentation warnings. Physical mobile, actual session restoration and staging + rollout remain pending release checks. +- Existing unpublished v1 draft bytes and source digests are deliberately + refreshed during implementation. First complete publication remains gated on + every mandatory phase and final verification. + +- [x] Resolve and verify the EdgeZero prerequisite before foundation integration. +- [x] Execute and review foundation tasks F0–F8. +- [x] Execute and review live-evidence tasks E1–E7. +- [x] Execute and review browser implementation V1–V6 and automated V7 journeys. +- [ ] Complete V7 physical-device, actual session restoration and staging acceptance. +- [ ] Record all acceptance evidence, full CI gates, and manual mobile results. +- [ ] Enable only a controlled staging fixture; verify CDN/private responses and operator privacy acceptance. +- [x] Keep enrichment and future schemas separately scheduled. +- [x] Present the resulting implementation diff for independent review before external publishing. + +| Scope | Current status | +| -------------------------- | --------------------------------------------------------------------------------------- | +| Mandatory production code | Implemented, independently reviewed; complete automated browser journeys pass | +| Final repository gates | All required automated gates pass | +| Physical mobile acceptance | iOS Safari and Android Chrome checks pending; see operator guide release checklist | +| Real session restoration | Manual check pending; actual opener cloning and simulated future-load expiry are tested | +| Staging and deployment | Pending operator rollout/privacy/CDN acceptance; feature remains default off | +| Optional phase 4 | Unscheduled; unavailable fields remain omitted rather than inferred | + +## Independent plan review + +Reviewed on 2026-10-05 by three independent read-only subagents against the approved spec and current source. Each reviewer assessed the consolidated document without relying on the earlier multi-document reviews; the findings were corrected centrally and the changed tasks were reviewed again. + +| Reviewer | Coverage | Final result | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- | +| Spec alignment | All normative sections, acceptance criteria, activation, exact schemas, privacy, storage, failure behavior, rollout and optional scope | Approved | +| Server and all adapters | Rust ownership, upstream API, runtime conversion, Fastly native shortcuts/finalization, authentication, cookies, controls, live outcomes, transports and parity | Approved after F0/F4 corrections | +| Browser, UI and build | Both API callers, registered Prebid hooks, GPT binding, IIFE sharing, validation/truncation, local storage/export, CSP/mobile/accessibility, frozen assets and real fixture plumbing | Approved | + +Baseline review corrections included early Fastly capture, actual converter/runtime probes, the browser-gated Cloudflare runner, successful empty-plan versus unsuccessful split-dispatch outcomes, throwing diagnostic-callback fail-open tests, dedicated fixture scoping and measured Prebid artifact-size handling. The runtime-boundary amendment supersedes its original-target/original-wire acceptance assumptions. File maps were checked against the checkout and consolidated phase paths. + +This historical review approves the baseline plan's alignment and task ownership, not completed implementation or the subsequent amendment. Under the proposed runtime-boundary amendment, F0 still requires a reviewed upstream revision and actual resolved-runtime evidence, but original-path/header recovery is no longer a release gate. Full runtime/CI and supported-origin browser acceptance remain required during execution. + +### Independent runtime-boundary amendment review + +Two independent read-only subagents reviewed the amended spec and this single +plan together on 2026-10-05. The spec reviewer covered the complete normative +contract, path/auth precedence, runtime failure boundary, cookies and acceptance +criteria. The plan reviewer checked the actual EdgeZero ingress API and task +coverage across adapters, capture gates, lifecycle, schema validation, UI, +storage, export and size bounds. + +The review found one status alignment gap: encoded namespace aliases needed an +explicit enabled `400` in the spec to match F2. That was corrected with the +existing authentication and disabled-feature precedence preserved. Document +cleanup added acceptance matrix rows 16–17, corrected the source/execution +boundary and stale raw-wire/first-PR wording, and clarified that unrelated-pair +tolerance applies after aggregate cookie checks. Both reviewers re-read the +corrections and approved with no remaining findings. + +This is approval of document/API alignment. The user subsequently accepted the +amendment and authorized implementation on 2026-10-05; no implementation, +immutable dependency pin, runtime/browser acceptance or CI pass is claimed by +this review. + +## Phase 1: Foundation + +### Inputs, scope, and dependency + +Read the shared sections above and approved [spec](../specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md) sections 5.1–5.4, 6.1, 8, 9.1–9.2, 11–14.2, and 17.1. Execute in the current branch. This phase supplies setup/lifecycle behavior; the feature is released only after combined reports and the viewer are complete in phase 3. + +Pinned EdgeZero v0.0.8 cannot satisfy method-independent pre-router dispatch. F0 is an actual prerequisite, not a suggested optimization. The EdgeZero feature branch implements the required hook and metadata API; a reviewed immutable upstream revision and resolved-runtime verification are still needed before adapter integration. If that revision is unavailable, finish the pure core/browser tasks and report adapter integration blocked. Original-target SDK recovery is not required. Never substitute ordinary middleware, enumerate only standard HTTP methods, edit the Cargo cache, or claim parity from Fastly alone. + +### File map + +| Action | Exact path | Responsibility | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Modify | `Cargo.toml`, `Cargo.lock` | Reviewed EdgeZero revision supporting pre-dispatch, trusted origin and conservative request metadata | +| Modify | `crates/trusted-server-core/src/lib.rs` | Export only adapter-facing `trace` entry points | +| Create | `crates/trusted-server-core/src/trace/mod.rs` | Base/document gates, frozen runtime-visible cookie inspection, adapter-facing responder and typed terminal trace-response marker | +| Create | `crates/trusted-server-core/src/trace/types.rs` | Versioned public network/context/cookie types; no request structs | +| Create | `crates/trusted-server-core/src/trace/cookies.rs` | Read-only bounded exact-name scanner | +| Create | `crates/trusted-server-core/src/trace/context.rs` | IP masking and bounded optional network projection | +| Create | `crates/trusted-server-core/src/trace/routes.rs` | Reserved-path classifier, POST controls/body validation, response hardening | +| Create | `crates/trusted-server-core/src/trace/shell.rs` | Escaped fixed shell/setup facts, data-URL favicon, external asset references | +| Modify | `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs` | Default-off option, shared set/clear cookie policy, effective document gate integration | +| Modify | `crates/trusted-server-core/src/config.rs` | Raw-config validation on deploy/runtime paths | +| Modify | `crates/trusted-server-core/src/auth.rs` | Bounded trace-auth failure logging without changing canonical first-match handler selection | +| Modify | `crates/trusted-server-core/src/publisher.rs` | Request-scoped early trace bootstrap, never cached template/ESI data | +| Modify | `crates/trusted-server-core/src/html_processor.rs` | Place request-scoped trace bootstrap before synchronous TSJS initialization | +| Modify | `crates/trusted-server-adapter-fastly/src/app.rs`, `crates/trusted-server-adapter-fastly/src/main.rs` | Hook registration and native ingress snapshot capture before conversion | +| Modify | `crates/trusted-server-adapter-axum/src/app.rs`, `crates/trusted-server-adapter-cloudflare/src/app.rs`, `crates/trusted-server-adapter-spin/src/app.rs` | Same hook for production and `routes_with_settings` seams | +| Modify | `crates/trusted-server-adapter-fastly/src/platform.rs`, `crates/trusted-server-adapter-axum/src/platform.rs`, `crates/trusted-server-adapter-cloudflare/src/platform.rs`, `crates/trusted-server-adapter-spin/src/platform.rs` | Read-only existing client/geo sources, trusted scheme/authority adapter mapping | +| Create | `crates/trusted-server-js/lib/src/trace/types.ts`, `crates/trusted-server-js/lib/src/trace/lifecycle.ts`, `crates/trusted-server-js/lib/src/trace/viewer.ts`, `crates/trusted-server-js/lib/src/trace/viewer.css` | Setup/state/retry UI and shared lifecycle contracts, no report parser yet | +| Create | `crates/trusted-server-js/lib/test/trace/lifecycle.test.ts`, `crates/trusted-server-js/lib/test/trace/setup.test.ts` | Setup actions and observed-state tests | +| Modify | `crates/trusted-server-js/lib/build-all.mjs`, `crates/trusted-server-js/build.rs`, `crates/trusted-server-js/src/lib.rs` | Separate fixed trace build/embedding pipeline | +| Modify | `crates/trusted-server-js/Cargo.toml` | Reuse workspace serde_json as a build dependency for the committed JSON asset manifest | +| Create | `crates/trusted-server-js/src/trace_assets.rs`, `crates/trusted-server-js/lib/trace-assets-manifest.json`, `crates/trusted-server-js/lib/test/trace-assets.test.mjs` | Versioned immutable bytes, digest verification, asset lookup independent of integration discovery | +| Modify | `crates/trusted-server-js/lib/.prettierignore`, `crates/trusted-server-js/lib/eslint.config.js` | Exclude frozen generated asset bytes from source format/lint rewrites | +| Create | `crates/trusted-server-js/lib/trace-assets/v1.js`, `crates/trusted-server-js/lib/trace-assets/v1.css` | Frozen release bytes retained across later asset-set builds | +| Create | `crates/trusted-server-integration-tests/browser/tests/shared/mobile-trace.spec.ts`, `crates/trusted-server-integration-tests/browser/helpers/trace-fixture.ts`, `crates/trusted-server-integration-tests/fixtures/configs/trusted-server.trace.toml` | Dedicated setup endpoint/browser runtime, independent of shared disabled fixtures | +| Modify | `crates/trusted-server-integration-tests/browser/helpers/infra.ts`, `crates/trusted-server-integration-tests/browser/helpers/state.ts`, `crates/trusted-server-integration-tests/browser/global-setup.ts`, `crates/trusted-server-integration-tests/browser/global-teardown.ts`, `scripts/integration-tests-browser.sh`, `scripts/generate-integration-viceroy-configs.sh` | Launch and clean up dedicated enabled/disabled trace test runtime | +| Modify | `crates/trusted-server-integration-tests/tests/parity.rs` | Common route/auth/order/body contract across native adapter seams | +| Modify | `crates/trusted-server-integration-tests/tests/integration.rs`, `crates/trusted-server-integration-tests/tests/common/config.rs` | Isolated real Workers transport/config regression using `crates/trusted-server-integration-tests/tests/environments/cloudflare.rs` harness | +| Modify | `trusted-server.example.toml`, `docs/guide/integrations/gpt-diagnostics.md`, `docs/guide/configuration.md` | Default-off config, disclosure/transport/session/auth limits | + +Rust unit tests live beside each changed module. Do not add a new crate, UI framework, cookie parser dependency, or generic diagnostics subsystem. + +### F0: Supply the missing EdgeZero pre-dispatch seam + +**External files:** `crates/edgezero-core/src/router.rs`, `crates/edgezero-core/src/request.rs` or the existing request-metadata module, and `crates/edgezero-adapter-{fastly,axum,cloudflare,spin}/src/request.rs` in the upstream EdgeZero repository. These are not Trusted Server paths. Use a separate temporary source checkout only for dependency investigation; leave the Trusted Server branch intact. + +- [ ] Confirm the existing pinned router with `cargo metadata --format-version 1 --no-deps` and `rg -n 'edgezero|v0.0.8' Cargo.toml Cargo.lock`. Expected: all EdgeZero packages resolve consistently to the current tag/revision before changes. +- [ ] Verify the reviewed upstream implementation of the hook API below. The EdgeZero feature branch now supplies `RouterBuilder::pre_dispatch_hook`, `PreDispatchHook` and bounded `RequestIngress` metadata; it has not yet supplied an immutable reviewed dependency revision. Retain `RouterService` as the concrete return type so existing adapter runners and test seams use it. + +```rust +#[async_trait::async_trait(?Send)] +pub trait PreDispatchHook: Send + Sync + 'static { + async fn handle( + &self, + request: &mut Request, + ) -> Result, EdgeError>; +} +``` + +- [ ] Verify upstream regressions for no hook, continuation mutation, early response, async empty stream inspection, shared hook across router clones, and an unregistered extension method. Both method and path must be inspected before `find_route`, state/introspection insertion, `RequestContext::new`, and ordinary middleware. Hook errors stop routing. Trace policy failures must instead return `Ok(Some(hardened_response))` to retain the exact response contract through the generic router. +- [ ] Verify `RouterBuilder::pre_dispatch_hook` stores `Arc` and runs first in `RouterInner::dispatch`. `Some(response)` terminates; `None` preserves the possibly updated request for ordinary dispatch. Existing routers remain behavior-identical when no hook is installed. +- [ ] Verify runtime-visible method preservation in every converter. Cloudflare must use `req.inner().method()` and parse it with `http::Method::from_bytes`, not Workers `req.method()` (which maps unknown tokens to GET). Its browser contract constructs a `web_sys::Request` extension token and proves conversion. Application-seam tests require authenticated hardened 405 and Allow for that token on every exact trace route. Real Worker wire tests instead record pre-invocation rejection if the parser returns 501; never claim those two tests prove the same outcome. +- [ ] Consume `RequestIngress::{origin,header_fidelity}` without a parallel metadata type. Route decisions use the converted `Request::uri().path()`, not `CapturedTarget` or guessed original bytes. Original-target metadata may remain unavailable/transformed/over cap without rejecting an inspectable pathname. Canonical origin still requires adapter-owned `RequestIngress.origin()`, never `RequestInfo` forwarding-header fallbacks. Verify literal/encoded dot/separator and absolute/origin-form transport observations, including normalization into and out of trace. +- [ ] Verify Fastly's early snapshot captures origin before native mutations and is carried through `into_core_request_with_ingress`. Classify its SDK-visible pathname before shortcuts. Raw original target remains NotExposed and no SDK getter work is required for v1. Test `/_ts/trace/../../health` and `/_ts/trace/../debug/ja4` as ordinary paths when the runtime normalizes them outside trace; test normalizations into trace and visible reserved ambiguities against full local auth/hardening. Record existing health/JA4 auth limitations rather than asserting trace authentication on those ordinary requests. +- [ ] Verify runtime-visible cookie/control semantics against the resolved graph. Worker strings are UTF-8 and must copy via `HeaderValue::from_bytes(value.as_bytes())`; do not cast scalar values to bytes. Cookie-specific non-preserved octets plus U+FFFD, or non-preserved multiplicity plus comma, make all four states unavailable/runtime_header_ambiguous. Unknown/missing metadata alone permits marker-free sessions. Check exact visible reserved-name counts/duplicate precedence and visible byte bounds; global/same-name order and original-wire reconstruction are not prerequisites. Validate complete Origin/action/Fetch Metadata values and reject duplicates/folded values, never comma-split controls. Expose no raw bytes or parser messages. +- [ ] Run exact raw-byte Cookie/control tests: valid session plus original FF versus original valid EF BF BD, comma-coalesced repeated reserved cookies, visible duplicates, and visible header caps. Include actual isolated Workers transport beyond synthetic HeaderValue tests. Record runtime-rejected, conversion-failed and application-handled/transformed cases separately; only the latter can assert CookieHealth and hardened trace responses. Use Rust or existing native/shell tooling, with no Python shipped. +- [ ] In the upstream checkout run `cargo test -p edgezero-core pre_dispatch` and its adapter request-conversion tests. Expected: all new cases pass and old no-hook routing tests remain green. For Cloudflare’s browser-gated `tests/contract.rs`, reproduce the pinned upstream workflow: install `wasm-bindgen-cli` at the version resolved from its Cargo.lock, establish the required browser/WebDriver, then run `CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --test contract`. A host-only run or a missing browser/runner is not conversion evidence; record it as pending. +- [ ] Obtain a reviewed immutable upstream release/revision exposing that API, update all workspace EdgeZero dependencies consistently in `Cargo.toml`, and regenerate `Cargo.lock` with `cargo update`. Do not invent a future tag/hash or ship a local Cargo-cache edit. Record the resolved commit and dependency graph. Current local upstream evidence uses worker 0.8.3 / wasm-bindgen 0.2.122; this consumer currently resolves 0.8.5 / 0.2.126. Repeat browser converter and real Worker ingress tests against the repinned graph; do not downgrade merely to match prior evidence. +- [ ] Run `cargo check-fastly`, `cargo check-axum`, `cargo check-cloudflare`, and `cargo check-spin`. Expected: all adapters compile against the same pin. Planned commit: `Add EdgeZero pre-dispatch support for reserved trace routes`. + +**Amended capability gate:** Keep runtime rejection, adapter bootstrap/conversion failure and application-visible handling distinct. The proposed spec now accepts normalization outside trace as ordinary traffic and defines conservative Cookie ambiguity suppression. Original-target support remains documented external work, not a v1 blocker. F0 remains open until a reviewed immutable EdgeZero revision is adopted, converters and control/cookie semantics pass on the resolved graph, and safe normal requests work on every adapter. F4 additionally proves the trace terminal path bypasses ordinary lifecycle/finalizers. The hook does not precede adapter bootstrap/body buffering; no runtime rejection is falsely credited with a local response. + +### F1: Validate default-off configuration and unify cookie policy + +**Files:** `integrations/gpt_diagnostics.rs`, `config.rs`, `trusted-server.example.toml` under the paths in the file map. + +- [ ] Write `trace_config_requires_enabled_on_both_validation_paths`: trace=true with enabled=false or omitted must fail both `validate_settings_for_deploy` and `validate_settings_for_runtime`. Add accepted flag=false/true cases and disabled unknown-field rejection. +- [ ] Run `cargo test-fastly trace_config`. Expected red: field rejected as unknown or the disabled-gate combination is not validated; inspect the actual failure. +- [ ] Add `#[serde(default)] pub trace_page_enabled: bool` to `GptDiagnosticsConfig`. Add a schema check for the dependency and a raw-config hook next to `validate_js_asset_proxy_config`; explicitly deserialize/validate even when `enabled` is false. Invoke it in both validation paths before enabled integration lookup. +- [ ] Write query/endpoint policy tests for enable→query enable, query enable→enable, repeated explicit activation, either clear surface, no ordinary-request refresh, and feature-disabled query activation. Expected set header: `__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800`; clear uses `Max-Age=0`, never Domain. +- [ ] Run `cargo test-fastly gpt_diagnostics`. Confirm a lifetime regression fails, then centralize the set/clear policy in that module for both writers. Keep the existing action enum/API where possible; update its obsolete browser-session docs. +- [ ] Re-run both filters and `cargo clippy-fastly`. Expected: green; config validation cannot silently log-and-disable invalid enabled settings. Planned commit: `Add validated trace configuration and bounded diagnostics cookie policy`. + +### F2: Implement exact reserved classification and hardened responses + +**Files:** `trace/routes.rs`, `trace/mod.rs`, `trace/shell.rs`, `lib.rs`; reuse `ec/admin.rs` and `response_privacy.rs`; make only the bounded trace-auth logging adjustment in `auth.rs`, without broad refactoring. + +- [ ] Write table tests covering shell, state, enable, end, exact v1 assets, trailing slash, extra segments, lookalikes, repeated separators, encoded/repeatedly encoded separators, dot segments, decode-budget exhaustion, and unrelated paths. Reuse the four-round fixed-point decoding pattern in `deny_admin_diagnostic_fallback`. +- [ ] Run `cargo test-fastly trace::routes`. Expected red: missing classifier/responder, then specific status mismatches once compiled. +- [ ] Implement classification returning `NotTrace`, a supported route, or a reserved-path rejection from the application-visible `req.uri().path()`. Use bounded decoded variants only to reserve/reject aliases, never to serve them. Still-visible malformed encodings/dot ambiguities return 400 after auth/flag precedence; other reserved lookalikes return 404. Normalized-out paths continue as ordinary traffic; normalized-in exact trace paths receive full validation. Ignore original-target availability for decisions. +- [ ] Define the shared responder sequence: classify → `enforce_basic_auth` with the canonical request path/settings → harden challenge/error → flag check → reserved-path error → method check → route-specific handling. No body read or setup projection precedes authentication. Disabled requests are 404 after auth. +- [ ] Preserve existing first-match `Settings::handler_for_path(req.uri().path())` selection; neither decoded aliases nor original-target metadata creates a second auth-policy interpretation. Pin `/%5Fts/trace`: under `^/`, auth challenges before the local error; under a lone `^/_ts`, the unmatched alias is rejected without setup data/actions (400 when enabled, 404 when disabled). Set a diagnostic auth-log-policy extension before synchronous `enforce_basic_auth`; its wrong-credential log otherwise prints the full path. Emit a fixed trace-auth failure category while ordinary callers retain logging/authorization-marker behavior. Test a fictional secret-path sentinel never enters body/logs. +- [ ] Implement the complete local route table below. HEAD uses GET status/headers but always drops the body; HEAD on actions returns bodyless 405. + +| Path | Methods | Enabled response | Allow on 405 | +| -------------------------- | --------- | ------------------------------------------ | ------------ | +| `/_ts/trace` | GET, HEAD | 200 escaped setup shell | `GET, HEAD` | +| `/_ts/trace/state` | GET, HEAD | 200 exact `{ "observed_active": boolean }` | `GET, HEAD` | +| `/_ts/trace/enable` | POST | 200 bounded mutation-requested result | `POST` | +| `/_ts/trace/end` | POST | 200 bounded mutation-requested result | `POST` | +| `/_ts/trace/assets/v1.js` | GET, HEAD | Fixed JS bytes | `GET, HEAD` | +| `/_ts/trace/assets/v1.css` | GET, HEAD | Fixed CSS bytes | `GET, HEAD` | + +- [ ] Harden every local dynamic/error/challenge response, including assets errors: private/no-store, removal of shared/edge cache directives, nosniff, no-referrer, Permissions-Policy and exact spec CSP. Dynamic shell is HTML UTF-8; state/actions are JSON UTF-8. Build only bounded errors, no raw `Report` text in bodies/logs. Retain `WWW-Authenticate` on 401 and `Allow` on 405. +- [ ] Serve successful assets with correct JS/CSS MIME, nosniff, strong byte-derived ETag. If the selected handler has auth, use private/no-store even with accepted credentials; otherwise use public/max-age=31536000/immutable. Do not reflect query/report data into assets. +- [ ] Add regression tests for broad auth, earlier narrow-handler shadowing, disabled/auth order, protected assets, extension methods, HEAD bodylessness, and query nonreflection. Re-run filter and clippy. Planned commit: `Add authenticated local trace routing and response hardening`. + +### F3: Inspect runtime-visible cookies and project request facts + +**Files:** `trace/types.rs`, `trace/cookies.rs`, `trace/context.rs`, `trace/mod.rs`; canonical validators in `ec/generation.rs` and `ec/prebid_eids.rs` are reused. + +- [ ] Write scanner tests for all four reserved names, absent/valid/invalid, duplicate precedence, multiple header fields, additional equals in values, unrelated malformed names, exact malformed reserved tokens, oversized values, exactly/over 16 KiB aggregate, and non-UTF-8 headers. +- [ ] Add a table covering each non-preserved status plus missing metadata: marker-free session and empty collection remain inspectable; comma anywhere with non-preserved multiplicity or U+FFFD anywhere with non-preserved octets makes all four unavailable/runtime_header_ambiguous. Test independent per-Cookie overrides, unrelated/quoted markers, exact visible duplicates, Preserved-axis marker semantics, valid other multibyte UTF-8 and original invalid bytes. Current adapters' common Unknown is not a reason to disable normal sessions. +- [ ] Run `cargo test-fastly trace::cookies`. Expected red; implement a read-only frozen visible-field scan, not CookieJar. Sum as_bytes lengths (including introduced separators); above 16,384 yields header_too_large, then actual invalid UTF-8 yields header_not_utf8, then markers yield runtime_header_ambiguous. Use str::from_utf8, not HeaderValue::to_str. Get per-Cookie axes from RequestIngress::header_fidelity(&COOKIE); missing metadata is unproved. Do not comma-split. Preserve exact names/count malformed reserved occurrences before canonical value parsing. Order does not affect counts. Test combined failures pin this precedence and suppress the capture gate. +- [ ] Enforce visible UTF-8 per-value byte caps 512 EC, 8192 EIDs, 16 tester/session. Reuse canonical validators and literal tester=true/session=1. Add runtime_header_ambiguous to the exact Rust detail enum, legal only with unavailable; emit source: request, no values/parser text/fidelity metadata. After aggregate checks, duplicate wins over individual validity; absent has no detail. These bounds/counts do not reconstruct discarded original wire information. +- [ ] Write `trace_context_masks_and_omits_forbidden_fields` using `192.0.2.129` and `2001:db8:1234:5678::1`: expect a deterministic /24 and /48 display mask, never the full addresses. Put distinct fictional sentinels in fingerprints, path/query, user/auction IDs, headers and geo city/coordinates; serialized output must exclude them. +- [ ] Run `cargo test-fastly trace::context`. Implement the exact section 9.1 schema with strict RFC3339 UTC capture time and only optional allowlisted network members. Country ≤2 ASCII; region/POP/protocol/cipher ≤32 UTF-8 bytes; edge hostname/region ≤128. Reject invalid Unicode/control/bidi values, omit bad optional fields with bounded categories; do not silently shorten or log values. +- [ ] Project existing `RuntimeServices.client_info` and read-only geo: Fastly IP/TLS/server metadata/country/region, Axum trusted IP, Cloudflare trusted IP/country, Spin trusted IP. ASN stays absent unless actually populated. No speculative HTTP/POP mapping in this increment. +- [ ] Re-run both filters. Tests use panic/counting KV/auction/filter/sink mocks to prove inspection has no identity or telemetry side effects. Planned commit: `Add bounded read-only trace request and cookie projections`. + +### F4: Integrate the hook on every adapter before ordinary processing + +**Files:** all four `src/app.rs`, Fastly `src/main.rs`, platform metadata mapping, adapter-local tests, `tests/parity.rs`. + +- [ ] Write dispatch tests using each adapter's actual `routes_with_settings` or equivalent build seam with injected counting/panic services. Include exact/malformed/disabled routes, HEAD, extension methods, and auth failures. All ordinary lifecycle/filter/KV/auction/origin/sink counters must remain zero. Optional read-only geo is allowed only after auth. +- [ ] Run `cargo test-fastly trace_dispatch`, `cargo test-axum trace_dispatch`, `cargo test-cloudflare trace_dispatch`, and `cargo test-spin trace_dispatch`. Expected red until every route construction registers the hook. +- [ ] In Fastly main.rs capture RequestIngress before mutations and classify the SDK-visible pathname before native shortcuts. Only NotTrace may use health/JA4; runtime-normalized paths outside trace explicitly retain ordinary behavior. Carry the snapshot and consistent visible-path classification into the core responder. Real Viceroy raw-client tests cover normalizations out to health/JA4 and into trace, still-visible reserved ambiguity, exact routes, disabled/auth precedence and zero ordinary effects for trace. Do not wait for a raw-target SDK accessor or assert auth on normalized-out health/JA4. +- [ ] Register one hook capturing each adapter's `Arc`, before EdgeZero method routing. Consume `RequestIngress` facts and a lazy read-only metadata supplier; canonical origin must not use `RequestInfo` forwarding-header fallbacks. Capture Fastly metadata before native mutation/shortcuts and pass the same snapshot through `into_core_request_with_ingress` into the existing direct `oneshot` seam. Return every trace policy rejection as `Ok(Some(hardened_response))`, not a propagated generic `EdgeError`. Do not construct the full EC/event pipeline for setup facts. Preserve harmless transport metadata needed for trusted IP resolution; sanitize trust-secret headers on continuing ordinary requests using existing policy. +- [ ] On a non-trace continuation only when trace config is enabled, freeze CookieHealth and incoming-session validity before prepare_request. Compute the base gate once from config plus runtime-visible inspection; unavailable/invalid/duplicate/absent gives false. After existing diagnostics decision, compute document eligibility without rereading sanitized cookies. Retain no raw values; suppress trace tokens/evidence/sidecars on ambiguity while ordinary advertising/console behavior remains unchanged. +- [ ] Mark all reserved local responses, including 401/disabled/path/method errors and successful assets, with a core `TraceTerminalResponse` extension. Fastly main must detect it before `apply_entry_point_finalize_headers` and bypass that ordinary finalizer: it performs geo lookup and applies operator headers that could otherwise replace CSP/MIME or add cookies after the early responder. Use only a trace-specific terminal pass to preserve the complete route header/cache/body contract through native conversion; do not insert `EcFinalizeState` or `RequestFilterEffects`. Add entry-point regressions with hostile CSP/Content-Type/Set-Cookie/cache overrides: auth failure performs zero geo lookups, GET/HEAD never gain Set-Cookie, action cookies remain exactly the accepted policy, and Allow/WWW-Authenticate/security headers survive. Preserve native adapter startup/runtime config validation and `routes_with_settings_and_services` seams. +- [ ] Add native parity tests exercising arbitrary methods, malformed paths, auth matrices, missing optional facts, and no-Content-Type streamed POSTs. Fastly is checked separately under Viceroy; the native parity test does not execute Fastly. +- [ ] Run all four trace_dispatch filters and `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity trace`. Add isolated actual-runtime tests in tests/integration.rs using CloudflareWorkers and CLOUDFLARE_WRANGLER_DIR; parameterize tests/common/config.rs without changing disabled baseline fixtures. Run the Cloudflare build.sh and `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test integration trace_runtime_boundary -- --ignored --test-threads=1`. A raw client preserves duplicate/invalid bytes where reqwest/fetch cannot. Pin extension-method wire rejection (local 501), FF replacement, comma joining and normal safe GET/POST flows separately from application-seam hardened 405 tests. Add equivalent Fastly/Spin evidence, including pre-component invalid-byte rejection and conversion-failure counters. Only successful conversion can assert local status/Allow/hardening/CookieHealth; preserve no trace-specific writes/capture for failures. Planned commit: `Intercept trace requests before all adapter lifecycles`. + +### F5: Enforce deliberate actions and observe state separately + +**Files:** `trace/routes.rs`, `trace/mod.rs`, adapter body-parity tests. + +- [ ] Write same-origin action tests: missing/duplicate/conflicting action, Origin, Fetch Metadata, Host/authority/scheme; cross-site Origin; credentials/path/query/fragment in Origin; case/default-port canonicalization; forbidden action query; valid enable/end and repeated actions. +- [ ] Run `cargo test-fastly trace_actions`. Implement exact single control-header validation: correct `X-TS-Trace-Action`, exact `Sec-Fetch-Site: same-origin`, and one HTTP(S) Origin canonically equal to trusted inbound origin. Lowercase host/remove default ports for both; never trust arbitrary forwarding headers. Rejection is 403 with no mutation. +- [ ] Write body tests for missing/zero/invalid/positive Content-Length, any application-visible Transfer-Encoding, nonempty `Body::Once`, clean streamed EOF, empty chunks before EOF/nonempty chunk, and stream error. Assert 413 for invalid/positive length, encoding or actual body bytes, 400 for stream error, hardening and no Set-Cookie. Test identical zero lengths folded by the transport as one zero length plus an actually empty body; the spec does not require transport-level 413 for identical duplicates. Record conflicting-framing parser rejection separately rather than claiming the application returned a hardened response. +- [ ] Implement header prechecks, then explicitly match Body::Once/Stream. Only empty Once or clean stream EOF is accepted. Reject the first nonempty streamed chunk without continuing consumption. Do not use `into_bytes().unwrap_or_default()` or forward `into_bytes_bounded(0)` errors; those cannot implement the prescribed 413 behavior. +- [ ] For accepted action return the shared set/clear header independently of incoming CookieHealth; ambiguous cookies must not prevent a valid end action. State GET uses the shared frozen inspection and returns true only for exactly one present_valid/valid_diagnostics_value session. Absent/invalid/duplicate/unavailable is false, never cookie-absence proof. Test accepted enable/end with ambiguous cookies and separate unconfirmed activation observation. GET/HEAD never mutate cookies or identity; strict action/Origin/Fetch Metadata checks remain unchanged. +- [ ] Run `cargo test-fastly trace_actions`, all four adapter `trace_dispatch` filters, and parity. Expected: no mutation on any rejection, including bodiless fetch without Content-Type through Axum streaming. Planned commit: `Add same-origin trace actions and observed session state`. + +No transport deadline/one-byte read/408 guarantee is added. Fastly, Cloudflare, and Spin pre-buffer; Axum streams non-JSON; body acceptance here cannot bound earlier transport allocation. Document that deployment limitation. + +### F6: Build versioned setup assets and verified lifecycle UI + +**Files:** `trace/shell.rs`, JS `src/trace/{types,lifecycle,viewer}.ts`, `viewer.css`, setup tests, asset build/embedding files from the map. + +- [ ] Write lifecycle tests for activation success followed by active state, mismatch, failed verification, deactivation success/inactive, missing cookie, retry, rejected POST, and no added history entry. Assert POST is same-origin, bodyless, `credentials: 'same-origin'`, cache=no-store, with fixed action header; state GET is a distinct no-store request. +- [ ] Add setup/context rendering for unavailable/runtime_header_ambiguous with plain text explaining unreliable runtime-visible cookie inspection. An ambiguous follow-up state stays inactive and activation unconfirmed; successful end with inactive observation says no valid session observed, not cookie absent. Retain independent local cleanup/retry behavior. +- [ ] From `crates/trusted-server-js/lib` run `npx vitest run test/trace/lifecycle.test.ts test/trace/setup.test.ts`. Expected red. Implement `changeTraceSession(action: 'enable' | 'end')` returning mutation and observation results separately. Never equate POST success with confirmed state. +- [ ] Implement shell/setup UI from section 6.1: title, setup-request network/cookie facts, on/off state, reproduce-after-enable instructions, same-host/same-tab/history fallback, explicit reload guidance, 44 px enable/end/back controls and aria-live status. No previous-page URL inference, target parameter, automatic activation, or GET cookie mutation. Facts are escaped text nodes, not inline executable JSON; viewer reads fixed element attributes/text. +- [ ] Emit viewer JS as `dist/trace/v1.js` and CSS as `dist/trace/v1.css` separately from `tsjs-*.js`. Extend the existing Vite build with a standalone trace entry; no new integration registration or concatenation of viewer code into inactive publisher bundles. +- [ ] Add workspace `serde_json` to `crates/trusted-server-js/Cargo.toml` build-dependencies to parse the committed JSON manifest; this reuses an existing dependency rather than introducing another parser. Extend `build.rs` to embed assets independently, verify committed SHA-256 digests from `lib/trace-assets-manifest.json`, and fail closed for missing/stale assets under `TSJS_SKIP_BUILD=1`. Expose narrow static asset metadata through `src/trace_assets.rs`. Exclude only generated frozen `trace-assets/**` from JS Prettier and ESLint source rules via `lib/.prettierignore` and `lib/eslint.config.js`; digest tests govern those bytes and source TypeScript/CSS remain formatted/linted. Store published bytes in versioned source-controlled files `crates/trusted-server-js/lib/trace-assets/v1.js` and `v1.css`; manifest/digests alone cannot preserve old routes' bytes. Build output must match these frozen bytes at release, and future changes add a new version. +- [ ] Add a Vitest `test/trace-assets.test.mjs` with `// @vitest-environment node`, following the existing build-artifact tests, checking rebuilt bytes/digests, exact lookup URLs, no dynamic/request data and shell references. Run `node build-all.mjs`, `npx vitest run test/trace-assets.test.mjs`, setup Vitest tests, and `cargo test-fastly trace::routes`. Expected green. Planned commit: `Add versioned mobile trace setup assets and session controls`. + +Keep v1 unpublished through all mandatory phases and update its draft digest/byte fixture with each source change. Freeze only the complete viewer set at first publication after V7. There is no planned interim setup publication or automatic v2 transition. + +### F7: Inject the literal document gate and request context privately + +**Files:** `publisher.rs`, `html_processor.rs`, `trace/mod.rs`, `integrations/gpt_diagnostics.rs`, publisher/unit cache regressions. + +- [ ] Write gate matrix tests: valid incoming cookie + trace flag + active decision succeeds; missing cookie/query-enable only, query-disable, duplicate/invalid directives, prefetch, bot/ineligible navigation, trace flag=false, and nonboolean browser globals cannot activate document tracing. +- [ ] Run `cargo test-fastly trace_document`. Thread a request-scoped optional trace bootstrap through `ProcessResponseParams`, `OwnedProcessResponseParams` and `HtmlProcessorConfig`; use the existing head injection point in `html_processor.rs` beside the diagnostics bootstrap, before the unified TSJS tag. After the effective diagnostics decision, inject `window.__tsjs_trace_active=true` and the immutable redacted context before TSJS initializes, only on eligible documents. Expose context as `window.__tsjs_trace_request_context` with the `TraceRequestContextV1` contract; use the existing script-safe serializer/escaper, not raw executable concatenation. Missing/failed context projection leaves advertising intact and causes a bounded capture failure later. +- [ ] Keep tokens/context out of shared HTML templates and ESI fragments. Add them only in request-scoped injection/body seams. On inactive publisher responses, emit no trace assets/bootstrap/listeners/cache-policy change; existing explicit console activation retains its own behavior. +- [ ] Test projection/serialization failure, hostile `` strings, unsupported metadata, shared-template reuse, and request-specific values for two users. Test hostile response_headers and late filter overrides; retain `late_filter_effects_cannot_make_an_assembled_response_public` and reuse `apply_response_headers_with_cache_privacy`/Fastly terminal guards. +- [ ] Run `cargo test-fastly trace_document`, `cargo test-fastly late_filter_effects_cannot_make_an_assembled_response_public`, all four adapter tests, and relevant clippy gates. Expected: private/no-store terminally, no shared bytes contain trace context, advertising preserved. Planned commit: `Inject eligible request-scoped trace context with terminal privacy`. + +### F8: Document and verify the foundation review unit + +**Files:** example TOML and operator docs in the map; all changed files for verification. + +- [ ] Document default-off option beside `enabled`, same-origin script visibility of HttpOnly cookie health/opaque auction outcomes, potentially personal masked IP/coarse geo, and explicit operator disclosure acceptance. +- [ ] Document first-match-wins auth and public mobile deployment scope, cookie/session caveats, host-only origin scope, browser/service-worker trust limits, transport pre-buffering, local 400/401/403/404/405/413, and no timeout guarantee. +- [ ] Document the three runtime-boundary outcomes, normalized-out ordinary health/JA4 behavior, literal visible-path auth for rejected encoded aliases, visible cookie bounds and conservative comma/U+FFFD false negatives. Require a suitable same-origin test deployment for Secure host-only cookies; never weaken cookie attributes or infer HTTPS from untrusted headers. F0 no longer depends on external raw-target/header reconstruction work. +- [ ] Create a dedicated setup test runtime/helper and trace TOML without changing the shared disabled integration config. Parameterize the existing `scripts/generate-integration-viceroy-configs.sh` app-config input and generate isolated trace output with its existing `generate-viceroy-config --app-config` binary; preserve baseline input/output defaults. Track extra process IDs in browser state and extend both failed-setup cleanup and `global-teardown.ts` to stop every runtime/container; preserve baseline runtime teardown. Add foundation-specific browser tests to `crates/trusted-server-integration-tests/browser/tests/shared/mobile-trace.spec.ts` for read-only GET, cross-site actions, history, observed state, exact CSP/favicon, auth and inactive traffic. Run `./scripts/integration-tests-browser.sh tests/shared/mobile-trace.spec.ts` for this foundation checkpoint, plus affected adapter builds/clippy/docs and the trace parity suite. V7 owns the single complete release gate run. The later viewer phase extends these fixtures; foundation verification does not wait for the viewer. +- [ ] Review diff for accidental real data, raw logs, unrelated refactors, and unimplemented upstream promises. Planned commit: `Document and verify trace setup privacy and adapter parity`. +- [ ] Checkpoint the default-off setup phase with its test evidence and explicit dependency/mobile gaps, then continue E1; do not publish interim assets or declare the full feature complete. + +## Phase 2: Live auction evidence + +### Inputs and invariants + +Depends on foundation tasks F0–F8. Read [spec](../specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md) sections 5.2, 9.3–9.5, 10, 12, 13, and 14.1–14.4. Keep the existing branch and follow the shared verification's verification/commit rules. + +Maintain the existing console-only `diagnostics_auction_id` behavior when trace is off; only the new trace carry/evidence/slot tokens are gated by trace. For active trace, mint the public auction token once before dispatch and use that same token in both public evidence and the existing GPT opportunity marker, including zero-bid/no-candidate paths. + +### File map + +| Action | Exact path | Responsibility | +| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Create | `crates/trusted-server-core/src/trace/auction.rs` | Typed opaque tokens, private observation carry, public evidence projection/bounds | +| Modify | `crates/trusted-server-core/src/trace/mod.rs`, `crates/trusted-server-core/src/trace/types.rs` | Export the narrow auction contracts/hooks | +| Modify | `crates/trusted-server-core/src/auction/orchestrator.rs` | Trace-only launch-order/terminal-fact carry through dispatch/collect/abandon | +| Modify | `crates/trusted-server-core/src/auction/formats.rs` | Permissive trace extension extraction along exact accepted-slot conversion; actual response conversion dispositions | +| Modify | `crates/trusted-server-core/src/auction/endpoints.rs` | API base gate, pre-dispatch identity, response evidence and privacy | +| Modify | `crates/trusted-server-core/src/openrtb.rs` | Optional namespaced public response extension | +| Modify | `crates/trusted-server-core/src/publisher.rs` | SSAT/SPA request-scoped carry, token-bearing slot definitions, held-tail scheduler transport, legacy alias | +| Modify | `crates/trusted-server-js/lib/src/trace/types.ts` | Evidence/transport/correlation types | +| Create | `crates/trusted-server-js/lib/src/trace/validation.ts`, `crates/trusted-server-js/lib/src/trace/collector.ts`, `crates/trusted-server-js/lib/src/trace/runtime.ts`, `crates/trusted-server-js/lib/src/trace/pending.ts` | Strict public evidence checks, one collector facade, API unit mapping and bounded pending lifecycle | +| Modify | `crates/trusted-server-js/lib/src/core/types.ts`, `crates/trusted-server-js/lib/src/core/global.d.ts`, `crates/trusted-server-js/lib/src/core/index.ts` | Optional typed trace facade/slot extension and scheduler third argument | +| Modify | `crates/trusted-server-js/lib/src/core/auction.ts`, `crates/trusted-server-js/lib/src/core/request.ts` | Final grouped unit tokens, direct response collection before bid parsing | +| Modify | `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` | Initial/SPA transport validation, slot identity to existing recorder | +| Modify | `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts`, `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts`, `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts` | Trace-only recorder input/callback at concrete cycle binding | +| Modify | `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` | Actual registered timeout/error hooks and request-associated evidence | +| Create | `crates/trusted-server-js/lib/test/trace/evidence.test.ts`, `crates/trusted-server-js/lib/test/trace/collector.test.ts`, `crates/trusted-server-js/lib/test/trace/pending.test.ts`, `crates/trusted-server-js/lib/test/trace/tokens.test.ts` in the same directory | Strict bounds, coverage truth table, token/mapping/pending behavior | +| Modify | `crates/trusted-server-js/lib/test/core/auction.test.ts`, `crates/trusted-server-js/lib/test/core/request.test.ts`; `crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts`, `crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts`, `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts`; `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts`, `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts`, `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts`; `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` | Behavior compatibility at existing seams | +| Modify | `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs` | Real pinned Prebid register/newBidder callback routing | +| Modify | `crates/trusted-server-integration-tests/browser/tests/shared/mobile-trace.spec.ts` | Live transport/attribution regression fixtures added to foundation suite | + +New Rust tests stay in module `#[cfg(test)]` blocks. Do not serialize the telemetry observation, add fields to public GPT export, rename telemetry source vocabulary, or change provider protocols. + +### E1: Define opaque tokens and the strict public auction contract + +**Files:** `trace/auction.rs`, `trace/types.rs`, `trace/mod.rs`, JS trace types/validation, token/evidence tests. + +- [ ] Add tests that accept exactly the existing unhyphenated lowercase auction UUID-v4 shape and new hyphenated lowercase slot UUID-v4 shape, enforce RFC variant, and reject uppercase, whitespace, wrong UUID versions, internal IDs, and normalized alternatives. +- [ ] Run `cargo test-fastly trace::auction` and, from `crates/trusted-server-js/lib`, `npx vitest run test/trace/tokens.test.ts test/trace/evidence.test.ts`. Expected red. +- [ ] Add newtypes `DiagnosticAuctionId` and `TraceSlotRef` with checked parsing and strong internal ownership. Generate auction UUID via `Uuid::new_v4().simple()` and slot UUID via canonical hyphenated UUID v4. Compare exact stored bytes. Browser token validators are: + +```typescript +const auctionToken = /^ts-auc-[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$/ +const slotToken = + /^ts-slot-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +``` + +- [ ] Define the exact `TraceAuctionEvidenceV1`/`TraceAuctionTransportV1` types from spec 9.4/9.4.1. Explicit enums cover all source, terminal reason/status, provider role/status and candidate members. Transport is a discriminated one-of: schema_version=1 plus evidence OR unavailable_reason=`evidence_projection_failed`, never both/neither. +- [ ] Write table tests for every enum member and rejection, unknown keys at every boundary, provider ≤16, auction slots ≤64, requested sizes ≤16, token/string bounds, dimensions 1..100000, u16 ordinals/counts/counters, u32 durations, required positive ordinals and exact fixed coverage=`unavailable`. +- [ ] Implement strict validators that copy accepted values into trace-owned models, reject extra keys/unsafe numbers/invalid Unicode/control/bidi strings, and never modify a supplied ad object. Optional duration conversion failure omits/counts; required ordinal/count or checked u16 omission overflow rejects that evidence. Browser rejects invalid core-bounded inner evidence without truncating it again. +- [ ] Re-run tests and `cargo clippy-fastly`; expected green. Planned commit: `Define bounded public trace auction evidence and opaque tokens`. + +### E2: Preserve live auction facts before dispatch and through terminal paths + +**Files:** `trace/auction.rs`, `auction/orchestrator.rs`, publisher request/stream carries, endpoint carries. + +- [ ] Write `trace_auction_identity_is_stable_across_terminal_outcomes` and `trace_auction_provider_numbers_follow_launch_order` with completed, zero-bid, skipped, dispatch-failed, execution-failed and abandoned cases. Randomize completion order and legacy HashMap insertion; output order must remain launch order. +- [ ] Run `cargo test-fastly trace_auction`. Expected red. Introduce a private optional `TraceAuctionCarry` owned by the request/dispatch carry with source, pre-dispatch token, exact accepted-slot token mapping, ordered provider observations and monotonic clock start. Instantiate only under the applicable server gate; do not use `AuctionRequest.id` or telemetry UUID. +- [ ] Extend the existing private dispatch/collect outcomes to preserve trace facts on failures. Observe calls at their actual launch point, including dispatch failures and mediator calls; never infer missing provider observations from a `Report`. Number providers in deterministic dispatch order, not completion/HashMap order; pass only redacted summaries into projection. +- [ ] Map terminal observations explicitly: + +| Live condition | Status | Reason | +| ------------------------------------------------- | ------------------------------------- | ------------------------------------------------- | +| Successful execution, including zero bids | completed | Omit unless an actual allowlisted reason is known | +| Consent/policy skip | skipped | policy_skipped | +| Definitive converted slot list empty | skipped | no_eligible_slots | +| Unsuccessful split-path NotStarted without launch | dispatch_failed | no_provider_launched | +| Execution failed with provider failure known | execution_failed | provider_execution_failed | +| Collection failure | execution_failed | collection_failed | +| Dispatched but cannot be collected/delivered | abandoned | unknown or a directly observed allowlisted reason | +| Other terminal failure | Corresponding observed failure status | unknown | + +- [ ] Distinguish path semantics before mapping `NotStarted`: `run_planned_auction` already returns successful `OrchestrationResult::no_bid()` for an enabled empty plan. Successful API/SPA empty-plan execution is completed with zero providers/no candidates; it is not a dispatch failure merely because internal dispatch was NotStarted. Add separate empty-plan API/SPA success and unsuccessful split-dispatch regressions, preserving ordinary response/status behavior and explicit consent/no-slot skips. +- [ ] Treat actual `DispatchAuctionOutcome::DispatchFailed` as dispatch_failed with provider_execution_failed when launch failures prove it, otherwise unknown; do not label NotStarted as a launch failure. Keep no-slot/policy-skipped trace observations independent of whether current telemetry constructs `AuctionObservationContext`. Do not create telemetry rows solely to get a trace identity. Abandoned telemetry's existing clamped duration and unordered lists are not a valid trace projection source; perform separate checked duration conversions. +- [ ] Derive provider statuses/counts and role from actual live responses/pending/disposition. Provider numbers never expose names. Compute per-slot bid counts from returned bids; no provider-to-slot no-bid attribution is inferred. Preserve final `provider_to_slot_no_bid: 'unavailable'`. +- [ ] Test that diagnostics disabled allocates no trace carry/tokens and launches exactly the same provider work. Re-run all four adapter suites after changing shared carries/constructors, plus focused filter/clippy. Planned commit: `Carry trace auction identity and live outcomes across dispatch`. + +### E3: Preserve exact slot associations and API acceptance compatibility + +**Files:** `auction/formats.rs`, `auction/endpoints.rs`, `openrtb.rs`, `trace/auction.rs`, `publisher.rs` request-scoped slot construction. + +- [ ] Write converted-slot mapping tests for grouped multi-bidder units, duplicate codes, skipped non-banner units, filtered inputs, missing/invalid/duplicate token values, duplicate extension members and wrong scalar/object shapes. Ordinary AdRequest success/bids must equal the baseline for every trace-only malformed input. +- [ ] Run `cargo test-fastly trace_slot_conversion`. Extract only `adUnits[].ext.trusted_server.trace_slot_ref` permissively while retaining occurrence validity; do not add a strict serde string field that rejects requests whose unknown extension was previously ignored. Preserve duplicate-member detection with a narrow deserialize visitor if needed; do not reparse with `serde_json::Value` alone and silently lose duplicate evidence. +- [ ] Thread the accepted unit's token alongside the exact unit-to-`AuctionRequest.slots` conversion. One-based `slot_number` uses only definitive post-conversion order, never client response association. Invalid/missing/duplicate tokens get server-generated refs; ordinary input validity is unchanged. +- [ ] Keep trace association in a private sidecar rather than serialized `AdSlot`/provider input. Add bidder and mediator outbound JSON sentinel tests proving `trace_slot_ref`/diagnostic auction tokens never reach their requests, even if supplied as input extensions. +- [ ] For duplicate accepted routing keys preserve distinct instance ordinals/refs. Count actual returned records matching the ordinary key, documenting shared, non-disjoint counts rather than a unique-bid total. Since winner/delivery maps cannot prove an instance-specific disposition, emit `unknown`, omit selected size and never infer an instance/GPT join. Pin this with duplicate-code regressions and viewer wording. +- [ ] Use `OpenRtbResponseConversion` delivered-winner dispositions and actual bid-map inclusion to distinguish selected, selected_unrenderable, no_candidate and unknown. Selection alone is insufficient; malformed dimensions/creative rejection/missing price/cache fallback handling stay unchanged. +- [ ] Add `ext.trusted_server.trace_auction` to the response only under the base gate; preserve existing orchestrator extension, status, seats/bids and headers apart from deliberate private/no-store. Projection failure returns the exact unavailable envelope, no raw error. +- [ ] Run focused tests, `cargo test-fastly auction::endpoints`, native adapter tests, and clippy. Planned commit: `Associate trace slots through auction conversion without changing bids`. + +### E4: Transport SSAT and SPA evidence at request-scoped seams + +**Files:** `publisher.rs`, trace carry/projection, existing HTML processing; JS scheduler/SPA hooks in E6. + +- [ ] Write initial-navigation and both canonical/legacy page-bids tests for matching slot tokens, single shared opportunity/evidence auction token, every terminal outcome, zero bids, context gate suppression, and projection failure with unchanged normal bids. +- [ ] Run `cargo test-fastly trace_transport`. Add tokens to exact request-scoped slot definitions while constructing the corresponding auction slots. Do not add random tokens inside generic `build_slot_json` when it is called for shared templates; use an optional request-scoped argument/map, leaving template output token-free. +- [ ] Extend `build_seam_script`/held-tail path to serialize the optional transport with the existing script-safe JSON method and call `scheduleInitialAdInit(bids, slots?, traceAuctionTransport?)`. Record available failure/skip evidence even when no winning bid exists. If tail injection never reaches the browser, do not fabricate a failure marker; collector remains not_observed absent other evidence. +- [ ] Add optional top-level `trace_auction` next to `slots` and `bids` in both `/_ts/page-bids` and `/__ts/page-bids`. Strip both envelope and slot extensions when gate=false; base gate applies without navigation eligibility. Initial documents additionally require effective navigation decision. +- [ ] Assert current GPT-only marker behavior when trace flag=false. When active, reuse the pre-dispatch trace token in the existing opportunity marker instead of minting another token at collection. No candidate/failed auctions still carry the same identity when deliverable. +- [ ] Mark every evidence-bearing response terminal private/no-store. Test late operator/cache overrides, ESI/shared-template absence, malformed HTML/serialization fail-open and old scheduler that ignores the third arg. +- [ ] Re-run focused Rust tests, all four adapters and cache guard regression; expected unchanged baseline bids/slots/render status except optional gated public members. Planned commit: `Carry live trace evidence through initial and SPA ad responses`. + +### E5: Install one gated browser collector and instrument direct API requests + +**Files:** JS trace runtime/collector/validation, core facade/globals, `core/auction.ts`, `core/request.ts`, collector/core tests. + +- [ ] Write gate tests with missing/false/string/number globals while GPT diagnostics=true: no token generation, listeners, unit mappings, pending records, collector facade activation, or storage access. Test repeated integration IIFE installs share one active facade, not separate imported singletons. +- [ ] From `crates/trusted-server-js/lib`, run `npx vitest run test/trace/collector.test.ts test/core/auction.test.ts test/core/request.test.ts`. Expected red. Instantiate the collector once through a typed `window.tsjs` facade only behind the strict literal gate. Pure validation imports are allowed; import-time trace side effects are not. +- [ ] Accept immutable validated transport before normal bid parsing. Absent optional member adds no issue and never resets earlier records/issues; malformed supplied member adds evidence_validation_failed; supplied unavailable marker adds evidence_projection_failed. Direct non-OK/unreadable JSON/rejected fetch paths add evidence_transport_failed without retaining errors/bodies. +- [ ] Retain newest 16 server records/newest 128 sidecars, stable arrival/emission order and checked omission counters. Server eviction adds record_evicted; sidecar-only eviction adds correlation_unavailable. No sessionStorage writes during collection. +- [ ] Implement capture status independently of interpretation issues: + +```typescript +type CaptureStatus = 'complete' | 'partial' | 'unavailable' | 'not_observed' + +function captureStatus( + recordCount: number, + issues: readonly string[] +): CaptureStatus { + const hasCaptureIssue = issues.some((issue) => + [ + 'evidence_projection_failed', + 'evidence_transport_failed', + 'evidence_validation_failed', + 'record_evicted', + ].includes(issue) + ) + if (recordCount > 0) return hasCaptureIssue ? 'partial' : 'complete' + return hasCaptureIssue ? 'unavailable' : 'not_observed' +} +``` + +- [ ] Deduplicate/sort the six exact issue enums in their documented order, max 16. `correlation_unavailable` and `external_client_side_unobservable` never change capture status. Mark external refresh limits only when the existing browser source actually observes them. +- [ ] Assign `crypto.randomUUID()` refs after `buildAdRequest` groups/deduplicates final units, then keep the exact request-scoped token-to-unit mapping through `sendAuction`. No Web Crypto means no client refs; ordinary request still runs. Validate echoed tokens/association without indices/raw unit codes in public evidence; record API evidence with correlation_unavailable and no GPT opportunity/sidecar. +- [ ] Add throwing token-generator and collector-callback fixtures for both actual `/auction` callers and the scheduler/SPA boundary. Assert the ordinary request, ad initialization, bid acceptance/order and targeting still complete unchanged; diagnostic failures remain bounded and never escape into advertising. +- [ ] Re-run focused tests; add unchanged bid-result assertions on absent, invalid and failed evidence. Planned commit: `Collect gated trace evidence without changing direct auction delivery`. + +### E6: Emit sidecars only at the existing SSAT/SPA recorder binding + +**Files:** `core/types.ts`, `integrations/gpt/index.ts`, diagnostics store/API/index, existing scheduler/SPA/recorder tests. + +- [ ] Write initial scheduler third-argument and SPA parser tests: validate/record before adInit, absent optional member leaves old behavior, malformed transport still initializes ads, legacy retry preserves transport, stale/superseded SPA result does not join a current cycle. +- [ ] Run `npx vitest run test/integrations/gpt/schedule_initial_ad_init.test.ts test/integrations/gpt/spa_hook.test.ts test/integrations/gpt/ad_init.test.ts`. Add optional AuctionSlot.ext and the exact transport argument/member from spec; do not spread arbitrary extensions into trace objects. +- [ ] Validate a delivered slot ref occurs exactly once in both delivered slots and matching validated auction slots before associating it with the concrete slot object. Missing/malformed/duplicate/conflicting refs affect only correlation: preserve slot/bid order/content, add correlation_unavailable, and keep complete server capture. Absent optional envelope does not start token validation. +- [ ] Add an optional trace identity parameter to the existing opportunity forwarding and a trace-only store callback. Keep the current GPT export type unchanged. Pass trace identities only for validated SSAT/SPA opportunities; preserve existing attribution/source selection logic. +- [ ] Emit `TraceSlotCorrelationV1` inside `GptDiagnosticsStore.recordSlotRequested` after `consumeRequestIntent` selects evidence and concrete runtimeSlotNumber/requestNumber exist. It contains only schema/version, two tokens, two positive safe integers. No second attribution engine, timestamps, raw DOM/ad-unit IDs or best-effort joins. +- [ ] Write recorder tests for candidate/no-candidate/unrenderable, expired opportunities, competing sources, ambiguous/no concrete cycles, repeated callbacks, exact token equality and type assertions that GptDiagnosticsExportV1 has no new fields. Inject a throwing trace-sidecar callback and assert the recorder still completes ordinary binding and public GPT export unchanged. API evidence cannot call this binding. +- [ ] Run `npx vitest run test/integrations/gpt test/integrations/gpt_diagnostics test/trace/collector.test.ts`. Expected green, including source export type gate. Planned commit: `Record exact SSAT and SPA trace sidecars at GPT cycle binding`. + +### E7: Consume Prebid transport exactly once through real registered hooks + +**Files:** Prebid integration, `trace/pending.ts`, pending/Prebid tests and real artifact test. + +- [ ] Write buildRequests/interpretResponse/onTimeout/onBidderError tests keyed by original bid IDs and final grouped unit refs, including multi-unit outcomes, concurrent requests, duplicate callbacks, absent response extension, malformed evidence and server-gate absence. Pending refs are request-local, not a global current request. +- [ ] Run `npx vitest run test/trace/pending.test.ts test/integrations/prebid/index.test.ts`. Add trace-only optional hooks to the actual spec passed to `pbjs.registerBidAdapter(undefined, ADAPTER_CODE, spec)` only when trace is active. Preserve all ordinary bidder spec behavior when inactive. +- [ ] Create a pending record after final buildRequests with maximum 128 retained entries. interpretResponse consumes before bids; `onTimeout(bidRequestsWithTimeout)` and `onBidderError({ error, bidderRequest })` consume the same matching pending record and add transport failure exactly once. Never store error text/XHR responses; capped/expired records without supported outcome hooks invent no failure. +- [ ] Implement and test this checked expiry helper; capture effective browser bidderTimeout once per record, unrelated to server `[auction]` timers: + +```typescript +const MAX_CAPTURED_BIDDER_TIMEOUT = 2 ** 31 - 1 - 5000 + +function pendingExpiry( + createdAtMs: number, + configuredTimeout: unknown +): number | undefined { + if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0) return undefined + const timeout = + typeof configuredTimeout === 'number' && + Number.isInteger(configuredTimeout) && + configuredTimeout >= 0 && + configuredTimeout <= MAX_CAPTURED_BIDDER_TIMEOUT + ? configuredTimeout + : 3000 + const expiresAtMs = createdAtMs + timeout + 5000 + return Number.isSafeInteger(expiresAtMs) ? expiresAtMs : undefined +} +``` + +- [ ] Use an integer wall-clock `Date.now()` (or its injected test clock) for record creation and read `pbjs.getConfig('bidderTimeout')` at creation. Configured `merged.timeout` already supplies that browser setting. Use a bounded timer delay of captured timeout+5000; future config changes do not alter existing expiry. Clock/checked-add failure declines only that pending attempt with no capture issue or reset; expiry alone removes the marker and remains not_observed. +- [ ] Cover configured 0/normal/max, missing/negative/fractional/NaN/infinite/string/over-max values, default 3000, safe-integer clock overflow, callback after expiry, cap eviction, timer cleanup on runtime destruction and repeated hooks. Use fake timers, not wall-clock waiting. +- [ ] Inject throwing token-generation/collector hooks in the registered Prebid path and prove bid callbacks/results remain unchanged. Measure the built shim against its existing 41,000-character guard in `test/prebid-artifact-integration.test.mjs`; if trace code requires an increase, record the measured before/after size and justify a narrow new bound while preserving the Prebid-free regression guard. +- [ ] Extend `test/prebid-artifact-integration.test.mjs` using its real external bundle+shim setup. Drive real adapterManager timeout/error callbacks and prove `registerBidAdapter` → `newBidder` → registered spec hooks. Do not stop at calling mock spec methods directly. +- [ ] Run `npx vitest run test/trace/pending.test.ts test/integrations/prebid/index.test.ts test/prebid-artifact-integration.test.mjs`, full JS tests/build/format, and affected Rust adapter checks. Verify API evidence stays independent and neither hook emits GPT sidecars or `trusted_server_direct`. Planned commit: `Capture Prebid trace transport through registered timeout and error hooks`. + +### Review-unit verification and handoff + +- [ ] Extend dedicated foundation browser fixtures with initial/SPA/API normal, zero-bid and failure results, token conversion filtering, old bundles, malformed members, legacy page-bids retry, disabled cookie-bearing traffic and fail-open ad assertions. The fixture must exercise actual built integration bundles, not fabricated evidence alone. +- [ ] Run `./scripts/integration-tests-browser.sh tests/shared/mobile-trace.spec.ts`, focused evidence tests and affected-target build/clippy/docs checks; V7 owns the single full release gate run. Expected: advertising assertions stay identical to baseline; all evidence/privacy/token cases pass. +- [ ] Search serialized trace fixture output for distinct forbidden sentinels covering IDs, names, prices, payloads, consent and raw errors. Assert no trace-specific logs contain sentinels or cookie/network values. +- [ ] Review deterministic provider numbering and every terminal path centrally; preserve existing telemetry tests without adopting its private fields or saturation rules. Planned commit: `Verify live trace transports and advertising compatibility`. +- [ ] Checkpoint evidence collection as memory-only, then continue V1. User snapshot/storage/viewer behavior remains phase 3; do not imply retained observations already survive navigation. + +## Phase 3: Browser handoff and viewer + +### Inputs and scope + +Depends on foundation tasks F0–F8 and live-evidence tasks E1–E7. Read [spec](../specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md) sections 6, 9.1–9.5, 10, 12, 13, 14.3–14.5, and 16. Follow the shared verification's same-branch and verification rules. + +Use `window.tsjs.gptDiagnostics.snapshot()` (the public API in `integrations/gpt_diagnostics/api.ts`) as the only GPT input. No private-store export traversal, second GPT attribution engine, server upload, telemetry query, target URL, report ID, or new UI dependency. + +### File map + +| Action | Exact path | Responsibility | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Modify | `crates/trusted-server-js/lib/src/trace/types.ts`, `crates/trusted-server-js/lib/src/trace/validation.ts`, `crates/trusted-server-js/lib/src/trace/runtime.ts` | Complete trace/report/storage types and strict ingestion | +| Create | `crates/trusted-server-js/lib/src/trace/projection.ts` | Exhaustive explicit GPT source-to-public projection | +| Create | `crates/trusted-server-js/lib/src/trace/report.ts` | Snapshot assembly, checked omissions and deterministic bounds | +| Create | `crates/trusted-server-js/lib/src/trace/storage.ts` | Single key, exact wrapper, age/size/origin validation and independent deletion | +| Create | `crates/trusted-server-js/lib/src/trace/export.ts` | One validated formatted artifact for copy/download/share | +| Create | `crates/trusted-server-js/lib/src/trace/correlation.ts` | Exact-token joins and explicit ambiguous/unmatched results | +| Create | `crates/trusted-server-js/lib/src/trace/handoff.ts` | Explicit capture → validate → write → same-tab navigation and recovery | +| Modify | `crates/trusted-server-js/lib/src/trace/viewer.ts`, `crates/trusted-server-js/lib/src/trace/viewer.css`, `crates/trusted-server-js/lib/src/trace/lifecycle.ts` | Full report DOM/accessibility and independent cleanup/state verification | +| Modify | `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts`, `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts` | Optional gated prominent handoff action; keep old GPT export intact | +| Create | `crates/trusted-server-js/lib/test/trace/fixtures.ts`, `crates/trusted-server-js/lib/test/trace/types.test.ts`, `crates/trusted-server-js/lib/test/trace/validation.test.ts`, `crates/trusted-server-js/lib/test/trace/projection.test.ts`, `crates/trusted-server-js/lib/test/trace/report.test.ts`, `crates/trusted-server-js/lib/test/trace/storage.test.ts`, `crates/trusted-server-js/lib/test/trace/export.test.ts`, `crates/trusted-server-js/lib/test/trace/correlation.test.ts`, `crates/trusted-server-js/lib/test/trace/handoff.test.ts`, `crates/trusted-server-js/lib/test/trace/viewer.test.ts` | Complete source and hostile/bounded fixtures, type/runtime/DOM/action tests | +| Modify | `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts`, `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts` | Publisher action gating/presentation compatibility | +| Modify | `crates/trusted-server-js/lib/trace-assets-manifest.json`, `crates/trusted-server-js/lib/test/trace-assets.test.mjs`, `crates/trusted-server-js/lib/build-all.mjs`; `crates/trusted-server-js/src/trace_assets.rs`; `crates/trusted-server-core/src/trace/routes.rs`, `crates/trusted-server-core/src/trace/shell.rs` | Complete unpublished v1 asset set/shell lookup; preserve byte contracts after first publication | +| Modify | `crates/trusted-server-js/lib/trace-assets/v1.js`, `crates/trusted-server-js/lib/trace-assets/v1.css` | Finalize the complete unpublished v1 bytes created in F6 before first release | +| Modify | `crates/trusted-server-integration-tests/browser/tests/shared/mobile-trace.spec.ts`, `crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts`, `crates/trusted-server-integration-tests/browser/helpers/state.ts`, `crates/trusted-server-integration-tests/browser/helpers/infra.ts`, `crates/trusted-server-integration-tests/browser/global-setup.ts`, `crates/trusted-server-integration-tests/browser/global-teardown.ts`, `crates/trusted-server-integration-tests/browser/playwright.config.ts` | Dedicated trace runtime and realistic server→GPT→creative fixtures | +| Modify | `crates/trusted-server-integration-tests/browser/helpers/trace-fixture.ts`, `crates/trusted-server-integration-tests/fixtures/configs/trusted-server.trace.toml` | Extend foundation runtime/settings with live auction cases | +| Create | `crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/mobile-trace/page.tsx` | Real publisher slot fixture for the complete viewer journey | +| Modify | `scripts/integration-tests-browser.sh`, `scripts/generate-integration-viceroy-configs.sh`, `.github/workflows/integration-tests.yml` | Build/install any new trace fixture dependencies and browser projects consistently | +| Modify | `docs/guide/integrations/gpt-diagnostics.md`, `docs/guide/configuration.md` | Full support journey, trust/privacy limits and rollout | + +`test/trace/types.test.ts` joins the existing Vitest typecheck include. Preserve its scoped source-error behavior; do not claim package-wide `tsc --noEmit` passes because Vitest passes. + +### V1: Define exact report ingestion and classify every GPT source member + +**Files:** trace types/validation/fixtures/type tests; source interface reference `src/core/types.ts`. + +- [ ] Create a complete fictional source fixture exercising every current `GptDiagnosticsExportV1` property, every request-cycle optional value, all callback/attribution/coverage metadata and all relevant enums. Use distinct forbidden sentinels for every `adManager` member, previousCreativeId, slotElementId, adUnitPath, issue slotElementId and a secret-bearing example.com pathname. +- [ ] Write type-level member coverage using `satisfies Record` and equivalent checks for the source root, slot, issues, coverage counters, metadata and nested durations/binding. All current keys must be classified and no later source key passes automatically. Source additions fail typecheck until classified. +- [ ] From `crates/trusted-server-js/lib`, run `npx vitest run test/trace/types.test.ts test/trace/validation.test.ts`. Expected red, including a real type assertion failure when a field is deliberately unclassified. +- [ ] Define only the compatibility matrix `TraceReportV1`, `TraceAuctionEvidenceV1`, `TraceSlotCorrelationV1`, `TraceGptDiagnosticsV1` and source GPT v1. Unknown versions at any boundary fail with an actionable bounded category. All stored objects have exact allowed/required keys, not optional passthrough fields. +- [ ] Implement request context/CookieHealth validators from foundation contracts. Validate masked addresses as the chosen /24 or /48 display representation, not arbitrary full IP strings; only canonical HTTP(S) origins equal to location.origin are accepted, with no credentials/path/query/fragment. Validate real UTC RFC3339 calendar times without permissive Date normalization. +- [ ] Propagate runtime_header_ambiguous through the exact TypeScript CookieHealthDetail union and setup/context/TraceReportV1 validation. It is legal only with unavailable; test wrong state/detail pairs and unknown reason rejection. Retain supported schema versions and bounds; expose no fidelity metadata or original header values. +- [ ] Implement exact GPT request-cycle allowlist below, with source optionality and enum membership from the current interfaces. Never accept adManager/previousCreativeId/slot or issue DOM IDs/ad-unit paths. The viewer accepts only pathname=`/[redacted]`. + +```typescript +const TRACE_CYCLE_KEYS = [ + 'requestNumber', + 'requestedAtMs', + 'responseAtMs', + 'renderAtMs', + 'loadAtMs', + 'viewableAtMs', + 'durations', + 'isEmpty', + 'requestedSlotSizes', + 'size', + 'observedSlotSize', + 'isBackfill', + 'slotContentChanged', + 'incompleteSequence', + 'responseClass', + 'requestPath', + 'requestIntentId', + 'trustedServerAuctionId', + 'opportunityToRequestMs', + 'replacedRequestNumber', + 'previousRenderToRequestMs', + 'creativeChanged', + 'loadObservedBeforeRender', + 'trustedServerOpportunity', + 'trustedServerCreativeRequestAtMs', + 'trustedServerCreativeResponseAtMs', + 'trustedServerCreativeFailures', + 'delivery', +] as const + +const CALLBACK_REASONS = [ + 'invalid_event_order', + 'missing_response_before_render', + 'invalid_visibility_percentage', + 'evicted_slot', + 'no_compatible_request_cycle', + 'overlapping_request_cycles', +] as const +``` + +- [ ] Validate remaining exact objects: root GPT schema/source version/capturedAt/page/slots/callbackIssues/attributionIssues/coverage/metadata; slot runtimeSlotNumber/binding/current+max visibility/requests; binding status/reason; five duration members; callback kind/runtimeSlotNumber/timestampMs/disposition/reason; attribution reason/timestampMs/runtimeSlotNumber; six callback coverage keys with observed/matched/unmatched/ambiguous; metadata droppedCallbacks/droppedAttributionIssues/evictedSlots/evictedRequestCycles. +- [ ] Apply all bounds: 64 GPT slots, ≤10 cycles/slot, 128 callback/attribution issues, ≤16 sizes/failure enums, origin ≤255 bytes, other allowed strings ≤128 bytes, exact tokens, finite nonnegative safe integer sequences/counters, browser timestamp/duration finite 0..MAX_SAFE_INTEGER, visibility 0..100, positive integer requested/selected/fill dimensions ≤100000; observed CSS dimensions integer 0..100000 including zeros. Outer/inner u16 omission counters retain their stricter bound. +- [ ] Count nested containers from report=1, not wrapper; objects and arrays each increment, max 10. Reject level 11, unknown properties/prototype keys, unpaired surrogates, C0/C1 and bidi override/isolate controls before any DOM/export. Validate wrapper separately as exactly stored_at_ms/report; precheck compact UTF-8 bytes before rendering/JSON parsing where input is serialized. +- [ ] Add one test per bound edge, forbidden member, enum/version/extra-key boundary, Unicode/control class, true complete-depth fixture, numeric overflow and zero observed dimensions. Re-run the filtered suites/type gate. Planned commit: `Define strict combined trace report validation`. + +### V2: Project the public GPT model and implement deterministic report bounds + +**Files:** projection/report/fixtures and their tests; collector interface from phase 2. + +- [ ] Write field-by-field projection tests for the complete fixture. Expected root transforms: source `version`→source_schema_version=1, outer schema_version=1, source capturedAt retained, canonical origin retained, pathname replaced with literal. Every forbidden sentinel must be absent; no source object spreads/dynamic property inheritance. +- [ ] Run `npx vitest run test/trace/projection.test.ts`. Implement a new owned projection with explicit assignments for all V1-classified copied fields, checked source values and new nested objects/arrays. Invalid source values reject snapshot creation, not stringify/coerce/truncate strings. Mutating source after capture cannot mutate the projected report. +- [ ] Validate exact source container/key/own-data contracts and every value consumed by the projection. Never inspect excluded identity contents; their malformed contents cannot contaminate the report. Require the source pathname to be an own string, then replace it without reading its content or imposing a raw-path cap. Reject source arrays beyond their existing 64-slot/10-cycle/128-issue bounds; validate all supplied bounded sizes/failure enums before retaining the first sixteen and counting omissions. +- [ ] Write table tests for initial cardinality projection: newest 16 server records/128 sidecars, first 16 requested sizes/failure enums, ≤64 slots/10 cycles/128 issue records as defined by the source contract. Merge collector auction/sidecar evictions into outer omission counters exactly once; retain existing GPT metadata as its source meaning, not an extra outer omission count. Reject invalid inner server evidence rather than re-truncating it. +- [ ] Add the checked counter primitive and use it for every initial discard, later cycle/issue/auction/sidecar removal and optional invalid numeric discard from core: + +```typescript +function addOmissions(current: number, added: number): number { + if ( + !Number.isInteger(current) || + !Number.isInteger(added) || + current < 0 || + added < 0 || + current > 65535 || + added > 65535 - current + ) { + throw new Error('omission_counter_overflow') + } + return current + added +} +``` + +- [ ] Define stored_at_ms from the capture clock independently of source content. Outer report captured_at and GPT capturedAt must be within 60 seconds of that clock; request_context.captured_at may be older and is not overwritten by setup/time data. Validate the complete compact wrapper with `TextEncoder`, limit 512\*1024 bytes. +- [ ] Write truncation-order tests with deterministic IDs/timestamps: missing requestedAtMs sorts first; otherwise cycle order is requestedAtMs/runtimeSlotNumber/requestNumber. Callback/attribution order is timestampMs then original index. Array output retains original relative order after removals; server/provider/slot and sidecar observation orders remain stable. +- [ ] Implement the complete removal sequence, measuring the whole compact wrapper after each eligible removal: + 1. Protect each GPT slot's newest retained cycle for the entire algorithm. + 2. Remove globally oldest non-floor GPT cycles. Remove/count every associated sidecar in the same operation; never retain a claimed dangling join. + 3. Remove oldest callback issues, then oldest attribution issues. + 4. Remove oldest uncorrelated server auctions. Treat any remaining GPT-cycle auction-token reference as correlated even if no sidecar validates; do not orphan that cycle just because the join is unknown. Remove/count all referencing sidecars with the auction. + 5. Remove oldest eligible correlated server auctions together with all retained cycles that reference them and their sidecars. Skip any auction referenced by a protected floor cycle. Eligibility must preserve every GPT slot's newest cycle; no stage can empty a slot that started with cycles. + 6. Recompute capture_status/issues after each auction removal; auction omission adds record_evicted, sidecar-only omission adds correlation_unavailable. Deduplicate/sort issues and recalculate compact wrapper bytes including changed counters/issues. + 7. If still oversized after all eligible removals, fail with a bounded snapshot-size category. Do not remove the protected floor, return an empty GPT section, store, navigate, or offer a combined-report fallback. + +- [ ] Add worst-case successful ≤512 KiB fixture with all source fields populated, multi-byte strings and exact six outer counters; counter-overflow fixture; full server eviction→unavailable/record_evicted; partial eviction→partial; sidecar-only eviction preserves complete; protected-floor+auction set >budget rejects. Use bounded valid model construction or a test-only injected smaller budget to exercise deterministic branches, plus an actual 512 KiB worst-case fixture. Do not weaken production v1 bounds. +- [ ] Include runtime_header_ambiguous in valid worst-case request-context/cookie fixtures and reassert exact encoded size, truncation/protected-floor behavior and existing depth/string bounds. Adding a valid reason does not authorize expanding the report envelope. +- [ ] Run `npx vitest run test/trace/projection.test.ts test/trace/report.test.ts test/trace/collector.test.ts`. Expected green and exact byte/counter assertions. Planned commit: `Project and deterministically bound combined trace snapshots`. + +### V3: Store one validated report and export the identical model + +**Files:** storage/export and unit tests. + +- [ ] Write storage tests for one namespaced versioned key `trusted-server.trace.report.v1`; exact `{ stored_at_ms, report }` wrapper; replacement; absent/get/set/remove exceptions; malformed JSON; future, rollback and expiry; origin mismatch; hostile keys; oversized compact UTF-8 input. No storage write occurs during observation or viewer load. +- [ ] Run `npx vitest run test/trace/storage.test.ts`. Implement explicit read/write/delete results with bounded categories. Validate before setItem and after every getItem; remove rejected/expired entries best-effort, ignore them even when deletion fails. Enforce finite nonnegative safe stored_at_ms, max age 15 minutes, >60-second future skew rejection, capture-time consistency. Exactly 15 minutes remains within the age cap; older expires. +- [ ] Write export tests requiring the same TraceReportV1 bytes/model for download/copy/share/direct storage-failure recovery. Export the report, not private source objects or a page URL; use deterministic filename `trusted-server-trace-v1.json`, MIME `application/json`, formatted JSON and visible browser-carried/unverified labeling in the UI. Do not add an unknown provenance field to the approved schema. +- [ ] Pin unavailable/runtime_header_ambiguous through storage roundtrip, download, formatted copy, shared File and direct export. Wrong state/detail pairs reject on ingestion; every accepted output retains the same reason and no raw values. +- [ ] Implement one `formatTraceReport(report)` after validation used by all actions. Copy invokes clipboard only on explicit user action. Share supplies a `File` with the same formatted JSON only after `navigator.canShare({ files })` support and a tap; cancellation/rejection/absence leaves Copy and Download available with status. Never fall back to URL share/upload. +- [ ] Implement the download primitive with independent deferred URL cleanup: + +```typescript +function downloadJson(json: string, filename: string): void { + const url = URL.createObjectURL( + new Blob([json], { type: 'application/json' }) + ) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.append(anchor) + try { + anchor.click() + } finally { + anchor.remove() + window.setTimeout(() => URL.revokeObjectURL(url), 1000) + } +} +``` + +- [ ] Use fake timers/mocked URLs to assert click before scheduling, no synchronous revoke, each repeated download owns its URL, exact 1000 ms cleanup and accessible failure reporting. Do not change the pre-existing GPT-only export's synchronous cleanup in `api.ts`; that is outside this feature. +- [ ] Re-run storage/export tests. Expected: failure preserves visible valid report, no report upload/continuous storage. Planned commit: `Add validated trace storage and equivalent local exports`. + +### V4: Join exact evidence without inventing winners or mixing clocks + +**Files:** correlation, tests and renderer-facing result types. + +- [ ] Write one valid SSAT and one SPA fixture joining exact diagnostic_auction_id/slot_ref and runtime_slot_number/request_number. Include no_candidate, unrenderable, empty/filled, creative bridge render/load/viewability and conflicting browser refresh paths. +- [ ] Run `npx vitest run test/trace/correlation.test.ts`. Implement joins only when one validated sidecar uniquely matches one server slot and one exported GPT cycle with consistent auction token. Runtime numbers/slot ordinals alone, timestamps, ad-unit/DOM paths and implicit array position never join. +- [ ] Add unmatched/duplicate/conflicting/forged/missing token and evicted-cycle fixtures. Keep structurally valid ambiguous records independent and show Correlation unknown; never choose a best match or assert no auction ran. Prune known dangling joins during builder truncation, not by fabricating replacement sidecars. +- [ ] Reject any stored sidecar referencing an auction_api record: unsupported in v1. API evidence displays independently with correlation_unavailable, retains complete server capture when otherwise valid, and never changes prebid_refresh to trusted_server_direct/competing. +- [ ] Map labels exactly: SSAT initial-page server auction, SPA Trusted Server page-refresh auction, API Trusted Server auction API; publisher/prebid_refresh=`Browser refresh observed; winner not determined`; competing/unattributed=`Multiple or unknown delivery paths`. Show `Trusted Server creative rendered` only when matched existing nonempty creative-bridge evidence proves participation, not candidate selection or GPT fill alone. +- [ ] Present server auction-local duration, request-relative milestones=`Unavailable in v1`, and browser/GPT timings in separate groups. Never subtract/add timestamps across clocks. Provider status remains auction-wide, not a per-slot no-bid reason. +- [ ] Re-run tests and assert source models are unchanged by joining. Planned commit: `Join exact trace evidence with explicit coverage and ambiguity`. + +### V5: Add the explicit publisher handoff and storage-failure recovery + +**Files:** handoff/runtime, diagnostics index/overlay, handoff/overlay/index tests. + +- [ ] Write gated action tests: missing/false/nonboolean trace flag with console=true exposes no action, listeners, mappings, storage reads/writes; active=true supplies one prominent `View trace results` action with ≥44 px target. Existing GPT-only export remains separate. +- [ ] Run `npx vitest run test/trace/handoff.test.ts test/integrations/gpt_diagnostics/overlay.test.ts test/integrations/gpt_diagnostics/index.test.ts`. Add an optional overlay callback/options entry rather than rewriting the overlay or moving its internals into the trace viewer. +- [ ] On tap, obtain public GPT snapshot, immutable request context and collector snapshot; build/project/bound/validate the combined wrapper, then setItem, then `location.assign('/_ts/trace')`. No continuous persistence, new tab, URL payload, target URL, or extra diagnostic network request. +- [ ] If context/projection/validation/bounds fail, keep publisher page, announce bounded capture failure, do not navigate/write or offer a combined artifact. A GPT-only export is not an equivalent fallback. +- [ ] If only storage write fails after a valid report exists, keep publisher page, announce storage failure, retain that immutable combined report for an explicit direct Download action using V3. Navigation never happens after rejected storage. Destroy retry listeners/recovery state with the diagnostics runtime. +- [ ] Test exact action order, no navigation before successful write, snapshot source mutation isolation, serialization errors, quota/unavailable storage and direct-download equality. Re-run focused tests plus all GPT diagnostics tests. Planned commit: `Add explicit same-tab mobile trace handoff and recovery`. + +### V6: Render the mobile report and independent cleanup outcomes + +**Files:** viewer/styles/lifecycle, viewer/setup/export tests, Rust shell/routes and immutable asset metadata. + +- [ ] Write DOM tests for no report→setup, rejected/expired report→reproduce message, valid report→summary/network/cookies/server/GPT/coverage/export/cleanup. Setup-request facts stay separately labeled and never fill missing publisher facts. +- [ ] Run `npx vitest run test/trace/viewer.test.ts test/trace/setup.test.ts test/trace/lifecycle.test.ts`. Render only the validated bounded model with `createElement`, safe attributes and textContent. No report innerHTML, inline style attributes/blocks or JS style writes under CSP; use classes/hidden. +- [ ] Add browser-carried/unverified heading and per-entry server-produced/copied-through-browser versus browser-observed provenance. All unavailable fields/states/ambiguities have plain text labels; no winner inference, exact page/path, or provider identity appears. Preserve zero observed box sizes without changing isEmpty. +- [ ] Render runtime_header_ambiguous as cookies unavailable for reliable inspection, never as actual invalid UTF-8 or absence. Cover all four affected cookie rows and retain export equality; no parsing detail or runtime fidelity metadata is displayed. +- [ ] Add formatted Copy, deterministic Download, and progressive file Share from V3. Status is aria-live; errors retain report. Disclosure says the selected share app receives the JSON. Keyboard focus and visible focus styles work independently of color/hover. +- [ ] Add `Clear report and end tracing` with explicit confirmation and distinct always-available `Delete local report`. After confirmation, attempt local deletion and end POST independently even if either fails; after POST attempt state verification independently. Successful deletion removes on-screen saved-report claims; failed deletion retains retry; failed/mismatched server observation uses unconfirmed/may remain active wording and independent retry. Never undo deletion because network failed. +- [ ] Cover the entire local-delete × mutation × state-verification outcome matrix with explicit UI assertions, including offline, successful mutation/unconfirmed observation, inactive-invalid/duplicate cookie, successful server end/failed deletion and retries. A false observed_active says no valid session observed, not cookie absent. +- [ ] Implement 320 px full-document layout with ≥44×44 primary controls, no horizontal page scroll, content-safe action placement, semantic headings/lists, zoom enabled, safe-area insets and readable long values. Tests check no content is covered by controls and report sections remain navigable by keyboard. +- [ ] Finalize the existing unpublished v1 JS/CSS bytes and committed digests with the complete viewer. Keep the exact v1 routes and shell references from F2/F6. At the V7 release checkpoint freeze this asset set; tests reject any later changed published bytes without a new URL/digest. No intermediate setup asset version is published as part of this plan. +- [ ] Run `node build-all.mjs`, `npx vitest run test/trace-assets.test.mjs`, `npx vitest run test/trace`, `cargo test-fastly trace::routes`, adapter parity and format checks. Expected: manifest pins every retained frozen version and current rebuilt bytes; shell references current URLs; altered bytes fail without a new version. Planned commit: `Add accessible consolidated mobile trace viewer and independent cleanup`. + +### V7: Prove the complete journey with real endpoints and built bundles + +**Files:** dedicated browser fixtures/helper/config/spec and build/CI paths in the file map; operator docs. + +- [ ] Extend the foundation trace suite with a dedicated enabled config/runtime and real publisher slots; keep existing baseline config unchanged (it disables GPT/auction). Build images/artifacts through existing scripts/global setup. The runner executes both Next.js and WordPress: explicitly scope the Next.js-only publisher journey with the existing framework setting, or add equivalent WordPress content; run shared endpoint/setup scenarios against both. Generate the dedicated trace Viceroy config from the trace TOML through F8’s parameterized generator, never reuse the disabled baseline output. The existing observer-only GPT stub has no working defineSlot/refresh and cannot prove the chain; implement realistic callbacks in the dedicated fixture or extend the helper without changing existing test behavior. +- [ ] Write browser test: open publisher → navigate trace → assert read-only/no cookie → tap enable → separate state confirms → history Back → explicit real reload → collect SSAT and multiple GPT cycles → tap handoff → same-tab viewer → parse export and compare every displayed model section. Include SPA canonical/legacy page-bids and both actual `/auction` callers. +- [ ] Execute normal safe-cookie setup/enable/state/reload/capture/view/export/end/state across all four adapter fixtures on suitable same-origin deployments. Unknown fidelity alone must not suppress capture. Assert exact privacy/action/session contracts while documenting runtime rejection separately. Browser tests must prove cookie storage/observation, not inject a synthetic active-cookie header and call it a browser journey. Record unsuitable origins or unavailable runtime/browser environments as pending acceptance. +- [ ] Add raw-transport ambiguity fixtures alongside the browser workflow: valid session plus unrelated FF, original EF BF BD, comma-folded repeats and visible duplicates. For converted ambiguous requests assert state false, no trace tokens/evidence/sidecars, ordinary advertising unchanged, exact CookieHealth reason in setup/capture where observable, and valid end remains possible. Runtime-rejected requests produce no invented report. Normalization-in/out and encoded-alias auth tests follow F2/F4's actual visible path. +- [ ] Run `./scripts/integration-tests-browser.sh tests/shared/mobile-trace.spec.ts` from root. Expected red for missing journey fixtures/behavior, not artifact or Docker setup failure. Then fill the concrete fixture behavior and re-run after each scenario group. +- [ ] Add initial-navigation fixtures for selected, no-candidate, selected-unrenderable, skipped, dispatch-failed, execution-failed and abandoned evidence where delivery is possible; stopped/unreached tail remains not_observed. Test correlated SSAT/SPA chain, API independent, browser/client intent unknown, empty/ambiguous/unattributed states and malformed/absent transport with unchanged ads. +- [ ] Test genuine second-origin top-level GET, form and fetch activation/end attempts; configured auth; history unchanged by actions; BFCache guidance does not claim new capture; same-origin service-worker-forged exchange/report remains labeled unverified and real server endpoints still reject invalid requests. +- [ ] Test unexpired viewer reload, replacement, expired/hostile storage removal, removal failure, opener-cloned entry and expiry, restoration simulation, future-clock rollback, origin/hostname changes and same-host recovery instructions. Do not claim real browser restart coverage from a unit fixture; document any manual session-restore check still pending. +- [ ] Under the exact CSP test complete Blob JSON download with 1000 ms cleanup, repeated download, storage-failure direct export, clipboard/share absence/rejection/cancellation, no URL sharing, favicon without publisher `/favicon.ico`, framing/inline/third-party/report-derived injection blocked. Assert no upload/beacon or third-party trace request. +- [ ] Populate forbidden sentinels across the entire source fixture and assert absence in server trace HTML/JSON, stored wrapper, DOM, formatted copy, shared File and both downloads; hostile stored forbidden properties are rejected. Check output model/version equality, not just filename/download event. +- [ ] Add 320 px/mobile viewport and keyboard/focus/status tests to the existing Chromium runner. Keep real iOS Safari and Android Chrome manual acceptance as a separate checklist for zoom, safe areas, native file share/download, layperson instructions and retention of visible report after failures. Any optional automated WebKit project requires matching Playwright install changes in script and CI. +- [ ] Test rollback fresh loads with valid diagnostics cookie and console query activation retained: no trace gate/action/listeners/storage access/tokens/response evidence/new cache change. Already-running pages cannot be remotely revoked; later server requests immediately stop evidence. Previously cached static assets remain inert. +- [ ] Document reproduction/support/export instructions and browser-carried trust limits, no historic recovery, no client winner inference, host-only scope, independent cleanup retries and controlled staging rollout. Mark #1081/#1074/#1076 fields unavailable, not missing implementation. +- [ ] Run the full shared verification gates/builds/docs and the complete browser runner, plus manual mobile checklist. Review final diff and acceptance matrix. Planned commit: `Verify the mobile trace journey and document release acceptance`. +- [ ] Handoff v1 feature only after the first three phases meet all mandatory acceptance criteria; record pending environment/manual checks separately. Publishing/deployment is a subsequent explicit action. + +## Approved review corrections — 2026-10-06 + +The user authorized this focused corrective pass after independent review of +Claude's findings. Keep the same feature branches, spec and plan. + +- [x] Add a gated trace-only initial seam when ordinary ad-slot injection is + withheld. Execute the emitted script and verify evidence is recorded once + without invoking the scheduler, changing bids/slots or setting the ad-init + latch; preserve queue initialization, generation guards and fail-open behavior. +- [x] Remove the redundant unconditional page-bids delivery observation. Retain + the successful branch's definitive delivery observation and verify actual SPA + success/fallback projections. The proposed partial-bid `Err` scenario is not + reachable in current planned production execution; do not fabricate a + legacy-test-only regression or claim a reproduced production truth defect. +- [x] Keep View usable after a navigation does not depart, and retain connected + trace controls/live status across GPT data updates. Add retry/focus regressions. +- [x] Replace the five newly introduced ES2022 test APIs with ES2020-compatible + access, without broadening the existing TypeScript target. +- [x] Correct EdgeZero's per-observation preservation metadata conservatively; + document Fastly Content-Length folding; verify and pin the new upstream commit. +- [x] Clarify mediator-inclusive record counts, asset-manifest maintenance, + publisher CSP compatibility, completed automation versus pending manual + acceptance, and deployed HTTPS Enable/End checks. +- [x] Run target-matched checks and required full gates, independently review + the corrections, and update both existing draft PRs without adding Python. + +### Corrective-pass verification + +Three independent reviewers approved the final corrections with no remaining +actionable findings: publisher/spec alignment, browser controls/types, and +EdgeZero metadata/architecture. EdgeZero commit +`75067d2c9a3cf865591665e88a736db4c8a13be0` replaces the previous reviewed pin +in all six workspace dependencies and eight lockfile sources, without unrelated +dependency changes. Its full workspace gates, adapter contracts and 54 raw +ingress cases pass; all checks on EdgeZero PR #403 pass. + +Trusted Server's current-source format, all eight target-matched clippy gates, +four adapter test suites, host CLI tests and 21 parity cases pass against the new +pin. The host-only emitted-script regression executes real publisher output for +four skip conditions and buffered/streaming finalizers, each with five callback +modes; no scheduler or ad initialization runs and publisher-owned state remains +unchanged. The overlay regressions prove retained focus/live status and retry. +The normal JS and external Prebid builds, formatting, lint and all 1,736 Vitest +tests in 69 files pass. The five new ES2022 test errors are removed; the broader +JS TypeScript check retains unrelated baseline errors. Browser TypeScript +passes using the workspace's existing Node type declarations. Core documentation, +docs lint/format/build and Markdown formatting pass with existing warnings. + +Fresh Fastly, Axum, Cloudflare worker-build and Spin artifacts pass the complete +repository browser runner: Next.js 49 passed with two expected skips; WordPress +28 passed with 23 expected skips. All four separate runtime browser workflows +pass, as do the three raw Cloudflare/Fastly/Spin boundary suites. These verify +local runtime behavior and do not replace physical-device, actual restoration or +deployed HTTPS/CDN/CSP acceptance. Both existing draft PRs receive the corrections; +neither feature branch contains Python files. Frozen standalone v1 asset bytes +remain unchanged by this corrective pass. + +Claude's re-review found no new runtime defects and identified one automated +coverage gap: the ignored host-only emitted-script regression was not selected +by CI. The existing Node-equipped Rust job now runs that exact core library test +with an explicit Linux host target and `--ignored`; other ignored tests remain +excluded. The equivalent command on the macOS host selects one test and passes. +An independent reviewer approved the step with no findings. Workflow validation +passes; the nine pre-existing ShellCheck quoting notices are unchanged. + +All four integration jobs on `b2930d7b9`, including framework browser tests and +four-runtime trace acceptance, have now passed remotely. The unrelated generated +Python CodeQL job fails because this feature branch contains no Python. The +separate CodeQL security gate reported test-fixture alerts; their resolution is +recorded below. Cloudflare's new-pin worker-build artifact and actual local +browser/raw-boundary suites use Trusted Server's resolved worker 0.8.5 and +wasm-bindgen 0.2.126; that consumer runtime check is complete. Deployed-platform +and physical-device acceptance remain separate release gates. + +### CodeQL alert triage + +On 2026-10-06, the user authorized fixing CodeQL findings or dismissing verified +false positives. Two independent read-only reviewers confirmed alerts #196–203 +are confined to fictional authentication fixtures in `#[cfg(test)]` modules or +the parity integration-test target. Alerts #202–203 flag test-only passwords; +alerts #196–201 flag `expect()` calls on `Handler` deserialization. That +deserialization does not invoke the `Settings`/Tinybird secret validation named +in the alleged logging flow. No production secret reaches these fixtures. + +All eight alerts were dismissed individually with GitHub's `used in tests` +reason and per-alert evidence. A fresh API check confirms no open alerts on +`refs/pull/1107/merge` and a successful CodeQL security gate. The earlier +explanation attributing the aggregate gate solely to Python extraction was +incomplete; the extraction error and these alerts are independent. + +The generated `dynamic/github-code-quality/codeql` workflow still attempts Python +analysis on `refs/pull/1107/head` and exits 32 because that head contains no +Python. The default branch contains an unrelated Python browser helper, which +accounts for repository-wide language detection. This is an analysis failure, +not a dismissible vulnerability alert. Reading Code Quality setup returned HTTP +403 with the current maintainer account; configuration changes require a +repository admin. Any Python language-selection change is repository-wide and +must account for that default-branch helper. No scanner rules, source files or +production behavior were weakened to dismiss the test-only alerts. + +Bundle splitting remains a separate load-order/performance design. Preserve +the build-input freshness guard and ordinary reserved-route/cache policy. +Physical mobile and deployed CDN/HTTPS acceptance remain release gates. + +## Latest independent review follow-up — 2026-10-06 + +Keep the approved report schema, exact truncation order, protected slot floors, +omission overflow checks and 512 KiB UTF-8 budget. Implement the confirmed +corrections centrally on the existing branches, then independently review them. + +- [x] Select both host-only Node emitted-script regressions explicitly in CI. +- [x] Replace repeated whole-report measurement with exact byte accounting for + removed values, commas, counter digit changes and coverage updates. Retain + one final complete measurement and all existing survivor-order assertions. +- [x] Bind the handoff URL to the captured publisher origin, including pages + with an external base element, and recover focus when cleanup hides the + focused control without stealing focus from another control. +- [x] Add operator-facing changelog entries for the default-off trace workflow, + bounded diagnostics cookie, reserved paths and private document handling. +- [x] Include Cloudflare in deployed HTTPS acceptance and document TLS-offload + constraints without trusting forwarded scheme/authority headers. +- [x] Correct EdgeZero's Axum metadata wording and identify earlier probe counts + as historical. Preserve URI registered-name syntax, including valid + punctuation and trailing dots; a DNS-only policy is not this API's contract. +- [x] Rebuild and review unpublished trace assets, run the required gates and + actual browser workflows, and obtain independent review of the final fixes. + +Bundle splitting and optional network enrichment remain separately scoped. +Upstream merge and repinning to its merged revision remain integration steps; +this corrective pass does not authorize merging either draft PR. + +### Executed corrective-pass evidence + +The initial complexity regression failed with 838 complete-wrapper JSON +serializations. The corrected builder passes with at most two, retaining the +existing worst-case survivor order, exact omission counters and protected slot +floors. Additional exact-budget tests cover UTF-8/escaped strings, counter +digit transitions, array commas, empty arrays, coverage changes and byte-stage +omission overflow. + +Fresh verification after the main merge: all eight target-matched clippy gates, +four adapter test aliases, the host CLI helper including its browser fixtures, +21 parity tests, format, core rustdoc, native/WASM builds and both explicitly +selected Node regressions pass. The normal JS and pinned external Prebid builds, +1,748 Vitest tests in 69 files, final focused regressions, lint and formatting +pass. The final asset guard confirms rebuilt bytes and manifest digests; v1 is +still unpublished. Package-wide TypeScript retains unrelated existing errors, +with none in the modified trace source/tests; browser TypeScript passes. + +The complete browser runner passes Next.js 49 and WordPress 28 tests, including +a real handoff under an external base and real Delete/Retry focus checks. Its +first attempt stopped before tests because Docker was inactive; starting Docker +and retrying resolved that prerequisite. All-four-runtime trace workflows pass +4/4, and fresh Cloudflare/Fastly/Spin raw boundary suites pass 3/3 using the +current consumer dependency graph and freshly built artifacts. + +Three independent scopes reviewed report performance/byte bounds, remaining +runtime/UI/CI/operator surfaces, and EdgeZero authority/provenance/docs. Review +corrections include typed immutable expected-report fixtures, the actual config +key and explicit Axum plain-HTTP limitations; final rereviews have no remaining +actionable findings. EdgeZero docs format/lint/build pass under Node 24.12.0; +its source is unchanged by this follow-up, and all checks pass at docs revision +`4f221d257e3224c946326b949bb966ac822ae5a3`. Trusted Server keeps the reviewed +implementation pin until the upstream merge/release handoff. + +Hosted Trusted Server checks passed for main-merge head `b7e48c7bc`; the latest +corrective working tree passed the local gates recorded above. The PR merge +reference has zero open CodeQL alerts. The earlier Python extraction +failure cleared after that merge; prior CodeQL paragraphs are historical +checkpoints. No Python is added by this feature. Physical-device, real browser +session restoration and deployed HTTPS/CDN/CSP acceptance remain release gates. + +## Phase 4: Optional network enrichment + +### Scheduling boundary + +This is the optional fourth phase from approved [spec](../specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md) sections 11 and 17.4. It does not block the first three phases or #1050 acceptance. Follow the shared sections above, keep the current branch while executing authorized work, and use the already strict optional field validators rather than expanding the report. + +Do not adopt request-relative #1074/#1076 milestones, bidder/price/creative-number/winner disclosures from #1081, resolver facts, speed probes, raw IP or JA4/H2 here. Each later public contract change needs its own approved design/compatibility plan; this document schedules no speculative schema implementation. + +### File map + +| Action | Exact path | Responsibility | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| Modify if a source is verified | `crates/trusted-server-core/src/platform/types.rs` | Optional adapter-owned HTTP version/POP metadata, default None | +| Modify | `crates/trusted-server-core/src/trace/context.rs` | Map only verified optional metadata through existing bounds | +| Modify if needed | `crates/trusted-server-adapter-fastly/src/main.rs`, `crates/trusted-server-adapter-fastly/src/platform.rs` | Native HTTP metadata before conversion, optional SDK ASN/POP source | +| Modify if a source is verified | `crates/trusted-server-adapter-axum/src/platform.rs`, `crates/trusted-server-adapter-cloudflare/src/platform.rs`, `crates/trusted-server-adapter-spin/src/platform.rs` | Explicit supported mapping, not fabricated cross-platform values | +| Modify | `crates/trusted-server-integration-tests/tests/parity.rs` | Common schema/omission expectations with differing supported fields | +| Modify | `crates/trusted-server-js/lib/test/trace/validation.test.ts`, `crates/trusted-server-js/lib/test/trace/viewer.test.ts` | Optional values accepted/bounded/rendered without default invention | +| Modify | `docs/guide/integrations/gpt-diagnostics.md` | Source/support matrix and privacy/provenance documentation | + +New Rust tests stay in the changed modules. Do not edit a dependency's Cargo-cache source. If upstream conversion must preserve a fact, release/pin that change through the foundation's dependency process. + +### N1: Prove source availability against the actual SDK pin + +- [ ] Record SDK revisions from `cargo metadata --format-version 1` and Cargo.lock and inspect the corresponding sources. Existing investigation found Fastly `Request::get_version()` and `Geo::as_number()` in the installed pinned SDK; verify again at execution, after the foundation EdgeZero upgrade. +- [ ] Establish the source table below with a concrete native accessor/type per newly enabled cell. Official runtime documentation must support environment-derived facts; an available header name is not a trusted source. Do not read arbitrary forwarded headers for HTTP/TLS/POP facts. + +| Public field | Existing baseline | Enrichment source to verify | Failure behavior | +| ----------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| http_version/Fastly | EdgeZero converter does not copy Fastly native version | Capture native `req.get_version()` before conversion and carry adapter-owned fact | Omit unsupported/error value | +| http_version/Axum | Native inbound request version is available before conversion | Prove version is copied from inbound `http::Request` parts, not default-created downstream request | Omit if original version cannot be proved | +| http_version/Cloudflare, Spin | No current trace mapping | Stable documented native runtime metadata only | Absent until verified | +| asn/Fastly | `geo_from_fastly` sets None | Pinned `Geo::as_number()` with documented unknown/sentinel handling | Unknown/sentinel/error remains None | +| asn/Cloudflare | Geo adapter currently sets None | Typed documented Workers connection metadata with checked u32 conversion | Absent until verified | +| edge_pop/Fastly, Cloudflare | No existing mapping | Stable documented runtime POP field/environment variable | Absent until verified | +| edge_pop/Axum, Spin | Unavailable in v1 | No invented source | Absent | + +- [ ] Add red tests only for mappings with established sources. Test unavailable cells remain absent and do not inherit a default HTTP/1.1 value, hostname-derived POP, region-as-POP, fabricated ASN or full IP. +- [ ] Run adapter platform tests for selected mappings and `cargo test-fastly trace::context`. Expected red for supported new fields, existing omission tests remain green. If no source is established for a field, document it as unavailable and finish that field's discovery without runtime edits. + +### N2: Add protocol/POP metadata without broadening disclosure + +- [ ] For verified HTTP sources, add optional fields to `ClientInfo` (or the established adapter-owned inbound metadata extension) with defaults None. Preserve existing callers/constructors and do not infer protocol from a synthetic provider request or forwarding header. +- [ ] Use a closed protocol mapping for supported HTTP versions, bounded ≤32 bytes. Store it before the conversion point that would lose it; core receives only that normalized fact. Other adapters omit it until they can supply the same provenance. +- [ ] For a verified POP source, normalize printable Unicode/control rules and ≤32 UTF-8 bytes with the existing core helper. Invalid/unavailable/overlong values are omitted with only a bounded category; no source value enters logs. +- [ ] Test getter failures, absent metadata, values at/over bounds, control/bidi strings, and spoofed headers that cannot overwrite the adapter fact. Retain all masking/JA4/H2 exclusions and setup-vs-publisher labeling. +- [ ] Run `cargo test-fastly`, `cargo test-axum`, `cargo test-cloudflare`, `cargo test-spin`, and `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity`. Expected: common schema, intentional availability differences, unchanged route/auth/cache behavior. Planned commit, only if a mapping was added: `Add verified optional trace protocol and POP facts`. + +### N3: Populate verified ASN sources and verify the optional increment + +- [ ] For Fastly's proven getter, add a test distinguishing a valid documented ASN from unknown/sentinel values, then populate `GeoInfo.asn` without copying city/coordinates or adding an active lookup beyond existing geo behavior. For Cloudflare, add a mapping only after N1 establishes the exact typed source; checked conversion failure omits it. +- [ ] Run selected geo tests to observe red, implement the minimal optional mapping and re-run. Update ordinary geo consumer tests if their intentionally observable GeoInfo now includes ASN; verify no identity/routing/consent behavior changes unintentionally. +- [ ] Test public network projection accepts only u32 ASN and emits no unsupported fallback. Viewer preserves unavailable labels and existing report validity/expiry/export equality. From JS lib run `npx vitest run test/trace/validation.test.ts test/trace/viewer.test.ts`. +- [ ] Document the actual resulting adapter support/provenance matrix and remaining unavailable cells. Verify masked IP/coarse geo privacy acceptance still applies and that optional field errors do not fail publisher delivery. +- [ ] Run all shared verification CI/build/doc gates and the trace browser suite. Re-run cache/auth/inactive gates because extra metadata crosses the shared platform type; feature-disabled behavior remains unchanged. Planned commit, only if ASN was added: `Add verified trace ASN projection and document platform support`. +- [ ] Handoff only implemented, verified optional facts. Future #1081/#1074/#1076 schemas remain separate work; do not mark them complete or add placeholders to the v1 report. diff --git a/docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md b/docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md new file mode 100644 index 000000000..79ae8b0cc --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md @@ -0,0 +1,1898 @@ +# Creative asset host allowlist implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `[rewrite] include_domains`, an asset host allowlist for the creative rewrite pass and `/first-party/sign`, and make `exclude_domains` matching case-insensitive. + +**Architecture:** Replace `creative::to_abs` with a pure normalizer, `normalize_creative_url`, and move host policy onto `Rewrite` as `should_proxy_asset` and `should_wrap_click`, built on the existing `proxy::is_host_allowed` and `proxy::is_host_permitted`. Asset call sites go through `asset_target`, the anchor handler through `click_target`, and `/first-party/sign` pairs the normalizer with `should_proxy_asset`. The include list is then one clause in `should_proxy_asset`. + +**Tech Stack:** Rust 2024 (`trusted-server-core`), `url`, `lol_html`, `cssparser`, `serde`, `validator` 0.20. + +**Spec:** `docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md` + +**Status:** Validated. Every code block below was implemented on `7a0ecb4c`, driven red then green, and passed the gates in Task 7. Line numbers are against `7a0ecb4c`. + +## Global Constraints + +- Issue: [IABTechLab/trusted-server#1231](https://github.com/IABTechLab/trusted-server/issues/1231). Sibling: #1234. +- Setting: `[rewrite] include_domains`, `Vec`, `#[serde(default, skip_serializing_if = "Vec::is_empty")]`. Never named `allowed_domains`. +- Empty `include_domains` keeps today's rewritten output byte-for-byte for every existing fixture. +- `exclude_domains` wins over `include_domains`. `include_domains` never applies to `` or ``. +- Pattern syntax for both lists: exact host, or `*.example.com` matching the apex and any subdomain. Case-insensitive, dot boundary, matched against `Url::host_str()` only. +- Load-time normalization: trim and ASCII-lowercase both lists. `exclude_domains` drops `""` and `*` with a `log::warn!`. +- `include_domains` validation rejects every entry that can never equal `Url::host_str()`. After normalization an entry must be a DNS name of non-empty `[a-z0-9-]` labels with an optional leading `*.`, a dotted-quad IPv4 address, or a bracketed IPv6 address in the form `Url::host_str()` renders, such as `[::1]` or `[::ffff:c000:201]`. Every entry finishes with a round trip through `url::Host::parse`, so an entry that `url` would rewrite or reject fails. Rejected entries include `""`, `*`, `*.`, a `*` other than a leading `*.`, a scheme, a port, a path, spaces or other characters outside `[a-z0-9.-]`, a leading or trailing `.`, an empty label, an unbracketed IPv6 address, and a wildcard or malformed IP address. These fail with `invalid_rewrite_include_domain`. Non-ASCII (IDN) entries fail with `non_ascii_rewrite_include_domain` and are not converted to punycode. +- `/first-party/sign`: an excluded or off-list host returns `TrustedServerError::Proxy { message: "unsupported url" }` (`502`) for absolute and `//` input, through GET and POST. That check runs before `proxy.allowed_domains`; `403` stays reserved for `proxy.allowed_domains`. +- No JS, adapter, routing or dependency changes. +- Fictional data only: `example.com`, `example.net`, `example.org` hosts. +- Rust conventions from `AGENTS.md`: `expect("should ...")`, no `unwrap()` outside tests, no local `use` inside functions, comments above code, descriptive assertion messages, doc comments on public items. +- Tests build `Rewrite` with `Rewrite::default()` and push or extend its lists. Never write a `Rewrite { .. }` literal, and never assign a whole field on a `Default::default()` value: `clippy::field_reassign_with_default` rejects it under `-D warnings`. +- Commits: sentence case, imperative, no `fix:`-style prefixes, `--signoff -S`, no AI attribution lines. + +## Coordination with #1234 (read before Task 1) + +Both #1231 and #1234 rely on one shared refactor, reproduced verbatim in [Shared step](#shared-step). Whichever implementation PR lands first ships it. Tasks 1 and 2 are that shared step. + +Before starting, check `main`: + +```bash +git fetch origin main +git grep -n "fn normalize_creative_url" origin/main -- crates/trusted-server-core/src/creative.rs +git grep -n "fn should_proxy_asset" origin/main -- crates/trusted-server-core/src/settings.rs +``` + +- **Both found** (#1234 landed first): rebase onto `origin/main`, **skip Tasks 1 and 2**, and start at Task 3. Diff the merged code against [Shared step](#shared-step); if names differ, use the merged names and note it in the PR description. +- **Neither found:** do Tasks 1 and 2. In the PR description, say that this PR introduces the shared step and that #1234 should rebase and skip its equivalent. +- **Only one found:** stop and ask the coordinator; the split landed partially. + +## Shared step + +This section is identical in the #1231 and #1234 plans. Both PRs write this production code and this test set verbatim; whichever PR lands first ships them, and the other skips its shared-step task. `proxy::is_host_permitted` stays private in the shared step: #1231 makes it `pub(crate)` in the task that adds the include clause, and #1234 never needs it. + +`crates/trusted-server-core/src/settings.rs`, imports (`:26`). The shared step uses only `is_host_allowed`, so it imports only that; importing `is_host_permitted` here would fail `-D warnings` as an unused import. #1231's include-clause task widens the import. + +```rust +use crate::proxy::is_host_allowed; +``` + +`settings.rs`, replaces `impl Rewrite` (`:645-670`), removing `is_excluded` and its `#[allow(dead_code)]`: + +```rust +impl Rewrite { + /// Returns `true` when an asset URL on `host` should be rewritten to + /// `/first-party/proxy`. + /// + /// The host must not match [`Self::exclude_domains`]. Matching is + /// case-insensitive; see `crate::proxy::is_host_allowed` for the pattern rules. + #[must_use] + pub fn should_proxy_asset(&self, host: &str) -> bool { + !self.is_excluded_host(host) + } + + /// Returns `true` when a click-through URL on `host` should be wrapped in + /// `/first-party/click`. + /// + /// Only [`Self::exclude_domains`] applies to click-through links. + #[must_use] + pub fn should_wrap_click(&self, host: &str) -> bool { + !self.is_excluded_host(host) + } + + fn is_excluded_host(&self, host: &str) -> bool { + self.exclude_domains + .iter() + .any(|pattern| is_host_allowed(host, pattern)) + } + + /// Trims and lowercases host patterns in place. + /// + /// Empty and bare `*` entries in `exclude_domains` are dropped with a + /// warning: neither can match a host, so dropping them changes no behavior. + fn normalize(&mut self) { + let before = self.exclude_domains.len(); + self.exclude_domains = self + .exclude_domains + .iter() + .map(|pattern| pattern.trim().to_ascii_lowercase()) + .filter(|pattern| !pattern.is_empty() && pattern != "*") + .collect(); + if self.exclude_domains.len() < before { + log::warn!( + "rewrite.exclude_domains: removed empty or bare \"*\" entries, which never match a host" + ); + } + } +} +``` + +`settings.rs`, in `Settings::normalize_deserialized` (`:2988`), right after `self.proxy.normalize();`: + +```rust + self.rewrite.normalize(); +``` + +`crates/trusted-server-core/src/creative.rs`, replaces `to_abs` and its comment (`:52-75`): + +```rust +/// Normalizes a creative URL to an absolute HTTP(S) [`url::Url`]. +/// +/// Trims surrounding whitespace, resolves a protocol-relative `//host/...` +/// against `https:`, and accepts only `http://` and `https://` input (ASCII +/// case-insensitive). Returns `None` for empty, relative, non-network-scheme or +/// unparseable input. Applies no host policy; see +/// [`crate::settings::Rewrite::should_proxy_asset`] and +/// [`crate::settings::Rewrite::should_wrap_click`]. +pub(super) fn normalize_creative_url(url: &str) -> Option { + let trimmed = url.trim(); + let is_http = trimmed + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")); + let is_https = trimmed + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")); + if trimmed.starts_with("//") { + url::Url::parse(&format!("https:{trimmed}")).ok() + } else if is_http || is_https { + url::Url::parse(trimmed).ok() + } else { + None + } +} + +/// Normalizes `raw` and returns it when asset policy allows proxying its host. +fn asset_target(settings: &Settings, raw: &str) -> Option { + normalize_creative_url(raw).filter(|url| { + url.host_str() + .is_some_and(|host| settings.rewrite.should_proxy_asset(host)) + }) +} + +/// Normalizes `raw` and returns it when click policy allows wrapping its host. +fn click_target(settings: &Settings, raw: &str) -> Option { + normalize_creative_url(raw).filter(|url| { + url.host_str() + .is_some_and(|host| settings.rewrite.should_wrap_click(host)) + }) +} +``` + +`creative.rs` call sites (`:402`, `:409`, `:639-641`, `:726-727`, `:1285-1287`): + +```rust + // CssUrlRewriter::rewrite + let Some(target) = asset_target(self.settings, value) else { + return; + }; + // ... later in the same function: + let proxied = build_proxy_url(self.settings, target.as_str(), self.base_origin); + +// proxy_if_abs +pub(super) fn proxy_if_abs(settings: &Settings, val: &str, base_origin: &str) -> Option { + asset_target(settings, val).map(|url| build_proxy_url(settings, url.as_str(), base_origin)) +} + + // rewrite_srcset + let rewritten = if let Some(target) = asset_target(settings, url) { + build_proxy_url(settings, target.as_str(), base_origin) + } else { + url.to_owned() + }; + + // Anchor handler + element!("a[href], area[href]", |el| { + if let Some(href) = el.get_attribute("href") + && let Some(target) = click_target(settings, &href) + { + let click = build_click_url(settings, target.as_str(), base_origin); + let _ = el.set_attribute("href", &click); + let _ = el.set_attribute("data-tsclick", &click); + } + Ok(()) + }), +``` + +`creative.rs` module doc (`:25-27`): + +```rust +//! - `normalize_creative_url(&str) -> Option`: Normalizes an absolute or +//! protocol-relative http(s) URL; returns `None` for relative input, +//! non-network schemes and unparseable values. Host policy lives on +//! [`crate::settings::Rewrite`]. +``` + +`proxy.rs`, `handle_first_party_proxy_sign` (`:1625`): replace `:1672-1703` (from `let trimmed` up to the `is_host_permitted` check) with the block below, then at `:1723` and `:1726` use `target` in place of `parsed` and `target.as_str()` in place of `&abs`. The old parse, scheme and duplicate exclusion checks go away because the normalizer already guarantees an `http(s)` URL. + +```rust + let trimmed = payload.url.trim(); + let protocol_relative; + // A protocol-relative target inherits the signing request's scheme before + // normalization, so `//` and absolute input reach the same policy check. + let candidate = if trimmed.starts_with("//") { + protocol_relative = format!("{request_scheme}:{trimmed}"); + protocol_relative.as_str() + } else { + trimmed + }; + let target = crate::creative::normalize_creative_url(candidate).ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "unsupported url".to_string(), + }) + })?; + let host = target.host_str().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "missing host".to_string(), + }) + })?; + if !settings.rewrite.should_proxy_asset(host) { + log::debug!("sign request for `{host}` declined by rewrite policy"); + return Err(Report::new(TrustedServerError::Proxy { + message: "unsupported url".to_string(), + })); + } +``` + +```rust + let mut base = target.clone(); + base.set_query(None); + base.set_fragment(None); + let proxied = crate::creative::build_proxy_url_with_extras(settings, target.as_str(), &extras); +``` + +### Shared tests + +Every shared test builds `Rewrite` with `Rewrite::default()` and `.extend`/`.push` on its lists, or sets `settings.rewrite.exclude_domains` on a full `Settings`. None uses a `Rewrite { .. }` literal or `#[allow(clippy::field_reassign_with_default)]`. The code for each test is in the task that adds it in each plan. + +| File | Test | Replaces or placement | +| ------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `settings.rs` | `rewrite_policy_matches_exclude_patterns_case_insensitively` | replaces `test_rewrite_is_excluded` | +| `settings.rs` | `rewrite_normalize_trims_lowercases_and_drops_inert_exclude_entries` | after the previous test | +| `settings.rs` | `rewrite_exclude_domains_match_mixed_case_entries_from_toml` | after the previous test | +| `settings.rs` | `settings_load_normalizes_rewrite_exclude_domains` | after the previous test | +| `creative.rs` | `normalized` helper and `normalize_creative_url_conversions` | replace `to_abs_conversions`, `to_abs_preserves_port_in_protocol_relative` and `to_abs_additional_cases` | +| `creative.rs` | `proxy_if_abs_respects_exclude_domains` | replaces `to_abs_respects_exclude_domains` and `to_abs_respects_wildcard_domains` | +| `creative.rs` | `unparseable_absolute_url_is_left_byte_identical` | after the previous test | +| `creative.rs` | `unparseable_absolute_click_url_is_left_byte_identical` | after the previous test | +| `creative.rs` | `exclude_domains_match_case_insensitively_in_the_rewrite_pass` | after the previous test | +| `proxy.rs` | `proxy_sign_rejects_excluded_urls` | rewritten in place: GET and POST, plus an uppercase target host | +| `proxy.rs` | `proxy_sign_rejects_excluded_urls_case_insensitively` | after the previous test: mixed-case config entry, absolute and `//` input, GET and POST | + +The `creative.rs` test module's `use super::{...}` list gains `normalize_creative_url` and `proxy_if_abs` and loses `to_abs`. + +CHANGELOG: the shared step adds this entry as the first item under `## [Unreleased]` › `### Fixed` (create the heading if absent), in the same commit as the code: + +```markdown +- `rewrite.exclude_domains` matching is now case-insensitive and ignores surrounding whitespace. Entries written with uppercase letters previously never matched and now take effect, so those hosts stop being proxied and click-wrapped, and `/first-party/sign` now rejects them with `502`; it used to sign them, or return `403` when the host was also outside `proxy.allowed_domains`. Empty and bare `"*"` `exclude_domains` entries, which never matched a host, are dropped at load with a warning. Absolute `http(s)` creative URLs that cannot be parsed (for example, a host containing a space) are now left untouched; previously the attribute was re-quoted and a link also gained `data-tsclick`. Audit `exclude_domains` for mixed-case entries before upgrading. +``` + +## File map + +| File | Responsibility in this change | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/settings.rs` | `Rewrite` fields, normalization, validation, policy methods, their unit tests. | +| `crates/trusted-server-core/src/creative.rs` | `normalize_creative_url`, `asset_target`, `click_target`, call sites, rewrite-pass tests. | +| `crates/trusted-server-core/src/proxy.rs` | `is_host_permitted` visibility, `/first-party/sign` normalization and policy, sign tests. | +| `crates/trusted-server-core/src/config_payload.rs` | Blob default-omission and round-trip tests. | +| `docs/guide/*.md`, `trusted-server.example.toml`, `CHANGELOG.md` | Operator documentation. | + +Run Rust test steps with `cargo test-fastly -- `. Core tests run on `wasm32-wasip1` under Viceroy, which must be on `PATH` (`export PATH="$HOME/.cargo/bin:$PATH"` if `cargo install` put it there). A wasm test panic aborts the whole test binary, so when one test fails, the remaining tests in that binary do not run; filter to the tests you are working on, and rerun with `--nocapture` to see the panic message above the wasm backtrace. + +--- + +### Task 1: Host policy methods and case-insensitive `exclude_domains` (shared step, part 1) + +Skip if `fn should_proxy_asset` is already on `main` (see Coordination). + +**Files:** + +- Modify: `crates/trusted-server-core/src/settings.rs:26` (import), `:645-670` (`impl Rewrite`), `:2988-2990` (`normalize_deserialized`) +- Test: `settings.rs` tests, after `test_rewrite_is_excluded` (`:6523`) + +**Interfaces:** + +- Consumes: `crate::proxy::is_host_allowed(host: &str, pattern: &str) -> bool` (`proxy.rs:1242`, `pub(crate)`). +- Produces: `pub fn Rewrite::should_proxy_asset(&self, host: &str) -> bool`, `pub fn Rewrite::should_wrap_click(&self, host: &str) -> bool`, private `fn Rewrite::is_excluded_host(&self, host: &str) -> bool`, private `fn Rewrite::normalize(&mut self)`. + +- [ ] **Step 1: Write the failing tests** + +```rust + #[test] + fn rewrite_policy_matches_exclude_patterns_case_insensitively() { + let mut rewrite = Rewrite::default(); + rewrite + .exclude_domains + .extend(["cdn.example.com", "*.example.org"].map(str::to_owned)); + + for (host, expected) in [ + ("cdn.example.com", false), + ("CDN.EXAMPLE.COM", false), + ("example.org", false), + ("a.b.example.org", false), + ("evil-example.org", true), + ("example.org.evil.example.net", true), + ("sub.cdn.example.com", true), + ("other.example.com", true), + ] { + assert_eq!( + rewrite.should_proxy_asset(host), + expected, + "should_proxy_asset(`{host}`) should be {expected}" + ); + assert_eq!( + rewrite.should_wrap_click(host), + expected, + "should_wrap_click(`{host}`) should be {expected}" + ); + } + } +``` + +```rust + #[test] + fn rewrite_normalize_trims_lowercases_and_drops_inert_exclude_entries() { + let mut rewrite = Rewrite::default(); + rewrite + .exclude_domains + .extend([" CDN.Example.com ", "", "*", "*.Example.ORG"].map(str::to_owned)); + + rewrite.normalize(); + + assert_eq!( + rewrite.exclude_domains, + vec!["cdn.example.com".to_owned(), "*.example.org".to_owned()], + "should trim, lowercase, and drop empty and bare `*` entries" + ); + } +``` + +```rust + #[test] + fn rewrite_exclude_domains_match_mixed_case_entries_from_toml() { + let toml_str = crate_test_settings_str() + + r#" + [rewrite] + exclude_domains = ["CDN.Example.com"] + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert!( + !settings.rewrite.should_proxy_asset("cdn.example.com"), + "should exclude a host whose config entry was written in mixed case" + ); + } +``` + +```rust + #[test] + fn settings_load_normalizes_rewrite_exclude_domains() { + let toml_str = crate_test_settings_str() + + r#" + [rewrite] + exclude_domains = [" CDN.Example.com ", "*", ""] + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert_eq!( + settings.rewrite.exclude_domains, + vec!["cdn.example.com".to_owned()], + "should trim, lowercase, and drop inert entries when settings load" + ); + } +``` + +`settings_load_normalizes_rewrite_exclude_domains` is the only test here that proves `normalize` is wired into settings loading: the matcher is case-insensitive on its own, so the mixed-case TOML test would pass even without the `normalize_deserialized` call. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test-fastly -- rewrite_policy_matches rewrite_normalize_trims rewrite_exclude_domains_match settings_load_normalizes` + +Expected: compile errors, `no method named should_proxy_asset found for struct settings::Rewrite` (and `should_wrap_click`, `normalize`). + +- [ ] **Step 3: Implement** + +Add the import and the methods from [Shared step](#shared-step) (`use crate::proxy::is_host_allowed;`, `should_proxy_asset`, `should_wrap_click`, `is_excluded_host`, `normalize`), and the `self.rewrite.normalize();` call. For this task only, keep `is_excluded` in place above the new methods; its two callers still exist until Task 2. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test-fastly -- rewrite_policy_matches rewrite_normalize_trims rewrite_exclude_domains_match settings_load_normalizes test_rewrite_is_excluded` + +Expected: `5 passed`. + +- [ ] **Step 5: Lint** + +Run: `cargo fmt --all && cargo clippy-fastly` + +Expected: no warnings. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/settings.rs +git commit --signoff -S -m "Add rewrite host policy methods and case-insensitive exclude matching" +``` + +--- + +### Task 2: Replace `to_abs` with `normalize_creative_url` (shared step, part 2) + +Skip if `fn normalize_creative_url` is already on `main` (see Coordination). + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative.rs:25-27`, `:52-75`, `:402-409`, `:639-641`, `:726-727`, `:1285-1287`, `:1497-1501` (test imports) +- Modify: `crates/trusted-server-core/src/proxy.rs:1672-1726` +- Modify: `crates/trusted-server-core/src/settings.rs` (remove `is_excluded` and `test_rewrite_is_excluded`) +- Test: `creative.rs` tests (`:1699-1738`, `:3418-3504`), `proxy.rs` `proxy_sign_rejects_excluded_urls` (`:2692-2715`) + +**Interfaces:** + +- Consumes: `Rewrite::should_proxy_asset`, `Rewrite::should_wrap_click` from Task 1. +- Produces: `pub(super) fn normalize_creative_url(url: &str) -> Option` (visible crate-wide, as `to_abs` was); private `fn asset_target(settings: &Settings, raw: &str) -> Option` and `fn click_target(settings: &Settings, raw: &str) -> Option`. Signatures of `proxy_if_abs`, `proxied_attr_value`, `rewrite_srcset`, `CssUrlRewriter::rewrite` and the `build_*_url` helpers do not change. + +- [ ] **Step 1: Write the failing tests** + +In `creative.rs` tests, change the `use super::{...}` list (`:1497-1501`) to: + +```rust + use super::{ + CreativeCssProcessor, StreamProcessor as _, normalize_creative_url, + process_auction_creative, proxy_if_abs, rewrite_creative_html, + rewrite_inline_creative_html, rewrite_srcset, rewrite_style_urls, sanitize_creative_html, + }; +``` + +Replace `to_abs_conversions` and `to_abs_preserves_port_in_protocol_relative` (`:1699-1738`) with: + +```rust + fn normalized(raw: &str) -> Option { + normalize_creative_url(raw).map(|url| url.as_str().to_owned()) + } + + #[test] + fn normalize_creative_url_conversions() { + for (raw, expected) in [ + ("//cdn.example/x", Some("https://cdn.example/x")), + ("HTTPS://cdn.example/x", Some("https://cdn.example/x")), + ("http://cdn.example/x", Some("http://cdn.example/x")), + (" //cdn.example/y ", Some("https://cdn.example/y")), + (" https://cdn.example/a ", Some("https://cdn.example/a")), + ( + "//cdn.example.com:8080/asset.js", + Some("https://cdn.example.com:8080/asset.js"), + ), + ( + "//cdn.example.com:9443/img.png", + Some("https://cdn.example.com:9443/img.png"), + ), + ("/local/x", None), + ("", None), + ("data:image/png;base64,abcd", None), + ("javascript:alert(1)", None), + ("mailto:test@example.com", None), + ("blob:xyz", None), + ("tel:+123", None), + ("about:blank", None), + ("https://exa mple.example/x", None), + ] { + assert_eq!( + normalized(raw).as_deref(), + expected, + "should normalize `{raw}` to {expected:?}" + ); + } + } +``` + +Delete `to_abs_additional_cases` (`:3418-3428`); its cases are in the table above. + +Replace `to_abs_respects_exclude_domains` and `to_abs_respects_wildcard_domains` (`:3445-3504`) with: + +```rust + #[test] + fn proxy_if_abs_respects_exclude_domains() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.rewrite.exclude_domains = vec![ + "trusted-cdn.example.com".to_owned(), + "*.example.org".to_owned(), + ]; + + for excluded in [ + "https://trusted-cdn.example.com/lib.js", + "//trusted-cdn.example.com/lib.js", + "https://example.org/cdn.js", + "//cdnjs.example.org/lib.js", + ] { + assert_eq!( + proxy_if_abs(&settings, excluded, ""), + None, + "should leave excluded URL `{excluded}` unproxied" + ); + } + for proxied in [ + "https://other-cdn.example.com/lib.js", + "//other-cdn.example.com/lib.js", + "https://notexample.org/lib.js", + ] { + assert!( + proxy_if_abs(&settings, proxied, "") + .is_some_and(|url| url.starts_with("/first-party/proxy?tsurl=")), + "should proxy non-excluded URL `{proxied}`" + ); + } + } +``` + +```rust + #[test] + fn unparseable_absolute_url_is_left_byte_identical() { + let settings = crate::test_support::tests::create_test_settings(); + let html = ""; + + let out = rewrite_creative_html(&settings, html); + + assert!( + out.contains(html), + "should leave an unparseable absolute URL untouched, including its quoting: {out}" + ); + } +``` + +The unparseable test asserts `contains`, not equality: `rewrite_creative_html` injects the TSJS script tag even when the markup has no ``, so the output is never equal to the input. + +```rust + #[test] + fn unparseable_absolute_click_url_is_left_byte_identical() { + let settings = crate::test_support::tests::create_test_settings(); + let html = "x"; + + let out = rewrite_creative_html(&settings, html); + + assert!( + out.contains(html), + "should leave an unparseable click URL untouched, including its quoting: {out}" + ); + assert!( + !out.contains("data-tsclick"), + "should not mark an unparseable link for the click guard: {out}" + ); + } + + #[test] + fn exclude_domains_match_case_insensitively_in_the_rewrite_pass() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings + .rewrite + .exclude_domains + .extend(["Landing.Example.com", "CDN.example.com"].map(str::to_owned)); + let html = r#"x"#; + + let out = rewrite_creative_html(&settings, html); + + assert!( + out.contains(r#""#), + "should leave a link raw when its host matches a mixed-case entry: {out}" + ); + assert!( + out.contains(r#""#), + "should leave an asset raw when its host matches a mixed-case entry: {out}" + ); + } +``` + +These two cover the click path, which `proxy_if_abs_respects_exclude_domains` and the asset-only unparseable test do not, and mixed-case entries that bypass load-time normalization because the test sets them directly. + +In `proxy.rs`, replace `proxy_sign_rejects_excluded_urls` (`:2692-2715`): + +```rust + #[test] + fn proxy_sign_rejects_excluded_urls() { + futures::executor::block_on(async { + let mut settings = create_test_settings(); + settings.rewrite.exclude_domains = vec!["cdn.example".to_owned()]; + + for method in [&Method::GET, &Method::POST] { + for url in [ + "https://cdn.example/asset.js", + "//cdn.example/asset.js", + "https://CDN.Example/asset.js", + ] { + let req = build_proxy_sign_request( + method, + "https://edge.example/first-party/sign", + url, + ); + let err: Report = + handle_first_party_proxy_sign(&settings, &noop_services(), req) + .await + .expect_err("should reject excluded URL"); + + assert_eq!( + err.current_context().status_code(), + StatusCode::BAD_GATEWAY, + "{} should reject excluded URL `{url}` as unsupported", + method.as_str() + ); + } + } + }); + } +``` + +Directly after it, add: + +```rust + #[test] + fn proxy_sign_rejects_excluded_urls_case_insensitively() { + futures::executor::block_on(async { + let mut settings = create_test_settings(); + settings.rewrite.exclude_domains = vec!["CDN.Example".to_owned()]; + + for method in [&Method::GET, &Method::POST] { + for url in ["https://cdn.example/asset.js", "//cdn.example/asset.js"] { + let req = build_proxy_sign_request( + method, + "https://edge.example/first-party/sign", + url, + ); + let err: Report = + handle_first_party_proxy_sign(&settings, &noop_services(), req) + .await + .expect_err("should reject a host excluded by a mixed-case entry"); + + assert_eq!( + err.current_context().status_code(), + StatusCode::BAD_GATEWAY, + "{} should reject `{url}` excluded by a mixed-case entry as unsupported", + method.as_str() + ); + } + } + }); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test-fastly -- normalize_creative_url proxy_if_abs_respects unparseable_absolute exclude_domains_match_case proxy_sign_rejects_excluded` + +Expected: `error[E0432]: unresolved import super::normalize_creative_url`. To observe the runtime reds, do this in two passes. First add only `unparseable_absolute_click_url_is_left_byte_identical`, `exclude_domains_match_case_insensitively_in_the_rewrite_pass` and `proxy_sign_rejects_excluded_urls_case_insensitively`, keeping the old `use super::{...}` list; they use only existing APIs. Run `cargo test-fastly -- unparseable_absolute_click exclude_domains_match_case proxy_sign_rejects_excluded_urls_case` and watch all three fail: the anchor gains `data-tsclick`, the mixed-case-excluded link is wrapped, and sign returns `200`. Then switch the import and add the rest of Step 1, which brings the compile error above. + +- [ ] **Step 3: Implement the normalizer, helpers and call sites** + +Apply the `creative.rs` parts of [Shared step](#shared-step): `normalize_creative_url`, `asset_target`, `click_target`, the four call sites and the module doc. + +- [ ] **Step 4: Migrate `/first-party/sign`** + +Apply the `proxy.rs` parts of [Shared step](#shared-step): the new normalization block, and `target` in the `base`/`proxied` lines. `is_host_permitted` stays private until Task 4. + +- [ ] **Step 5: Remove `is_excluded`** + +Delete `Rewrite::is_excluded` (with its `#[allow(dead_code)]`) and the `test_rewrite_is_excluded` test (`settings.rs:6522-6546`); Task 1's tests cover its cases. + +Run: `git grep -nw -e to_abs -e is_excluded crates/` + +Expected: no matches. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `cargo test-fastly -- normalize_creative_url proxy_if_abs_respects unparseable_absolute exclude_domains_match_case proxy_sign_` + +Expected: `12 passed` (the seven new or changed tests plus the five existing `proxy_sign_*` tests). + +Then prove empty-list parity with the full suite: + +Run: `cargo test-fastly` + +Expected: all pass with no edits to any existing rewrite-output assertion. If one fails, the refactor changed output; fix the code, not the assertion. + +- [ ] **Step 7: Lint** + +Run: `cargo fmt --all && cargo clippy-fastly` + +Expected: no warnings. + +- [ ] **Step 8: CHANGELOG** + +Add the [Shared step](#shared-step) CHANGELOG entry verbatim, as the first item under `## [Unreleased]` › `### Fixed`. It ships with the shared step, so Task 6 does not repeat it. + +- [ ] **Step 9: Commit** + +```bash +git add crates/trusted-server-core/src/creative.rs crates/trusted-server-core/src/proxy.rs crates/trusted-server-core/src/settings.rs CHANGELOG.md +git commit --signoff -S -m "Split creative URL normalization from rewrite host policy" -m "Replace to_abs with normalize_creative_url and route asset, click and first-party sign decisions through Rewrite::should_proxy_asset and Rewrite::should_wrap_click." +``` + +--- + +### Task 3: `include_domains` setting, validation and blob compatibility + +**Files:** + +- Modify: `crates/trusted-server-core/src/settings.rs:636-643` (`struct Rewrite`), `Rewrite::normalize`, new validator before `validate_publisher_domain` (`:3384`) +- Test: `settings.rs` tests (before `test_auction_creative_processing_defaults_when_omitted`, `:6549`), `crates/trusted-server-core/src/config_payload.rs` tests (before `disabled_rewrite_creatives_survives_blob_round_trip`, `:739`) + +**Interfaces:** + +- Consumes: `Rewrite::normalize` from Task 1. +- Produces: `pub include_domains: Vec` on `Rewrite`; private `fn validate_include_domains(patterns: &[String]) -> Result<(), ValidationError>`. + +- [ ] **Step 1: Write the failing tests** + +In `settings.rs` tests: + +```rust + #[test] + fn rewrite_exclude_domains_keep_entries_that_never_match() { + let toml_str = crate_test_settings_str() + + r#" + [rewrite] + exclude_domains = ["https://cdn.example.com", "cdn.example.com:443", "[2001:0db8::1]"] + "#; + + let settings = Settings::from_toml(&toml_str) + .expect("should load inert exclude_domains entries with a warning"); + + assert_eq!( + settings.rewrite.exclude_domains, + vec![ + "https://cdn.example.com".to_owned(), + "cdn.example.com:443".to_owned(), + "[2001:0db8::1]".to_owned(), + ], + "should keep inert exclude_domains entries unchanged" + ); + } + + #[test] + fn rewrite_include_domains_are_normalized_from_toml() { + let toml_str = crate_test_settings_str() + + r#" + [rewrite] + include_domains = ["*.CDN.Example.com", " img.example.net "] + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert_eq!( + settings.rewrite.include_domains, + vec!["*.cdn.example.com".to_owned(), "img.example.net".to_owned()], + "should trim and lowercase include_domains entries" + ); + } +``` + +```rust + fn include_domains_toml(entry: &str) -> String { + crate_test_settings_str() + &format!("\n[rewrite]\ninclude_domains = [\"{entry}\"]\n") + } + + #[test] + fn rewrite_include_domains_reject_malformed_entries() { + for entry in [ + "", + " ", + "*", + "*.", + "cdn.*.example.com", + "**.example.com", + "https://cdn.example.com", + "cdn.example.com:443", + "cdn.example.com/x", + ".example.com", + "example.com.", + "cdn..example.com", + "cdn example.com", + "cdn_assets.example.com", + "::1", + "[::1", + "[2001:0db8::1]", + "[::ffff:192.0.2.1]", + "xn--zz.example", + "*.192.0.2.1", + "192.0.2", + "256.0.2.1", + ] { + let result = Settings::from_toml(&include_domains_toml(entry)); + + let err = result.expect_err(&format!("should reject include_domains entry `{entry}`")); + assert!( + format!("{err:?}").contains("invalid_rewrite_include_domain"), + "should report `{entry}` as an invalid include_domains entry: {err:?}" + ); + } + } + + #[test] + fn rewrite_include_domains_reject_non_ascii_entries_with_a_punycode_hint() { + for entry in ["bücher.example", "*.bücher.example"] { + let result = Settings::from_toml(&include_domains_toml(entry)); + + let err = result.expect_err(&format!("should reject non-ASCII entry `{entry}`")); + assert!( + format!("{err:?}").contains("non_ascii_rewrite_include_domain"), + "should report `{entry}` as non-ASCII: {err:?}" + ); + } + } + + #[test] + fn rewrite_include_domains_accept_hosts_and_ip_literals() { + for entry in [ + "cdn.example.com", + "*.example.com", + "xn--bcher-kva.example", + "192.0.2.1", + "[2001:db8::1]", + "[::1]", + "[::ffff:c000:201]", + ] { + let settings = + Settings::from_toml(&include_domains_toml(entry)).unwrap_or_else(|err| { + panic!("should accept include_domains entry `{entry}`: {err:?}") + }); + + assert_eq!( + settings.rewrite.include_domains, + vec![entry.to_owned()], + "should keep accepted entry `{entry}` as written" + ); + } + } +``` + +```rust + #[test] + fn rewrite_include_domains_default_to_empty() { + let settings = create_test_settings(); + + assert!( + settings.rewrite.include_domains.is_empty(), + "should default include_domains to an empty list" + ); + } +``` + +In `config_payload.rs` tests: + +```rust + #[test] + fn legacy_blob_without_rewrite_include_domains_loads_with_empty_list() { + let data = + serde_json::to_value(test_settings()).expect("should serialize settings to JSON"); + let rewrite = data + .get("rewrite") + .and_then(serde_json::Value::as_object) + .expect("should serialize rewrite settings as an object"); + assert!( + !rewrite.contains_key("include_domains"), + "should omit the default include list from the payload" + ); + + let reconstructed = load_settings(&envelope_json(&test_settings())) + .expect("should reconstruct settings without include_domains"); + + assert!( + reconstructed.rewrite.include_domains.is_empty(), + "should load an empty include list from a blob without the key" + ); + } +``` + +```rust + #[test] + fn rewrite_include_domains_survive_blob_round_trip() { + let mut original = test_settings(); + original.rewrite.include_domains = vec!["*.cdn.example.com".to_owned()]; + + let reconstructed = load_settings(&envelope_json(&original)) + .expect("should reconstruct settings with include_domains"); + + assert_eq!( + reconstructed.rewrite.include_domains, + vec!["*.cdn.example.com".to_owned()], + "should preserve the configured include list" + ); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test-fastly -- rewrite_include_domains rewrite_exclude_domains_keep legacy_blob_without_rewrite` + +Expected: compile errors, `error[E0609]: no field include_domains on type settings::Rewrite`. + +- [ ] **Step 3: Implement** + +Replace `struct Rewrite` (`settings.rs:636-643`): + +```rust +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct Rewrite { + /// Hosts never rewritten, for assets and click-through links. + /// + /// Each entry is an exact host or `*.example.com`, which matches the apex + /// and any subdomain. Matching is case-insensitive. Wins over + /// [`Self::include_domains`]. + #[serde(default)] + pub exclude_domains: Vec, + /// When non-empty, the only hosts whose asset URLs are rewritten to + /// `/first-party/proxy`. + /// + /// Same pattern syntax as [`Self::exclude_domains`]. Empty (the default) + /// proxies every eligible asset URL. Never applies to click-through links. + /// The default is omitted from serialized config so older binaries, which + /// reject unknown fields, can still read it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[validate(custom(function = validate_include_domains))] + pub include_domains: Vec, +} +``` + +At the end of `Rewrite::normalize`, after the `exclude_domains` warning block: + +```rust + self.include_domains = self + .include_domains + .iter() + .map(|pattern| pattern.trim().to_ascii_lowercase()) + .collect(); + // An `exclude_domains` entry that can never equal a URL host keeps its + // host proxied and receiving `ts-ec`. Rejecting it would stop existing + // blobs from loading, so warn once per entry and keep it. + for pattern in &self.exclude_domains { + if let Err(reason) = check_include_domain(pattern) { + log::warn!( + "rewrite.exclude_domains: entry `{pattern}` never matches a URL host ({reason})" + ); + } + } +``` + +Before `validate_publisher_domain`: + +```rust +/// Rejects `rewrite.include_domains` entries that can never equal a URL host. +/// +/// Runs after [`Rewrite::normalize`], so entries are already trimmed and +/// lowercased. Entries are matched against [`Url::host_str`], so each one must +/// be: +/// +/// - a DNS name, optionally prefixed with `*.`: one or more `.`-separated, +/// non-empty labels of ASCII `a-z`, `0-9` and `-`; +/// - an IPv4 address in dotted-quad form, such as `192.0.2.1`; or +/// - an IPv6 address in brackets and compressed form, such as `[2001:db8::1]`. +/// +/// A scheme, port, path, space, leading or trailing `.`, empty label, wildcard +/// IP address or unbracketed IPv6 address is rejected with +/// `invalid_rewrite_include_domain`. A non-ASCII (IDN) name is rejected with +/// `non_ascii_rewrite_include_domain`; write it in punycode (`xn--`) form. +fn validate_include_domains(patterns: &[String]) -> Result<(), ValidationError> { + for pattern in patterns { + if !pattern.is_ascii() { + let mut err = ValidationError::new("non_ascii_rewrite_include_domain"); + err.add_param("value".into(), pattern); + err.message = Some( + "rewrite.include_domains entries must be ASCII; write internationalized domain names in punycode (xn--) form" + .into(), + ); + return Err(err); + } + if let Err(reason) = check_include_domain(pattern) { + let mut err = ValidationError::new("invalid_rewrite_include_domain"); + err.add_param("value".into(), pattern); + err.add_param("reason".into(), &reason); + err.message = Some( + "rewrite.include_domains entries must be a host name, an optional leading `*.`, a dotted-quad IPv4 address or a bracketed IPv6 address" + .into(), + ); + return Err(err); + } + } + Ok(()) +} + +/// Checks one normalized ASCII `include_domains` entry, returning why it can +/// never equal a URL host. +fn check_include_domain(pattern: &str) -> Result<(), &'static str> { + if let Some(bracketed) = pattern.strip_prefix('[') { + bracketed + .strip_suffix(']') + .and_then(|inner| inner.parse::().ok()) + .ok_or("not a bracketed IPv6 address")?; + return check_host_round_trip(pattern); + } + if pattern.parse::().is_ok() { + return Err("IPv6 address without brackets"); + } + + let name = pattern.strip_prefix("*.").unwrap_or(pattern); + if name.is_empty() { + return Err("no host"); + } + if !name.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'.') + }) { + return Err("character outside a-z, 0-9, `-` and `.`"); + } + if name.split('.').any(str::is_empty) { + return Err("leading or trailing `.` or empty label"); + } + + // A URL host whose last label is numeric is parsed as an IPv4 address, so + // such an entry only matches as an exact dotted-quad address. + let last_label = name.rsplit('.').next().unwrap_or(name); + let numeric_last_label = + last_label.bytes().all(|byte| byte.is_ascii_digit()) || last_label.starts_with("0x"); + if numeric_last_label + && (name.len() != pattern.len() || name.parse::().is_err()) + { + return Err("numeric host that is not a dotted-quad IPv4 address"); + } + check_host_round_trip(name) +} + +/// Parses `host` with the parser behind [`Url::host_str`] and requires the +/// rendered form to equal the input, so an entry that `url` rewrites (an +/// IPv4-mapped IPv6 address in dotted form, an invalid `xn--` label) or rejects +/// can never load and then silently fail to match. +fn check_host_round_trip(host: &str) -> Result<(), &'static str> { + let parsed = url::Host::parse(host).map_err(|_| "not a valid URL host")?; + if parsed.to_string() != host { + return Err("not in the form Url::host_str() renders"); + } + Ok(()) +} +``` + +The `validator` derive passes `&Vec`, which coerces to `&[String]`, so no `clippy::ptr_arg` allowance is needed. + +`validation_error_summary` reports only each error's code, so the code carries the distinction an operator needs. `non_ascii_rewrite_include_domain` says to use punycode, and `invalid_rewrite_include_domain` covers everything else. The `message` and `value`/`reason` params give the detail to anyone reading `ValidationErrors` directly. The checks are exact on purpose. Patterns are compared with `Url::host_str()`, so an entry with a scheme, a port, a path, a non-canonical IPv6 form (including dotted IPv4-mapped forms such as `[::ffff:192.0.2.1]`), an invalid `xn--` label or a non-ASCII name would load fine and then never match. The operator would see a silently empty allowlist instead of an error at `ts config push`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test-fastly -- rewrite_include_domains rewrite_exclude_domains_keep legacy_blob_without_rewrite` + +Expected: the eight new tests pass (`rewrite_exclude_domains_keep_entries_that_never_match`, `rewrite_include_domains_are_normalized_from_toml`, `rewrite_include_domains_reject_malformed_entries`, `rewrite_include_domains_reject_non_ascii_entries_with_a_punycode_hint`, `rewrite_include_domains_accept_hosts_and_ip_literals`, `rewrite_include_domains_default_to_empty`, `legacy_blob_without_rewrite_include_domains_loads_with_empty_list` and `rewrite_include_domains_survive_blob_round_trip`), together with the existing `legacy_blob_without_rewrite_*` tests. + +- [ ] **Step 5: Lint** + +Run: `cargo fmt --all && cargo clippy-fastly` + +Expected: no warnings. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/settings.rs crates/trusted-server-core/src/config_payload.rs +git commit --signoff -S -m "Add rewrite include_domains setting with validation" -m "Add the [rewrite] include_domains field with load-time normalization, strict entry validation and blob round-trip tests. Enforcement arrives in the next commit, which applies the list to asset rewriting and first-party sign." +``` + +--- + +### Task 4: Apply `include_domains` to asset rewriting and `/first-party/sign` + +**Files:** + +- Modify: `crates/trusted-server-core/src/settings.rs` (import, `Rewrite::should_proxy_asset`) +- Modify: `crates/trusted-server-core/src/proxy.rs:1228` (`is_host_permitted` visibility), decline log line +- Test: `settings.rs` tests, `creative.rs` tests (before `rewrite_html_excludes_blacklisted_domains`), `proxy.rs` tests (before `proxy_sign_inherits_the_request_scheme_for_protocol_relative_urls`) + +**Interfaces:** + +- Consumes: `Rewrite::include_domains` (Task 3); `proxy::is_host_permitted` (made `pub(crate)` in Step 3); `rewrite_creative_html`, `rewrite_inline_creative_html(settings, base_origin, markup)`, `rewrite_proxied_html`, `rewrite_css_body(settings, css) -> Result`; `build_proxy_sign_request(method, uri, target)` and `noop_services()` (existing `proxy.rs` test helpers). +- Produces: `should_proxy_asset` with the include clause. No new public items. + +- [ ] **Step 1: Write the failing tests** + +In `settings.rs` tests: + +```rust + #[test] + fn rewrite_should_proxy_asset_honors_include_list() { + let mut rewrite = Rewrite::default(); + rewrite + .include_domains + .extend(["*.example.com", "img.example.net", "*.UPPER.Example.org"].map(str::to_owned)); + rewrite + .exclude_domains + .push("blocked.example.com".to_owned()); + + for (host, expected) in [ + ("example.com", true), + ("a.b.example.com", true), + ("img.example.net", true), + ("IMG.EXAMPLE.NET", true), + ("cdn.upper.example.org", true), + ("evil-example.com", false), + ("cdn.example.org", false), + ("blocked.example.com", false), + ] { + assert_eq!( + rewrite.should_proxy_asset(host), + expected, + "should_proxy_asset(`{host}`) should be {expected}" + ); + } + for host in ["cdn.example.org", "evil-example.com", "example.com"] { + assert!( + rewrite.should_wrap_click(host), + "should wrap click for `{host}` regardless of include_domains" + ); + } + assert!( + !rewrite.should_wrap_click("blocked.example.com"), + "should not wrap a click to an excluded host" + ); + } +``` + +In `creative.rs`, add `rewrite_css_body` and `rewrite_proxied_html` to the test `use super::{...}` list, giving: + +```rust + use super::{ + CreativeCssProcessor, StreamProcessor as _, normalize_creative_url, + process_auction_creative, proxy_if_abs, rewrite_creative_html, rewrite_css_body, + rewrite_inline_creative_html, rewrite_proxied_html, rewrite_srcset, rewrite_style_urls, + sanitize_creative_html, + }; +``` + +Then add, before `rewrite_html_excludes_blacklisted_domains`: + +```rust + const ALLOWLIST_LISTED: &[&str] = &[ + "https://assets.example.com/a.css", + "https://assets.example.com/p1.png", + "https://assets.example.com/p1-2x.png", + "https://assets.example.com/a.js", + "https://assets.example.com/i.css", + "https://assets.example.com/bg.png", + "https://assets.example.com/set.png", + "https://assets.example.com/img.png", + "https://assets.example.com/lazy.png", + "https://assets.example.com/s1.png", + "https://assets.example.com/v.mp4", + "https://assets.example.com/o.swf", + "https://assets.example.com/e.swf", + "https://assets.example.com/btn.png", + "https://assets.example.com/icon.svg", + "https://assets.example.com/sprite.svg", + "https://assets.example.com/frame.html", + "https://assets.example.com/inline.png", + ]; + + const ALLOWLIST_OFF_LIST: &[&str] = &[ + "https://cdn.example.net/a.css", + "https://cdn.example.net/p2.png", + "https://cdn.example.net/a.js", + "https://cdn.example.net/i.css", + "https://cdn.example.net/bg.png", + "https://cdn.example.net/set.png", + "https://cdn.example.net/img.png", + "https://cdn.example.net/lazy.png", + "https://cdn.example.net/s2.png", + "https://cdn.example.net/v.mp4", + "https://cdn.example.net/o.swf", + "https://cdn.example.net/e.swf", + "https://cdn.example.net/btn.png", + "https://cdn.example.net/icon.svg", + "https://cdn.example.net/sprite.svg", + "https://cdn.example.net/frame.html", + "https://cdn.example.net/inline.png", + ]; + + const ALLOWLIST_CREATIVE: &str = r#" + + + + + + + + + + + + + + + + + + + + + +
+
+
Offer +Area +"#; + + fn encoded_tsurl(url: &str) -> String { + format!( + "tsurl={}", + url::form_urlencoded::byte_serialize(url.as_bytes()).collect::() + ) + } + + fn allowlist_settings() -> crate::settings::Settings { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.rewrite.include_domains = vec!["*.example.com".to_owned()]; + settings + } + + fn assert_allowlist_applied(label: &str, out: &str) { + for listed in ALLOWLIST_LISTED { + assert!( + out.contains(&encoded_tsurl(listed)), + "{label}: should proxy listed URL `{listed}`: {out}" + ); + } + for off_list in ALLOWLIST_OFF_LIST { + assert!( + !out.contains(&encoded_tsurl(off_list)), + "{label}: should not proxy off-list URL `{off_list}`: {out}" + ); + assert!( + out.contains(off_list), + "{label}: should keep off-list URL `{off_list}` raw: {out}" + ); + } + for landing in [ + "https://landing.example.net/offer", + "https://landing.example.org/area", + ] { + assert!( + out.contains(&format!("/first-party/click?{}", encoded_tsurl(landing))), + "{label}: should wrap click `{landing}` regardless of include_domains: {out}" + ); + } + assert_eq!( + out.matches("data-tsclick=").count(), + 2, + "{label}: should mark both click-through links" + ); + } + + #[test] + fn include_domains_limit_asset_rewriting_on_every_html_path() { + let settings = allowlist_settings(); + + assert_allowlist_applied( + "auction", + &rewrite_creative_html(&settings, ALLOWLIST_CREATIVE), + ); + assert_allowlist_applied( + "inline", + &rewrite_inline_creative_html(&settings, "https://www.example.com", ALLOWLIST_CREATIVE), + ); + assert_allowlist_applied( + "proxied", + &rewrite_proxied_html(&settings, ALLOWLIST_CREATIVE), + ); + } + + #[test] + fn include_domains_limit_css_body_rewriting() { + let settings = allowlist_settings(); + let css = r#"@import "https://assets.example.com/i.css"; +@import "https://cdn.example.net/i.css"; +.a { background: url(https://assets.example.com/bg.png); } +.b { background: url(https://cdn.example.net/bg.png); }"#; + + let out = rewrite_css_body(&settings, css).expect("should rewrite CSS body"); + + for listed in [ + "https://assets.example.com/i.css", + "https://assets.example.com/bg.png", + ] { + assert!( + out.contains(&encoded_tsurl(listed)), + "should proxy listed CSS URL `{listed}`: {out}" + ); + } + for off_list in [ + "https://cdn.example.net/i.css", + "https://cdn.example.net/bg.png", + ] { + assert!( + out.contains(off_list) && !out.contains(&encoded_tsurl(off_list)), + "should keep off-list CSS URL `{off_list}` raw: {out}" + ); + } + } +``` + +```rust + #[test] + fn host_in_both_lists_is_left_alone() { + let mut settings = allowlist_settings(); + settings.rewrite.exclude_domains = vec!["assets.example.com".to_owned()]; + let html = ALLOWLIST_CREATIVE.replace( + "", + r#"Excluded"#, + ); + + for (label, out) in [ + ("auction", rewrite_creative_html(&settings, &html)), + ( + "inline", + rewrite_inline_creative_html(&settings, "https://www.example.com", &html), + ), + ("proxied", rewrite_proxied_html(&settings, &html)), + ] { + assert!( + !out.contains("tsurl=https%3A%2F%2Fassets.example.com"), + "{label}: should not rewrite any URL on a host in both lists: {out}" + ); + for listed in ALLOWLIST_LISTED { + assert!( + out.contains(listed), + "{label}: should keep `{listed}` raw when its host is also excluded: {out}" + ); + } + assert!( + out.contains(r#"Excluded"#), + "{label}: should leave an excluded click-through link raw: {out}" + ); + assert_eq!( + out.matches("data-tsclick=").count(), + 2, + "{label}: should wrap only the two links on hosts that are not excluded: {out}" + ); + } + } +``` + +In `proxy.rs` tests: + +```rust + #[test] + fn proxy_sign_applies_rewrite_include_domains_for_get_and_post() { + struct Case { + name: &'static str, + include_domains: &'static [&'static str], + exclude_domains: &'static [&'static str], + allowed_domains: &'static [&'static str], + target: &'static str, + expected: StatusCode, + } + + let cases = [ + Case { + name: "listed absolute", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &[], + target: "https://img.example.com/a.png", + expected: StatusCode::OK, + }, + Case { + name: "listed protocol-relative", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &[], + target: "//img.example.com/a.png", + expected: StatusCode::OK, + }, + Case { + name: "off-list absolute", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &[], + target: "https://img.example.net/a.png", + expected: StatusCode::BAD_GATEWAY, + }, + Case { + name: "off-list protocol-relative", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &[], + target: "//img.example.net/a.png", + expected: StatusCode::BAD_GATEWAY, + }, + Case { + name: "host in both lists", + include_domains: &["*.example.com"], + exclude_domains: &["img.example.com"], + allowed_domains: &[], + target: "https://img.example.com/a.png", + expected: StatusCode::BAD_GATEWAY, + }, + Case { + name: "off include list and off proxy allowlist", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &["*.example.org"], + target: "https://img.example.net/a.png", + expected: StatusCode::BAD_GATEWAY, + }, + Case { + name: "listed but off proxy allowlist", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &["*.example.org"], + target: "https://img.example.com/a.png", + expected: StatusCode::FORBIDDEN, + }, + Case { + name: "mixed-case host", + include_domains: &["*.example.com"], + exclude_domains: &[], + allowed_domains: &[], + target: "https://IMG.Example.COM/a.png", + expected: StatusCode::OK, + }, + ]; + + let to_owned = |values: &[&str]| -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + }; + + futures::executor::block_on(async { + for case in &cases { + for method in [&Method::GET, &Method::POST] { + let label = format!("{} {}", method.as_str(), case.name); + let mut settings = create_test_settings(); + settings.rewrite.include_domains = to_owned(case.include_domains); + settings.rewrite.exclude_domains = to_owned(case.exclude_domains); + settings.proxy.allowed_domains = to_owned(case.allowed_domains); + let req = build_proxy_sign_request( + method, + "https://edge.example.com/first-party/sign", + case.target, + ); + + let status = + match handle_first_party_proxy_sign(&settings, &noop_services(), req).await + { + Ok(response) => response.status(), + Err(error) => error.current_context().status_code(), + }; + + assert_eq!( + status, case.expected, + "{label} should return {}", + case.expected + ); + } + } + }); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run each separately, because a wasm panic stops the binary: + +```bash +cargo test-fastly -- rewrite_should_proxy_asset_honors --nocapture +cargo test-fastly -- include_domains_limit_asset --nocapture +cargo test-fastly -- include_domains_limit_css --nocapture +cargo test-fastly -- proxy_sign_applies_rewrite_include_domains --nocapture +``` + +Expected failures: + +- `should_proxy_asset(`evil-example.com`) should be false` +- `auction: should not proxy off-list URL `https://cdn.example.net/a.css`` +- `should keep off-list CSS URL `https://cdn.example.net/i.css` raw` +- `GET off-list absolute should return 502 Bad Gateway` + +`host_in_both_lists_is_left_alone` already passes here: `exclude_domains` wins without the include clause. It guards precedence after Step 3. It runs the whole `ALLOWLIST_CREATIVE`, plus an anchor on the excluded host, through all three HTML paths with `exclude_domains = ["assets.example.com"]`, so every handler is covered. It asserts that no `assets.example.com` URL is rewritten anywhere, that every listed URL stays raw, and that only the two non-excluded links are wrapped. + +The `link imagesrcset` listed candidate (`p1-2x.png`) is a different URL from the link's `href` (`p1.png`). That way the matrix proves `imagesrcset` itself is proxied while the list is set, and an `href` match cannot satisfy the assertion. + +- [ ] **Step 3: Implement the include clause** + +In `proxy.rs:1228`, widen `is_host_permitted`'s visibility only. This belongs to the include clause, not the shared step: + +```rust +pub(crate) fn is_host_permitted>(allowed_domains: &[S], host: &str) -> bool { +``` + +In `settings.rs`, widen the import: + +```rust +use crate::proxy::{is_host_allowed, is_host_permitted}; +``` + +Replace `should_proxy_asset` and its doc comment: + +```rust + /// Returns `true` when an asset URL on `host` should be rewritten to + /// `/first-party/proxy`. + /// + /// The host must not match [`Self::exclude_domains`], and must match + /// [`Self::include_domains`] when that list is non-empty. Matching is + /// case-insensitive; see `crate::proxy::is_host_allowed` for the pattern rules. + #[must_use] + pub fn should_proxy_asset(&self, host: &str) -> bool { + !self.is_excluded_host(host) && is_host_permitted(&self.include_domains, host) + } +``` + +`is_host_permitted` returns `true` for an empty list, which is the "include list empty" half of the rule. + +In the `creative.rs` module docs, append to the key behaviors list after the "Non-network schemes are ignored" bullet (`creative.rs:21-22`): + +```rust +//! - When `[rewrite] include_domains` is non-empty, only asset URLs on listed +//! hosts are proxied. Links ignore it. +``` + +In `proxy.rs`, update the decline log in `handle_first_party_proxy_sign`: + +```rust + log::debug!( + "sign request for `{host}` declined: host excluded or not in rewrite.include_domains" + ); +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test-fastly -- rewrite_should_proxy_asset_honors include_domains_limit host_in_both_lists proxy_sign_` + +Expected: `12 passed`. + +- [ ] **Step 5: Run the full core suite and lint** + +Run: `cargo fmt --all && cargo test-fastly && cargo clippy-fastly` + +Expected: PASS, no warnings. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/settings.rs crates/trusted-server-core/src/creative.rs crates/trusted-server-core/src/proxy.rs +git commit --signoff -S -m "Limit creative asset rewriting to rewrite include_domains" -m "Apply the include list to every asset handler and to first-party sign. Click-through links and fetch-time proxy checks are unchanged." +``` + +--- + +### Task 5: Rewrite `` once on the inline path + +On ``, both the `link[href]` handler and the standalone `[imagesrcset]` handler rewrite `imagesrcset`. On the root-relative paths (`/auction`, proxied HTML) the second pass sees `/first-party/proxy?...`, a relative URL, and leaves it alone. On the inline path the first pass emits absolute `https:///first-party/proxy?...` URLs, so the second pass proxies them again, producing duplicated `tsurl` and `tstoken` parameters. The fix removes the `imagesrcset` handling from `link[href]`. `[imagesrcset]` already rewrites it once on every element. + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative.rs` (`link[href]` handler in `rewrite_creative_html_impl`, `:1220-1242`) +- Test: `creative.rs` tests, before `host_in_both_lists_is_left_alone` + +**Interfaces:** none change. + +- [ ] **Step 1: Write the failing test and the root-relative guard** + +```rust + // `imagesrcset` is rewritten only by the `[imagesrcset]` handler. When the + // `link[href]` handler also rewrote it, the inline path's absolute proxy + // URLs were proxied a second time. + #[test] + fn inline_link_imagesrcset_is_proxied_once() { + let settings = crate::test_support::tests::create_test_settings(); + let html = r#""#; + + let out = rewrite_inline_creative_html(&settings, "https://publisher.example.com", html); + + let imagesrcset = out + .split("imagesrcset=\"") + .nth(1) + .and_then(|rest| rest.split('"').next()) + .expect("should keep an imagesrcset attribute"); + assert_eq!( + imagesrcset.matches("tsurl=").count(), + 1, + "should proxy the imagesrcset candidate exactly once: {imagesrcset}" + ); + } + + #[test] + fn root_relative_link_imagesrcset_is_proxied_once() { + let settings = crate::test_support::tests::create_test_settings(); + let html = r#""#; + + for (label, out) in [ + ("auction", rewrite_creative_html(&settings, html)), + ("proxied", rewrite_proxied_html(&settings, html)), + ] { + let imagesrcset = out + .split("imagesrcset=\"") + .nth(1) + .and_then(|rest| rest.split('"').next()) + .expect("should keep an imagesrcset attribute"); + assert_eq!( + imagesrcset.matches("tsurl=").count(), + 1, + "{label}: should proxy the imagesrcset candidate exactly once: {imagesrcset}" + ); + } + } +``` + +- [ ] **Step 2: Run the tests to verify the inline one fails** + +Run each alone (a wasm panic aborts the whole test binary): + +```bash +cargo test-fastly -p trusted-server-core --lib -- --exact creative::tests::inline_link_imagesrcset_is_proxied_once --nocapture +cargo test-fastly -p trusted-server-core --lib -- --exact creative::tests::root_relative_link_imagesrcset_is_proxied_once +``` + +Expected: + +- The inline test panics with "should proxy the imagesrcset candidate exactly once: https://publisher.example.com/first-party/proxy?tsurl=https%3A%2F%2Fpublisher.example.com%2Ffirst-party%2Fproxy&tsurl=...". +- The root-relative guard passes. Those paths were never affected. + +- [ ] **Step 3: Implement** + +In the `link[href]` handler, delete the `imagesrcset` block and keep the `href` handling. A let-chain keeps `clippy::collapsible_if` quiet without depending on where a comment sits: + +```rust + // Stylesheets and preloads. `imagesrcset` is left to the + // `[imagesrcset]` handler, which also runs on this element; + // rewriting it here too would proxy the inline path's absolute + // proxy URLs a second time. + element!("link[href]", |el| { + let rel = el + .get_attribute("rel") + .unwrap_or_default() + .to_ascii_lowercase(); + if (rel.contains("stylesheet") + || rel.contains("preload") + || rel.contains("prefetch")) + && let Some(p) = + proxied_attr_value(settings, el.get_attribute("href"), base_origin) + { + let _ = el.set_attribute("href", &p); + } + Ok(()) + }), +``` + +In the `creative.rs` module docs, drop "and `imagesrcset`" from the `` bullet, which becomes ``//! - `` ``. The `[imagesrcset]` bullet above it already covers the attribute. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test-fastly -p trusted-server-core --lib -- imagesrcset include_domains_limit` + +Expected: `9 passed`. This includes both new tests, `include_domains_limit_asset_rewriting_on_every_html_path` (whose inline case uses the realistic `https://www.example.com` origin), and the existing `imagesrcset` rewrite and sanitizer tests. + +- [ ] **Step 5: Run the full core suite and lint** + +Run: `cargo fmt --all && cargo test-fastly && cargo clippy-fastly` + +Expected: PASS, no warnings. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/creative.rs +git commit --signoff -S -m "Rewrite link imagesrcset once on the inline creative path" +``` + +--- + +### Task 6: Operator documentation, example config and changelog + +**Files:** + +- Modify: `trusted-server.example.toml:127-129` +- Modify: `docs/guide/configuration.md:985-1068` +- Modify: `docs/guide/creative-processing.md:637-700` +- Modify: `docs/guide/first-party-proxy.md:173`, `:509-521` +- Modify: `docs/guide/api-reference.md:565-570` +- Modify: `CHANGELOG.md` (`## [Unreleased]`: `### Fixed` at `:34`, `### Added` at `:41`; both already exist) + +- [ ] **Step 1: `trusted-server.example.toml`** + +Replace lines 127-129 with: + +```toml +# [rewrite] +# Hosts never rewritten: assets stay direct and click-through links are not +# wrapped. Supports "*.example.com" wildcards (matches the apex too). +# exclude_domains = ["*.cdn.example.com"] +# When non-empty, only asset URLs on these hosts are proxied; other assets load +# directly. Click-through links are not affected. Set this in TOML; environment +# overrides cannot replace arrays. +# include_domains = ["assets.example.com", "*.img.example.com"] +``` + +- [ ] **Step 2: `docs/guide/configuration.md`** + +- Section intro (`:989`): "Control which hosts first-party rewriting covers." +- `[rewrite]` table (`:993-995`): + +```markdown +| Field | Type | Required | Description | +| ----------------- | ------------- | ---------------- | --------------------------------------------------------------------------------------------------- | +| `exclude_domains` | Array[String] | No (default: []) | Hosts never rewritten, for assets and click-through links | +| `include_domains` | Array[String] | No (default: []) | When non-empty, the only hosts whose asset URLs are proxied. Does not apply to click-through links. | +``` + +- Add `include_domains = ["assets.example.com", "*.img.example.com"]` to the example and change the Environment Override text to "EdgeZero v0.0.4 cannot replace these arrays or address their elements by index. Edit `exclude_domains` and `include_domains` in TOML, then validate and push the file again." +- After the exact-pattern list, add: + +```markdown +**Normalization and precedence**: + +- Both lists are trimmed and lowercased at load, so matching is case-insensitive. +- `exclude_domains` wins when a host matches both lists. +- Each `include_domains` entry must be able to equal a URL host. After + lowercasing, an entry must be one of: + - a host name of ASCII letters, digits, `-` and `.`, optionally prefixed with + `*.`, with no leading or trailing `.` and no empty label; + - an IPv4 address in dotted-quad form, such as `192.0.2.1`; + - an IPv6 address in brackets and compressed form, such as `[2001:db8::1]`. +- Settings load and `ts config validate` reject any other `include_domains` + entry: an empty entry, a bare `*`, a `*` other than a leading `*.`, a scheme + (`https://`), a port, a path, spaces, or an unbracketed IPv6 address. + Non-ASCII (internationalized) names are rejected too; write them in punycode, + such as `xn--bcher-kva.example`. +- Empty and bare `*` entries in `exclude_domains` are dropped with a warning, + because they never match a host. + +::: tip +When both `include_domains` and `proxy.allowed_domains` are set, keep every +`include_domains` host inside `proxy.allowed_domains`. Otherwise a listed asset +is rewritten to a proxy URL that then fails with `403`. +::: + +::: warning Upgrade sequencing and rollback for `include_domains` +An empty `include_domains` is omitted from stored JSON. A non-empty list is +serialized, and older binaries reject a blob that carries it because `[rewrite]` +uses `deny_unknown_fields`. + +**Upgrading:** deploy the binary **first**, then add `include_domains` to +`trusted-server.toml`, run `ts config validate`, and push. + +**Rolling back:** remove `include_domains` from the TOML, run +`ts config validate`, push the resulting blob, and only then roll back the +binary. Rolling back the binary also restores case-sensitive `exclude_domains` +matching. +::: +``` + +The warning box mirrors the existing `[auction] rewrite_creatives` upgrade and rollback box (and #1234's `[auction] rewrite_clicks` box if that has landed). + +- [ ] **Step 3: `docs/guide/creative-processing.md`** + +- Wildcard example (`:656-663`): replace `❌ cdn.example.com (no subdomain)` with `✅ cdn.example.com (base domain)`. +- Under `### Pattern Matching`, add "Matching is case-insensitive. Entries are trimmed and lowercased at load." and change the wildcard lead-in to "`*.` matches the base domain and any subdomain". +- After the Exclude Domains use cases, add: + +```markdown +## Include Domains + +Limit asset rewriting to hosts you list: + + [rewrite] + include_domains = ["assets.example.com", "*.img.example.com"] + +- When `include_domains` is empty (the default), every eligible asset URL is + proxied. +- When it is non-empty, only asset URLs whose host matches an entry are + rewritten to `/first-party/proxy`. Other assets keep their original URL, load + directly, and do not receive the EC ID that proxied fetches append. +- `exclude_domains` wins when a host matches both lists. +- Click-through links (``, ``) are not affected. Whether + they are wrapped in `/first-party/click` depends only on `rewrite_clicks` and + `exclude_domains`. +- HTML and CSS fetched through `/first-party/proxy` go through the same rewrite + pass and follow the same list. +- `/first-party/sign` declines off-list hosts with a non-`403` error, so the + creative runtime loads them directly. + +Patterns use the same syntax as `exclude_domains`. Entries that can never +equal a URL host, such as ones with a scheme, port, path or non-ASCII name, fail +validation; see [Pattern Matching](/guide/configuration#pattern-matching). +``` + +If #1234 has not landed, drop the `rewrite_clicks` mention from the click-through bullet. + +(In the guide the TOML is a fenced `toml` block.) + +- [ ] **Step 4: `docs/guide/first-party-proxy.md`** + +- Rename `### URL Rewrite Exclusions` (`:509`) to `### URL Rewrite Host Lists`, and after "URLs matching these patterns will NOT be rewritten to `/first-party/proxy`." add a fenced `toml` example with `include_domains = ["assets.example.com", "*.img.example.com"]` followed by: "When `include_domains` is non-empty, only asset URLs on matching hosts are rewritten. `exclude_domains` wins when a host matches both lists, and click-through links ignore `include_domains`." +- Before "A valid host outside a non-empty allowlist returns `403 Forbidden`." (`:173`), add the paragraph: "A host excluded by `rewrite.exclude_domains`, or outside a non-empty `rewrite.include_domains`, returns `502`. The creative runtime treats it as a non-policy failure and loads the URL directly. These checks run before `proxy.allowed_domains`." + +- [ ] **Step 5: `docs/guide/api-reference.md`** + +In `/first-party/sign` Error Responses, after the `413` bullet: + +```markdown +- `502 Bad Gateway`: The target host matches `rewrite.exclude_domains` or is outside a non-empty `rewrite.include_domains` +``` + +- [ ] **Step 6: `CHANGELOG.md`** + +Append to the existing `### Fixed` list under `## [Unreleased]`. The `exclude_domains` entry already came with the shared step. + +```markdown +- Inline SSAT/page-bids creatives no longer proxy `` candidates twice. The `imagesrcset` attribute is now rewritten once, by the same handler on every creative path; previously the inline path's absolute proxy URLs were wrapped in a second `/first-party/proxy` URL. +``` + +Insert at the top of the existing `### Added` list under `## [Unreleased]`: + +```markdown +- `[rewrite] include_domains` limits creative asset rewriting to listed hosts. When non-empty, only asset URLs on matching hosts are proxied through `/first-party/proxy`, so only those hosts receive the EC ID on proxied fetches. Other assets keep their original URL, and `/first-party/sign` returns `502` for them so the creative runtime loads them directly. Click-through links are not affected, and `exclude_domains` still wins. Entries must be host names, optionally prefixed with `*.`, dotted-quad IPv4 addresses, or bracketed IPv6 addresses; entries with a scheme, port, path, spaces or non-ASCII characters are rejected at load. Upgrading: deploy the binary first, then push a config that sets `include_domains`. Rolling back: remove `include_domains`, push the resulting config, then roll back the binary; older binaries reject the key because `[rewrite]` denies unknown fields. +``` + +- [ ] **Step 7: Format and check** + +Run: `cd docs && npm ci && cd .. && docs/node_modules/.bin/prettier --config docs/.prettierrc --write docs/guide/configuration.md docs/guide/creative-processing.md docs/guide/first-party-proxy.md docs/guide/api-reference.md CHANGELOG.md` + +Expected: only `configuration.md` changes (table column alignment); the rest report `(unchanged)`. + +- [ ] **Step 8: Commit** + +```bash +git add docs/guide/configuration.md docs/guide/creative-processing.md docs/guide/first-party-proxy.md docs/guide/api-reference.md trusted-server.example.toml CHANGELOG.md +git commit --signoff -S -m "Document rewrite include_domains and case-insensitive exclusions" +``` + +--- + +### Task 7: Full CI gate + +**Files:** none. + +- [ ] **Step 1: Format and lint** + +```bash +cargo fmt --all -- --check +cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm \ + && cargo clippy-spin-native && cargo clippy-spin-wasm && cargo clippy-cli && cargo clippy-codegen +``` + +Expected: all pass. + +- [ ] **Step 2: Tests** + +```bash +cargo test-fastly && cargo test-fastly-reuse && cargo test-axum && cargo test-cloudflare && cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +./scripts/test-cli.sh +``` + +Expected on `7a0ecb4c` plus #1234's code and this change (the review-fix run): `test-fastly` 3169 passed, 10 ignored (core 2922 passed, 6 ignored); `test-fastly-reuse` 219 passed; `test-axum` 43 passed; `test-cloudflare` 53 passed; `test-spin` 87 passed; parity 17 passed; CLI 735 passed across `./scripts/test-cli.sh`. Without #1234 in front, the counts are lower by #1234's own tests. + +- [ ] **Step 3: JS and docs** + +```bash +cd crates/trusted-server-js/lib && npx vitest run && npm run format && cd - +cd docs && npm run format && cd - +docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md" +``` + +Expected: all pass. JS is unchanged. + +- [ ] **Step 4: Spec conformance** + +| Criterion | Evidence | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Empty list leaves existing rewrite assertions unchanged | Task 2 Step 6 and Task 4 Step 5: full suite, no assertion edits | +| Listed proxied, off-list raw, every handler, three HTML paths | `include_domains_limit_asset_rewriting_on_every_html_path`, `include_domains_limit_css_body_rewriting` | +| Host in both lists left alone | `host_in_both_lists_is_left_alone`, sign case "host in both lists" | +| Case-insensitive; `*.example.com` matches apex | Task 1 and Task 4 policy tests, `rewrite_exclude_domains_match_mixed_case_entries_from_toml` | +| Sign returns `502` off-list for absolute and `//`, GET and POST | `proxy_sign_applies_rewrite_include_domains_for_get_and_post` | +| Anchors wrapped regardless of allowlist | `assert_allowlist_applied` click assertions | +| Default omitted from serialized blob | `legacy_blob_without_rewrite_include_domains_loads_with_empty_list` | +| Docs, example TOML, CHANGELOG | Task 6 | + +- [ ] **Step 5: Open the PR** + +Use the `pr-creator` agent. Link the spec and #1231. State whether this PR introduced the shared step (Tasks 1-2) or rebased onto #1234's. + +## Notes + +- **Unparseable absolute URLs.** Before this change an absolute-looking value that `url::Url::parse` rejects had its attribute rewritten to its own value, re-quoted with double quotes. After, it is left untouched. `unparseable_absolute_url_is_left_byte_identical` and `unparseable_absolute_click_url_is_left_byte_identical` pin the new behavior. Against the pre-change code both were observed failing: `` came back re-quoted, and the anchor also gained `data-tsclick`. diff --git a/docs/superpowers/plans/2026-10-07-1234-creative-click-rewrite-switch.md b/docs/superpowers/plans/2026-10-07-1234-creative-click-rewrite-switch.md new file mode 100644 index 000000000..674abf296 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-1234-creative-click-rewrite-switch.md @@ -0,0 +1,1856 @@ +# Creative Click Rewrite Switch Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `[auction] rewrite_clicks` so creative click wrapping (`` / `` → signed `/first-party/click`) can be turned on or off independently of asset rewriting, without changing output for any existing config. + +**Architecture:** + +- Replace `to_abs` with a pure URL normalizer plus host policy on `Rewrite`. This "shared step" is written identically in #1231's PR. +- Add an `Option` field on `AuctionConfig` with two resolution methods. +- Give the lol_html rewrite pass a `CreativeFeatures { assets, clicks }` parameter, and register the asset and anchor handlers only when their feature is on. +- `` handling and TSJS injection run whenever the pass runs. The pass drops the bidder base URL (`href`) and keeps `target`; a `` with no `target` is removed as today. + +**Tech Stack:** Rust 2024 (`trusted-server-core`, `trusted-server-cli`), `lol_html`, `url`, `serde`, EdgeZero typed app-config loader, Viceroy for `wasm32-wasip1` tests. + +**Spec:** `docs/superpowers/specs/2026-10-07-1234-creative-click-rewrite-switch-design.md` (issue [#1234](https://github.com/IABTechLab/trusted-server/issues/1234)). Read it first. + +**Status:** Validated by an exploratory implementation on 2026-10-07. Every code block below is the code that compiled, passed clippy and passed its tests. Each step's "Expected" line is the observed result. + +## Global Constraints + +- With `rewrite_clicks` unset, rewritten output must be byte-identical to today for both values of `rewrite_creatives` on `/auction` and inline SSAT/page-bids, and unchanged for `/first-party/proxy` HTML, except for the shared step's CHANGELOG Fixed items. +- `rewrite_clicks` is `Option` with `#[serde(default, skip_serializing_if = "Option::is_none")]`. `AuctionConfig` keeps `deny_unknown_fields`. +- Unset resolution: auction paths use `rewrite_clicks.unwrap_or(rewrite_creatives)`, and proxied HTML uses `rewrite_clicks.unwrap_or(true)`. +- `exclude_domains` applies to anchors. The #1231 include list never does. +- The shared matcher is `crate::proxy::is_host_allowed` (`proxy.rs:1242`), which is case-insensitive. +- No JavaScript, adapter, routing or dependency changes. `/first-party/click` and `/first-party/proxy-rebuild` stay routed. +- AGENTS.md rules apply: + - example.com-style domains only; + - `expect("should ...")` messages; + - comments on their own line; + - no local `use` inside functions; + - every public item documented; + - at most 7 function arguments. +- Commits follow `CONTRIBUTING.md`: sentence case, imperative mood, subject near 50 characters, wrapped body. No prefixes, no attribution trailers. + +## Environment notes (learned during validation) + +- `cargo test-fastly` runs the wasm test binary through Viceroy. If `viceroy` is installed under `~/.cargo/bin` but not on `PATH`, prefix commands with `PATH=$HOME/.cargo/bin:$PATH`. Validation used Viceroy 0.17.0, as pinned in `.tool-versions`. +- Under Viceroy, one panicking test aborts the whole wasm test binary (exit 134) and hides the assertion message. To watch a test fail, run it alone: + + ```bash + cargo test-fastly -p trusted-server-core --lib -- --exact --nocapture + ``` + + Read the `panicked at` line. + +- A test that only adds a new symbol fails at compile time (`E0425`/`E0599`/`E0609`). That is the red step for API-shaped tests. Tests that only use existing APIs must be run against the old code to see a runtime red. +- The worktree may have no `docs/node_modules`. Use a checkout that has one: + + ```bash + /path/to/checkout/docs/node_modules/.bin/prettier --config docs/.prettierrc ... + ``` + + `cd docs && npm run format` (check mode) still works. + +## File Map + +| File | Responsibility in this change | +| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/creative.rs` | `normalize_creative_url`, `asset_target`/`click_target`, `CreativeFeatures`, conditional handler registration, caller wiring, matrix tests. | +| `crates/trusted-server-core/src/settings.rs` | `Rewrite::should_proxy_asset` / `should_wrap_click` / `normalize`, removal of `is_excluded`, TOML tests. | +| `crates/trusted-server-core/src/proxy.rs` | `/first-party/sign` call-site migration, sign and proxied-HTML tests. | +| `crates/trusted-server-core/src/auction_config_types.rs` | `rewrite_clicks`, `rewrites_auction_clicks` / `rewrites_proxied_clicks`, serialization tests. | +| `crates/trusted-server-core/src/config_payload.rs` | Blob round-trip tests. | +| `crates/trusted-server-core/src/auction/orchestrator.rs` | `rewrite_clicks: None` in the one exhaustive `AuctionConfig` literal. | +| `crates/trusted-server-core/src/auction/formats.rs` | Doc comment, debug log, `/auction` end-to-end test. | +| `crates/trusted-server-core/src/publisher.rs` | Inline end-to-end test. | +| `crates/trusted-server-core/src/auction/endpoints.rs` | Response doc comment. | +| `crates/trusted-server-cli/tests/config_env_overlay.rs` | Overlay tests for `TRUSTED_SERVER__AUCTION__REWRITE_CLICKS`. | +| `trusted-server.example.toml` | Reworded `rewrite_creatives` comment and commented-out `rewrite_clicks` leaf. | +| `docs/guide/*.md`, `crates/trusted-server-core/src/auction/README.md`, `CHANGELOG.md` | Operator documentation. | + +## Shared step + +This section is identical in the #1231 and #1234 plans. Both PRs write this production code and this test set verbatim; whichever PR lands first ships them, and the other skips its shared-step task. `proxy::is_host_permitted` stays private in the shared step: #1231 makes it `pub(crate)` in the task that adds the include clause, and #1234 never needs it. + +`crates/trusted-server-core/src/settings.rs`, imports (`:26`). The shared step uses only `is_host_allowed`, so it imports only that; importing `is_host_permitted` here would fail `-D warnings` as an unused import. #1231's include-clause task widens the import. + +```rust +use crate::proxy::is_host_allowed; +``` + +`settings.rs`, replaces `impl Rewrite` (`:645-670`), removing `is_excluded` and its `#[allow(dead_code)]`: + +```rust +impl Rewrite { + /// Returns `true` when an asset URL on `host` should be rewritten to + /// `/first-party/proxy`. + /// + /// The host must not match [`Self::exclude_domains`]. Matching is + /// case-insensitive; see `crate::proxy::is_host_allowed` for the pattern rules. + #[must_use] + pub fn should_proxy_asset(&self, host: &str) -> bool { + !self.is_excluded_host(host) + } + + /// Returns `true` when a click-through URL on `host` should be wrapped in + /// `/first-party/click`. + /// + /// Only [`Self::exclude_domains`] applies to click-through links. + #[must_use] + pub fn should_wrap_click(&self, host: &str) -> bool { + !self.is_excluded_host(host) + } + + fn is_excluded_host(&self, host: &str) -> bool { + self.exclude_domains + .iter() + .any(|pattern| is_host_allowed(host, pattern)) + } + + /// Trims and lowercases host patterns in place. + /// + /// Empty and bare `*` entries in `exclude_domains` are dropped with a + /// warning: neither can match a host, so dropping them changes no behavior. + fn normalize(&mut self) { + let before = self.exclude_domains.len(); + self.exclude_domains = self + .exclude_domains + .iter() + .map(|pattern| pattern.trim().to_ascii_lowercase()) + .filter(|pattern| !pattern.is_empty() && pattern != "*") + .collect(); + if self.exclude_domains.len() < before { + log::warn!( + "rewrite.exclude_domains: removed empty or bare \"*\" entries, which never match a host" + ); + } + } +} +``` + +`settings.rs`, in `Settings::normalize_deserialized` (`:2988`), right after `self.proxy.normalize();`: + +```rust + self.rewrite.normalize(); +``` + +`crates/trusted-server-core/src/creative.rs`, replaces `to_abs` and its comment (`:52-75`): + +```rust +/// Normalizes a creative URL to an absolute HTTP(S) [`url::Url`]. +/// +/// Trims surrounding whitespace, resolves a protocol-relative `//host/...` +/// against `https:`, and accepts only `http://` and `https://` input (ASCII +/// case-insensitive). Returns `None` for empty, relative, non-network-scheme or +/// unparseable input. Applies no host policy; see +/// [`crate::settings::Rewrite::should_proxy_asset`] and +/// [`crate::settings::Rewrite::should_wrap_click`]. +pub(super) fn normalize_creative_url(url: &str) -> Option { + let trimmed = url.trim(); + let is_http = trimmed + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")); + let is_https = trimmed + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")); + if trimmed.starts_with("//") { + url::Url::parse(&format!("https:{trimmed}")).ok() + } else if is_http || is_https { + url::Url::parse(trimmed).ok() + } else { + None + } +} + +/// Normalizes `raw` and returns it when asset policy allows proxying its host. +fn asset_target(settings: &Settings, raw: &str) -> Option { + normalize_creative_url(raw).filter(|url| { + url.host_str() + .is_some_and(|host| settings.rewrite.should_proxy_asset(host)) + }) +} + +/// Normalizes `raw` and returns it when click policy allows wrapping its host. +fn click_target(settings: &Settings, raw: &str) -> Option { + normalize_creative_url(raw).filter(|url| { + url.host_str() + .is_some_and(|host| settings.rewrite.should_wrap_click(host)) + }) +} +``` + +`creative.rs` call sites (`:402`, `:409`, `:639-641`, `:726-727`, `:1285-1287`): + +```rust + // CssUrlRewriter::rewrite + let Some(target) = asset_target(self.settings, value) else { + return; + }; + // ... later in the same function: + let proxied = build_proxy_url(self.settings, target.as_str(), self.base_origin); + +// proxy_if_abs +pub(super) fn proxy_if_abs(settings: &Settings, val: &str, base_origin: &str) -> Option { + asset_target(settings, val).map(|url| build_proxy_url(settings, url.as_str(), base_origin)) +} + + // rewrite_srcset + let rewritten = if let Some(target) = asset_target(settings, url) { + build_proxy_url(settings, target.as_str(), base_origin) + } else { + url.to_owned() + }; + + // Anchor handler + element!("a[href], area[href]", |el| { + if let Some(href) = el.get_attribute("href") + && let Some(target) = click_target(settings, &href) + { + let click = build_click_url(settings, target.as_str(), base_origin); + let _ = el.set_attribute("href", &click); + let _ = el.set_attribute("data-tsclick", &click); + } + Ok(()) + }), +``` + +`creative.rs` module doc (`:25-27`): + +```rust +//! - `normalize_creative_url(&str) -> Option`: Normalizes an absolute or +//! protocol-relative http(s) URL; returns `None` for relative input, +//! non-network schemes and unparseable values. Host policy lives on +//! [`crate::settings::Rewrite`]. +``` + +`proxy.rs`, `handle_first_party_proxy_sign` (`:1625`): replace `:1672-1703` (from `let trimmed` up to the `is_host_permitted` check) with the block below, then at `:1723` and `:1726` use `target` in place of `parsed` and `target.as_str()` in place of `&abs`. The old parse, scheme and duplicate exclusion checks go away because the normalizer already guarantees an `http(s)` URL. + +```rust + let trimmed = payload.url.trim(); + let protocol_relative; + // A protocol-relative target inherits the signing request's scheme before + // normalization, so `//` and absolute input reach the same policy check. + let candidate = if trimmed.starts_with("//") { + protocol_relative = format!("{request_scheme}:{trimmed}"); + protocol_relative.as_str() + } else { + trimmed + }; + let target = crate::creative::normalize_creative_url(candidate).ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "unsupported url".to_string(), + }) + })?; + let host = target.host_str().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "missing host".to_string(), + }) + })?; + if !settings.rewrite.should_proxy_asset(host) { + log::debug!("sign request for `{host}` declined by rewrite policy"); + return Err(Report::new(TrustedServerError::Proxy { + message: "unsupported url".to_string(), + })); + } +``` + +```rust + let mut base = target.clone(); + base.set_query(None); + base.set_fragment(None); + let proxied = crate::creative::build_proxy_url_with_extras(settings, target.as_str(), &extras); +``` + +### Shared tests + +Every shared test builds `Rewrite` with `Rewrite::default()` and `.extend`/`.push` on its lists, or sets `settings.rewrite.exclude_domains` on a full `Settings`. None uses a `Rewrite { .. }` literal or `#[allow(clippy::field_reassign_with_default)]`. The code for each test is in the task that adds it in each plan. + +| File | Test | Replaces or placement | +| ------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `settings.rs` | `rewrite_policy_matches_exclude_patterns_case_insensitively` | replaces `test_rewrite_is_excluded` | +| `settings.rs` | `rewrite_normalize_trims_lowercases_and_drops_inert_exclude_entries` | after the previous test | +| `settings.rs` | `rewrite_exclude_domains_match_mixed_case_entries_from_toml` | after the previous test | +| `settings.rs` | `settings_load_normalizes_rewrite_exclude_domains` | after the previous test | +| `creative.rs` | `normalized` helper and `normalize_creative_url_conversions` | replace `to_abs_conversions`, `to_abs_preserves_port_in_protocol_relative` and `to_abs_additional_cases` | +| `creative.rs` | `proxy_if_abs_respects_exclude_domains` | replaces `to_abs_respects_exclude_domains` and `to_abs_respects_wildcard_domains` | +| `creative.rs` | `unparseable_absolute_url_is_left_byte_identical` | after the previous test | +| `creative.rs` | `unparseable_absolute_click_url_is_left_byte_identical` | after the previous test | +| `creative.rs` | `exclude_domains_match_case_insensitively_in_the_rewrite_pass` | after the previous test | +| `proxy.rs` | `proxy_sign_rejects_excluded_urls` | rewritten in place: GET and POST, plus an uppercase target host | +| `proxy.rs` | `proxy_sign_rejects_excluded_urls_case_insensitively` | after the previous test: mixed-case config entry, absolute and `//` input, GET and POST | + +The `creative.rs` test module's `use super::{...}` list gains `normalize_creative_url` and `proxy_if_abs` and loses `to_abs`. + +CHANGELOG: the shared step adds this entry as the first item under `## [Unreleased]` › `### Fixed` (create the heading if absent), in the same commit as the code: + +```markdown +- `rewrite.exclude_domains` matching is now case-insensitive and ignores surrounding whitespace. Entries written with uppercase letters previously never matched and now take effect, so those hosts stop being proxied and click-wrapped, and `/first-party/sign` now rejects them with `502`; it used to sign them, or return `403` when the host was also outside `proxy.allowed_domains`. Empty and bare `"*"` `exclude_domains` entries, which never matched a host, are dropped at load with a warning. Absolute `http(s)` creative URLs that cannot be parsed (for example, a host containing a space) are now left untouched; previously the trimmed value was written back re-quoted (with `https:` prepended to `//` input, so `src=" //exa mple.example/x "` became `https://exa mple.example/x`), an unparseable quoted URL in `
AdExcludedMail"#; + const MATRIX_FIXTURE_FRAGMENT: &str = r#"
AdExcludedMail"#; + const INLINE_ORIGIN: &str = "https://www.example.com"; + + #[derive(Debug, Clone, Copy)] + enum CreativePath { + Auction, + Inline, + } + + fn process_for_path(settings: &Settings, path: CreativePath, html: &str) -> String { + match path { + CreativePath::Auction => process_auction_creative(settings, html), + CreativePath::Inline => { + super::process_inline_auction_creative(settings, INLINE_ORIGIN, html) + } + } + } + + fn matrix_settings(assets: bool, clicks: Option) -> Settings { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.auction.sanitize_creatives = false; + settings.auction.rewrite_creatives = assets; + settings.auction.rewrite_clicks = clicks; + settings.rewrite.exclude_domains = vec!["excluded.example.com".to_owned()]; + settings + } +``` + +- [ ] **Step 2: Matrix and unset-pin tests** + +`MATRIX_DEFAULT_AUCTION_OUTPUT` and `MATRIX_DEFAULT_INLINE_OUTPUT` are the exact output of `MATRIX_FIXTURE_WITH_BODY` on `main`, before this change. To capture them, add a throwaway test to a scratch worktree of `main` that builds the same settings without `rewrite_clicks` (`create_test_settings()`, `sanitize_creatives = false`, `rewrite_creatives = true`, `exclude_domains = ["excluded.example.com"]`). Have it print `process_auction_creative` and `process_inline_auction_creative(.., "https://www.example.com", ..)` twice each, run it with `--nocapture`, and confirm the two runs match. Then remove the scratch worktree. Signatures are deterministic for the fixed test settings, and the injected script tag carries no hash. + +```rust + #[test] + fn asset_and_click_switches_combine_on_auction_and_inline_paths() { + for path in [CreativePath::Auction, CreativePath::Inline] { + for (fixture, has_body) in [ + (MATRIX_FIXTURE_WITH_BODY, true), + (MATRIX_FIXTURE_FRAGMENT, false), + ] { + for (assets, clicks) in [(true, true), (true, false), (false, true), (false, false)] + { + let settings = matrix_settings(assets, Some(clicks)); + let label = format!("{path:?} assets={assets} clicks={clicks} body={has_body}"); + let prefix = match path { + CreativePath::Auction => "", + CreativePath::Inline => INLINE_ORIGIN, + }; + + let out = process_for_path(&settings, path, fixture); + + let expected_proxied = match (assets, has_body) { + (false, _) => 0, + (true, true) => 4, + (true, false) => 3, + }; + assert_eq!( + out.matches("/first-party/proxy?tsurl=").count(), + expected_proxied, + "{label}: proxied asset URL count: {out}" + ); + assert_eq!( + out.contains(&format!("src=\"{prefix}/first-party/proxy?tsurl=")), + assets, + "{label}: image proxied only when assets are on: {out}" + ); + assert_eq!( + out.contains(r#"src="https://cdn.example.com/ad.png""#), + !assets, + "{label}: image raw only when assets are off: {out}" + ); + + // Each wrapped `` and `` carries the click URL in + // href and data-tsclick. + let expected_click_urls = if clicks { 4 } else { 0 }; + assert_eq!( + out.matches("/first-party/click?tsurl=").count(), + expected_click_urls, + "{label}: click URL count: {out}" + ); + assert_eq!( + out.contains(&format!("href=\"{prefix}/first-party/click?tsurl=")), + clicks, + "{label}: anchor wrapped only when clicks are on: {out}" + ); + assert_eq!( + out.contains(r#"href="https://landing.example.com/page""#), + !clicks, + "{label}: anchor raw only when clicks are off: {out}" + ); + assert_eq!( + out.contains("data-tsclick"), + clicks, + "{label}: data-tsclick only when clicks are on: {out}" + ); + assert!( + out.contains(r#"href="https://excluded.example.com/page""#), + "{label}: excluded anchor always stays raw: {out}" + ); + assert!( + out.contains(r#"href="mailto:ads@example.com""#), + "{label}: mailto anchor always stays raw: {out}" + ); + + assert_eq!( + out.contains(" removed whenever either switch is on: {out}" + ); + let expected_tsjs = + usize::from(matches!(path, CreativePath::Auction) && (assets || clicks)); + assert_eq!( + out.matches("/static/tsjs=").count(), + expected_tsjs, + "{label}: TSJS injected once on /auction when either switch is on: {out}" + ); + if !assets && !clicks { + assert_eq!( + out, fixture, + "{label}: should pass through byte for byte with both switches off" + ); + } + } + } + } + } + + #[test] + fn unset_rewrite_clicks_preserves_existing_output() { + for path in [CreativePath::Auction, CreativePath::Inline] { + let unset = matrix_settings(true, None); + let explicit = matrix_settings(true, Some(true)); + assert_eq!( + process_for_path(&unset, path, MATRIX_FIXTURE_WITH_BODY), + process_for_path(&explicit, path, MATRIX_FIXTURE_WITH_BODY), + "{path:?}: unset rewrite_clicks with rewrite_creatives = true should match full rewriting" + ); + + let disabled = matrix_settings(false, None); + assert_eq!( + process_for_path(&disabled, path, MATRIX_FIXTURE_WITH_BODY), + MATRIX_FIXTURE_WITH_BODY, + "{path:?}: unset rewrite_clicks with rewrite_creatives = false should pass through" + ); + } + } + + // Captured from `main` before `rewrite_clicks` existed: default settings must + // keep producing these bytes on both auction paths. + const MATRIX_DEFAULT_AUCTION_OUTPUT: &str = r#"
AdExcludedMail"#; + const MATRIX_DEFAULT_INLINE_OUTPUT: &str = r#"
AdExcludedMail"#; + + #[test] + fn base_target_is_kept_when_the_base_url_is_dropped() { + let fixture = r#"Ad"#; + for path in [CreativePath::Auction, CreativePath::Inline] { + for (assets, clicks) in [(true, true), (true, false), (false, true), (false, false)] { + let settings = matrix_settings(assets, Some(clicks)); + let label = format!("{path:?} assets={assets} clicks={clicks}"); + + let out = process_for_path(&settings, path, fixture); + + if assets || clicks { + assert!( + out.contains(r#""#), + "{label}: should keep base target without href: {out}" + ); + assert!( + !out.contains("base.example.com"), + "{label}: should drop the bidder base URL: {out}" + ); + } else { + assert_eq!( + out, fixture, + "{label}: should pass through byte for byte with both switches off" + ); + } + assert!( + out.contains(r#""#), + "{label}: relative image should stay raw: {out}" + ); + assert!( + out.contains(r#""#), + "{label}: relative anchor should stay raw: {out}" + ); + } + } + } + + #[test] + fn default_settings_rewrite_matrix_fixture_byte_for_byte_as_before() { + let settings = matrix_settings(true, None); + + for (path, expected) in [ + (CreativePath::Auction, MATRIX_DEFAULT_AUCTION_OUTPUT), + (CreativePath::Inline, MATRIX_DEFAULT_INLINE_OUTPUT), + ] { + assert_eq!( + process_for_path(&settings, path, MATRIX_FIXTURE_WITH_BODY), + expected, + "{path:?}: default settings should match the pre-rewrite_clicks output exactly" + ); + } + } +``` + +- [ ] **Step 3: End-to-end tests** + +`auction/formats.rs`, before `sanitize_creatives_defaults_to_disabled`: + +```rust + #[test] + fn convert_to_openrtb_response_wraps_clicks_without_rewriting_assets() { + let mut settings = make_settings(); + settings.auction.sanitize_creatives = false; + settings.auction.rewrite_creatives = false; + settings.auction.rewrite_clicks = Some(true); + let auction_request = make_auction_request(); + let result = make_result(make_complete_creative_bid()); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert creative with click rewriting only"); + let adm = response_adm(response); + + assert!( + adm.contains("/first-party/click?tsurl=") && adm.contains("data-tsclick"), + "should wrap the landing link: {adm}" + ); + assert!( + !adm.contains("/first-party/proxy?tsurl="), + "should not proxy any asset: {adm}" + ); + assert!( + adm.contains(r#"src="https://cdn.example.com/ad.png""#), + "should keep the image URL direct: {adm}" + ); + assert!( + adm.contains("tsjs-unified.min.js"), + "should inject the creative runtime for the click guard: {adm}" + ); + } +``` + +`publisher.rs`, before `build_bid_map_uses_request_origin_for_inline_urls`: + +```rust + #[test] + fn build_bid_map_keeps_inline_anchors_raw_when_clicks_are_off() { + let mut settings = test_settings(); + settings.auction.rewrite_creatives = true; + settings.auction.rewrite_clicks = Some(false); + settings.publisher.domain = "example.com".to_string(); + + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "examplessp", + "abc123", + "https://ssp.example.com/win", + "https://ssp.example.com/bill", + ); + bid.creative = Some( + "" + .to_string(), + ); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); + let adm = map + .get("atf_sidebar_ad") + .and_then(|v| v.as_object()) + .and_then(|o| o.get("adm")) + .and_then(|v| v.as_str()) + .expect("should include a rewritten adm"); + + assert!( + adm.contains("https://example.com/first-party/proxy?tsurl="), + "should still proxy the image with an absolute URL, got: {adm}" + ); + assert!( + adm.contains("href=\"https://landing.example.com/page\""), + "should leave the landing link raw, got: {adm}" + ); + assert!( + !adm.contains("data-tsclick") && !adm.contains("/first-party/click"), + "should not wrap the landing link, got: {adm}" + ); + } +``` + +- [ ] **Step 4: Verify red** + +Run each test alone, as described in the environment notes. Expected: + +- `asset_and_click_switches_combine_on_auction_and_inline_paths` panics with "Auction assets=true clicks=false body=true: click URL count". +- `convert_to_openrtb_response_wraps_clicks_without_rewriting_assets` panics with "should wrap the landing link". Nothing was rewritten. +- `build_bid_map_keeps_inline_anchors_raw_when_clicks_are_off` panics with "should leave the landing link raw". The anchor was wrapped. +- `base_target_is_kept_when_the_base_url_is_dropped` panics with "Auction assets=true clicks=true: should keep base target without href". The whole element was removed. +- `unset_rewrite_clicks_preserves_existing_output` and `default_settings_rewrite_matrix_fixture_byte_for_byte_as_before` **pass**. They pin today's behavior. + +- [ ] **Step 5: Add `CreativeFeatures`** + +Above the `rewrite_creative_html_impl` doc comment: + +```rust +/// Which rewrite features one creative pass applies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CreativeFeatures { + /// Proxy eligible asset URLs through `/first-party/proxy`. + assets: bool, + /// Wrap eligible click-through links in `/first-party/click`. + clicks: bool, +} + +impl CreativeFeatures { + /// Both features on: the behavior of the public rewrite wrappers. + const ALL: Self = Self { + assets: true, + clicks: true, + }; + + /// Whether the pass needs to run at all. + fn any(self) -> bool { + self.assets || self.clicks + } +} +``` + +Extend the `rewrite_creative_html_impl` doc comment's sentence to read "...`max_output_size` bounds the rewritten result; `features` selects which handlers run. `` removal and TSJS injection always run, since callers only invoke the pass when at least one feature is on." Add `features: CreativeFeatures,` as the last parameter. + +- [ ] **Step 6: Build the handler vector conditionally** + +Inside `rewrite_creative_html_impl`, after `let overflowed = ...;`, build the vector, then hand it to `HtmlRewriter::new`. Each block below is the existing handler moved verbatim with its comment. Only the indentation changes, so review with `git diff -w`. + +```rust + let mut element_content_handlers = vec![ + // (`` handler, changed as shown below) + // (existing `` TSJS handler) + ]; + if features.assets { + element_content_handlers.extend([ + // (existing handlers, unchanged and in their current order: img, + // script[src], link[href], video/audio/source, object[data], + // embed[src], input[src], SVG image/use, [style], text!("style"), + // iframe, [srcset], [imagesrcset]) + ]); + } + if features.clicks { + element_content_handlers.push( + // Click-through links + element!("a[href], area[href]", |el| { + if let Some(href) = el.get_attribute("href") + && let Some(target) = click_target(settings, &href) + { + let click = build_click_url(settings, target.as_str(), base_origin); + let _ = el.set_attribute("href", &click); + let _ = el.set_attribute("data-tsclick", &click); + } + Ok(()) + }), + ); + } + let mut rewriter = HtmlRewriter::new( + HtmlSettings { + element_content_handlers, + ..HtmlSettings::default() + }, + // (existing output sink closure, unchanged) + ); +``` + +`extend([...])` with an array of `element!`/`text!` invocations type-checks with no annotations, because every invocation yields the same tuple type. The body-less TSJS fallback keeps `if inject_tsjs && !injected_ts_creative.get() && !rewritten.is_empty()`. + +Replace the `` handler so it keeps `target`: + +```rust + // Drop a bidder-supplied base URL: it would rebase the root-relative + // `/first-party/…` and `/static/tsjs=…` URLs this pass emits onto an + // attacker-chosen origin, hijacking proxy/click mediation and leaking + // signed URL data. Keep `target` so raw links still open where the + // bidder intended, and remove a `` left without one. The + // sanitizer also strips , but rewriting must not depend on + // sanitization, which is independently optional. + element!("base", |el| { + el.remove_attribute("href"); + if !el.has_attribute("target") { + el.remove(); + } + Ok(()) + }), +``` + +- [ ] **Step 7: Wire the callers** + +```rust +pub(crate) fn process_auction_creative(settings: &Settings, raw: &str) -> String { + process_auction_creative_with_rewriter(settings, raw, |sanitized, features| { + rewrite_creative_html_impl(settings, sanitized, "", true, MAX_CREATIVE_SIZE, features) + }) +} + +pub(crate) fn process_inline_auction_creative( + settings: &Settings, + base_origin: &str, + raw: &str, +) -> String { + process_auction_creative_with_rewriter(settings, raw, |sanitized, features| { + rewrite_creative_html_impl( + settings, + sanitized, + base_origin, + false, + MAX_CREATIVE_SIZE, + features, + ) + }) +} +``` + +In `process_auction_creative_with_rewriter`, change the closure type to `rewrite: impl FnOnce(&str, CreativeFeatures) -> String`, and replace the `if settings.auction.rewrite_creatives { ... }` tail with: + +```rust + let features = CreativeFeatures { + assets: settings.auction.rewrite_creatives, + clicks: settings.auction.rewrites_auction_clicks(), + }; + if features.any() { + rewrite(&sanitized, features) + } else { + sanitized + } +``` + +Public wrappers: pass `CreativeFeatures::ALL` as the sixth argument in `rewrite_creative_html` (`"", true, MAX_CREATIVE_SIZE`), `rewrite_inline_creative_html` (`base_origin, false, MAX_CREATIVE_SIZE`) and, for now, `rewrite_proxied_html` (`"", true, MAX_REWRITABLE_BODY_SIZE`). Task 4 changes the last one. + +- [ ] **Step 8: Doc comments** + +- `process_auction_creative`: "Sanitization is controlled by [`...sanitize_creatives`], asset rewriting by [`...rewrite_creatives`], and click wrapping by [`...rewrites_auction_clicks`]. With all three disabled the creative is returned exactly as the bidder sent it." +- `process_inline_auction_creative`: "Applies the same opt-in sanitization and the same asset and click switches as [`process_auction_creative`]. Proxy and click URLs are emitted as absolute URLs against `base_origin` without injecting the creative TSJS bundle." +- `rewrite_creative_html`: replace the stale `tsjs-creative` bullet with: + - "`` / `` → `/first-party/click?tsurl=…`, copied into `data-tsclick`"; + - "Injects the unified TSJS bundle (`/static/tsjs=tsjs-unified.min.js`), whose creative runtime installs the click guard, once at the top of ``." + + Add: "Rewrites assets and clicks unconditionally; the auction settings are applied by the auction processing entry points, not here." + + Add to both `rewrite_creative_html` and `rewrite_inline_creative_html` a `# Warning` section: "This wrapper applies neither `rewrite_creatives` nor `rewrite_clicks`. Production code must go through the auction processing entry points, which honor both switches." They stay `pub` because `pub(crate)` would trip `dead_code` outside tests. + +- `rewrite_inline_creative_html`: replace "its only job is to safeguard click URLs, which are already absolute here" with "its click guard is unnecessary for click URLs that are already absolute here". +- `auction/endpoints.rs` response doc: "Creative HTML is inlined in each bid's `adm` field after optional sanitization ([`auction.sanitize_creatives`][...]). First-party asset rewriting ([`auction.rewrite_creatives`][...]) and click wrapping ([`auction.rewrite_clicks`][...]) are enabled by default. Bidder `` removal and creative TSJS injection run when either is on." This replaces the stale "mandatory server-side sanitization". +- `auction/formats.rs`, on `convert_to_openrtb_response`: name `rewrite_clicks` next to `rewrite_creatives` and add the intra-doc link target `/// [`AuctionConfig::rewrite_clicks`]: crate::auction_config_types::AuctionConfig::rewrite_clicks`. Add `clicks {}` to the "Processed creative" debug log, fed by `settings.auction.rewrites_auction_clicks()`. +- `auction/formats.rs`, the comment above `serialize_renderer` in `convert_to_openrtb_response`: "sanitization is opt-in, asset rewriting and click wrapping are on by default, and with all three disabled the creative ships exactly as the bidder returned it." +- `creative.rs` module docs: + - add the goal "Route click-through links through a signed first-party click redirect.", and call the proxied list "asset URLs"; + - add key behaviors for ``/`` wrapping with `data-tsclick`, for `exclude_domains` applying to assets and links, and for `` removal; + - add a "Switches" list. Auction creatives rewrite assets on `rewrite_creatives` and wrap links on `rewrites_auction_clicks`, and an unset `rewrite_clicks` follows `rewrite_creatives`. `` removal and TSJS run when either is on, and with both off the pass is skipped. Proxied HTML always rewrites assets and wraps links unless `rewrite_clicks` is explicitly `false`. The public wrappers rewrite both. + - Link the `AuctionConfig` items with reference definitions at the end of the module docs, because `creative.rs` does not import `AuctionConfig`. + +- [ ] **Step 9: Verify green** + +Run: `cargo fmt --all && cargo test-fastly -p trusted-server-core --lib -- creative:: auction::formats publisher::` + +Expected: `554 passed; 0 failed`, including every pre-existing rewrite test. + +Run: `cargo clippy-fastly` + +Expected: exit 0. + +- [ ] **Step 10: Commit** + +```bash +git add crates/trusted-server-core/src/creative.rs crates/trusted-server-core/src/auction/formats.rs crates/trusted-server-core/src/publisher.rs crates/trusted-server-core/src/auction/endpoints.rs +git commit --signoff -S -m "Gate creative asset and click rewriting on separate switches" -m "The rewrite pass registers asset handlers only when rewrite_creatives is on and the anchor handler only when click rewriting resolves on. Base removal and TSJS injection run whenever either is on." +``` + +--- + +### Task 4: Apply an explicit `rewrite_clicks` to HTML fetched through `/first-party/proxy` + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative.rs` (`rewrite_proxied_html`) +- Test: `crates/trusted-server-core/src/proxy.rs` + +- [ ] **Step 1: Write the test** + +Before `html_response_rewrite_preserves_non_standard_port`: + +```rust + #[test] + fn proxied_html_click_wrapping_follows_explicit_rewrite_clicks() { + let html = r#"Excluded"#; + // (rewrite_creatives, rewrite_clicks, expect wrapped clicks) + let cases = [ + (true, None, true), + (false, None, true), + (true, Some(true), true), + (true, Some(false), false), + (false, Some(false), false), + (false, Some(true), true), + ]; + + for (rewrite_creatives, rewrite_clicks, expect_clicks) in cases { + let mut settings = create_test_settings(); + settings.auction.rewrite_creatives = rewrite_creatives; + settings.auction.rewrite_clicks = rewrite_clicks; + settings.rewrite.exclude_domains = vec!["excluded.example.com".to_owned()]; + let label = + format!("rewrite_creatives={rewrite_creatives} rewrite_clicks={rewrite_clicks:?}"); + let req = build_http_request(Method::GET, "https://edge.example.com/first-party/proxy"); + let mut response = build_http_response(StatusCode::OK, EdgeBody::from(html)); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + + let body = response_body_string( + finalize( + &settings, + &req, + "https://cdn.example.com/creative.html", + response, + ) + .expect("should finalize proxied HTML"), + ); + + assert!( + body.contains("/first-party/proxy?tsurl="), + "{label}: proxied HTML always proxies assets: {body}" + ); + // A wrapped landing link carries the click URL in href and data-tsclick. + assert_eq!( + body.matches("/first-party/click?tsurl=").count(), + if expect_clicks { 2 } else { 0 }, + "{label}: click wrapping in proxied HTML: {body}" + ); + assert_eq!( + body.matches("data-tsclick").count(), + usize::from(expect_clicks), + "{label}: data-tsclick in proxied HTML: {body}" + ); + if !expect_clicks { + let landing_href = body + .split("Excluded"#), + "{label}: excluded link always stays raw: {body}" + ); + assert!( + !body.contains(": {body}" + ); + assert!( + body.contains("/static/tsjs="), + "{label}: proxied HTML always receives the runtime: {body}" + ); + } + } +``` + +- [ ] **Step 2: Verify red** + +Run the test alone. Expected: a panic with "rewrite_creatives=true rewrite_clicks=Some(false): click wrapping in proxied HTML". + +- [ ] **Step 3: Implement** + +In `rewrite_proxied_html`, replace `CreativeFeatures::ALL` with: + +```rust + CreativeFeatures { + assets: true, + clicks: settings.auction.rewrites_proxied_clicks(), + }, +``` + +Append this paragraph to its doc comment: "Asset rewriting here is unconditional. Click wrapping follows an explicit `[auction] rewrite_clicks`; when that is unset, links keep being wrapped regardless of `rewrite_creatives`." + +Fix the `CreativeHtmlProcessor` doc comment, which names `rewrite_creative_html` although the processor calls `rewrite_proxied_html`: + +```rust +/// Stream processor for HTML fetched through `/first-party/proxy` that rewrites +/// asset and click-through URLs to first-party endpoints. +/// +/// This processor buffers input chunks and processes the complete HTML document +/// when the stream ends, using [`rewrite_proxied_html`] internally. Asset URLs are +/// always proxied; links are wrapped unless `[auction] rewrite_clicks` is +/// explicitly `false`. +``` + +- [ ] **Step 4: Verify green** + +Run: `cargo test-fastly -p trusted-server-core --lib -- proxy::tests::proxied_html proxy::tests::auction_rewrite_setting_does_not_change` + +Expected: `2 passed; 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/creative.rs crates/trusted-server-core/src/proxy.rs +git commit --signoff -S -m "Apply an explicit rewrite_clicks to HTML fetched through the first-party proxy" +``` + +--- + +### Task 5: Example config and CLI overlay coverage + +**Files:** + +- Modify: `trusted-server.example.toml` +- Test: `crates/trusted-server-cli/tests/config_env_overlay.rs` + +- [ ] **Step 1: Write the overlay tests** + +Add a constant after `SANITIZE_ENV`: + +```rust +const CLICKS_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CLICKS"; +``` + +Before `config_validate_explains_legacy_provider_list_migration`: + +```rust +fn pushed_auction_with_env( + project: &MigratedProject, + key: &str, + raw_value: &str, +) -> serde_json::Value { + let output = Command::new(env!("CARGO_BIN_EXE_ts")) + .args(["config", "push", "--adapter", "axum", "--manifest"]) + .arg(&project.manifest_path) + .arg("--app-config") + .arg(&project.config_path) + .args(["--yes", "--no-diff"]) + .current_dir(project.directory.path()) + .env(key, raw_value) + .output() + .expect("should run ts config push"); + assert!( + output.status.success(), + "config push should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let local_store_path = project + .directory + .path() + .join(".edgezero/local-config-trusted_server_config.json"); + let local_store: serde_json::Value = serde_json::from_str( + &fs::read_to_string(local_store_path).expect("should read pushed local config"), + ) + .expect("should parse local config store"); + let envelope_json = local_store + .as_object() + .and_then(|entries| entries.values().next()) + .and_then(serde_json::Value::as_str) + .expect("should contain a blob envelope"); + let envelope: serde_json::Value = + serde_json::from_str(envelope_json).expect("should parse blob envelope"); + envelope["data"]["auction"].clone() +} + +#[test] +fn rewrite_clicks_environment_override_applies_when_leaf_present() { + let project = migrated_project(); + let mut document = fs::read_to_string(&project.config_path) + .expect("should read migrated config") + .parse::() + .expect("should parse migrated config"); + document["auction"]["rewrite_clicks"] = value(true); + fs::write(&project.config_path, document.to_string()).expect("should write config"); + + let auction = pushed_auction_with_env(&project, CLICKS_ENV, "false"); + + assert_eq!( + auction["rewrite_clicks"], + serde_json::Value::Bool(false), + "pushed config should contain the rewrite_clicks environment override" + ); +} + +#[test] +fn rewrite_clicks_environment_override_is_ignored_without_leaf() { + let project = migrated_project(); + + let auction = pushed_auction_with_env(&project, CLICKS_ENV, "false"); + + assert!( + auction.get("rewrite_clicks").is_none(), + "an override for a missing leaf should be ignored and the unset default omitted" + ); +} +``` + +- [ ] **Step 2: Run** + +Run: `cargo test --package trusted-server-cli --target "$(rustc -vV | awk '/host:/ { print $2 }')" --test config_env_overlay rewrite_clicks` + +Expected: `2 passed; 0 failed`. + +These pin existing EdgeZero behavior, so there is no red step. The leaf-present test proves the override is read. The leaf-absent test proves an override for a missing leaf is ignored, so the spec's overlay caveat holds. If the leaf-absent test ever fails, update the spec and the configuration guide before continuing. + +- [ ] **Step 3: Example TOML** + +Replace the `rewrite_creatives` comment and line in `[auction]`: + +```toml +# Rewrite winning-bid creative asset URLs (images, scripts, styles, media, +# iframes, CSS url()) to first-party /first-party/proxy endpoints (default +# true). Set false to leave asset URLs direct. Bidder removal and +# creative TSJS injection run when this or rewrite_clicks is on. Sanitization +# is controlled separately by `sanitize_creatives` below. Restore true before +# rolling back to an older binary that rejects unknown fields. +rewrite_creatives = true +# Wrap creative click-through links (, ) in signed +# /first-party/click redirects. Unset (the default) follows rewrite_creatives +# for auction creatives and keeps wrapping links in HTML fetched through +# /first-party/proxy; a set value applies to both. Leave it commented out to +# keep that behavior: any explicit value is stored in the pushed config, and +# binaries older than this setting reject it. Uncomment it before relying on +# TRUSTED_SERVER__AUCTION__REWRITE_CLICKS, which cannot create a missing leaf. +# rewrite_clicks = true +``` + +In the `sanitize_creatives` comment just below, replace the sentence that starts "Note that with `rewrite_creatives = true`" so it names both switches: + +```toml +# (executable markup preserved). Note that with `rewrite_creatives` or +# `rewrite_clicks` on, the adm is still not untouched: eligible asset URLs +# and/or links are rewritten, bidder `` elements are removed, and the +# creative TSJS runtime is injected. Enable it whenever creatives can render +# in a context that shares the publisher origin (its primary defence there); +# leave it off when creatives render in a +``` + +- [ ] **Step 4: Commit** + +```bash +git add trusted-server.example.toml crates/trusted-server-cli/tests/config_env_overlay.rs +git commit --signoff -S -m "Document rewrite_clicks in the example config and pin its overlay behavior" +``` + +--- + +### Task 6: Operator documentation and CHANGELOG + +**Files:** + +- Modify: `docs/guide/configuration.md` +- Modify: `docs/guide/creative-processing.md` +- Modify: `docs/guide/auction-orchestration.md` +- Modify: `docs/guide/api-reference.md` +- Modify: `crates/trusted-server-core/src/auction/README.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: `configuration.md` `[auction]`** + +- Table: change the `rewrite_creatives` description to "Rewrite winning-bid asset URLs through first-party endpoints". Add a row: + + ```markdown + | `rewrite_clicks` | Boolean | unset | Wrap creative links in `/first-party/click`; unset follows `rewrite_creatives` | + ``` + + Prettier re-pads the whole table. + +- Processing paragraph: + - assets go to `/first-party/proxy`; + - `rewrite_clicks` controls ``/`` and follows `rewrite_creatives` when unset; + - `` is removed, and TSJS injected on `POST /auction`, whenever either setting is on; + - "With all three disabled" replaces "With both disabled"; + - an explicit `rewrite_clicks` applies to links in proxied HTML, and unset keeps wrapping them. +- Warning block: + - add "any explicit `rewrite_clicks`" to the serialized values; + - rollback note: "Older binaries tie click wrapping to `rewrite_creatives` in both directions: rolling back from `rewrite_creatives = true` with `rewrite_clicks = false` turns click wrapping back on, and rolling back from `rewrite_creatives = false` with `rewrite_clicks = true` turns it off."; + - processing paragraph's last sentence (`configuration.md:1956`): replace "Neither setting affects HTML or CSS fetched through `/first-party/proxy`" with "`rewrite_creatives` and `sanitize_creatives` do not affect HTML or CSS fetched through `/first-party/proxy`; an explicit `rewrite_clicks` does apply to links in proxied HTML." + - overlay note: "`rewrite_clicks` is unset by default and so cannot appear as a TOML leaf until you set it; add `rewrite_clicks = true` or `false` under `[auction]` before relying on `TRUSTED_SERVER__AUCTION__REWRITE_CLICKS`." + +- [ ] **Step 2: `creative-processing.md`** + +- Processing Triggers, item 1, append: "Click-through links are wrapped according to `[auction].rewrite_clicks`, which follows `rewrite_creatives` when unset." +- Item 2, append: "An explicitly set `[auction].rewrite_clicks` also applies to links in proxied HTML." +- "Auction Rewrite Control" intro: "Three auction settings control the processing applied to winning-bid `adm` ... Sanitization is opt-in (default `false`) and asset rewriting is enabled by default. Click wrapping is controlled by `rewrite_clicks`, which follows `rewrite_creatives` when unset; see [Assets and clicks](#assets-and-clicks)." Add `# rewrite_clicks unset: follows rewrite_creatives` to its TOML snippet. +- Its `sanitize_creatives` × `rewrite_creatives` table: asset URLs follow `rewrite_creatives` and links follow `rewrite_clicks`. + - `false`/`false`: "Asset URLs stay direct. With `rewrite_clicks` unset or `false`, deliver the creative exactly as the bidder returned it (subject to the size cap); with `rewrite_clicks = true`, wrap links as described in [Assets and clicks](#assets-and-clicks)." + - `true`/`false`: "...then deliver without asset rewriting. Sanitizer-accepted external resource and inline CSS URLs remain direct; links follow `rewrite_clicks` and stay direct when it is unset or `false`." + - `false`/`true`: "Rewrite eligible resource/CSS URLs ... removing any bidder `` element. Links are wrapped unless `rewrite_clicks = false`. Executable markup is preserved." +- After the sanitization warning in "Auction Rewrite Control", add an `### Assets and clicks` subsection containing: + - the TOML snippet (`rewrite_creatives = true`, `# rewrite_clicks = false`); + - the four-row table (assets × clicks → asset URLs, links, ``, TSJS); + - "SSAT/page-bids follows the same table with absolute URLs and never injects TSJS. `exclude_domains` applies to both assets and links."; + - the `clickGuard` versus `rewrite_clicks` paragraph; + - a `renderGuard` note: "With `rewrite_creatives = false` and `rewrite_clicks = true`, `POST /auction` still injects TSJS, but this does not reopen asset proxying in practice: `/auction` creatives render in a sandbox without `allow-same-origin`, so the render guard skips `/first-party/sign` and script-inserted assets load direct. `/first-party/sign` itself is not gated by `rewrite_creatives`." + - a `` note: "Whenever either switch is on, a bidder `` loses its `href` and keeps `target`; a `` without `target` is removed. With `rewrite_creatives = false` and `rewrite_clicks = true`, relative asset URLs therefore no longer resolve against the bidder base, so bidders should use absolute asset URLs." +- "Anchors (Click Tracking)": add `**Controlled by**: [auction].rewrite_clicks (follows rewrite_creatives when unset)`, switch the example to `advertiser.example.com`, and show the `data-tsclick` attribute. + +- [ ] **Step 3: Remaining guides** + +- `auction-orchestration.md`: + - mermaid note: `rewrite_creatives=false: asset URLs direct; clicks follow rewrite_clicks`; + - flow: `├─[rewrite_creatives or rewrite_clicks] Rewrite assets and/or links, inject creative TSJS`; + - Creative Processing paragraph: link to `/guide/creative-processing#assets-and-clicks`; + - the full example TOML (line 704): add `# rewrite_clicks = true` under `rewrite_creatives`; + - the environment-override section (lines 837-844): list `TRUSTED_SERVER__AUCTION__REWRITE_CLICKS` next to `REWRITE_CREATIVES` and repeat that the leaf must exist in the TOML first; + - the rollback note (line 853): name both switches and point to the configuration guide's rollback order. +- `configuration.md:2171`: replace "`rewrite_creatives = false` skips first-party URL rewriting and creative TSJS injection" with "`rewrite_creatives = false` leaves asset URLs direct; links still follow `rewrite_clicks`, and creative TSJS is still injected when `rewrite_clicks = true`." +- `api-reference.md`: list `[auction].rewrite_clicks` with the other two settings. +- `auction/README.md`: add the `rewrite_clicks` bullet. + +- [ ] **Step 4: CHANGELOG** + +Under `### Added`, as the first entry: + +```markdown +- Added `[auction].rewrite_clicks` to control creative click-through wrapping (``/`` → signed `/first-party/click`) independently of asset rewriting. Unset (the default) follows `rewrite_creatives` for `POST /auction` and SSAT/page-bids and keeps wrapping links in HTML fetched through `/first-party/proxy`, so existing configs behave as before; an explicit value applies to every path. `rewrite_creatives` now governs asset URLs only; bidder `` handling and creative TSJS injection run when either setting is on. A bidder `` is now kept with its `href` dropped, instead of removing the whole element, so raw links keep opening where the bidder intended. Upgrading: deploy the binary first, then push a config that sets `rewrite_clicks`. Rolling back: remove any explicit `rewrite_clicks` (and its environment override), push the resulting config, then roll back the binary. Older binaries tie click wrapping to `rewrite_creatives` in both directions, so rolling back turns clicks back on for `rewrite_creatives = true` with `rewrite_clicks = false`, and off for `rewrite_creatives = false` with `rewrite_clicks = true`. +``` + +In the existing Unreleased `rewrite_creatives`/`sanitize_creatives` entry, replace "(proxy/click URL conversion, bidder `` removal; creative TSJS injection on `POST /auction` only)." with: + +```markdown +(asset URL conversion to `/first-party/proxy`, bidder `` removal; creative TSJS injection on `POST /auction` only). Click-through wrapping is controlled by `[auction].rewrite_clicks`, which follows `rewrite_creatives` when unset. +``` + +- [ ] **Step 5: Format** + +```bash +PRETTIER=/path/to/checkout/docs/node_modules/.bin/prettier +$PRETTIER --config docs/.prettierrc --write docs/guide/configuration.md docs/guide/creative-processing.md docs/guide/auction-orchestration.md docs/guide/api-reference.md +(cd docs && npm run format) +$PRETTIER --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md" +``` + +Expected: the first `npm run format` before `--write` flags `guide/configuration.md`, because of the new table row width. After `--write`, both checks print "All matched files use Prettier code style!". + +- [ ] **Step 6: Commit** + +```bash +git add docs/guide CHANGELOG.md crates/trusted-server-core/src/auction/README.md +git commit --signoff -S -m "Document the rewrite_clicks creative setting" +``` + +--- + +### Task 7: Full verification + +- [ ] **Step 1: Run every CI gate** + +```bash +cargo fmt --all -- --check +cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm && cargo clippy-cli && cargo clippy-codegen +cargo test-fastly && cargo test-fastly-reuse && cargo test-axum && cargo test-cloudflare && cargo test-spin +./scripts/test-cli.sh +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +(cd crates/trusted-server-js/lib && npx vitest run && npm run format) +(cd docs && npm run format) +``` + +Expected: every command exits 0. The validation run's results are in the spec handoff. + +- [ ] **Step 2: Map completion criteria to tests** + +| Criterion | Test | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Unset output unchanged | `default_settings_rewrite_matrix_fixture_byte_for_byte_as_before`, `unset_rewrite_clicks_preserves_existing_output`, existing rewrite tests, unset rows of `proxied_html_click_wrapping_follows_explicit_rewrite_clicks` | +| Four-combination matrix on both paths | `asset_and_click_switches_combine_on_auction_and_inline_paths` | +| Clicks off: raw href, no `data-tsclick` | matrix, `build_bid_map_keeps_inline_anchors_raw_when_clicks_are_off` | +| Clicks on, assets off | matrix, `convert_to_openrtb_response_wraps_clicks_without_rewriting_assets` | +| `exclude_domains` applies to anchors | matrix (excluded anchor), `rewrite_click_urls_excludes_blacklisted_domains`, `exclude_domains_match_case_insensitively_in_the_rewrite_pass` | +| Proxied HTML rule | `proxied_html_click_wrapping_follows_explicit_rewrite_clicks` | +| Default omitted from the blob | `default_rewrite_clicks_is_unset_and_not_serialized`, `legacy_blob_without_rewrite_clicks_follows_rewrite_creatives`, `rewrite_clicks_environment_override_is_ignored_without_leaf` | +| Docs and CHANGELOG | Task 6 | + +- [ ] **Step 3: Hand off** + +Open the PR through the `pr-creator` agent. Link the spec, state whether this PR introduced the shared step or rebased onto #1231's, and paste the rollout and rollback order from the spec. diff --git a/docs/superpowers/plans/2026-10-09-authenticated-forwarder.md b/docs/superpowers/plans/2026-10-09-authenticated-forwarder.md new file mode 100644 index 000000000..33014b215 --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-authenticated-forwarder.md @@ -0,0 +1,84 @@ +# Authenticated forwarder implementation plan + +> For agentic workers: use `superpowers:subagent-driven-development` for independent implementation and review tasks. Review each completed task for requirements and code quality before moving on. + +**Goal:** Let the development proxy preserve browser Origin and authenticate the browser-facing host and scheme, with the server changes in #1107 and CLI changes in #1251. + +**Architecture:** Preserve immutable runtime `RequestIngress` transport facts. Capture a separate validated public origin before trace pre-dispatch and forwarded-header sanitation. Share this origin between trace authorization and request information used to construct public URLs. Production behavior remains unchanged when the optional configuration is absent. + +**Tech stack:** Rust, HTTP request extensions, existing secret-store envelopes, constant-time digest comparison, Fastly/Axum/Cloudflare/Spin, native CLI proxy and browser integration fixtures. + +**Wire contract:** The CLI uses `x-ts-forwarder-auth`; configure the server's optional authentication header to that name when using this CLI. The forwarded sources are `x-forwarded-host` and `x-forwarded-proto`. Secrets contain at least 32 ASCII graphic bytes; a file may end in a single line terminator. Only one field of each kind is accepted, with no lists, whitespace, userinfo or URL suffixes. Schemes are HTTP/HTTPS and origins canonicalize hostname case and default ports. Invalid forwarding falls back to transport facts. Authentication headers cannot overlap forwarded sources, trace controls or configured client-IP trust headers. + +**Adapter seam:** Preparation is the first operation of the existing trace pre-dispatch hook, which is registered by every adapter. Fastly captures a typed preparation outcome before its existing native sanitation and inserts that outcome after conversion. Preparation is idempotent and contains no secret material. Startup-error routers remain local-only. + +**Authority and evidence:** Credential stamping requires a single valid inbound Host, or an unambiguous absolute URI authority, with agreement when both exist. Never authenticate a guessed CONNECT fallback. Preserve browser ports. Forwarding must not upgrade cookie health, request-target provenance or header fidelity. + +## Task 1: Server configuration and origin contract (#1107) + +Files: core `settings.rs`, `config.rs`, `config_payload.rs`, `http_util.rs`, a focused `forwarder.rs` module, and `lib.rs`. + +- [x] Add failing tests for optional/omitted configuration, redacted secrets, secret-store resolution, placeholder/length validation and unsafe/colliding authentication names. +- [x] Implement optional `TrustedForwarderConfig` using the existing trusted-client-IP secret conventions. +- [x] Add failing tests for authenticated public origin: single auth/host/proto fields; valid publisher domain/subdomains and explicit ports; duplicate/list/malformed/foreign values; default fallback; removal of authentication fields regardless of acceptance. +- [x] Implement a typed authenticated public-origin extension and strict canonical parsing. Compare fixed-size secret digests in constant time. Do not mutate `RequestIngress` or use public forwarding to manufacture target/header fidelity. +- [x] Make `RequestInfo` read the authenticated public-origin extension first. +- [x] Run native core tests and independent requirements/code-quality review; fix findings. + +## Task 2: Adapter entry and trace authorization (#1107) + +Files: four adapters' entry points/hooks/middleware, core `trace/actions.rs` and `trace/dispatch.rs`, adapter/parity fixtures. + +- [x] Write regression tests for real trace enable/end over rewritten Host and plaintext transport with authenticated forwarding. Cover invalid/missing/duplicate auth and Origin, foreign origin, query strings and empty-body/action/Fetch Metadata controls. +- [x] Resolve forwarding before trace pre-dispatch and before any sanitizer removes inputs in every adapter; remove the configured secret before routing/forwarding, including rejected requests. +- [x] Check received URI/Host consistency against transport ingress independently of browser Origin, which must match the effective public origin. +- [x] Keep trace transport-cookie/header-fidelity rules intact. +- [x] Run target-matched tests and independent review; fix findings. + +## Task 3: Public-origin consumers and documentation (#1107) + +Files determined by complete consumer audit: core publisher/proxy/integrations/HTML paths, configuration guide/example, trace design specification. + +- [x] Classify each URI/Host/scheme consumer as public-origin or transport-sensitive. Add behavioral regression tests before changing public-origin consumers (including protocol-relative signing and redirects). +- [x] Use the shared effective origin for generated public URLs and public-scheme decisions. Keep actual upstream routing and ingress evidence tied to transport. +- [x] Document opt-in trust, host bounds, secret-store provisioning, unchanged defaults and rejection/fallback rules. Amend trace specification's forwarded-header prohibition to permit only authenticated forwarding. +- [x] Run relevant core/integration tests and independent review; fix findings. + +## Task 4: Proxy credentials and unchanged Origin (#1251) + +Files: CLI proxy `mod.rs`, `config.rs`, `rewrite.rs`, `server.rs`, E2E support/tests, proxy guide and design specification. + +- [x] Write failing tests for `--forwarder-secret-file`, valid/invalid file contents, redacted/sensitive header values and non-loopback credential rejection. +- [x] Add file-only, prevalidated forwarder credentials. Remove incoming forwarder authentication on mapped traffic and stamp the configured token after hop-by-hop sanitation. +- [x] Preserve and validate the inbound browser authority, including its port, when stamping authenticated forwarding; reject ambiguity rather than authenticating guessed metadata. +- [x] Remove route-specific Origin rewriting and `RewriteOutcome::upstream_origin`. Preserve all browser Origin fields unchanged. +- [x] Replace echo tests with meaningful forwarding contract coverage, including auth overwrite/absence, plaintext/TLS, Host rewriting, ports, near-miss routes and connection reuse. +- [x] Update guide/header tables/specification; remove obsolete caveats about Origin rewriting and document server dependency. +- [x] Run CLI tests/lint and independent requirements/code-quality review; fix findings. + +## Task 5: Joint verification and PR updates + +- [x] Run real proxy-to-server trace and public-URL tests using both branches: TLS/plaintext, rewritten/preserved Host, public non-default ports, valid auth and hostile/ambiguous headers. Verify enable/end cookies, identify CORS, vendor Origin preservation, auth stripping and signing scheme. +- [x] Run the repository's complete CI gate list on the server branch and the CLI branch, plus production adapter builds. Record failures with evidence and distinguish environmental issues from regressions. +- [x] Obtain independent final reviews of both branch diffs and their combined behavior, resolving actionable findings. +- Final handoff: commit tested changes using repository conventions and push each PR branch without force. Refresh PR descriptions to explain the final contract and dependency. +- Final handoff: recheck remote heads and CI, and report actual validation results and any remaining limitations. + +## Reviewed corrections + +- Independent configuration review found a collision with the DataDome bypass header; runtime and deployment validation now reject it, including mixed-case names and unresolved key references. +- Origin review caught paired ingress facts being mixed with mutable Host; the resolver now keeps immutable authority and scheme together. +- CLI wire review found authentication leaking through streaming trailers; both declarations and actual frames are filtered while benign trailers remain intact. +- Full core verification found a cache test fixture relying on unauthenticated forwarded HTTPS; the fixture now supplies explicit runtime ingress. +- Cross-surface review found Spin losing HTTPS when its original URI is path-only. A separate validated runtime-origin extension supplies public URL fallback and survives snapshots without granting trace or fidelity trust. +- Operator trace guidance now documents the opt-in offload arrangement, and an actual Fastly router regression keeps configured identify CORS independent of the forwarded public authority. + +## Final local verification + +Both branch trees passed all required CI gates, target-matched adapter builds, Fastly/Spin release builds and core documentation. Native core: 3,057 passed; cross-adapter parity: 27 passed; JS: 1,757 passed. Final stacked CLI: 731 unit tests, 41 proxy wire tests and three real-server regressions passed, plus its remaining integration and documentation suites. CLI lint and formatting passed again after explicit TLS provider setup was added to test fixtures. A serial proxy unit run passed 111 tests, and an isolated TLS wire regression passed in a fresh process. + +The real operator `ts config validate` command accepted the optional forwarder section with a secret-store key reference. A shared-target artifact interruption in the server CLI documentation run cleared on a sequential full-suite rerun. Independent reviews approved every implementation and corrective pass, including the runtime-origin fallback, streaming trailer removal, identify CORS and joint listener fixture. + +GitHub CI and the existing deployed/mobile release acceptance are tracked in the PR descriptions; local wire tests do not replace physical mobile, browser session restoration or operator staging acceptance. + +The new CodeQL password-hashing alert #204 was independently reviewed and classified as a false positive: SHA-256 normalizes temporary bearer-token digests for comparison; no verifier digest is stored or exposed. The operator guide explicitly requires random-token generation and distinguishes length from entropy. diff --git a/docs/superpowers/plans/2026-10-09-server-side-identity-foundation-and-lockr.md b/docs/superpowers/plans/2026-10-09-server-side-identity-foundation-and-lockr.md new file mode 100644 index 000000000..8e4ece62b --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-server-side-identity-foundation-and-lockr.md @@ -0,0 +1,148 @@ +# Server-side identity foundation and Lockr capture implementation plan + +**Status:** Milestones 1–6 implemented in the draft change. Local verification is recorded below, with the default-off capture handoff checked separately. Provider activation and deployment compatibility remain gated; this is not live acceptance. + +**Specs:** [Foundation](../specs/2026-10-09-server-side-identity-foundation-design.md), [Lockr capture](../specs/2026-10-08-lockr-identity-capture-design.md), [KV snapshot and recovery](../specs/2026-07-10-kv-eid-request-snapshot-ec-recovery-design.md). The [existing LiveRamp browser design](../specs/2026-08-21-liveramp-integration-design.md) does not approve a native resolver. + +**Goal:** Persist registered browser auction EIDs and trusted Lockr proxy observations on an existing, consenting EC row, retain expiry and writer metadata through every mutation, and expose only usable records to later auctions. Keep the response intact and send it before capture writes on Fastly. Keep browser-written `ts-eids` retired. The optional authenticated `ts-eids-s` handoff carries bounded pending capture only. No root creation by acquisition or new provider fetch loop. + +```mermaid +flowchart TD + A[Record compatibility and expiry-aware readers] --> B[Ownership and mutation policy] + B --> C[Auction body ingestion] + C --> D[Retire EID cookie and diagnostic headers] + B --> E[Bounded capture and Fastly after-send effects] + D --> F[Lockr capture consumer] + E --> F + F --> H[Default-off authenticated capture handoff] + H --> G[Approved fixture and deployment gates] +``` + +## Boundaries and starting code (pre-implementation baseline) + +- Core EC owns record and mutation policy: `ec/kv_types.rs` stores schema-v1 `KvPartnerId { uid }`; `ec/kv.rs` has browser `sync_eid_cookie_updates_from_snapshot` with one CAS plus one conflict read, and authoritative `apply_partner_id_updates`, `upsert_partner_ids_from_snapshot`, `upsert_partner_id`, `upsert_partner_id_if_exists`. `ec/batch_sync.rs` calls the last method; `ec/pull_sync.rs` aggregates fill-missing responses. `ec/generation.rs`, `ec/finalize.rs` and `ec/admin.rs` participate in creation, recovery, withdrawal and administrative reads. `ec/identify.rs` separately returns partner UIDs without using `resolve_partner_ids`; include it in expiry-aware reader coverage. Audit every write/serialize site before enabling metadata. Tombstoning deliberately removes identity payload; ordinary mutations must not. +- `ec/registry.rs` builds `PartnerRegistry` from `settings.rs` `EcPartner`; `config.rs::validate_settings_for_deploy` and `validate_settings_for_runtime` are the shared CLI push/validate and startup validation boundaries. `integrations/registry.rs::IntegrationRegistration` and `IntegrationRegistry::with_plan` own capability discovery. Do not introduce a parallel vendor list. +- `auction/endpoints.rs::handle_auction` parses body EIDs, falls back to `ts-eids`, loads an `EcKvSnapshot` and hands it to finalization; its orchestration error path currently returns `Err`. `ec/finalize.rs::ec_finalize_response` currently ingests cookie EIDs plus `sharedId`. `ec/prebid_eids.rs` already normalizes EIDs into source-keyed updates; `ec/eids.rs::resolve_partner_ids` supplies registered KV IDs. `openrtb.rs::Eid` currently has only `source` and `uids`. +- `integrations/lockr.rs::handle_api_proxy` buffers POST bytes once, forwards them and returns the upstream response uninspected. It currently logs a full target URL, including query, which must be redacted before captured traffic is enabled. `integrations/mod.rs::collect_response_bounded` is destructive on overflow and cannot be used for capture pass-through. Fastly `app.rs::EcFinalizeState` carries request context, while `main.rs::edgezero_main` removes extensions before conversion and sends before `run_edgezero_pull_sync_after_send`. +- `auction/formats.rs` emits EID diagnostic headers; `ec/finalize.rs` clears them on withdrawal; `constants.rs::INTERNAL_HEADERS` strips inbound variants. The JS writer is `trusted-server-js/lib/src/integrations/prebid/index.ts::syncPrebidEidsCookie`; `ec/admin.rs` has an EID-cookie diagnostic endpoint. `cookies/template_cache_policy.rs` and `publisher.rs` still recognize the cookie. Inventory these consumers before deleting a constant or admin diagnostic. See also `adapter-fastly/src/app.rs` cookie extraction and `auction/types.rs` stale cookie documentation. + +## Check discipline + +Each numbered milestone contains smaller red/green steps. Finish and check one step before starting the next; do not change every listed file at once. Add the regression, observe failure, implement the step, then rerun it and adjacent tests. Compatibility tests for already-correct behavior must stay green rather than be forced to fail. + +Use these focused commands from the repository root, replacing the example filter with the affected module or regression: + +```bash +cargo test-fastly ec::kv_types::tests +cargo test-fastly ec::kv::tests +cargo test-fastly auction::endpoints::tests +cargo test-fastly integrations::lockr::tests +cargo test-axum +HOST_TARGET="$(rustc -vV | awk '/host:/ { print $2 }')" +cargo test --package trusted-server-cli --target "$HOST_TARGET" +(cd crates/trusted-server-js/lib && npx vitest run test/integrations/prebid/index.test.ts) +``` + +`scripts/test-cli.sh` takes a host target, not a test filter, and runs the full CLI suite plus browser fixtures. Reserve it for the final integration gate. Verify each selected filter actually executes its intended tests. A compile-only check does not prove persistence. Reuse passing evidence while relevant source, fixtures, config and environment remain unchanged. Run broader suites and target-matched lint at stable integration checkpoints, not after each edit. + +### 1. Land compatible lifecycle records and reads, without managed writes + +**Files:** `crates/trusted-server-core/src/ec/{mod.rs,kv_types.rs,kv.rs,eids.rs,identify.rs,admin.rs,prebid_eids.rs,finalize.rs,generation.rs,batch_sync.rs,pull_sync.rs}`, `crates/trusted-server-core/src/auction/endpoints.rs`, plus existing tests in those files. Change an existing writer only where the audit finds UID-only reconstruction or equality, stale metadata inherited by a replacement UID, or a mutation-policy bypass. Keep schema version 1 and optional/defaulted `expires_at`, `writer`, `revision` fields. A legacy UID-only record has unknown expiry and no inferred provider provenance. Do not implement `updated_at`, `invalidated_at`, attempt state or a refresh scheduler without a current consumer; reserve invalidation semantics for the later provider delivery. + +- Red: decode old schema-v1 fixtures; reject unsupported schema; serialize a record with metadata across browser same-UID/different-UID paths, batch push, single partner push, bulk pull, generation/recovery and admin-related round trips. Demonstrate the current UID-only replacement strips metadata. Add equal-UID/different-expiry and equal-UID/equal-metadata cases. Check withdrawal removes payload, not a partial record. +- Green: preserve the existing record where the UID is unchanged, and give legacy authoritative UID replacements an explicit policy that does not inherit a different UID's old expiry. Source ownership will later reject conflicting writes. Assign a non-reused opaque revision for every material managed record change; do not bump on no-op. Test a change that later restores the earlier UID/expiry: it must not restore the earlier revision. Store a known expiry in seconds, reject already-expired `Issued`, retain it when a same-UID observation omits expiry. Do not mistake the existing `KvConsent.updated` for provider freshness. +- Red then green: centralize record usability and test `now < expires_at` versus `now >= expires_at` through `ec/eids.rs::resolve_partner_ids`, `ec/identify.rs` and publisher, `/auction`, SPA `/_ts/page-bids` callers. Obtain usable time through `ec::checked_current_timestamp`, not the epoch-zero failure fallback in `current_timestamp`. Test unavailable time explicitly; a known-expiry record cannot be declared usable without a usable clock. Keep unknown-expiry compatibility unchanged. Suppress an auction-body UID identical to the unusable stored UID before merge, using normalized source matching; a different valid body UID remains current-request-only. No cleanup write on reads. Admin inspection may show retained expired records and their metadata; it is not a delivery API. +- Check: filtered `cargo test-fastly` record, CAS, resolution, publisher and auction tests. Read back serialized KV state in storage tests. **Gate:** do not activate metadata-producing writers until all deployed readers and _all_ old writers have drained. Old binaries discard unknown fields and old readers forward expired UIDs. A passing new-binary test is not a deployment-compatibility proof. + +### 2. Validate source owners and separate mutation classes + +**Files:** `crates/trusted-server-core/src/settings.rs`, `config.rs`, `ec/{mod.rs,registry.rs,kv.rs,pull_sync.rs,pull_sync_marker.rs,batch_sync.rs}`, `integrations/{registry.rs,mod.rs}`, `crates/trusted-server-cli/src/run.rs` validation tests, `trusted-server.example.toml`; config, registry and KV tests nearby. Add `ec/identity.rs` for common outcomes, trusted update types and the shared acquisition service, and `integrations/identity.rs` for capability contracts and later bounded inspection. These are proposed new paths, not existing modules. Add `identity_owner` to source policy and a capture descriptor with finite source claims to integration registration. Keep disabled acquisition descriptors discoverable, so a `capture_identity = false` kill switch does not invalidate config. No production resolver, named identifier intake, synthetic resolver HTTP protocol or rate-limit state now. + +- Red: duplicate/missing/non-claiming owner, disabled owner descriptor, owner with enabled pull or push API conflict, unauthorized integration claiming a source and forged browser metadata. Exercise all three boundaries: `ts config validate`, `ts config push`, and startup `validate_settings_for_runtime`/`IntegrationRegistry::with_plan`. Ensure deploy validation works on unresolved secret references. Existing unowned sources retain legacy push/pull behavior; owned sources must reject legacy authoritative push and skip legacy pull completeness and dispatch, not merely suppress one write after making an outbound request. Resolve the legacy-write configuration decision below before implementing its acceptance rules. `api_token` authenticates read-only `identify` as well as writable `batch-sync`; token presence alone does not prove a conflicting writer. Tests must cover the approved read/write policy without accidentally removing identify access. Update the effective pull-partner set used by completeness, dispatch and `pull_sync_marker.rs` fingerprinting together; an ownership migration must not leave a stale completeness cookie suppressing an eligible legacy pull. +- Green: descriptor claims and runtime registry are one source of truth; validate source policy against claims in the shared deploy/runtime validation functions. Shared service validates trusted origin, registered source, owner, UID length/source count, consent and full EC-ID-bound snapshot. Implement the capture-used `Issued`, `NoChange` and approved withdrawal actions; reserve the remaining outcome contracts without an unused active resolver. Test expiry again at mutation time, because a token can expire between capture and post-send persistence. Browser update cannot set authority or lifecycle metadata. Keep `ec/kv.rs` browser one-CAS/one-conflict-read/no-retry policy distinct from authoritative bounded CAS/re-merge. Authoritative replacement preserves unrelated sources and never creates a missing, failed or tombstoned root. Same UID with changed expiry is a write; unchanged UID and lifecycle metadata is not. A stale result tied to a prior revision must be rejected when revision-conditional mutation becomes necessary; do not expose an unused resolver callback merely to test an imagined provider. +- Check: filtered `cargo test-fastly` config, registry, push/pull and KV tests; host CLI validation tests. Inspect log assertions for redaction. + +### 3. Persist browser body once, then retire cookie/header transport + +**Files:** `crates/trusted-server-core/src/{auction/endpoints.rs,auction/formats.rs,auction/types.rs,openrtb.rs,ec/prebid_eids.rs,ec/finalize.rs,ec/admin.rs,ec/eids.rs,constants.rs,cookies/template_cache_policy.rs,publisher.rs}`, `crates/trusted-server-adapter-fastly/src/{app.rs,main.rs}`, `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` and its existing tests; update operator docs at `docs/guide/integrations/prebid.md` and `docs/guide/configuration.md` for changed transport. Limit other adapters to compile/behavior parity; they currently have no EC KV graph. + +- Red: registered body EIDs enrich an existing root even on no-bid or handled auction-provider error; unknown sources remain current-request-only; no-token/consent-denied/missing/failed/tombstone roots do not write or recover. Prove one combined browser finalization attempt with `sharedId`, using the auction snapshot rather than a second unconditional KV read. Directly reuse the EID-to-update normalization in `ec/prebid_eids.rs`, not a fake cookie. Stage normalized updates in `EcContext` or a typed response effect before dispatch so `Err` paths retain them through the adapter's error conversion; inspect `app.rs` response-finalization behavior rather than assuming an error response keeps extensions. +- Green: keep normal EC finalization timing and publisher-only orphan recovery, including no seeding from the retired cookie. Extend `Eid` with optional string `inserter`/`matcher` and an OpenRTB-compatible integer `mm`, omitted only when absent. Update both the manual parser in `parse_client_auction_eids` and `merge_auction_eids`, not only serde derives. Explicit body-supplied provenance wins over reconstructed KV provenance; preserve empty `matcher` and supplied zero-valued `mm`. Keep existing UID merge precedence and retain UID `ext` for the current auction. Update affected struct constructors and serialization tests. KV-derived EIDs retain registry `atype` and reconstruct fixed Lockr ID5 provenance only from the persisted `lockr` writer. Keep the mapping in `integrations/lockr.rs`, not a source-only attribution table. Different browser UIDs suppress reconstruction for a mixed-origin EID without changing UID merging. Preserve source/UID caps and consent gates. Filter the matching known-expired body UID before merging. +- Only after body tests pass: remove `/auction` cookie fallback, server cookie ingest, JS writer, header emission and old cookie-dependent diagnostics. If retiring an admin route, keep it locally denied with its existing authentication/no-store behavior; do not remove its route and forward admin credentials to publisher fallback. Apply that protection to the Axum and Spin routes as well as Fastly. Keep `x-ts-eids` and `x-ts-eids-truncated` on the inbound strip list; keep EC/consent headers. Keep one transition JS release that expires the old host-only `Path=/` cookie, with a named removal condition: cached bundles that write it have aged beyond the existing one-day max-age and deployment cache lifetime. New servers ignore old bundles' writes. No unconditional server `Set-Cookie` cleanup. Agree an admin diagnostic replacement/removal and identify external header consumers before this deletion. +- Check: filtered `cargo test-fastly` auction/finalize/admin/publisher tests, JS Prebid tests, `rg -n 'ts-eids|x-ts-eids'` review of remaining intentional compatibility references. Verify before/after request cookies and response cache privacy through Fastly tests. + +### 4. Add bounded shared capture and one Fastly post-send executor + +**Files:** `crates/trusted-server-core/src/integrations/{identity.rs,registry.rs,mod.rs}`, `crates/trusted-server-core/src/ec/{identity.rs,mod.rs,kv.rs,finalize.rs}`, `crates/trusted-server-adapter-fastly/src/{app.rs,main.rs}`; tests beside the boundary. Start with a synthetic capture fixture, not Lockr. A capability declares exact method/path/source claims and interprets a bounded decoded response. The framework associates it with the actual proxied upstream result; inbound client headers/JSON cannot select trusted acquisition. + +- Red: 64 KiB original **and separate** 64 KiB decoded JSON cap at exact boundary and +1, compressed expansion, unsupported encoding, decoder failure, malformed capture, streamed oversized response with original prefix replayed, absent or misleading `Content-Length`, and byte-for-byte response/header preservation including encoding and length. Include an oversized single stream chunk and upstream read failure; replay consumed bytes and preserve the underlying stream error rather than manufacturing a capture error response. `collect_response_bounded` cannot be reused because it consumes oversize streams. Bound added capture buffers and decoder working memory; do not copy an oversized incoming chunk into an inspection buffer. Account for retained stream chunks and parser overhead too. No full decompression first. +- Green: inspect only selected proxy responses. A capture failure is a no-op, not a 502 or rewritten body. Stage cloneable effects holding full EC ID, source-validated trusted origin and `EcKvSnapshot`; consume them once before response conversion drops extensions. Before send, expose an approved captured withdrawal to normal finalization so it expires `ts-ec`, removes identity headers and suppresses enrichment. After send, run its existing-key-only tombstone work unless request finalization already handled that identity. A tombstone-only after-send handler cannot change the cookie already sent to the browser. Keep one shared Fastly executor for captured work, with no Lockr-specific branch. Apply withdrawal before capture enrichment and hand **persisted** resulting snapshots to legacy pull sync. `NotRead` may fetch after send; missing and failed never authorize creation. CAS conflicts may refresh, but no unbounded retry or zero-read claim. No-token queues no mutation. If finalization already tombstoned the root, suppress a second tombstone; do not turn a provider timeout into withdrawal. +- Check: filtered `cargo test-fastly` registry/capture/KV/entrypoint tests with a recording send and KV backend. Prove send precedes capture write, no response-byte changes, full-ID association across rotation, failed-write snapshot stays failed, no-token no capture KV work and resulting snapshot changes pull eligibility. Exercise settings-snapshot/reload, finalization-failure and fallback send branches. Failed or withdrawn state must suppress subsequent pull disclosure, not reuse the original live snapshot. Test Axum/Cloudflare/Spin behavior only where an EC KV graph exists; document before-send or unsupported capture on adapters lacking a post-send hook rather than promising persistence. + +### 5. Implement Lockr capture behind explicit approval gates + +**Files:** `crates/trusted-server-core/src/integrations/lockr.rs`, shared acquisition consent handling in `crates/trusted-server-core/src/{consent/mod.rs,consent/types.rs,ec/consent.rs,ec/identity.rs}`, its tests and sanitized fixtures under its existing test module or a dedicated `crates/trusted-server-core/tests/fixtures/lockr/` directory; `trusted-server.example.toml` and `docs/guide/integrations/lockr.md` for confirmed configuration and operator behavior. Register capture only, not a resolver. Add default-true `capture_identity` kill switch without removing the ownership descriptor. Keep SDK, settings and unlisted paths on the existing proxy path; inspect exact POST `page-view`, `generate-tokens`, `refresh-tokens`, `sync-no-hem-ids`, `revoke-consent` paths. Reuse the already buffered POST bytes for request consent without changing forwarding. + +- Fixture gate before enabling each mapping: sanitized SDK `tokenMappings` and `tokenSourceMappings` **and** response examples prove token shape, key/source/type, expiry unit and consent contract. Start with ID5, using `id5id`, `id5-sync.com` and `atype=1`, after its fixture and consent gates pass; decode URL-encoded JSON `advertising_token.universal_uid`, associate `ids..eid` when present, reject mismatched source/oversized UID, convert `identity_expires` milliseconds to seconds and reject expired issue. Do not infer LiveRamp, UID2, EUID, First ID, Yahoo, Criteo or Epsilon token decoding from ID5; leave each unmapped until its own fixture passes. Panorama source/type remains disabled until Lockr confirms it. No arbitrary provider provenance or `ext` is stored in KV. Reconstruct fixed `inserter="Lockr-For-Publishers"`, `matcher=""`, `mm=2` for usable Lockr-written ID5 records without a new field or mutation; other writers and sources remain unattributed. +- Red: exact route and method selection, aimTokens-only and associated EID, unknown key, source mismatch, non-2xx/malformed/no-token no invalidation, missing/extended expiry, repeat equal observation no write, original/decoded overflow with unchanged proxy response. Consent tests include populated opt-out, malformed non-empty strings, empty optional strings, cookie/body conflict, GPC and otherwise permissive/non-regulated or proxy-mode request policy. Map Lockr's buffered `gppString`, `consentString` and `ccpaString` into named shared consent inputs. Reuse shared decoders and request jurisdiction/expiration policy. Require explicit parse success for populated signals; the existing decoder's absent decoded value or a permissive jurisdiction must not turn malformed body consent into permission. Empty fields impose no positive permission, and body consent can restrict but never override denied request consent. Keep this acquisition check separate from approved explicit withdrawal handling. Never log UID, query-bearing URL, consent string or token; audit shared consent display and error logs as well as Lockr logs. Remove the current full-target-URL logging/error attachment in `lockr.rs` on captured paths, and audit shared error formatting. +- Withdrawal is gated separately: only after Lockr confirms 2xx application-success semantics **and** whether revoke is global EC withdrawal or per-source invalidation. Test revoke despite denied acquisition consent, finalization precedence, cookie expiry and existing-key-only tombstone if global scope is approved. Until then, leave revoke capture disabled and let ordinary request consent withdrawal continue; never infer withdrawal from empty tokens or a network error. Clarify ordering if overlapping SDK calls can return older tokens after newer ones; absent a provider sequence, CAS order is observation order only. +- Check: filtered `cargo test-fastly` Lockr parser/proxy/post-send tests with synthetic responses; no live IDs in fixtures. Then run approved publisher test-route acceptance: consent, unchanged original response, delayed KV readback of UID/source/expiry/writer/revision without exporting values, later bidder payload source presence, same-UID extension, known-expired omission including matching body UID, negative consent and approved revoke. Record statuses, counts and booleans only. Capture stays off for a source until its fixture, consent, ownership and compatibility gates pass. + +### 6. Restore bounded capture availability and retry without cookie authority over stored records + +**Files:** `ec/identity_handoff_codec.rs`, `ec/identity_handoff.rs`, `ec/identity_handoff_tests.rs`, shared context/effects/KV/finalization, auction resolution, integration dispatch, Fastly request admission, settings/app-config validation, cookie forwarding/cache policy and operator docs. + +- Implemented default-off `[ec.identity_handoff]`, dedicated secret-store signing keys, safe active/rotation IDs and deploy/runtime validation. Runtime keys decode to exactly 32 bytes and cannot reuse EC passphrase material. Disabled provisioned keys still resolve and validate. +- Implemented a publisher/full-EC-bound, versioned HMAC-SHA256 envelope. The complete signed value is bounded to 2,048 bytes. Entries retain absolute deadlines of at most five minutes, shortened by provider expiry. Oversized entries are dropped whole; multi-source eviction removes earliest deadlines first. Response Max-Age derives from retained signed entries, not pre-truncation candidates. +- Shared finalization issues capture handoff before send without an added KV read, revalidates incoming entries, retires acknowledgements/conflicts and clears empty or rejected cookies only when one was present. Cookie responses are private/no-store, preserve upstream body/encoding and exclude the handoff from auction-provider Cookie forwarding and template cache keys. +- All shared auction callers can deliver an eligible absent-source entry from a loaded, consenting full-ID-bound snapshot. Any observed stored record wins, including expired records. Current consent, capture kill switch, source claims/ownership, clock and expiry still apply. Eligibility retains existing KV read consistency; no strong-read or instant cross-POP withdrawal guarantee is added. +- Browser ingestion cannot strip authenticated pending capture expiry/writer metadata. Matching captures retain the original pending deadline and known expiry when the provider omits it. That pending expiry only fills an absent source and cannot change any stored expiry. A new direct capture supersedes pending replay for its source; existing-source refresh remains direct capture only. +- At most one eligible replay batch is staged for a browser persistence route or an observed permitted Lockr proxy request, including `NoChange`. Body denial clears the handoff and cannot borrow cookie permission. Fastly enables the feature only with its admitted EC/KV finalization graph; other adapters remain unsupported. +- The existing shared executor sends first, then proves the root and applies fill-absent-only CAS before legacy pull work. Conflicts recheck source absence and withdrawal. Replay never creates a root, replaces UIDs/metadata or extends the original deadline. Successful persistence is acknowledged on a later response. No provider refetch, durable queue or unconditional first-auction guarantee. + +## Release and rollback + +1. Ship optional schema-v1 metadata-aware readers and **every** metadata-preserving writer first, with managed acquisition off. Verify mixed-record fixtures. Obtain deployment propagation and old-invocation drain evidence; old UID-only readers ignore expiry and old writers erase metadata. Confirm operator procedure before the first managed write. +2. Treat browser writer/revision output as metadata activation too; complete the compatibility gate before enabling any path that produces lifecycle metadata. Activate body persistence in a coordinated server release; prove body/no-cookie behavior before the transition JS writer removal. Check diagnostic consumers before header retirement. Roll back the JS transition only with a server that still understands body submission; old cached JS may write the ignored cookie for its remaining lifetime. +3. Deploy synthetic capture framework inactive and verify Fastly send ordering. Although the Lockr setting defaults to true, explicitly deploy `capture_identity = false` until approval and compatibility gates pass; ownership validation must still work. On an approved route, add one confirmed Lockr source with owner and `atype` after resolving conflicting legacy writes or pull configuration. Enable capture, measure source-only counts, KV readback and later auction visibility. Canary first; do not claim first-auction guarantees or automatic retry. +4. Safe immediate mitigation after activation is `capture_identity = false` or removing ownership through an **approved migration**; disabling capture alone does not erase stored expiry. Rolling back to a UID-only binary is unsafe until managed writes stop and expiry-aware read/metadata compatibility is explicitly resolved. Do not automatically transfer owner on module failure. DSR deletion and global consent withdrawal remain authoritative over acquired records. +5. At intermediate checkpoints, run only suites affected by the completed milestone. At the final stable implementation checkpoint, run `cargo fmt --all -- --check`, `cargo test-fastly`, `cargo test-fastly-reuse`, `cargo test-axum`, `cargo test-cloudflare`, `cargo test-spin`, `./scripts/test-cli.sh`, JS tests/build/format, integration parity and affected target-matched clippy aliases per `AGENTS.md`. Reuse unchanged passing results rather than repeat this matrix at every checkpoint. Confirm CI and approved live-route evidence separately. Core tests are not a substitute for an observed deployed post-send KV write and later auction. Handoff activation additionally requires dedicated publisher key provisioning/rotation and approved delayed-visibility, failure-repair, cleanup and withdrawal checks. Keep `ec.identity_handoff.enabled = false` until those gates pass. + +## Decisions and remaining approval gates + +- The foundation requests revision-conditional invalidation tests but defers `invalidated_at`, invalidation storage and active resolver to later work. Does the first capture delivery need an invalidation marker at all? This plan avoids dead state and blocks source-specific revoke until scope is approved. Native LiveRamp and its intake/refresh questions remain deferred in [#1245](https://github.com/IABTechLab/trusted-server/issues/1245). Cross-browser linkage needs a separate approved design; #1245 is not authorization to build it. +- Implemented ownership policy: reject owned sources with legacy pull enabled. Keep `api_token` read-only for `identify`; reject owned-source batch writes with 403 before limiter/storage. Ownership is validated through deploy/config-push/runtime/startup paths, even with acquisition disabled. Operator ownership migration and invocation drain still need confirmation. +- Implemented `/_ts/admin/eids` retirement as authenticated local 410/no-store/nosniff on all four adapters; unknown admin subpaths remain locally denied. Cookie readers/writer and diagnostic emission are retired; inbound strips remain. External diagnostic consumers must be checked before deployment. +- Confirm deployment drain/rollback for mixed binary versions, external uses of `x-ts-eids` headers, Lockr fixtures and each enabled provider mapping, Lockr consent requirements and revoke semantics/scope. Clarify overlapping SDK response ordering. None is settled by local tests. + +## Local implementation verification (2026-10-09) + +- `cargo test-fastly`: 219 adapter tests and 2,924 core tests passed, with six core tests ignored; shared-crate and doc tests also passed. `cargo test-fastly-reuse`: 219 passed. +- `cargo test-axum`, `cargo test-cloudflare`, `cargo test-spin`: passed, including authenticated retirement and fallback protection routes. These adapters do not claim post-send KV capture persistence. +- `./scripts/test-cli.sh`: host CLI suite and all four required ignored browser-fixture filters passed. Native `template_build_digest`: ten tests passed. +- Full JS Vitest suite: 48 files, 1,186 tests passed, no type errors. Focused Prebid transport suite: 288 passed. +- Target-matched Clippy passed for Fastly, Axum, Cloudflare native/WASM, Spin native/WASM, CLI and codegen, plus native build-digest tests. Fastly, Axum and Cloudflare development builds passed. +- Recording tests use the actual shared send executor: send sees unchanged KV; factory runs after send; persisted lifecycle state matches the context snapshot consumed by later pull. No effects never opens storage; unavailable storage sends once and leaves KV/context unchanged. Transport tests preserve response parts/bytes and stream errors across independent wire/decoded caps and gzip failures. +- Ownership/config, restrictive consent, missing/failed/withdrawn/full-ID isolation, CAS/no-op/ABA/expiry, browser one-finalization, expiry bypass and withdrawal tests passed. Independent bounded source review found no additional verified privacy/concurrency defect. + +Not verified: real provider acceptance, sanitized provider-approved mappings/fixtures, consent/revoke scope approval, overlapping response ordering, deployment propagation/drain, remote CI or external diagnostic consumers. Synthetic fixtures are not provider approval. Keep both Lockr capture switches explicitly false until their independent gates pass. Native LiveRamp, refresh/invalidation scheduling, durable retry and cross-browser linking remain deferred. + +## Writer-based provenance follow-up verification + +- Adopted the fixed Lockr ID5 provenance values for issuance and refresh. No KV schema, acquisition policy or rollout switch changed. +- Added failing regressions before implementation. Native `cargo test -p trusted-server-core --target x86_64-unknown-linux-gnu lockr_provenance` now passes all three tests: stored source/writer eligibility, persisted KV-to-bidder payload with no mutation, and mixed-origin browser UID handling. The existing body-provenance regression also passes with explicit fields overriding reconstructed values. +- `cargo test-fastly`: 219 adapter tests and 2,933 core tests passed, with six core tests ignored; shared-crate and doc tests also passed. +- `cargo clippy-fastly`, `cargo fmt --all -- --check`, scoped Prettier checks for the updated specs/plan/guide, and `git diff --check` passed. +- Other adapter suites, the CLI/browser-fixture suites, remote CI and live provider acceptance were not rerun for this follow-up. Capture remains gated as above. + +## Authenticated capture handoff verification (2026-10-12) + +- Native `cargo test -p trusted-server-core --target x86_64-unknown-linux-gnu --lib`: 2,981 passed. The handoff/config/codec subset has 40 tests. It covers signed bounds/bindings/rotation, secret resolution and dedicated key material, response bytes/cache policy, absent-source delivery, metadata protection, replay/acknowledgement, retained expired records, same-UID changed-expiry conflicts, concurrent capture/withdrawal, deadline rechecks and no-token proxy repair/body denial. +- `cargo test-fastly`: 219 adapter tests and 2,975 core tests passed, with six native-only core tests ignored; shared-crate and doc tests passed. `cargo test-fastly-reuse`: 219 passed. An initial Fastly run found that the newly added example prose was contiguous with an uncommentable TOML block. Added a blank-line separator and reran the entire core/Fastly suite successfully. +- `cargo test-axum`, `cargo test-cloudflare`, `cargo test-spin`, `./scripts/test-cli.sh`, integration parity and native build-digest tests passed. CLI verification includes all four required ignored browser-fixture filters. Other adapters remain unsupported for handoff issuance/replay. +- All target-matched Clippy aliases passed, including native build-digest lint. Fastly/Axum/Cloudflare builds passed. Full JS Vitest, JS formatting and bundle build passed, with 48 files and 1,186 tests. Full docs and non-docs Markdown formatting, Rust formatting and whitespace checks passed. +- Executed safety proof: no handoff becomes auction input without valid authentication, current policy and an observed live consenting root. Proxy and shared-finalizer tests inspect original response bytes, cookie policy and auction provenance. Missing/failed/withdrawn/full-ID mismatch, disabled capture, tampering and unknown clock do not deliver. +- Executed safety proof: replay cannot replace any stored source or revive a concurrent withdrawal. Tests read back stored state after CAS conflicts and prove send precedes mutation. Provider expiry/writer survive replay, expired deadlines do not write, and `NoChange` retains the original signed deadline. Matching capture retains pending expiry/deadline; omitted provider expiry fills only an absent source and cannot override a newer stored expiry. Existing expiry changes win even when the UID matches. A planned PBS transport regression proves that all consent-forwarding modes strip the handoff, including opaque headers with whitespace around its name. +- Executed consistency limit: a simulated stale live snapshot can deliver its pending handoff despite an authoritative withdrawal elsewhere. The subsequent CAS rejects replay and preserves the tombstone. This is the existing snapshot-read consistency limit, not a strong-read or instant cross-POP withdrawal guarantee; deployed propagation/frequency remains unverified. + +Not verified: remote CI, live provider acceptance, actual cross-POP visibility/withdrawal timing, operational key provisioning/rotation, provider-approved fixture sizes, consent/revoke scope, overlapping SDK response ordering or metadata-compatible deployment drain. Keep handoff default-off and Lockr capture explicitly disabled until the applicable rollout gates pass. diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index 13785c98b..448484808 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -93,17 +93,17 @@ satisfies **HSTS**, which an "ignored" cert does not. Resolved during brainstorming and design review (2026-06-22): -| Decision | Choice | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | -| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | -| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | -| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | -| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | -| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | -| Crate wiring | **Excluded** from the workspace (like `integration-tests`), _not_ a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | -| Host / first-party | First-party host is anchored to `FROM` via an always-sent `X-Forwarded-Host: FROM` (TS prefers it over `Host` for URL rewriting). `Host = FROM` by default; `--rewrite-host` sends `Host = TO` for host-validating upstreams without moving first-party URLs off `FROM`. SNI is always the `TO` host; reach a server by IP with `--resolve` (§8.3). | -| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | +| Decision | Choice | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | +| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | +| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | +| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | +| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | +| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | +| Crate wiring | **Excluded** from the workspace (like `integration-tests`), _not_ a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | +| Host / first-party | Authenticated forwarding anchors the public origin to the validated browser authority, including ports. `Host = FROM` by default; `--rewrite-host` sends `Host = TO`. Preserving the public origin with rewritten Host requires the matching server opt-in. SNI is always the `TO` host; reach a server by IP with `--resolve` (§8.3). | +| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | --- @@ -115,21 +115,22 @@ ts dev proxy [OPTIONS] ### 4.1 Options -| Flag | Value | Default | Description | -| ---------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | -| `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Pairs with `--to`. | -| `-t, --to` | `HOST[:PORT]` | — | Shorthand for a single rule's `TO`. Pairs with `--from`. Keep it a hostname so the SNI/cert stay valid; pin a connection address with `--resolve`. A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | -| `--listen` | `ADDR` | `127.0.0.1:18080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | -| `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | -| `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | -| `--rewrite-host` | flag | false (Host = `FROM`) | Send `Host = TO` instead of the default `Host = FROM`. The TLS SNI is always the `TO` host (see §8.3). | -| `--resolve` | `HOST:IP` (repeatable) | — | Pin `HOST`'s upstream connection to `IP` (curl-style), so `TO` stays a hostname (valid SNI/cert) while the socket dials a chosen server. Keeps the tool self-contained — no `/etc/hosts` (see §8.3). | -| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file`. | -| `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | -| `--insecure` | flag | false | Skip **upstream** certificate verification. | -| `--upstream-plaintext` | flag | false | Connect to upstream over HTTP (e.g. `localhost:3000`). | -| `--ca-dir` | `PATH` | `$XDG_DATA_HOME/trusted-server/dev-proxy` (macOS: `~/Library/Application Support/trusted-server/dev-proxy`) | Where the per-machine CA cert/key are stored (generated on first run). | +| Flag | Value | Default | Description | +| ------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | +| `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Pairs with `--to`. | +| `-t, --to` | `HOST[:PORT]` | — | Shorthand for a single rule's `TO`. Pairs with `--from`. Keep it a hostname so the SNI/cert stay valid; pin a connection address with `--resolve`. A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | +| `--listen` | `ADDR` | `127.0.0.1:18080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | +| `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | +| `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | +| `--rewrite-host` | flag | false (Host = `FROM`) | Send `Host = TO` instead of the default `Host = FROM`. The TLS SNI is always the `TO` host (see §8.3). | +| `--resolve` | `HOST:IP` (repeatable) | — | Pin `HOST`'s upstream connection to `IP` (curl-style), so `TO` stays a hostname (valid SNI/cert) while the socket dials a chosen server. Keeps the tool self-contained — no `/etc/hosts` (see §8.3). | +| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file`. | +| `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | +| `--forwarder-secret-file` | `PATH` | — | Read a single-line token of at least 32 ASCII graphic bytes for `x-ts-forwarder-auth`. Requires loopback and matching server trusted-forwarder configuration. | +| `--insecure` | flag | false | Skip **upstream** certificate verification. | +| `--upstream-plaintext` | flag | false | Connect to upstream over HTTP (e.g. `localhost:3000`). | +| `--ca-dir` | `PATH` | `$XDG_DATA_HOME/trusted-server/dev-proxy` (macOS: `~/Library/Application Support/trusted-server/dev-proxy`) | Where the per-machine CA cert/key are stored (generated on first run). | ### 4.2 Companion subcommands @@ -196,7 +197,8 @@ sequenceDiagram 2. On the MITM path, read decrypted HTTP/1.1 requests **in a loop** — one keep-alive tunnel carries many sequential requests. 3. For each request: rewrite upstream target + SNI to `TO`, set `Host` (§8.3), - strip any inbound `Forwarded`, add `X-Forwarded-Host: ` (and an + strip any inbound `Forwarded`, add the validated browser authority as + `X-Forwarded-Host` (and an informational `X-Orig-Host: `), inject auth if configured. An `Upgrade:` (WebSocket) request is out of scope in v1 (§16): log a clear note and close rather than corrupting the stream. @@ -360,36 +362,43 @@ are instead refused with `403` (§11), never blind-tunneled. ### 8.3 Header rewriting on match -| Header | Action | Rationale | -| ----------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| upstream **connection** | the `--resolve` pin for `rule.to` host if present, else `rule.to` host via DNS; + `rule.to` port | lets `TO` stay a hostname (valid SNI/cert) while the socket targets a chosen IP | -| **SNI** | `rule.to` host; **port stripped** | always the `TO` hostname; a `:port` or bare IP in SNI is invalid/unroutable, so keep `TO` a hostname | -| `Host` | `rule.from` (default); `rule.to` with `--rewrite-host` | the upstream's routing/validation host | -| `X-Forwarded-Host` | `rule.from` (always) | the original first-party host; TS core prefers it over `Host` for `request_host`, so first-party URLs stay on `FROM` even with `--rewrite-host` | -| `X-Orig-Host` | `rule.from` | informational duplicate of the original first-party host | -| `Authorization` | set if `--basic-auth` and not already present | clear `401` gates on staging upstreams | -| `Proxy-Connection` | removed | hop-by-hop hygiene | - -**First-party host is anchored to `FROM` via `X-Forwarded-Host`.** The §1 goal — -validate cookies, `Host`-sensitive logic, CMP/consent, and first-party context at -the _real_ domain — requires TS to treat `FROM` as the first-party host. Trusted -Server core derives `request_host` from `Forwarded` → `X-Forwarded-Host` → `Host` -(`extract_request_host` in `http_util.rs`) and anchors all HTML/RSC URL rewriting -to it (`request_url = "{scheme}://{request_host}"` and -`rewrite_bare_host_at_boundaries` in `publisher.rs` / `rsc_flight.rs`). The proxy -therefore **always sends `X-Forwarded-Host: FROM`** — standard forward-proxy -behavior — so `request_host = FROM` regardless of the routing `Host`. (Note: -`sanitize_forwarded_headers` would strip this at a hardened edge, but it is not -wired into the current request flow, so TS honors the inbound value.) - -This decouples routing from the first-party host: `Host` is free to be whatever -the upstream needs. The default `Host = FROM` works against a TS **Compute** -upstream (Fastly routes by SNI `= TO` and passes `Host` through). A Fastly -**Deliver** / host-validating upstream may reject an unconfigured `Host` -("unknown domain"); and because a domain can be active on only one service -(§2 ¶3), you cannot add the live production domain to a separate dev service. For -those, pass `--rewrite-host` (sends `Host = TO`) — first-party URLs still stay on -`FROM` because `X-Forwarded-Host` anchors them. +| Header | Action | Rationale | +| -------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------- | +| upstream **connection** | `--resolve` pin for `rule.to`, otherwise DNS; `rule.to` port | Keep the TLS identity separate from the connection address | +| **SNI** | `rule.to` host without port | Upstream certificate and routing identity | +| `Host` | `rule.from` by default; `rule.to` with `--rewrite-host` | Upstream routing authority | +| `X-Forwarded-Host` | Validated inbound browser authority, including port | Browser-facing public host | +| `X-Forwarded-Proto` | `https` | Browser leg always uses TLS, including plaintext upstreams | +| `X-Orig-Host` | `rule.from` | Informational mapping host | +| `x-ts-forwarder-auth` | Remove inbound headers and trailers; stamp file token when configured | Authenticate public-origin metadata | +| `Origin` | Preserve every field value unchanged on every route | Browser security and vendor consent contracts | +| `Authorization` | Inject Basic auth only if absent | Clear upstream gates | +| `Forwarded`, `Fastly-SSL`, hop-by-hop fields | Remove before stamping authoritative fields | Prevent spoofed or connection-specific metadata | + +Credential stamping requires one valid inbound Host, or one unambiguous absolute +URI authority. When both are present they must agree. Preserve the browser port; +reject duplicate, malformed, missing, or conflicting authorities with `400` when +the token is configured. Never authenticate a guessed CONNECT fallback. Route +each decrypted request against its own matched FROM host, which may differ from +the CONNECT hostname. + +Trusted Server must opt into authenticated forwarding, use the header +`x-ts-forwarder-auth`, and resolve the matching secret from its secret store: + +```toml +[trusted_forwarder] +auth_header = "x-ts-forwarder-auth" +shared_secret = "trusted_forwarder_shared_secret" +``` + +The key `trusted_forwarder_shared_secret` holds the actual local-file token. +Only bounded publisher hosts are accepted. The server captures validated public +origin before trace dispatch and forwarded-header sanitation, and removes the +credential before publisher/vendor forwarding. This allows browser Origin to +remain unchanged while trace checks and generated public URLs use the same +public origin. Actual upstream transport and ingress fidelity remain independent. +Without this server dependency, forwarded headers do not establish a trusted +public origin and rewritten Host may cause trace Origin validation to fail. **Targeting a specific server by IP.** Keep `TO` a hostname (so the SNI and certificate stay valid) and pin its connection address with `--resolve HOST:IP` @@ -399,10 +408,9 @@ so a host-routed endpoint would serve its default vhost). Cert verification stil applies; add `--insecure` if the endpoint serves a cert that doesn't match. This keeps the tool self-contained — no `/etc/hosts` edit. -`X-Orig-Host: FROM` is also sent as an informational duplicate (TS core does not -read it today). The functional header is `X-Forwarded-Host`. **Validation:** an -integration test asserts that with `--rewrite-host` the upstream sees `Host = TO` -while `X-Forwarded-Host = FROM` (`rewrite_host_keeps_forwarded_host_on_from`). +`X-Orig-Host: FROM` is informational. Forwarding contract tests cover browser +ports, both Host modes, TLS/plaintext upstreams, authentication overwrite and +absence, near-miss routes, vendor Origin preservation, and connection reuse. **Port handling.** With `--rewrite-host` (`Host = TO`) and a non-default `TO` port (e.g. `localhost:3000`, `staging.example.com:8443`), the port **is** included @@ -521,8 +529,8 @@ Rewrite rules are **never** inferred from `trusted-server.toml` (or any other file) — the upstream must always be passed on the command line via `--map` or `-f`/`-t`. This keeps what the proxy does fully explicit and visible in the invocation, with no hidden dependence on the working directory or on a config -key. The only file input the proxy reads is `--basic-auth-file` (and the -per-machine CA under `--ca-dir`). +key. Credential files are read through `--basic-auth-file` and +`--forwarder-secret-file`, alongside the per-machine CA under `--ca-dir`. The tool is **flags-only** — there are no `TS_DEV_PROXY_*` environment-variable overrides either. Every setting is a CLI flag (§4). @@ -555,7 +563,11 @@ overrides either. Every setting is a CLI flag (§4). path, and chosen upstream only. - **Credential input.** `--basic-auth USER:PASS` is **convenience only** — argv is visible via `ps` and shell history. Prefer `--basic-auth-file`; the file is - read once at startup and never logged. + read once at startup and never logged. Forwarding authentication is file-only: + `--forwarder-secret-file` accepts at least 32 ASCII graphic bytes and an optional + single LF/CRLF terminator; whitespace, multiline, short and non-ASCII values + fail without revealing the token. The wrapper is debug-redacted and its header + value is sensitive. Injected credentials require a loopback listener. - **Only matched hosts are decrypted.** Launched browsers proxy **HTTPS only** (§9) and unmatched CONNECT authorities are blind-tunneled (§5), so unrelated browsing is never MITM'd. @@ -615,8 +627,8 @@ request. **Unit (`rewrite.rs`):** host matching (case-insensitivity, port stripping, first-match-wins, no-match pass-through); header outcomes (default `Host=FROM` + -`X-Forwarded-Host=FROM`; `--rewrite-host` sends `Host=TO` while `X-Forwarded-Host` -stays `FROM`; inbound `Forwarded` stripped; non-default `TO` port in `Host` but +`X-Forwarded-Host` preserving the validated browser authority and port; `--rewrite-host` sends `Host=TO` while `X-Forwarded-Host` +stays the browser authority; inbound authentication and `Forwarded` stripped; non-default `TO` port in `Host` but not SNI; auth injected only when absent); URI normalization. **Unit (`ca.rs`):** CA is generated on first run and reloaded from `--ca-dir` on diff --git a/docs/superpowers/specs/2026-08-24-request-phase-timing-design.md b/docs/superpowers/specs/2026-08-24-request-phase-timing-design.md new file mode 100644 index 000000000..0d510971c --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-request-phase-timing-design.md @@ -0,0 +1,763 @@ +# Request phase timing: Server-Timing subtimings and access telemetry + +**Date:** 2026-08-24 +**Status:** Approved design, revised for review rounds 1 and 2, pending implementation +plan. +**Scope:** `trusted-server-core`, Fastly and Axum adapters, `tinybird/` schema, +performance dashboard (separate repo). + +--- + +## 1. Problem + +On 2026-08-21 a production deployment (publisher redacted, `prospect-a.example`) showed +an episodic stall: for a window of roughly 40 minutes, every request that reached the +application path carried a uniform extra ~600 ms of Fastly `time-elapsed`, and then +recovered to 20-50 ms with no deploy or config change we could observe. `/health` +(2-4 ms, short-circuits before app construction) and `/_ts/debug/ja4` (6-9 ms, settings +load only) stayed fast throughout, so the stall lived between app construction and +response send. + +Attributing that window required a live probing session: route-by-route bisection, +cookie-deletion experiments, and an eight-agent code trace. The trace found no +unconditional await on the path that could cost 570 ms, and exactly two +config-conditional candidates (the pre-route request filter's synchronous verification +POST, and EC identity KV writes before send), plus one dependency shared by every +application route (two geo hostcalls per request). We could not tell which one stalled, +because nothing in the response says where server time went. + +The Compute CPU budget is ~50 ms per request, so a large `time-elapsed` strongly +suggests wall-clock time outside active guest CPU: dependency awaits are the leading +explanation, with platform scheduling and hostcall queueing as the residual ones. The +comparison figure here is the fronting delivery layer's `time-elapsed` Server-Timing +entry, observed at its deliver phase. Either way, these are exactly the numbers a +response can carry about itself. + +## 2. Goals + +1. Every normal application response attributes its own server time by phase in a + standard header. Browsers expose the values to same-origin JavaScript via + `PerformanceResourceTiming.serverTiming`, so RUM tooling that reads that API can + surface the breakdown. Whether a given vendor or the publisher's own monitoring + extension actually collects it is verified separately in rollout; the publisher + extension needs a small change to render it. +2. The same numbers flow to Tinybird so we hold p50/p95/p99 per phase, per route class, + per PoP, per deployed version, and a future stall window self-diagnoses in one query. +3. No additional awaited I/O before first byte. The pre-send cost is a handful of + monotonic clock reads, one small allocation at entry, and rendering one header; + telemetry emission happens strictly after the last body byte. + +Scope note: phases cover the application lifecycle after T0. The `/health` and +`/_ts/debug/ja4` short-circuits, config-store open failures, and request-conversion +failures bypass the lifecycle and emit nothing. Requests served entirely by the +fronting cache never reach the guest and produce neither header entries nor rows. + +## 3. Non-goals + +- No trailer-based Server-Timing for body-phase spans (browsers do not expose trailer + values to JavaScript). +- No per-filter naming in any emitted surface. The request-filter span is `ts-filter` + regardless of which filter runs; vendor identity stays out of headers and telemetry. +- No Cloudflare or Spin emission wiring in v1. Core collection is adapter-neutral; those + adapters can wire emission later without core changes. +- No Tinybird endpoint pipe and no rollup materialized views in v1. Grafana queries the + datasource through the ClickHouse connector, matching the auction dashboards; rollups + only if panel latency demands them. +- No sampling of the header. The header is all-traffic when enabled; only Tinybird rows + sample. +- No cross-request circuit breaker for telemetry emission. Compute runs one isolate per + request; there is no shared mutable state to hold breaker state. The controls are the + bounded per-request cost and the `access_sample_rate` lever (section 10). + +## 4. Design overview + +``` +adapter entry (T0) + | RequestTimings::new() -> shared handle + v +app construction ................ ts-appbuild (adapter) +pre-route request filters ....... ts-filter (adapter wrapper) +geo lookup (single, deduped) .... ts-geo (adapter; result carried forward) +template cache lookup ........... ts-template-cache (core: publisher.rs) +origin fetch to resp headers .... ts-origin (core: publisher.rs) +EC identity KV, pre-send ........ ts-kv (core: KV abstraction) +auction wait, buffered mode ..... auction_wait_ms (row only; pre-header in this mode) + | +send_edgezero_response, immediately before into_parts(): + mark_headers_ready() snapshot (unconditional) + build AccessTelemetrySnapshot (only when tinybird.access_enabled) + append Server-Timing header (flag-gated, only on conclusively private responses) + | +headers committed; body streams + auction hold at seam .......... auction_wait_ms (row only; in-stream in this mode) + stream duration, bytes ........ stream_ms, resp_bytes (row only) + | +post-send (adapter main): + request_elapsed snapshot, then existing pull-sync, then: + sample gate -> one NDJSON row -> Tinybird Events API + bounded response await, 2xx validated +``` + +Collection is always-on and flag-free, including the `mark_headers_ready()` snapshot. +Two independent flags gate emission: the header (`observability.server_timing_enabled`) +and the telemetry row (`tinybird.access_enabled`). + +## 5. `RequestTimings` (core) + +New module `crates/trusted-server-core/src/request_timing.rs`. + +- `Phase`: a closed enum: `AppBuild`, `Filter`, `Geo`, `EcKv`, `Origin`, + `TemplateCacheLookup`, `AuctionWait`, `Stream`. Header rendering covers the first six + plus the stored total; the last two are row-only. +- Inner state: one fixed-size array of `Option` slots indexed by phase, + `t0: Instant`, `headers_ready_total: Option`, + `auction_wait_placement: Option` (`PreHeader` or `InStream`), + and `resp_bytes: Option`. Phases that repeat within a request (geo, KV) + accumulate by saturating addition into the same slot. +- `mark_headers_ready()`: stores `t0.elapsed()` once at the response-commit boundary, + unconditionally, before either emission flag is consulted. The header renders this + stored value as `ts-total`; the telemetry row reads the same stored value as + `time_elapsed_ms`. The two surfaces cannot disagree, and the row stays correct when + the header flag is off. Full request duration is captured separately as + `request_elapsed_ms`, snapshotted immediately after the body-stream drive returns and + before any other post-send work, so pull-sync and telemetry emission are never + included in it. +- Sharing: `RequestTimings` is a cheap-clone handle, `Arc>`. It crosses + three boundaries: adapter entry to core handlers, the streaming body closure (records + body-phase spans after the response object has been handed off), and the adapter's + post-send emission read. Access is exclusively `try_lock()`: a contended lock + drops the one sample rather than waiting, so recording can never delay a + request, and a poisoned lock is recovered (the guarded data are plain counters + with no invariant a panic can break) so one panic cannot silence the rest of + the request's timing. +- Recording API: `timings.record(Phase::Geo, dur)` and a scope guard + `timings.span(Phase::Origin)` that records on drop. Guards use saturating duration + math; a non-monotonic reading records zero rather than panicking. The auction-wait + recorder takes the placement explicitly so the two modes cannot be conflated. +- Rendering: `server_timing_value(&self) -> Option` produces + `ts-total;dur=41.2, ts-appbuild;dur=18.4, ts-filter;dur=9.1` with durations in + milliseconds at one decimal. Phases never recorded are omitted. Returns `None` when + `mark_headers_ready()` has not run. + +`Instant` is already used freely in the guest (`publisher.rs`, `auction/telemetry.rs`), +so no new clock abstraction is needed. + +## 6. Span taxonomy and recording sites + +| Entry | Measures | Site | +| ------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `ts-total` | T0 to `mark_headers_ready()` at the response-commit boundary | stored snapshot | +| `ts-appbuild` | config-store open, Settings parse, orchestrator + registry + router build | Fastly `main.rs` around `open_trusted_server_config_store()` + `build_app_with_state()` | +| `ts-filter` | pre-route request filters, end to end (backend ensure, secret read, POST) | around `run_pre_route_filters` (`app.rs:751`) | +| `ts-geo` | geo hostcall (single after dedupe; accumulates if any path still repeats) | `build_ec_request_state` (`app.rs:410`) and timed finalizer fallback lookups | +| `ts-kv` | EC identity KV operations before response send (see enumeration below) | the shared KV abstraction | +| `ts-origin` | publisher backend send to response headers available (read-through cache hit or miss) | `publisher.rs` around the origin `send` | +| `ts-template-cache` | template cache `lookup_or_reserve`, including the hit-path full-body read | `publisher.rs` around the lookup | + +Naming follows the completed template-cache terminology migration (`x-ts-template-cache` +is the emitted header on `main`; `c2` naming is retired). + +`ts-kv` is instrumented by a timing decorator implementing `PlatformKvStore` that +wraps the store handed to request-scoped consumers, because no single existing +abstraction covers the taxonomy: EC graph operations go through `KvIdentityGraph` +while consent persistence uses `PlatformKvStore` directly, and graphs are constructed +independently in request setup, identify, admin lookup, batch sync, and finalization. +Every request-path graph construction receives the timed store; pull-sync explicitly +constructs its graph from an untimed store. Consent-store reads pass through the same +decorator and are timed like any other store call. Included pre-send operations: EC +generation `create_or_revive`, identify-path graph reads and evaluation, finalize-path +`ingest_eid_cookies`/`upsert_partner_ids` and withdrawal tombstones, consent-store +reads on consent routes, and batch-sync graph access when it runs before send. +Explicitly excluded: pull-sync work, which runs strictly after `send_to_client` and is +invisible to both surfaces. The decorator measures store-call latency only: no value +passing through it is read, parsed, or recorded, and the emitted surfaces carry no +consent or identity payloads. This feature therefore needs no consent gate; it is the +site measuring its own infrastructure, not processing user data. The timings handle +reaches `ec_finalize_response` inside the graph it already receives; that function +keeps the repository maximum of seven arguments and does not gain an eighth. + +Row-only fields: + +| Field | Measures | Site | +| -------------------- | --------------------------------------------------------- | ------------------------------------------------ | +| `auction_wait_ms` | wait on the dispatched auction (placement varies by mode) | seam hold (streaming) or buffered finalizer wait | +| `body_mode` | `streamed` or `buffered` response assembly | set where the response body is built | +| `stream_ms` | headers committed to last body byte | adapter around the body-stream drive | +| `resp_bytes` | bytes written to the client body | same | +| `request_elapsed_ms` | T0 to immediately after the body-stream drive returns | post-send snapshot, before pull-sync | + +Auction-wait placement is not universal. On the ordinary streaming path the wait +happens at the `` seam inside the body stream and nests inside `stream_ms`. On +buffered paths (the Fastly shared-template authorized miss, which buffers the full +transform and auction before returning a response, and every Axum response) the wait +completes before headers commit. The row therefore carries `body_mode` plus +`auction_wait_placement` (`pre_header` or `in_stream`), and derivations are +conditional: + +- `in_stream`: `stream_other_ms = greatest(coalesce(stream_ms, 0) - coalesce(auction_wait_ms, 0), 0)`. +- `pre_header`: `auction_wait_ms` joins the pre-header phase set, and `stream_other_ms = coalesce(stream_ms, 0)`. + +`unattributed_ms = greatest(coalesce(time_elapsed_ms, 0) - (coalesce(appbuild_ms, 0) + +coalesce(filter_ms, 0) + coalesce(geo_ms, 0) + coalesce(kv_ms, 0) + +coalesce(origin_ms, 0) + coalesce(template_cache_ms, 0) + pre-header auction wait), 0)`. +Every phase column is nullable, so every query-time formula wraps each term in +`coalesce(column, 0)` and every subtraction in `greatest(..., 0)`; query tests cover +sparse phase combinations. + +## 7. Freeze point and header emission + +The freeze-and-emit point is `send_edgezero_response` (Fastly `main.rs`), immediately +before `response.into_parts()`. This is the single choke point every send path shares, +and it runs after everything that can still mutate the response: the router middleware, +entry-point finalize (`apply_finalize_headers`, asset-policy reapplication), EC +finalization and its KV work, and terminal filter/privacy effects. +`apply_finalize_headers` itself does not emit; the `HEADER_X_TS_FINALIZED` sentinel +marks middleware finalization, not header commitment, and must not be treated as the +timing boundary. + +At the freeze point, in order: `mark_headers_ready()` (unconditional), the +`AccessTelemetrySnapshot` build (gated on `tinybird.access_enabled`, +section 10), then, gated on +`observability.server_timing_enabled`, append one `Server-Timing` header from +`server_timing_value()`. Append semantics, never insert: an origin-supplied +Server-Timing survives, and the fronting delivery layer's own entries (`time-elapsed`, +`hit-state`) are additive per the header's list semantics. + +Header emission is conservative: it happens only when the response is conclusively +non-storable by any shared cache, meaning `Cache-Control` contains `private` or +`no-store` (the existing `cache_control_headers_are_private_or_no_store` predicate). +Anything else, including bare `max-age`, `s-maxage` without `private`, +heuristically-cacheable responses with no cache header at all, and anything a fronting +cache override might store, emits no header, because a stored object would replay one +request's timings for its full lifetime. The long-lived immutable `tsjs` asset route +is the concrete excluded case. The snapshot and the telemetry row are unaffected by +this skip, so excluded routes still report through Tinybird. + +The Axum adapter applies the same emission rule at its terminal point before response +serialization, with adapter-specific phase semantics (section 8a). + +## 8. Geo lookup dedupe (rider) + +Today every dispatched request pays two geo hostcalls for one answer: request-phase in +`build_ec_request_state` (`app.rs:410`) and response-phase in +`FinalizeResponseMiddleware` (`middleware.rs:83`, alternate site `main.rs:285`). + +Plain request extensions cannot carry the result out: the middleware moves the request +context into `next.run(ctx)` and holds only the response afterward. The resolved geo +travels on a dedicated `GeoLookupState` response extension, attached on every exit +path that attempted a lookup, including the asset fallback, which runs +`build_ec_request_state` and then returns without `EcFinalizeState` (which is why +`EcFinalizeState` is not an acceptable carrier). States: `NotAttempted`, +`Attempted(None)` (lookup ran and failed, do not retry), and `Resolved(GeoInfo)`. The +finalize path consumes the carried value and performs a live lookup only in the +`NotAttempted` state; those legitimate fallback lookups (admin, batch, error paths) +are themselves timed into `ts-geo` so degraded geo cannot hide inside +`unattributed_ms`. The 401 rule (`resolve_geo_for_response` skips lookup for +unauthorized responses) is preserved. + +## 8a. Adapter phase semantics + +The Fastly adapter is the reference implementation of the taxonomy. Axum differs +structurally and its emissions are defined accordingly rather than pretending parity: + +- `ts-appbuild` is absent: Axum builds application state once at startup. +- `body_mode` is always `buffered`: the Axum HTTP client buffers upstream bodies, so + `stream_ms` measures buffered-body write-out and `auction_wait_placement` is always + `pre_header`. +- The freeze point is an outer service wrapper around the `RouterService` inside + `AxumDevServer`, not router middleware: router-generated 404/405 responses bypass + router middleware, and middleware returns before Axum serializes the body. The + wrapper sees every response including router-generated ones; `/health` is excluded + by path match inside the wrapper. +- Axum emits the header only; no Tinybird rows in v1 (unchanged). + +Cloudflare and Spin attach the per-request collector so core request-scoped consumers +share the adapter clock, but have no header or telemetry emission wiring in v1. + +## 9. Access telemetry row + +Extends the reserved `tinybird/datasources/access_logs_raw.datasource`. + +Kept columns: `event_ts`, `method`, `status`, `time_elapsed_ms` (defined as the +`mark_headers_ready()` snapshot; nullable because a contended lock drop can lose the +snapshot), `sample_rate`, 30-day TTL. (`event_date` was later dropped for the +`toDate(event_ts)` sorting-key expression; see section 9's schema note.) + +Removed: raw `path`. Route identifiers like `/_ts/admin/ec/{id}` would otherwise put +EC identifiers into a 30-day dataset, and publisher paths carry unbounded cardinality +and user-generated content (search terms, usernames, emails in slugs). Replaced by +`route_template`: + +- Named routes: the matched route-table pattern verbatim, parameters left as + placeholders. +- Publisher fallback: a coarse fixed template, `/` plus the first path segment + restricted to a bounded allowlisted charset, plus `/*` when deeper (for example + `/news/*`). The auction-telemetry normalizer is explicitly not sufficient here: it + redacts long tokens but preserves short identifiers and arbitrary slugs. +- Publisher templates come from an operator-configured allowlist of section names + (`observability.route_sections`, default empty), not from the request: a path + whose first segment matches an allowlist entry and that has at least one further + segment emits `/{section}/*` (the lowercased allowlist entry itself); everything + else emits `/other/*`, and the root path emits `/`. This replaces the earlier + shape heuristics (charset, length, digit bounds), which review showed cannot + bound identity: depth-2 first segments are usernames on `/{username}/posts` + shapes, and single-segment paths are documents under `/%postname%/` permalinks. + With the allowlist, the emitted value set is fixed by configuration, so no + request-derived byte ever reaches the row. The character allowlist alone does not bound identity (`[a-z0-9_-]` + is exactly the alphabet of UUIDs, hex ids, and reset tokens), and a truncated + prefix of any of those is still identifying, so the length and digit bounds reject + the segment outright. +- Tests are adversarial, not just the happy path: a literal EC identifier on the admin + route, an email address in a path segment, search-term-shaped segments, overlong + segments, UUIDs, hex ids, reset tokens, and full article slugs must all normalize + to bounded, content-free templates. + +Added columns (all dimension columns non-nullable with an `unknown` sentinel, because +ClickHouse sorting keys cannot contain nullable columns): + +``` +`service_id` LowCardinality(String), -- FASTLY_SERVICE_ID; immutable deployment identity +`publisher_domain` LowCardinality(String), -- matches auction schema +`env` LowCardinality(String), -- adapter-derived: production | staging | unknown +`route_class` LowCardinality(String), -- publisher_html | tsjs | integration_proxy | ec | auction_api | other +`route_template` String, -- bounded, normalized; replaces path +`body_mode` LowCardinality(String), -- streamed | buffered +`auction_wait_placement` LowCardinality(String), -- pre_header | in_stream | none +`appbuild_ms` Nullable(UInt32), +`filter_ms` Nullable(UInt32), +`geo_ms` Nullable(UInt32), +`kv_ms` Nullable(UInt32), +`origin_ms` Nullable(UInt32), +`template_cache_ms` Nullable(UInt32), +`auction_wait_ms` Nullable(UInt32), +`stream_ms` Nullable(UInt32), +`request_elapsed_ms` Nullable(UInt32), +`resp_bytes` Nullable(UInt64), +`template_cache_state` LowCardinality(String), -- from the typed response extension, not the public header +`country` LowCardinality(String), +`ts_version` LowCardinality(String), +`pop` LowCardinality(String), -- FASTLY_POP, 'unknown' when absent +`auction_dispatched_ms` Nullable(UInt32), -- section 18 +`auction_resolved_ms` Nullable(UInt32), -- section 18 +`auction_committed_ms` Nullable(UInt32), -- section 18 +`auction_id` Nullable(UUID) -- section 18; join key to auction_events_raw +``` + +The four auction columns are specified in section 18; they are listed here so +this block stays the single canonical column list. + +The matched route pattern does not survive dispatch today, so a typed +`RouteMetadata` response extension carries `route_class` and `route_template`: each +named-route handler wrapper attaches its route-table pattern verbatim (handlers +serving multiple patterns attach the one that matched), and the fallback and tsjs +handlers attach their class plus the coarse template. The freeze point consumes the +extension; nothing reconstructs routes from a handler enum or path regex. + +Typed sources only: `env` is adapter-owned, derived from the same Fastly +`FASTLY_IS_STAGING` input that drives `x-ts-env` (`Settings` has no environment +field and does not gain one). `template_cache_state` comes from a typed response +extension, not the `x-ts-template-cache` header (operator-configured response +headers can override managed headers): the currently private +`TemplateCacheResponseState` in `publisher.rs` becomes a typed response extension, +and every state transition sets the managed header and the extension together so +the two can never drift. `service_id` and `pop` come from the Fastly environment. `cache_state` +from the reserved schema is dropped: the guest cannot observe the fronting cache, and +guest-visible cache behavior is already carried by `template_cache_state` and +`origin_ms`. Rows exist only for guest-handled requests; fronting-cache hits are +invisible by construction and the dashboard documentation says so. + +Sorting key: `(toDate(event_ts), service_id, publisher_domain, env, route_class, +pop, status)`. Every column carries a `json:$.` path (the Events API rejects +NDJSON into a datasource without JSONPaths, discovered live); `event_date` was +dropped in favor of the sorting-key expression because a DEFAULT column cannot +carry a JSONPath the producer never sends. Grafana time filtering uses `$__timeFilter(event_ts)` and every panel query +also carries a `toDate(event_ts)` predicate so the primary index prunes; rollout validates +the panel queries with `EXPLAIN` before the dashboard is committed. This replaces the +reserved key `(event_date, path, status, method)`. Rollout step 4 verifies whether the +reserved datasource was ever deployed to the remote workspace; if it was, this schema +ships as a versioned replacement datasource with a cutover, not an in-place edit. + +## 10. Emission mechanics + +- `AccessTelemetrySnapshot`: built at the freeze point when + `tinybird.access_enabled` is set (revised in review from the original + unconditional build, so a disabled deployment pays nothing here), before + `into_parts()` consumes the response. It captures method, status, route metadata + (from the `RouteMetadata` extension), and typed dimension states (`env`, + `template_cache_state`, geo country). It exists because nothing else survives to + post-send on every path: the request is consumed by dispatch, the response by + `into_parts()`, and `EcFinalizeState` is absent on asset, admin, and error paths. +- The emitter's transport context is adapter-owned and route-independent: the + Events API target (backend spec, secret store name, dataset, token secret, sample + rate) derives from settings once at entry in `main.rs`, and the HTTP client is the + adapter's stateless platform client. Asset, admin, and error responses therefore + emit without `RuntimeServices` or `EcFinalizeState`. +- `send_edgezero_response` returns a delivery outcome instead of `()`, with + per-mode semantics because the two body paths observe different things. Streamed + bodies: a counting writer reports bytes written and distinguishes complete, + partial (truncated), and error outcomes. Buffered bodies: `send_to_client()` + returns no delivery result, so the byte count is captured from the body length + before the send and the outcome is complete-on-return with no partial detection; + `body_mode` in the row keeps the two regimes distinguishable in analysis. +- Ordering after the body-stream drive returns: snapshot `request_elapsed_ms` first, + run the existing pull-sync dispatch unchanged, then telemetry emission last, so + pull-sync is never delayed behind the ingest await and never included in + `request_elapsed_ms`. +- Sampling: uniform per-request decision against `tinybird.access_sample_rate`. No + client stickiness. Sampled-out requests are silent; every other drop (row build + failure, send failure, non-2xx) logs one warning naming the reason. There is no + cross-request warning suppression (per-request isolates hold no shared state); the + overload controls are the 2 s bounded await, the single-warning-per-request cap, and + `access_sample_rate` pushed down by config as the operational abort lever. Ingest + health is monitored from the Tinybird side via ingestion freshness on the + datasource, which catches quarantine and schema rejection that per-request warnings + cannot. +- Transport: one NDJSON row to the Tinybird Events API: same `api_host`, reserved + `access_dataset` and `access_token_secret`, 2 s first-byte and between-bytes + timeouts, `max_body_bytes` guard, no retry. +- Delivery confirmation: unlike the auction sink, which starts `send_async` and drops + the pending response (it runs before delivery completes and cannot afford to wait), + the access emitter runs after the client has the full response and therefore awaits + the bounded ingest response and validates 2xx. A non-2xx or timeout logs a warning + with the status. +- Budget: at `access_sample_rate = 1.0` this adds one backend request per request to + the service, after delivery; during a Tinybird outage each such request holds its + sandbox for up to the bounded timeout. The sample rate is the budget control; 1.0 is + a diagnosis setting, not a steady state, and rollout treats sustained emission + warnings as the signal to dial it down. +- Axum adapter: emits the header only; no Tinybird rows in v1. + +## 11. Dashboard and query model + +No endpoint pipe in v1. Grafana queries `access_logs_raw` directly through the +ClickHouse connector with `$__timeFilter(event_ts)` plus a `toDate(event_ts)` +predicate, matching the auction dashboards. + +Dashboard: a new standalone `grafana/dashboards/edge-performance.json` in the +telemetry repo (`trusted-server-tinybird`), performance only, no panels shared with +the revenue and auction dashboards. Panels: + +- Phase percentiles (p50/p95/p99) by `route_class`, per phase column. +- Stacked phase breakdown over time using the non-overlapping set: `appbuild_ms`, + `filter_ms`, `geo_ms`, `kv_ms`, `origin_ms`, `template_cache_ms`, pre-header + auction wait (where `auction_wait_placement = 'pre_header'`), and derived + `unattributed_ms`. In-stream auction wait and derived `stream_other_ms` chart in a + separate body-phase panel and never stack with pre-header phases. +- PoP split, `ts_version` overlay, template-cache state rates. +- Stall panel: rows with `request_elapsed_ms > 500` (post-body total, so body-only + stalls are caught) grouped by dominant phase, where `unattributed_ms` competes as a + phase so the panel cannot confidently blame a small measured span while most time is + uninstrumented. + +All derivations use the `coalesce`/`greatest` forms from section 6; query tests cover +sparse phase combinations and both `auction_wait_placement` modes. + +Sampling semantics for every aggregate: `sample_rate` must be operationally stable +within any queried window. Quantile panels filter strictly to a single `sample_rate` +value. Volume panels weight each row by `1.0 / sample_rate` (the inverse-probability +estimator is `sum(1.0 / sample_rate)` over emitted rows; `count() / rate` is valid +only when the query is already filtered to one rate). Pooled unweighted quantiles +across a rate change are documented as invalid. + +## 12. Config surface + +```toml +[observability] +# Append TS phase timings to the Server-Timing response header. +server_timing_enabled = false # example default +``` + +New `ObservabilitySettings` struct with the single boolean, default off, standard +environment override (`TRUSTED_SERVER__OBSERVABILITY__SERVER_TIMING_ENABLED`). +Collection has no flag: the flags gate the two emission surfaces independently. + +Tinybird flag structure: `tinybird.enabled` today arms the auction sink by itself, so +"enable Tinybird for access telemetry" would silently enable auction emission too. The +master flag is demoted to transport-only (host, store, credentials), and each emitter +gets its own switch: a new `tinybird.auction_enabled` defaulting to `true` (preserving +current behavior for existing configs) and the reserved `tinybird.access_enabled` +defaulting to `false`. A settings test locks the decoupling in both directions. + +Validation when `access_enabled = true`: `tinybird.enabled`, non-empty `api_host`, +non-empty `secret_store`, `access_dataset`, and `access_token_secret`, a positive +`max_body_bytes`, and `access_sample_rate > 0`. An armed-but-silent configuration +(`access_enabled = true`, `access_sample_rate = 0`) is a configuration error, not a +valid state; disabling is done with the flag, not the rate. + +Rollback and compatibility, because `Settings` is `deny_unknown_fields`: + +- Deployment order is binary first, config second. Rollback order is config first + (remove the `[observability]` table and any new tinybird keys), binary second. A + config containing the new fields must never be pushed while a pre-observability + binary can still run. +- Config serialization omits the table when it equals the default, so round-tripping a + config through tooling does not inject a field an older binary rejects. A + compatibility test asserts the serialized default config parses under the previous + schema. +- The environment-variable overlay cannot create a missing leaf, so the key ships + present-but-false in the base operator TOML (the same pattern the GPT integration + documents in `trusted-server.example.toml`) and is flipped by config push. + +## 13. Error handling + +- Recording is infallible: saturating math, lock-failure drops the sample, no panics. +- Header rendering failure (defensive `HeaderValue::from_str` error) logs and skips + the header. +- Row emission failure logs one warning naming the reason and drops the row. The + response has already been delivered; there is nothing to degrade. + +## 14. Testing + +- Core unit tests: phase accumulation, saturating math, `mark_headers_ready()` + idempotence and both-surface consistency, render format (one decimal, omission of + unrecorded phases), row serialization shape, auction-wait placement recording. +- Adapter tests (Fastly via Viceroy, Axum native): header present and well-formed on a + conclusively-private publisher route with the flag on; absent with the flag off; + absent on the shared-cacheable tsjs route and on a bare `max-age` response with the + flag on; exactly one TS-owned metric set (a single `ts-total`) with every + pre-existing Server-Timing value preserved, across all send paths; `ts-kv` captures + EC finalize work (proving the freeze point sits after it). +- Body-mode tests: ordinary streaming (in-stream wait nested in `stream_ms`), + Fastly shared-template authorized miss (buffered, pre-header wait), and Axum + (always buffered), each asserting placement and non-negative derivations. +- Geo dedupe: finalize consumes `Resolved`; no retry on `Attempted(None)`; live + lookup only on `NotAttempted`; fallback lookups timed into `ts-geo`; asset-fallback + path carries `GeoLookupState` without `EcFinalizeState`; 401 skip preserved. +- Route template: adversarial normalization tests (literal EC identifier on the admin + route, email address in a segment, search-term segments, overlong segments) all + producing bounded content-free templates. +- Settings: the access validation matrix including the armed-but-silent rejection; + auction/access flag decoupling in both directions; the former rejection test becomes + the wiring test; the serialized-default-config compatibility test against the + previous schema. +- Sink tests: `RecordingHttpClient` pattern; assert URI, NDJSON body shape, token + header, 2xx validation and warning on non-2xx, skip when sampled out, ordering after + pull-sync. +- Query tests: derivation formulas against sparse rows and both placements. + +## 15. Rollout and verification + +1. Land collection + freeze point + header emission behind the flag, off everywhere. + Full CI gate. +2. Staging deploy with the flag on. Delivery-layer verification is two-sided: a + pass-through request confirming the appended Server-Timing survives the fronting + VCL, and a MISS-then-HIT replay against a cacheable route confirming no stale + timing header is ever served from cache. Fallback if the VCL clobbers the header: a + one-line VCL change on the delivery service, or mirroring the value to + `x-ts-timing` while that lands. +3. Production flag on. Confirm + `performance.getEntriesByType('navigation')[0].serverTiming` shows `ts-*` entries + in a real browser session, and separately confirm what the publisher's RUM tooling + actually collects; the publisher monitoring extension renders it only after a small + change on their side. +4. Verify whether `access_logs_raw` exists in the remote Tinybird workspace. If yes, + ship the schema as a versioned replacement with cutover; if no, edit in place. + Validate the dashboard panel queries with `EXPLAIN` against the sorting key. Then + land the row schema, sink, and settings changes; sample at 1.0 during stall + diagnosis with ingestion-freshness monitoring on the datasource; then the + dashboard. +5. Success criterion: the next stall window is attributable from one response header + or one dashboard query, with no live probing session. + +## 16. Overhead + +Roughly ten monotonic clock reads, two stored snapshots, and one ~130-byte header per +request; one sampled HTTP POST with a bounded await after the response has fully +streamed. No allocation in the hot path beyond the one `Arc` at entry, the +`AccessTelemetrySnapshot` at the freeze point, and the rendered header string. + +## 17. Decisions and open questions + +- **Public exposure is a decision, not an open question.** The header is all-traffic + when enabled. Rationale: values are durations only; the delivery layer already + exposes `hit-state` and `time-elapsed` publicly on every response; filter vendor + identity is masked; emission is restricted to conclusively-private responses so no + cache can replay stale timings. Revisit (quantization or gating) only if a concrete + abuse surfaces. +- The fronting delivery layer's Server-Timing pass-through is unverified until the + first staging deploy (step 2). This is the only known external dependency. +- Body-phase capture threads the timings handle into the streaming closure in + `publisher.rs`; the exact seam is an implementation-plan detail, with the constraint + that a dropped handle (error paths, early client disconnect) must still yield a + valid row with null body-phase fields and a recorded delivery outcome. +- The stall window itself remains unattributed until this ships. If it recurs first, + the bisection runbook from 2026-08-21 (cookie-free curl UA request, static-asset + path versus HTML path) is the fallback. + +## 18. Auction timeline offsets (follow-up increment) + +Status: spec amendment written ahead of implementation, then implemented in the +same PR on top of the initial implementation (#1074). Builds only on machinery +that spec sections 5, 9, and 10 already define. + +### Problem + +The pipeline has two clocks that never meet. The auction dataset +(`auction_events_raw`, PR #813) measures the auction internally: `total_time_ms` +from auction start to terminal, `provider_response_time_ms` per bidder call. Its +clock starts when the auction observation is created, so nothing places those +numbers on the request timeline. The access row is T0-anchored but records only +`auction_wait_ms`: time the handler was blocked at collect, deliberately not the +auction's own timeline. + +That leaves three questions unanswerable today: + +1. At what request-relative time did the auction start (dispatch leave the edge)? +2. At what request-relative time did the auction resolve (final bid or timeout)? +3. At what request-relative time were the results committed toward GAM? + +These are the overlap-proof questions. A client-side wrapper cannot dispatch until +the browser boots (t~3000ms on measured prospect pages); the server-side auction +dispatches while the origin fetch is in flight. Proving that requires all +milestones on one clock. + +### Design + +One first-call-wins id setter and three first-call-wins marks on +`RequestTimings`, in the style of `mark_headers_ready()`, each mark storing +`Option` since T0: + +| Call | Recorded at | Meaning | +| --------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------- | +| `set_auction_id()` | where the `AuctionObservationContext` is built, on all three auction sources | the join key to `auction_events_raw` for this request | +| `mark_auction_dispatched()` | where the bid requests leave the edge | bid requests have left the edge | +| `mark_auction_resolved()` | where the auction returns, terminal on success, failure, or timeout | final bid returned or auction timed out | +| `mark_auction_committed()` | where winning bids become available to the response pipeline | targeting is committed | + +All three auction sources are instrumented, because all three emit rows to +`auction_events_raw` and all three emit an access row: + +| Source | Route | Dispatch / resolve bracket | Commit | +| ------------------- | ---------------- | ---------------------------- | -------------------------- | +| `InitialNavigation` | publisher HTML | `dispatch_auction` / collect | `write_bids_to_state` | +| `SpaNavigation` | `/_ts/page-bids` | `run_auction` | bid map built | +| `AuctionApi` | `POST /auction` | `run_auction` | OpenRTB response converted | + +Notes on the definitions: + +- "Committed toward GAM" is defined as the point where targeting becomes part of + the response: `write_bids_to_state` returning on the navigation path, the bid + map on page-bids, the converted OpenRTB response on `/auction`. TS never calls + GAM server-side; the browser's GPT call carries the targeting, and that half of + the timeline belongs to client-side measurement. The edge proves when targeting + was available; the client proves when GAM saw it. +- The id is set separately from the dispatch mark, and earlier. Every auction + outcome emits an `auction_events_raw` row under that id, including skipped and + dispatch-failed auctions, so stamping the id at dispatch would leave exactly + those rows unjoinable. It also means a dropped dispatch sample cannot take the + join key with it. +- First-call-wins on the id and all three marks. A request produces at most one + auction today; if a second ever occurs in one request, the row describes the + first and the auction dataset still carries both in full. +- Same locking and failure model as every other `RequestTimings` write: + `try_lock`, drop on contention, poisoned lock recovered, saturating conversion + at serialization. + +### Row changes + +Four additive columns on `access_logs_raw`, all populated from the +`TimingSnapshot` at the existing freeze/emission points (no new emission path): + +``` +`auction_dispatched_ms` Nullable(UInt32), `json:$.auction_dispatched_ms` +`auction_resolved_ms` Nullable(UInt32), `json:$.auction_resolved_ms` +`auction_committed_ms` Nullable(UInt32), `json:$.auction_committed_ms` +`auction_id` Nullable(UUID), `json:$.auction_id` +``` + +Null on an offset means "this milestone was not reached", not "no auction ran". +`auction_id` is what separates the cases, and the two read together: + +| `auction_id` | `dispatched` | `resolved` | Meaning | +| ------------ | ------------ | ---------- | ----------------------------------------------------------- | +| null | null | null | no auction was attempted (assets, EC endpoints, disabled) | +| set | null | null | attempted, then skipped or failed to dispatch | +| set | set | null | dispatched, never collected (origin error, 304, disconnect) | +| set | set | set | ran to completion or timed out | + +The third row is the case worth watching: bid requests went out and the response +they were for never used them. It is distinguishable now, where before it was +indistinguishable from "no auction". + +- `auction_id` is the telemetry auction UUID already present on every + `auction_events_raw` row, carried onto the access row as the join key between + the T0 timeline and per-bidder detail. It is `Nullable(UUID)` rather than a + String with a sentinel so it joins natively against `auction_events_raw` + .`auction_id`, which is `UUID`: a String column would make the join a type + error, and casting a `'none'` sentinel through `toUUID` throws. It is a random + UUID, not identity-bearing; unbounded cardinality is accepted for the same + reason it is accepted in the auction dataset. It is not in the sorting key, so + the section 9 non-nullable-dimension rule (which exists because ClickHouse + sorting keys cannot contain nullable columns) does not apply to it. +- Schema evolution is additive with JSONPaths on every new column and + `FORWARD_QUERY` carrying the existing columns, per the deployed datasource's + established evolution path. Verified with `tb --cloud deploy --check` before + deploy. + +### Interpretation model + +Combined with existing columns, one access row now reads as a timeline: + +``` +t=0 ......... request entry +t=D ......... auction_dispatched_ms (bids out; origin fetch typically in flight) +t=R ......... auction_resolved_ms (R - D ~ auction duration; join auction_id + for the per-bidder long pole) +t=C ......... auction_committed_ms (targeting available to the response) +``` + +`time_elapsed_ms` (t=H, headers committed) does **not** belong at the end of that +ladder, and where it lands depends on `body_mode`: + +- Buffered (`auction_wait_placement = 'pre_header'`): H comes after C. The auction + is collected before the response headers are built, so D < R < C < H. +- Streamed (`auction_wait_placement = 'in_stream'`): H comes **before** R and C. + `mark_headers_ready` is stamped at the terminal layer before the lazy body is + polled, while the collect happens at the `` seam inside that body. So + D < H < R < C is the normal ordering for a streamed HTML page. + +A sanity filter of the form `auction_committed_ms <= time_elapsed_ms` therefore +discards every valid streamed row. Slice by `body_mode` before comparing the +auction marks against H at all. + +Derivations the dashboard can add without schema help: auction duration on the +request clock (`R - D`), commit latency (`C - R`), and overlap ratio (share of +`R - D` that ran concurrently with `ts-origin`). `C - R` is whole-millisecond +like every other column here, and on the navigation path it brackets +`write_bids_to_state`, whose cost is per-winning-bid creative processing +(sanitize, first-party URL rewrite, signing, with `rewrite_creatives` on by +default). Expect 0 on light auctions and non-zero as creative count and size +grow; it is not a restatement of `R`. `auction_wait_ms` keeps its existing +meaning (blocked time only) and is now interpretable next to the timeline: +`R - D` minus `auction_wait_ms` approximates how much of the auction was +absorbed by work the request needed anyway. + +Joining to per-bidder detail, now that both sides are `UUID`: + +```sql +SELECT a.auction_id, a.auction_resolved_ms - a.auction_dispatched_ms AS edge_window, + e.provider, e.provider_response_time_ms +FROM access_logs_raw a +INNER JOIN auction_events_raw e ON a.auction_id = e.auction_id +WHERE a.auction_id IS NOT NULL AND e.event_kind = 'provider_call' +``` + +One caveat on reconciling the two clocks: D is stamped where dispatch returns to +the caller, while the auction dataset's `total_time_ms` and +`provider_response_time_ms` start inside the orchestrator's launch loop. So +`R - D` is the post-launch window and can read slightly smaller than a joined +per-provider time. Treat `R - D` as the request-clock cost of the auction, and +the auction dataset as the authority on per-bidder duration. + +### Scope + +- Fastly emits. Axum, Cloudflare, and Spin attach a collector and record the marks but + do not emit them. Cloudflare and Spin added collection when GPT diagnostics began + exposing request-scoped auction offsets; without an adapter collector those offsets + silently used a different handler-entry clock. This matches section 8a adapter + semantics: collection is portable, while emission remains adapter-specific. +- No header emission for any of these values: they are post-hoc analysis fields, + and two of the three are typically unknown at the header freeze point in + streaming mode. +- No config surface: the marks are always-on collection like every other phase, + gated at emission by the existing `tinybird.access_enabled`. diff --git a/docs/superpowers/specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md b/docs/superpowers/specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md new file mode 100644 index 000000000..8e1ec1999 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md @@ -0,0 +1,2317 @@ +# Mobile Ad-Rendering Trace Endpoint Design + +**Status:** Approved, including runtime-boundary amendment (2026-10-05) + +**Runtime-boundary amendment:** Version one evaluates the method, pathname, +headers and body exposed at application dispatch. It does not require recovery +of information discarded by an SDK/runtime or application responses to requests +rejected before dispatch. Cookie ambiguity suppresses trace capture under the +explicit rules in section 9.2. See section 8 for response scope and section 16 +for acceptance tests. This amends the original-path and original-wire promises; +authentication, deliberate actions and response privacy remain required. + +**Issue:** [#1050 — Create debug endpoint for mobile user to trace ad rendering](https://github.com/IABTechLab/trusted-server/issues/1050) + +**Related work:** + +- [#1081 — Improvements to TS_CONSOLE for ad observability](https://github.com/IABTechLab/trusted-server/issues/1081) +- [#1074 — Request-phase timing](https://github.com/IABTechLab/trusted-server/pull/1074) +- [#1076 — Auction timing milestones](https://github.com/IABTechLab/trusted-server/pull/1076) +- [#974 — GPT runtime diagnostics](https://github.com/IABTechLab/trusted-server/pull/974) +- [#997 — Trusted Server delivery attribution](https://github.com/IABTechLab/trusted-server/pull/997) + +## 1. Summary + +Add a deployment-controlled, privacy-safe `GET /_ts/trace` page, public subject +to operator authentication rules, for a +mobile end user who needs to reproduce an ad-rendering problem and give support +an exportable diagnostic report. Visiting the page is read-only. The user +intentionally enables or ends tracing with a same-origin POST action. + +The endpoint is both a setup page and a report viewer. On the first visit it +offers a large `Enable tracing` action and explains how to reproduce the +problem. The user then returns to the real publisher page and reloads it. +Trusted Server supplies redacted request context and a minimal live summary of +each server auction, while the existing TS Console records GPT and render +evidence. A `View trace results` action creates one bounded, allowlisted +snapshot in same-tab `sessionStorage` and navigates to `/_ts/trace`. The +endpoint reads that untrusted snapshot, validates it, presents a mobile-first +HTML report, and offers JSON export, copy, and progressive Web Share actions. + +The design introduces no report database, server-side trace store, report ID, +target URL parameter, telemetry query, or publisher-origin change. It cannot +recover an event that happened before tracing was enabled; the user must +reproduce the problem. + +## 2. Problem and product interpretation + +Issue #1050 names three required data groups: + +1. Network information inspired by `fastly-debug.com`. +2. Auction information per ad slot, similar to the TS tracer and auction + telemetry. +3. End-user cookie information. + +The title additionally establishes two product constraints: the experience is +for a mobile user, and it is reached through an endpoint. On a deployment whose +authentication rules leave trace routes public, a mobile +user should not need browser developer tools, credentials, a copied trace ID, +or a second copy of the affected page URL. + +A standalone request cannot know what occurred in a previous document. Exact +render evidence exists only while the publisher page is running in the browser. +The design therefore separates two responsibilities without separating the user +experience: + +- `/_ts/trace` owns setup, consolidated presentation, and export. +- TS Console owns observation of the real publisher page. + +The browser-local handoff joins them without introducing a backend report +service. The report keeps three evidence layers separate: what the server +auction observed, what GPT observed in the page, and what the creative bridge +observed during rendering. Correlation is displayed only when an ephemeral +diagnostic token connects those layers. + +## 3. Goals + +- Give a non-technical mobile user one memorable URL: + `https:///_ts/trace`. +- Capture evidence from a real publisher-page reproduction, not a synthetic + auction. +- Display a Fastly-inspired network summary for the traced publisher request. +- Report health for an explicit allowlist of Trusted Server cookies without + exposing their values. +- Report each observed server auction as initial-navigation SSAT, SPA page-bids, + or the Trusted Server `/auction` API, including bounded auction-local timing, + provider-call outcomes, and per-slot candidate outcomes. +- Present the versioned, allowlisted TS Console evidence for every retained GPT + slot and request cycle that fits the public report bounds, with explicit + omission counts when deterministic size truncation is required. +- Support a full report in a narrow mobile viewport without developer tools. +- Export the same allowlisted model as formatted JSON. +- Keep the supported capture journey bounded, same-tab, temporary, and inactive + by default without treating browser storage as a security boundary. +- Preserve normal auction, GPT, rendering, origin, and caching behavior whenever + diagnostics is inactive. +- Keep core behavior platform-neutral while allowing Fastly to provide richer + optional request fields. + +## 4. Non-goals + +- Recovering a failure that occurred before tracing was enabled. +- Permanent history, cross-device retrieval, server upload, or support-ticket + integration. +- A database, distributed trace store, report token, or telemetry lookup. +- A target URL such as `/_ts/trace?target=/article`. +- Replaying an auction or treating a synthetic auction as evidence about a + publisher page. +- Exact parity with every field or active measurement on `fastly-debug.com`. +- Reading the browser's complete cookie jar, third-party cookies, cookie + attributes, or cookies withheld from the request. +- Exposing raw cookie values, EC IDs, EIDs, consent strings, unmasked IP + addresses, internal auction request IDs, creative markup, targeting maps, + cache URLs, or stack traces. +- Reimplementing TS Console auction and creative observability requested by + #1081. +- Naming bidders, exposing winning price, assigning final creative numbers, or + asserting which demand path ultimately won when the available evidence does + not prove it; those remain #1081 concerns. +- Querying Tinybird to build an interactive report. +- Adding exact provider-by-slot no-bid explanations before the auction model can + observe those dispositions. +- Introspection into third-party client-side auction internals. Version one can + report that a publisher/Prebid refresh was observed, but cannot identify its + participants or winner unless Trusted Server itself handled that auction. + +## 5. Decisions + +### 5.1 Public, redacted endpoint with intentional activation + +`/_ts/trace` is available when explicitly enabled by deployment configuration +and is public only when no operator authentication rule covers it. It is not +placed under `/_ts/admin`, because the intended user is a layperson on a phone. +Existing Basic Authentication rules still apply to every trace path, including +assets and actions; the early dispatcher must not carve out an exemption. +Operators offering the credential-free journey must scope authentication to +the paths they intend to protect, for example `^/_ts/admin` (see section 12.5). + +Public access is safe only because both the page and export use a strict +allowlist. The activation cookie is a feature toggle, not authentication. No +field becomes eligible merely because tracing is active. + +`GET /_ts/trace` is read-only and never activates or ends tracing. Activation +and deactivation use an in-page same-origin `fetch` POST accepted only when its +fixed custom action header, `Origin`, and Fetch Metadata identify the publisher +origin. Requests with a conflicting or missing signal fail closed. The POST +updates the existing page rather than adding a history entry, so browser Back +can still reach the article. This prevents an unrelated site from silently +toggling diagnostics through a top-level GET while preserving a one-URL, +one-tap mobile workflow. This control does not defend against code already +executing on the publisher origin. + +### 5.2 Reuse the existing diagnostics session + +The endpoint reuses `__Host-ts-console` and the existing GPT diagnostics +activation semantics rather than creating a second `ts-trace` session. The +cookie remains host-only, `Secure`, `HttpOnly`, and `SameSite=Lax`. +Both `POST /_ts/trace/enable` and the existing `?ts_console=1` writer must +set `Max-Age=1800` through one shared cookie policy, including when +`trace_page_enabled` is false. This explicitly changes the technical query flow +from a browser-session cookie to a 30-minute cookie. An explicit activation +restarts the 30-minute lifetime; ordinary publisher requests do not refresh it. +`POST /_ts/trace/end` and `?ts_console=0` clear the same cookie. Neither POST +action accepts state-changing query parameters. A later query activation must +never replace the bounded cookie with a session cookie. + +This bounds browser-managed persistence from the most recent activation by an +updated writer, not total session duration or server-enforced authorization. +Pre-existing session cookies carry no expiry in the request and cannot be +retroactively aged; users with those cookies must end or re-enable diagnostics +to adopt the new lifetime. Cookie expiry also does not unload a running page +or clear an existing report; report expiry is defined separately in section 9.5. + +The base trace gate requires both `trace_page_enabled = true` and exactly one +valid incoming diagnostics cookie, inspected from frozen runtime-visible +fields before cookie sanitation under section 9.2. An unavailable result, +including `runtime_header_ambiguous`, keeps this gate false. Unknown or missing +fidelity metadata alone does not invalidate a readable marker-free cookie. +Publisher-document tracing additionally requires the existing effective +`GptDiagnosticsRequestDecision.active` decision. Query disable or invalid +directives, prefetches, bots, and other ineligible navigations therefore +suppress document trace context, auction evidence, and browser activation even +when an incoming cookie is valid. A query activation without an incoming valid +cookie enables the existing console; trace capture starts on the next eligible +reload carrying that cookie. Page-bids and `/auction` requests use the base +configuration-plus-cookie gate without requiring a document-navigation +decision. References below to the trace gate include the additional effective +diagnostics decision for publisher documents. The shared cookie by +itself continues to activate the existing TS Console but does not authorize +trace tokens, server-auction projection, trace response extensions, or +correlation sidecars on a deployment whose trace page is disabled. This makes +the configuration flag the disclosure and rollback boundary for new requests. + +Core exposes the evaluated document trace gate as the literal boolean +`window.__tsjs_trace_active` before TSJS initializes on the publisher document; +it is true only when the base gate and effective document diagnostics decision +are both active. TSJS requires +`window.__tsjs_trace_active === true` before minting slot tokens, retaining +request mappings, installing trace listeners, collecting transport evidence, +emitting sidecars, or offering the trace handoff action. Missing or non-boolean +values mean inactive; `window.__tsjs_gpt_diagnostics_active` alone is +insufficient. Core independently rechecks the gate on each request and never +trusts the browser flag as authorization. Configuration changes or cookie expiry +cannot revoke code already loaded in a document; rollback takes effect on its +next reload, and subsequent server requests stop returning evidence immediately. + +### 5.3 Browser-local, explicit handoff + +TS Console remains memory-only during observation. It writes a report to +`sessionStorage` only after the user selects `View trace results`. The action: + +1. Builds the same versioned allowlisted snapshot used by export. +2. Adds the redacted request-context envelope. +3. Serializes and validates the size. +4. Stores it under one versioned key in the current tab. +5. Navigates the same tab to `/_ts/trace`. + +Continuous persistence is prohibited. Same-tab navigation is the supported +handoff, not an isolation guarantee: a browser may copy session storage into an +opener-created tab or preserve it during session restore. Same-origin scripts +and service workers can read, replace, or forge the snapshot. The viewer +therefore treats it as untrusted, applies an application-level expiry, and +labels it browser-observed rather than authoritative. + +### 5.4 Forward reproduction, not historical diagnosis + +The user's explicit activation enables tracing for subsequent eligible document +navigations. The setup page must say plainly that the user needs to return to +the affected page, reload it, and reproduce the problem. + +If the user replaced the affected URL in the address bar with `/_ts/trace`, the +page offers a `Return to previous page` action backed by browser history and +then instructs the user to reload once. History is only a convenience: it may +lead to a messaging app, search page, or unrelated site. The page includes a +fallback instruction to reopen the affected article on the same hostname and +in the same tab. The design does not claim that back-forward-cache restoration +caused a new server request. + +Support should preferably give the user the trace URL before reproduction. The +product does not attempt to discover the previous URL through `Referer`, because +address-bar navigation commonly omits it and relying on it would create +inconsistent behavior. + +### 5.5 Separate issue ownership + +#1050 defines the report shell, request context, mobile flow, browser-local +handoff, export, and the minimum server-auction facts needed to answer whether +SSAT or the Trusted Server auction API ran. Core produces a new redacted +`TraceAuctionEvidenceV1`; TS Console consumes it as immutable evidence rather +than reconstructing server behavior from GPT callbacks. + +#1081 remains the owner of bidder/price disclosure policy, creative numbering, +final user-facing terminology, and any richer cross-demand winner +classification. #1074/#1076 remain the owners of request-phase and +request-relative auction milestones. Version one may ship without those +follow-ups because it exposes already-available auction-local total and provider +durations and labels each clock explicitly. + +Version one also consumes `GptDiagnosticsExportV1` through TS Console's public +export contract and projects it into a distinct redacted +`TraceGptDiagnosticsV1`. It does not read TS Console internals or create a +second GPT attribution engine. The existing recorder emits an exact-token +`TraceSlotCorrelationV1` sidecar when it binds an opportunity to a request +cycle. The server-auction, correlation, and GPT projections are separate sibling +contracts in `TraceReportV1`; none is treated as a substitute for another. + +The deferred object-URL cleanup in section 12.3 deliberately diverges from TS +Console's existing export, which revokes synchronously in a `finally` block +immediately after `anchor.click()` +(`crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts`, +reachable through the console's `export()` action). That older path keeps the +truncation risk this design avoids. Version one does not change it; correcting +it belongs to TS Console under #1081, and until then the two download paths +intentionally differ. + +## 6. User experience + +### 6.1 First visit: no captured report + +`GET /_ts/trace` returns a mobile-first HTML page with: + +- Title: `Trusted Server ad diagnostics`. +- State derived from the setup request: `Tracing is off` unless the server + observed a valid existing diagnostics cookie, including one activated through + the technical query flow. +- A short explanation that no previous ad failure can be recovered. +- Network and cookie health for the setup request, labeled `Setup request`. +- Primary action: `Enable tracing`, implemented as an in-page same-origin fetch + POST that does not add a history entry. +- After activation and a successful state-verification request, state: `Tracing +is on — cookie observed by server`. +- After activation, primary action: `Return to previous page` when browser + history permits. +- Secondary instructions: return to the affected page, reload once, reproduce + the problem, then select `View trace results`. +- A recovery instruction to reopen the affected article on the exact same + hostname and in the same tab if browser history is not useful. +- Action to end tracing when it is active. + +The page must not imply that setup-request network facts or an empty auction +section describe the affected page. +The setup-request projection reuses the section 9.1 `network` block and section +9.2 `CookieHealth` contracts, including their bounds, without requiring a valid +diagnostics cookie or constructing a `TraceReportV1` envelope. + +### 6.2 Active publisher page + +The existing TS Console remains available. On mobile it gains a prominent +`View trace results` action. Selecting it never changes ad behavior; it only +snapshots retained observations and navigates after serialization succeeds. + +If a valid bounded snapshot is built but browser storage rejects it, the page +remains in place, announces the storage failure, and offers a direct download +of that same combined `TraceReportV1` envelope. If projection, validation, or +size bounding fails before a valid report exists, the page reports capture +failure and does not mislabel the existing GPT-only export as an equivalent +fallback. + +### 6.3 Report visit + +When a valid snapshot exists, `/_ts/trace` renders: + +1. Header with the unverified-data notice, capture time, origin and counts. +2. `What happened`: a headline, a short reading of observed facts, count tiles + and slot fill chips. +3. `Needs attention`, when any slot is empty, has no observed fill, has an + incomplete GPT event sequence or recorded a creative-bridge failure. Each + entry links to its slot card. +4. `Ad slots`: one card per numbered GPT slot in page order, with its fill + state, a proportional request timing bar (response, render, then viewable + or load), its exact correlation to a server slot when one exists, and a + collapsed `All details` disclosure with every retained request field. +5. Server-auction section grouped by auction and numbered slot. +6. Collapsed network/request, cookie-health and coverage/ambiguity sections. +7. Export actions. +8. `Clear report and end tracing` action. + +The summary is computed only from retained report fields. It counts filled, +empty and fill-unknown slots, completed auctions, failed provider calls, +bidder-role returned bids (mediator echoes are excluded so bids are not counted +twice), selected server candidates, GPT response classes of filled requests and +slots without an exact correlation. It never names an auction winner, never +places server and browser clocks on one timeline and applies no latency +thresholds. Timing bars use relative flex weights from stylesheet classes, +never inline styles; their exact milliseconds remain in text and in the bar's +accessible label. + +The setup request's facts are not merged into or substituted for missing traced +page facts. Missing fields display `Unavailable`, grouped behind a collapsed +`N fields unavailable` disclosure; missing evidence displays `Not observed` or +`Unknown`, following TS Console terminology. + +The viewer presents an evidence chain rather than one overloaded status: + +```text +Server auction -> GPT request/response -> creative render/load/viewability +``` + +For each step it shows the source, observed outcome, and correlation state. +`Client-side refresh observed` describes browser intent only; it must never be +rendered as `client-side auction won`. Likewise, a filled GPT slot does not +prove that the Trusted Server candidate rendered. + +Internal enums are exported for machines, but the page uses plain labels: + +| Evidence enum | Mobile label | +| ------------------------------------------ | ------------------------------------------------- | +| `initial_navigation_ssat` | `Initial-page server auction (SSAT)` | +| `spa_page_bids` | `Trusted Server page-refresh auction` | +| `auction_api` | `Trusted Server auction API` | +| GPT `prebid_refresh`/`publisher_refresh` | `Browser refresh observed; winner not determined` | +| GPT `competing`/`unattributed` | `Multiple or unknown delivery paths` | +| matched non-empty creative-bridge evidence | `Trusted Server creative rendered` | + +The report begins with `Browser-carried, unverified diagnostic data`. Server +auction entries are labeled `Produced by Trusted Server; copied through an +untrusted browser snapshot`, while GPT and creative entries are labeled +`Browser observed`. The viewer does not claim that the stored snapshot is +authentic or suitable as forensic or security evidence. + +`Copy` copies formatted JSON. `Share` supplies the same JSON file to the native +Web Share sheet only after an explicit tap and tells the user that the selected +app will receive it. If file sharing is unsupported or rejected, the viewer +keeps Copy and Download available; it does not silently share a URL or upload +the report. + +### 6.4 Mobile and accessibility requirements + +- Support viewport widths down to 320 CSS pixels without horizontal page + scrolling. +- Use a full-document report rather than the current floating 460-pixel panel. +- Use at least 44-by-44 CSS pixel primary touch targets. +- Keep export/end-trace actions reachable without covering report content. +- Use semantic headings, lists, tables only where they remain readable on a + narrow viewport, visible focus styles, and an `aria-live` status region. +- Do not rely on hover, color alone, badges alone, or precise pointer input. +- Preserve browser zoom and safe-area insets. +- Prefer native text and controls over a framework or new UI dependency. + +## 7. Architecture + +```text +GET /_ts/trace + | + |-- early reserved-route classifier applies configured auth, then terminates locally + |-- HTML explains forward reproduction; no state mutation + v +POST /_ts/trace/enable after explicit user action + | + |-- validates same-origin request signals + |-- response sets __Host-ts-console + |-- client requests /_ts/trace/state + |-- server reports whether the new request carried a valid cookie + v +Real publisher document reload + | + |-- adapter supplies optional network facts + |-- core computes allowlisted cookie health + |-- core injects redacted TraceRequestContextV1 + |-- core observes live server auctions and emits TraceAuctionEvidenceV1 + |-- existing TS Console observes GPT and creative delivery + v +User selects "View trace results" + | + |-- JS builds bounded TraceReportV1 + |-- same-tab sessionStorage write + |-- location.assign('/_ts/trace') + v +Report GET /_ts/trace + | + |-- static report shell reads and validates TraceReportV1 + |-- mobile HTML renders sections + |-- local JSON/copy/share actions +``` + +### 7.1 Core responsibilities + +- Define configuration and route behavior. +- Provide a shared exact-path reserved-route classifier that runs before event + context, filters, auctions, named routes, or publisher fallback. +- Define the platform-neutral request-context and report-envelope schemas. +- Build `TraceAuctionEvidenceV1` directly from the live auction observation and + orchestration result; never query telemetry or serialize telemetry rows. +- Mint and thread public diagnostic auction/slot tokens independently of + internal request IDs, including zero-bid and failed auctions. +- Build cookie-health facts through read-only parsing. +- Convert `ClientInfo` and available geo data into the public network allowlist. +- Inject request context only into an active private diagnostics document. +- Apply response privacy and security headers. +- Ensure requests classified within the application-visible trace namespace + never reach the publisher origin, including visible rejected aliases. + +### 7.2 Adapter responsibilities + +- Invoke the reserved-route classifier at the earliest adapter dispatch point + with exact application-visible path and method handling. Runtime/SDK + normalization before this point is not reconstructed; original-target + metadata is optional and does not govern a second route or auth policy. +- Populate optional `ClientInfo` fields available on the platform. +- Fastly may supply bounded POP, HTTP version, TLS, and edge-server data when + the SDK exposes them. JA4 and H2 fingerprints are excluded from version one. +- Other adapters return the same schema with unsupported fields absent. +- Adapter-specific errors omit optional facts rather than failing publisher + delivery. + +### 7.3 JavaScript responsibilities + +- Accept the immutable redacted request context at initialization. +- Accept immutable server-auction evidence delivered through the supported + initial-navigation, page-bids, and `/auction` transports. +- Emit the bounded `TraceSlotCorrelationV1` sidecar at the existing GPT + recorder's opportunity-to-cycle binding point. +- Preserve the existing bounded TS Console observation store. +- Build and validate `TraceReportV1` with redacted + `TraceAuctionEvidenceV1` and `TraceGptDiagnosticsV1` projections on explicit + user action. +- Store only one supported report for the same-tab workflow in + `sessionStorage`, while treating its contents as untrusted. +- Render the report shell from the validated model. +- Implement equivalent combined-report download, formatted-JSON copy, + progressive JSON-file Web Share, clearing, expiry, and accessible status + reporting. +- Never upload diagnostic data or issue a telemetry query. + +## 8. Route and configuration contract + +The version-one boundary is the request exposed by the runtime/SDK and +successfully converted for application dispatch, before ordinary routing and +middleware. Apply path classification to `Request::uri().path()` at that +boundary, before additional Trusted Server normalization. Apply cookie +inspection to the frozen incoming core headers, before sanitation. Distinguish +three outcomes in tests and deployment evidence: + +| Outcome | Version-one guarantee | +| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Runtime rejects before application invocation | No Trusted Server handler runs. Record runtime rejection separately; do not claim a local trace status, authentication challenge, `Allow` or hardening headers. | +| Runtime delivers but adapter bootstrap/conversion fails before trace dispatch | Preserve existing adapter failure behavior and record it separately. No successful trace action or capture is claimed; these failures must not perform trace-specific cookie writes or mint evidence. | +| Conversion succeeds and trace dispatch runs | Enforce this spec's authentication, method/action validation, local-response, privacy and no-publisher-fallback contracts against application-visible facts, including transformed facts. | + +Normal accepted browser requests on a suitable same-origin deployment must +support the complete workflow on Fastly, Axum, Cloudflare and Spin. The browser +must permit the required Secure host-only cookie; plain HTTP development must +not weaken its attributes or invent a trusted HTTPS scheme. Record an unsuitable +test origin separately rather than claiming a passing browser journey. +An unsupported malformed-wire case does not +justify disabling an adapter's normal trace journey. Conversely, accepting a +runtime rejection in the evidence does not prove a successful browser journey. +Existing adapter startup/config/store initialization and body buffering remain +outside the pre-dispatch hook; no new transport allocation/deadline promise is +introduced. A deliberately broken converter is not a compliant normal path. + +Original target spelling and original header octets/counts may already be +unavailable. Recovery is not a v1 prerequisite. `RequestIngress.target()` is +optional capability evidence, never a second route/auth interpretation; a +missing snapshot or whole-target capture cap does not invalidate an inspectable +pathname. Trusted canonical origin for actions still comes from the adapter's +validated `RequestIngress.origin()`, unless an explicitly configured authenticated +forwarder supplies a validated publisher-bounded public origin. Raw forwarding +headers never grant origin trust. Keep `RequestIngress` unchanged: received URI +and Host still validate against transport origin, and forwarding cannot upgrade +target provenance or header fidelity. + +Add an explicit default-off option to the existing integration: + +```toml +[integrations.gpt_diagnostics] +enabled = true +trace_page_enabled = false +``` + +Rules (route responses below apply after configured authentication): + +- `trace_page_enabled = true` requires `enabled = true`; invalid combinations + fail configuration validation, including when `enabled` is omitted (defaults + to false). Add a raw-config validation hook in both deploy and runtime + validation, following `validate_js_asset_proxy_config` in + `crates/trusted-server-core/src/config.rs`. It must deserialize and validate + this combination outside the enabled gate: `IntegrationSettings::get_typed` + returns `Ok(None)` before `validate()` for disabled integrations, so a schema + validator alone cannot enforce this rule. Preserve unknown-field rejection + on disabled configurations. This spec explicitly requires invalid enabled + configuration to fail rather than be logged and disabled; the prior rationale + is the HIGH-severity finding in + `2026-03-11-production-readiness-report-design.md`, not a contributor-doc rule. +- Operator documentation beside this option states that the public page makes + the allowlisted presence/validity of four HttpOnly Trusted Server cookies + visible to same-origin JavaScript whenever the feature is enabled. It also + states that masked IP prefixes and coarse geo remain potentially personal or + pseudonymous network data, and that opaque server-auction outcomes become + visible to same-origin JavaScript. Enabling the option is the deployment's + explicit acceptance of those bounded disclosures. +- `GET /_ts/trace` returns the setup/report shell without changing cookies or + browser storage at the HTTP layer. After load, the explicitly included viewer + script may remove a rejected or expired local entry. Unrelated query + parameters do not activate or deactivate tracing and are not reflected into + the page or export. +- `HEAD /_ts/trace` returns the GET status and headers without a body or state + mutation. Explicitly handle `HEAD` on every trace path in all four adapters, + returning a bodyless local 405 on POST-only paths. Fastly's + `publisher_fallback_methods()` includes `HEAD`; a GET registration alone + sends HEAD to the publisher fallback, as pinned by + `dispatch_head_on_named_get_route_falls_through_to_publisher_fallback` in + `crates/trusted-server-adapter-fastly/src/app.rs`. +- `GET /_ts/trace/state` returns private/no-store JSON containing only + `observed_active: true|false`, determined from whether that request carried + exactly one valid diagnostics cookie. `HEAD` returns the same status and + headers without a body. Other methods return local 405 responses. +- `GET` and `HEAD` on exactly `/_ts/trace/assets/v1.js` and + `/_ts/trace/assets/v1.css` return fixed versioned assets. They contain no + request or report data and may use immutable public caching. Other methods + return local 405 responses. These v1 URLs are immutable byte contracts: any + JS or CSS byte change requires a new asset-set URL such as `v2.js`/`v2.css` + and an updated shell reference; a release never replaces bytes at a published + immutable URL. +- `POST /_ts/trace/enable` accepts no query parameters, validates an empty body, + the exact `X-TS-Trace-Action: enable` header, and same-origin request signals; + sets the diagnostics cookie; and returns a small local JSON result. A success + response means only that the server requested the cookie change. +- `POST /_ts/trace/end` applies the same validation, clears the diagnostics + cookie using `X-TS-Trace-Action: end`, and returns a small local JSON result. + After explicit user confirmation, client JavaScript independently attempts + local report deletion and the end POST. Neither result gates the other. +- For both POST paths, accept absent or exactly-zero `Content-Length` only + when the body is empty; reject `Transfer-Encoding`, positive/invalid lengths, + and any actual body bytes with local `413 Payload Too Large` and no mutation. + Precheck headers, then explicitly match both body variants: accept + `Body::Once` only when its bytes are empty; for `Body::Stream`, consume chunks + until EOF, skipping empty chunks and rejecting the first non-empty chunk + with a local `413`. A stream read error returns local `400 Bad Request` + without mutation; only a clean EOF proves a streamed body empty. Build the + `StatusCode::PAYLOAD_TOO_LARGE` response explicitly, as in the + header-precheck/body-size-check pattern in + `crates/trusted-server-core/src/auction/endpoints.rs`. Do not copy that + handler's `into_bytes().unwrap_or_default()`: `Body::into_bytes` returns + `None` for streams, which would incorrectly treat a non-empty streamed body + as empty. Nor should `Body::into_bytes_bounded(0)` errors be propagated as + the response: overflow is `EdgeError::bad_request`, which maps to `400`, + and `EdgeError` has no `413` variant. All local body-validation errors use + the section 12.3 response hardening. This is an application + acceptance limit, not a transport read or allocation limit. Pinned EdgeZero + v0.0.8 buffers the Fastly body with blocking `read_to_end` and the Cloudflare + body with `req.bytes().await` before core handling. Spin also buffers the + body; Axum buffers JSON bodies but can stream other content types. `Body` + exposes no read deadline, and Fastly uses `futures::executor::block_on` without a timer. + Therefore v1 promises neither a one-byte transport read, a two-second timeout, + nor a local 408. Transport-level size/deadline protection requires a separate + adapter/upstream change; document the pre-buffering limitation at deployment. +- State-changing POSTs require an `Origin` exactly matching the canonical + request origin and `Sec-Fetch-Site: same-origin`. Missing, conflicting, + malformed, cross-site, or duplicate control values return local `403` without + mutation. This deliberately targets current supported mobile browsers rather + than weakening the check for legacy clients. +- The canonical request origin comes from adapter-owned inbound URL/scheme and + validated authority data, or the separate public-origin decision from an + explicitly authenticated forwarder. Resolve that decision before trace + pre-dispatch and remove the credential before any early return. Never trust + arbitrary forwarded headers. Both the public origin and + the single parsed `Origin` header are serialized with lowercase host and + default ports removed before exact comparison. Invalid or multi-valued host, + authority, scheme, or origin input fails closed. +- Unsupported application-visible methods on shell/state/assets or a + state-changing path return a local 405 + Method Not Allowed response with the path-specific `Allow` header: + `GET, HEAD` for shell/state/assets and `POST` for enable/end. Classification + must intercept unsupported methods before router dispatch. The router's + `MethodNotAllowed` response has no `Allow` header and bypasses + `FinalizeResponseMiddleware`, as pinned by + `dispatch_unregistered_method_returns_405_at_router_level` in the Fastly + adapter. The trace responder itself supplies `Allow` and all section 12.3 + error-response hardening; it must not rely on router-generated errors. + If the runtime rejects a method before invocation, record that outcome as + runtime rejection instead of claiming an application-generated 405. + Fastly entry-point finalization can add ordinary headers later, but does not + establish this trace-specific contract on behalf of the router. +- Disabled deployments return a local `404` for the complete trace route set, + including assets, and never fall through to the publisher origin. +- The `/_ts/trace` namespace is reserved. A trailing slash, extra path segment, + unsupported asset name, repeated separator, or lookalike beneath that + namespace returns a local `404`; an encoded namespace alias, encoded + separator, or ambiguous dot segment still visible at dispatch returns a + local `400` after authentication and the enabled-feature check. None of these + application-visible reserved requests falls through to the publisher origin. + Bounded percent-decoding identifies encoded aliases for rejection only; it + never turns an alias into a supported route or permits a trace action. + A path normalized by the runtime/SDK outside this namespace is ordinary + traffic, even if its unavailable original spelling mentioned trace. For + example, a Fastly request normalized to `/health` or `/_ts/debug/ja4` follows + that path's existing behavior, including its existing auth limitations. + A path normalized into an exact trace route must receive the full trace + authentication and validation contract. Additional original-target metadata + does not impose different v1 behavior on otherwise identical visible paths. + +Reuse the bounded percent-decode-to-fixed-point classification pattern from +`deny_admin_diagnostic_fallback` in `crates/trusted-server-core/src/ec/admin.rs` +(`MAX_PERCENT_DECODE_ROUNDS = 4`), moving trace classification before dispatch +rather than relying on fallback. Register and intercept trace paths on Fastly, +Axum, Cloudflare, and Spin from the first implementation phase; existing Fastly-only +`/_ts/*` routes are not a parity precedent. + +Every adapter implements the following order: + +1. Read the application-visible method, trusted host/origin, path, query, and bounded headers + required for route safety. +2. Classify an exact Trusted Server reserved path. +3. For a trace path, enforce the existing configured Basic Authentication + rules before any trace response, cookie mutation, body inspection, or setup + context projection. A matching rule challenges missing/invalid credentials + locally with the ordinary `401` and `WWW-Authenticate` behavior. After + authentication succeeds or no rule matches, apply the trace-specific + validation and bounded request-context inspection, including an optional + read-only platform geo lookup, and terminate locally. This applies to the + entire reserved namespace, assets, unsupported methods, and disabled routes; + the route statuses below authentication are never an auth exemption. + Preserve existing first-match `Settings::handler_for_path(req.uri().path())` + selection. Do not decode the path again for authentication or combine rules + selected from alternative spellings. An encoded namespace alias such as + `/%5Fts/trace` is rejected, never served: a matching `^/` rule challenges it + first, while a `^/_ts` rule that does not match its visible spelling does not + create a challenge and the alias receives the hardened path error. This + grants neither setup data nor a trace action. +4. For all other paths, continue through the adapter's ordinary event context, + authentication, request filters, geo enrichment, EC/EID processing, named + routes, auction handling, telemetry, and publisher fallback. + +Consequently a trace route never creates or finalizes an ordinary event +context, invokes publisher-configured filters, creates or refreshes an EC, +ingests EIDs, runs an auction, fetches the publisher origin, or emits auction +telemetry. Tests must verify ordering in Fastly, Axum, Cloudflare, and Spin; +ordinary named-route registration alone does not satisfy this contract. + +After an enable or end POST succeeds, the client performs a no-store state GET. +It claims `Tracing is on — cookie observed by server` only when that separate +request reports active, and `Tracing is off — no valid diagnostics session +observed` only when it reports inactive. An inactive result covers absent, +invalid, duplicate, or uninspectable cookies; it does not prove cookie absence. A mismatch or failed verification is +`Activation unconfirmed` or `Deactivation unconfirmed` and offers an idempotent +retry. These are server-observation statements, not proof that browser state is +authentic: same-origin service workers can forge or suppress the whole exchange. + +Versioned assets may remain in a CDN or browser cache after the feature is +disabled. Cache misses return the configured local 404, but rollback relies on +the uncached shell, state, and action routes being disabled; inert cached assets +alone cannot activate tracing or access a report page. + +The current `?ts_console=1` and `?ts_console=0` activation flow remains +supported for technical users with the shared 30-minute cookie policy in +section 5.2. Both activation surfaces drive the same cookie +and runtime; they must not create two concurrent diagnostic modes. That +pre-existing query flow has its existing top-level-navigation activation risk; +#1050 neither expands it to the new trace GET nor claims to remediate it. + +## 9. Data contracts + +### 9.1 Request context + +The server injects one immutable `TraceRequestContextV1` only into documents +that satisfy the applicable trace gate in section 5.2: + +```text +TraceRequestContextV1 + schema_version: 1 + captured_at: RFC 3339 UTC timestamp + network: + masked_client_ip?: string + country?: string + region?: string + asn?: u32 + http_version?: string + tls_protocol?: string + tls_cipher?: string + edge_hostname?: string + edge_region?: string + edge_pop?: string + cookies: + ts_ec: CookieHealth + ts_eids: CookieHealth + ts_tester: CookieHealth + diagnostics_session: CookieHealth +``` + +The request-context envelope intentionally contains no page URL, path, +referrer, query, or fragment. During the field-by-field trace projection, +`GptDiagnosticsExportV1.page.origin` is retained after validation and its +`pathname` is replaced with the literal `/[redacted]`. The trace viewer accepts +only that literal. The projection also omits `slotElementId` and `adUnitPath` +from slots and `slotElementId` from callback and attribution issues, because +these values can embed the same page path. They are not hashed or truncated. +Numbered slots and request cycles retain grouping and exact-token correlation. +Version one therefore does not intentionally retain an exact page path in any +of these source fields. Any future route-template policy requires a new schema and privacy review +because paths can contain accounts, emails, preview tokens, and other secrets. + +`masked_client_ip` uses a deterministic display-only mask for the current +request: IPv4 keeps at most the first 24 bits and IPv6 keeps at most the first +48 bits. The full address never enters HTML, JavaScript, browser storage, or +export. These prefixes can still be personal or pseudonymous network data; the +report labels them as approximate network identifiers and the operator privacy +decision covers them explicitly. + +All platform strings are normalized to printable characters and bounded before +they enter logs, HTML, storage, or export. Country uses at most 2 ASCII +characters; region and POP 32 UTF-8 bytes; HTTP/TLS enumerations 32 bytes; and +edge hostname/region 128 bytes. Values that fail their field contract are +omitted and produce only a bounded error category. JA4 and H2 fingerprints are +not members of `TraceRequestContextV1`. + +### 9.2 Cookie health + +```text +CookieHealth + state: + absent | present_valid | present_invalid | duplicate | unavailable + source: request + detail?: + valid_ec_format | valid_eids_format | valid_tester_value + | valid_diagnostics_value | malformed | oversized + | unsupported_value | multiple_values + | header_too_large | header_not_utf8 | runtime_header_ambiguous +``` + +Details describe shape, never value. `absent` has no detail; `duplicate` uses +`multiple_values`; `unavailable` uses `header_too_large`, `header_not_utf8`, +or `runtime_header_ambiguous`; +and a valid state uses its cookie-specific valid detail. + +The classifier uses this deterministic contract: + +- Inspect all incoming runtime-visible `Cookie` fields, measuring a combined + 16 KiB as the sum of their `HeaderValue::as_bytes().len()` values. Include + runtime-added separators; exactly 16,384 bytes is within the cap. This is a + visible-field bound, not a guarantee about discarded original bytes. +- Apply aggregate failures in this fixed precedence: above the cap gives + `unavailable/header_too_large`; otherwise any actual invalid UTF-8 gives + `unavailable/header_not_utf8`; otherwise the ambiguity rules below give + `unavailable/runtime_header_ambiguous`. Each aggregate failure applies to + all four cookie states and prevents trace activation. Check the complete + bounded collection before presenting cookie-specific results. Decode with + `str::from_utf8(value.as_bytes())`, not `HeaderValue::to_str()`, which rejects + some valid non-ASCII UTF-8. +- Read Cookie-specific fidelity with `RequestIngress::header_fidelity(&COOKIE)`. + When octets are not `Preserved`, any Unicode replacement character U+FFFD + anywhere in any Cookie field is ambiguous. When field multiplicity is not + `Preserved`, a literal comma is ambiguous when it could be a folded field + boundary that changes a reserved result: a comma inside a semicolon-delimited + segment whose name is reserved (it could truncate that value), or a comma + directly followed, after optional ASCII whitespace, by a reserved name (it + could hide an occurrence inside another cookie's value). Commas inside + unrelated values, such as JSON-valued third-party cookies, are not ambiguous + because they can neither hide nor truncate a reserved occurrence. Never + comma-split, recover guessed field counts, or select a valid-looking session + from part of a folded field. + Apply these fallbacks to `Unknown`, `Transformed`, `Unavailable` and missing + metadata alike; never select them by platform name. A non-preserved status + alone does not reject readable marker-free fields. Same-name/global field + order is unnecessary for exact occurrence counting and duplicate precedence. +- This deliberately treats original valid EF BF BD and runtime replacement of + invalid FF as the same ambiguity when preservation is unproved, rather than + falsely labeling valid UTF-8 `header_not_utf8`. If the applicable axis is + explicitly `Preserved`, its marker proceeds through the ordinary grammar; + a comma is never a field separator and canonical value validators still + apply. The per-name axes are independent. These rules cannot detect original + information erased without a visible marker, and make no such promise. +- Split each readable header on semicolons and trim optional ASCII whitespace. + A valid pair contains a non-empty RFC 6265 token name, one `=`, and the + remaining bytes as its value; additional `=` bytes belong to the value. Empty + segments and malformed pairs with an unrelated name are ignored. A segment + with no `=` counts as one malformed reserved occurrence only when its first + whitespace-delimited token is exactly a reserved name; a name such as + `ts-ec-extra` remains unrelated. After the aggregate size, UTF-8 and ambiguity + checks above, no malformed unrelated pair poisons a reserved-cookie result. +- Count exact, case-sensitive reserved names before passing values to existing + parsers. Zero occurrences is `absent`; more than one is `duplicate`, + regardless of whether one value would otherwise be valid. Duplicate + precedence is therefore diagnostic rather than first- or last-value + selection. +- Per-value limits are 512 bytes for `ts-ec`, 8 KiB for `ts-eids`, and 16 bytes + each for `ts-tester` and `__Host-ts-console`, measured in visible UTF-8 bytes. + Only the 8 KiB EID limit is + inherited (`MAX_EIDS_COOKIE_BYTES` in `ec/prebid_eids.rs`); the other limits + are new trace-inspection bounds. The 512-byte EC limit is an outer guard, + above the exact 71-character format accepted by `is_valid_ec_id` in + `ec/generation.rs`, and does not broaden EC validity. A single value beyond its limit + is `present_invalid/oversized`; it does not change the other three states. +- One `ts-ec` occurrence is valid only when the canonical EC cookie validator + accepts its complete value. +- One `ts-eids` occurrence is valid only when the existing bounded Base64/JSON + EID parser accepts its complete value, including its current 8 KiB value cap. +- One `ts-tester` occurrence is valid only when its value is exactly `true`. +- One `__Host-ts-console` occurrence is valid only when its value is exactly + `1`. +- A single rejected value is `present_invalid` with exactly one of the public + details `malformed`, `oversized`, or `unsupported_value`. Parser error text + and the value itself never enter the report or logs. + +The parser freezes incoming runtime-visible facts before diagnostics-cookie sanitation, +while preserving existing authoritative-cookie and consent semantics. It must +scan without using the current lossy `CookieJar` representation, which skips +malformed pairs and cannot preserve duplicate evidence. Inspection is +read-only: it must not generate an EC, touch the identity graph, sync partner +IDs, or extend any cookie lifetime. + +State observation and capture eligibility both use this same frozen result: +exactly one `present_valid/valid_diagnostics_value` session is active; absent, +invalid, duplicate or unavailable is inactive. Never reread sanitized cookies. +Cookie ambiguity does not change the deliberate enable/end action policy: +valid authenticated same-origin empty-body actions may request a cookie write +or clear, and a separate state request must observe the result. End remains +available when cookies are ambiguous. Ambiguity may leave activation +unconfirmed; the UI must not claim cookie absence or successful browser storage. +Do not change ordinary EC/EID, consent or existing TS Console behavior because +trace inspection was unavailable. + +Add `runtime_header_ambiguous` to the exact Rust/TypeScript cookie detail enums, +setup/context/report validators and storage/export fixtures. It is allowed only +with `unavailable`; unknown details and invalid state/detail pairs still reject. +Render a plain explanation that the runtime-visible cookies could not be +reliably inspected. Retain the same reason in download/copy/share and direct +storage-failure export. Never expose fidelity metadata, raw values or parser +errors. Existing report versions and byte/depth/string bounds remain unchanged; +include the new detail in worst-case size fixtures before the unpublished v1 +assets are frozen. + +Only those four Trusted Server-owned cookie names are reported. Arbitrary +cookie names and values are excluded. The endpoint cannot claim knowledge of +browser attributes, expiry, or cookies the browser withheld from the request. +Because the result reveals presence and validity of HttpOnly cookies to +same-origin JavaScript, enabling this public feature requires an explicit +operator privacy decision documented beside `trace_page_enabled`. + +### 9.3 Report envelope + +```text +TraceReportV1 + schema_version: 1 + captured_at: RFC 3339 UTC timestamp + request_context: TraceRequestContextV1 + server_auctions: TraceAuctionEvidenceV1[] + slot_correlations: TraceSlotCorrelationV1[] + gpt_diagnostics: TraceGptDiagnosticsV1 + auction_coverage: + capture_status: complete | partial | unavailable | not_observed + issues: + evidence_projection_failed | evidence_transport_failed + | evidence_validation_failed | record_evicted + | correlation_unavailable | external_client_side_unobservable + truncation: + omitted_server_auctions: u16 + omitted_slot_correlations: u16 + omitted_request_cycles: u16 + omitted_callback_issues: u16 + omitted_attribution_issues: u16 + omitted_nested_values: u16 +``` + +`TraceAuctionEvidenceV1` is a server-produced public model defined in section +9.4. `TraceSlotCorrelationV1` is the browser-produced exact-token sidecar +defined in section 9.4.1. `TraceGptDiagnosticsV1` is a separate trace-owned +projection sourced only from `GptDiagnosticsExportV1`. It contains: + +- `schema_version: 1` and `source_schema_version: 1`; +- the source `capturedAt` value; +- `page.origin` after validation and `page.pathname` fixed to `/[redacted]`; +- explicit field-by-field projections of current v1 slots, requests, callback + issues, attribution issues, coverage, and metadata, subject to the following + exclusions and the bounds/truncation below. + +The trace schema omits the entire request-cycle `adManager` object, including +`lineItemId`, `creativeId`, `campaignId`, `advertiserId`, +`sourceAgnosticLineItemId`, `sourceAgnosticCreativeId`, `yieldGroupIds`, and +`companyIds`, and omits `previousCreativeId`. Derived `responseClass` and +`creativeChanged` facts remain eligible without their underlying identifiers. +It also omits slot `slotElementId` and `adUnitPath` and callback/attribution +issue `slotElementId`. These properties are forbidden in the trace schema, +not optional passthrough fields: the builder never copies them and the viewer +rejects a stored report that supplies them. The remaining current v1 fields +retain their source meaning and require explicit allowlisting; future source +fields are not inherited automatically. `trustedServerAuctionId`, when +present, must satisfy the diagnostic auction-token contract in section 9.4. + +The following is the exhaustive v1 property allowlist derived from the current +interfaces in `crates/trusted-server-js/lib/src/core/types.ts`, after the above +exclusions. Preserve source optionality and validate each source enum against its +explicit current members; do not spread source objects or dynamically inherit +later fields. Section 9.5 supplies numeric, string, array, and depth bounds. + +| Object | Allowed properties | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GPT projection | `schema_version`, `source_schema_version`, `capturedAt`, `page`, `slots`, `callbackIssues`, `attributionIssues`, `coverage`, `metadata` | +| `page` | `origin`, `pathname` (fixed redacted literal) | +| Slot | `runtimeSlotNumber`, `binding`, `currentVisibilityPercentage`, `maximumVisibilityPercentage`, `requests` | +| `binding` | `status`, `reason` (current `GptDiagnosticsBindingReason` enum) | +| Request cycle | `requestNumber`, `requestedAtMs`, `responseAtMs`, `renderAtMs`, `loadAtMs`, `viewableAtMs`, `durations`, `isEmpty`, `requestedSlotSizes`, `size`, `observedSlotSize`, `isBackfill`, `slotContentChanged`, `incompleteSequence`, `responseClass`, `requestPath`, `requestIntentId`, `trustedServerAuctionId`, `opportunityToRequestMs`, `replacedRequestNumber`, `previousRenderToRequestMs`, `creativeChanged`, `loadObservedBeforeRender`, `trustedServerOpportunity`, `trustedServerCreativeRequestAtMs`, `trustedServerCreativeResponseAtMs`, `trustedServerCreativeFailures`, `delivery` | +| `durations` | `requestToResponseMs`, `responseToRenderMs`, `requestToRenderMs`, `renderToLoadMs`, `renderToViewableMs` | +| Callback issue | `kind`, `runtimeSlotNumber`, `timestampMs`, `disposition`, `reason` (only the documented values below) | +| Attribution issue | `reason` (current `GptDiagnosticsAttributionIssueReason` enum), `timestampMs`, `runtimeSlotNumber` | +| `coverage` | Exactly the six current callback-kind keys: `slotRequested`, `slotResponseReceived`, `slotRenderEnded`, `slotOnload`, `impressionViewable`, `slotVisibilityChanged` | +| Each coverage counter | `observed`, `matched`, `unmatched`, `ambiguous` | +| `metadata` | `droppedCallbacks`, `droppedAttributionIssues`, `evictedSlots`, `evictedRequestCycles` | + +Callback `reason` accepts only the current store's six emitted literals: +`invalid_event_order`, `missing_response_before_render`, +`invalid_visibility_percentage`, `evicted_slot`, `no_compatible_request_cycle`, +and `overlapping_request_cycles`. +Although the source interface types it as `string`, the trace validator rejects +all other values rather than copying arbitrary text. + +`requestPath` is the bounded `GptDiagnosticsRequestPath` enum, not a URL or +pathname; include it as browser intent only. `requestIntentId` is a local numeric +sequence, not an external request identifier. Source `version` becomes +`source_schema_version`; no other source properties pass through. Tests must +classify every current source member as copied, transformed, or excluded, and +reject extra keys at every object boundary. + +It is deliberately not named or represented as `GptDiagnosticsExportV1`, +because redaction, excluded identifiers, and trace-level bounds change the +source field semantics. TS Console continues to own the source schema; the trace envelope +owns its public projection and transport. The initial compatibility matrix is +exactly `TraceReportV1`, `TraceAuctionEvidenceV1`, `TraceSlotCorrelationV1`, and +`TraceGptDiagnosticsV1`, with the GPT projection sourced from +`GptDiagnosticsExportV1`. The viewer rejects every unknown outer, +server-auction, correlation, GPT-projection, or GPT-source version with an +actionable message rather than guessing. A future TS Console successor requires +an additive source compatibility change and, if the public projection changes, +a new trace-envelope version. + +`auction_coverage.capture_status` describes only what reached the browser +collector: `not_observed` means no valid server-auction record arrived and no +capture issue is known, not that no server auction ran. `partial` requires at +least one retained server-auction record plus a projection, transport, +validation, or eviction issue; `unavailable` requires no retained server-auction +records plus a known projection, transport, validation, or eviction issue; and +`complete` requires at least one retained server-auction record without those +capture issues. +`correlation_unavailable` and `external_client_side_unobservable` describe +interpretation limits and do not change an otherwise complete capture status. +Evicting all received server-auction records therefore yields `unavailable` with +`record_evicted`, never `not_observed`. Recompute status after both in-memory +eviction and snapshot size truncation. +The issue array is deduplicated, sorted in enum order, bounded to 16 values, and +contains no error text. + +Origin validation requires a parseable HTTP(S) origin whose canonical +serialization exactly equals `window.location.origin`; credentials, paths, +queries, and fragments are rejected. Outer and source capture times require +strict RFC 3339 UTC strings and must be within 60 seconds of `stored_at_ms`. +`TraceRequestContextV1.captured_at` requires strict RFC 3339 UTC but may be older +because it represents the publisher document request rather than snapshot time. + +### 9.4 Server-auction evidence + +Core creates one `TraceAuctionEvidenceV1` at the live auction boundary. It is +not derived from the browser, reconstructed from winning-bid targeting, or +loaded from auction telemetry: + +```text +TraceAuctionEvidenceV1 + schema_version: 1 + diagnostic_auction_id: string + source: + initial_navigation_ssat | spa_page_bids | auction_api + terminal_status: + completed | execution_failed | dispatch_failed | abandoned | skipped + terminal_reason?: + policy_skipped | no_eligible_slots | no_provider_launched + | provider_execution_failed | collection_failed | unknown + total_time_ms?: u32 + provider_calls: + - provider_number: u16 + role: bidder | mediator | unknown + status: success | no_bid | error | pending | abandoned | unknown + response_time_ms?: u32 + returned_bid_count: u16 + slots: + - slot_number: u16 + slot_ref: string + requested_sizes: [u32, u32][] + returned_bid_count: u16 + candidate: + selected | no_candidate | selected_unrenderable | unknown + selected_creative_size?: [u32, u32] + truncation: + omitted_provider_calls: u16 + omitted_slots: u16 + omitted_nested_values: u16 + coverage: + provider_to_slot_no_bid: unavailable +``` + +The model has deliberately lower cardinality and sensitivity than the existing +telemetry and OpenRTB objects: + +- `diagnostic_auction_id` is a fresh opaque `ts-auc-...` correlation token. When + trace capture is active under the applicable trace gate in section 5.2, it is minted + once when an eligible auction is observed, before dispatch, and is retained + for zero-bid, skipped, dispatch-failed, execution-failed, and abandoned + outcomes. It is never `AuctionRequest.id`, the telemetry UUID, a provider + request ID, or an identifier joinable to user-bearing logs. +- Auction tokens are `ts-auc-` followed by a lowercase UUID v4 in the existing + producer's 32-hex-digit unhyphenated form (`Uuid::new_v4().simple()`). Slot + tokens are new: neither a `ts-slot-` producer nor a TSJS `crypto.randomUUID()` + call exists today. This design introduces tokens that are `ts-slot-` followed by a canonical lowercase hyphenated UUID v4, + matching `crypto.randomUUID()`. Both validators enforce UUID version 4 and + the RFC variant and reject every other shape. Tokens are compared verbatim, + never normalized during validation or correlation; no producer format change + is required for the existing GPT auction opportunity marker. For the GPT + projection, validation and comparison start from the marker returned by + `normalizedAuctionId` in `gpt_diagnostics/store.ts`, which already trims + whitespace and caps the stored value. Trace validation performs no further + normalization; invalid shapes are not repaired into valid tokens. +- `slot_number` is a one-based ordinal over the exact post-conversion + `AuctionRequest.slots` sequence observed by orchestration. It is display-only + and is never used to map a response back to pre-conversion client input. + `slot_ref` is a fresh auction-local opaque token carried with that slot. Core + creates it for initial-navigation and SPA auctions. For a TSJS `/auction` + request, TSJS creates it only when `window.__tsjs_trace_active === true` and + only after `buildAdRequest` has finished grouping and + deduplicating the final `adUnits` array, attaches it to that exact outgoing + unit as `adUnits[].ext.trusted_server.trace_slot_ref`, and retains the + request-scoped token-to-unit mapping. Core accepts that member only under the + applicable trace gate, validates and echoes the token for accepted converted + slots, and strips it before every provider or mediator request. A missing or + invalid client token causes core to mint a server token with no browser + correlation; it never changes ordinary auction acceptance. TSJS uses + `crypto.randomUUID()` and, if unavailable, omits the client token rather than + using weak randomness. Raw publisher slot IDs, ad-unit paths, and internal + impression IDs are not copied into this model. Neither the ordinal nor token + is a creative number. +- `source` is assigned by the server call site: initial document auction is + `initial_navigation_ssat`, `/_ts/page-bids` is `spa_page_bids`, and + `POST /auction` is `auction_api`. Browser `requestPath` does not determine or + override this value. These are intentionally trace-owned public names: + existing `AuctionSource` in `auction/telemetry.rs` uses `initial_navigation`, + `spa_navigation`, and `auction_api`, respectively. Map these explicitly; + do not change telemetry vocabulary or serialize it directly into the report. +- `provider_number` is assigned deterministically in provider dispatch order + and is stable only within one auction. Provider names, bidder/seat names, and + provider metadata are omitted. `returned_bid_count` is a count, not a bid + payload. +- `terminal_reason` is mapped to the allowlisted category at the observation + boundary. Raw error messages, parser errors, URLs, and provider text never + enter the model. +- Slot candidate state is computed from the requested slots, returned bids, + winner selection, and final response-conversion disposition. A winner that + cannot safely enter the bid map/OpenRTB response is + `selected_unrenderable`, not `selected` and not `no_candidate`. +- Provider calls are auction-wide. The model does not claim that a provider was + called, timed out, or returned no bid for a particular slot. Only returned + bids can contribute to a slot's `returned_bid_count`; the fixed coverage + value makes the missing provider-to-slot no-bid relation explicit. +- A slot's `returned_bid_count` counts actual returned bid records whose + ordinary internal routing key matches that accepted slot. Accepted slots with + matching keys count records from both bidder and mediator calls, including + mediator echoes; the count is not unique across provider stages. Slots with + duplicate routing keys retain distinct ordinals and opaque refs, but share + that observed count. These counts are non-disjoint and must not be summed as + unique bids. When existing winner/delivery data cannot distinguish those + accepted instances, their candidate is `unknown` and + `selected_creative_size` is omitted. No internal routing key is exposed and + no instance-specific winner or GPT association is inferred. +- `total_time_ms` and `response_time_ms` use the server's auction-local monotonic + durations. They are not request-relative milestones and are never + arithmetically combined with browser timestamps. +- Core applies the provider, slot, size, string, and numeric limits before the + model crosses into HTML or JSON. It retains request/dispatch order and records + every discarded nested entry in the auction-local `truncation` object. An + omission-counter overflow rejects that auction evidence rather than wrapping + or saturating. A duration that cannot convert to its optional public integer + type is omitted and counted; a required count or ordinal conversion failure + rejects that auction evidence. Values are never clamped to a plausible value. + +The server evidence and GPT evidence retain separate meanings: + +| Question | Authoritative v1 source | +| --------------------------------------------------- | ------------------------------------------------------------- | +| Did initial-navigation SSAT run? | `server_auctions[].source = initial_navigation_ssat` | +| Did the Trusted Server auction API run? | `server_auctions[].source = auction_api` | +| Was a publisher/Prebid refresh observed? | GPT `requestPath`, labeled as browser intent | +| Did a server auction select a slot candidate? | matching server-auction slot `candidate` | +| Did GPT request, fill, and render the slot? | `TraceGptDiagnosticsV1` request-cycle evidence | +| Did the Trusted Server creative bridge participate? | `TraceGptDiagnosticsV1` creative-delivery evidence | +| Which path ultimately won? | only when existing correlation proves it; otherwise `Unknown` | + +Absence is not converted into a negative assertion. If transport or +correlation failed, the viewer shows `Server auction evidence unavailable` or +`Correlation unknown`, not `SSAT did not run`. + +#### 9.4.1 Live transport and correlation + +Evidence is transported only when the applicable trace gate in section 5.2 +is active, including the effective diagnostics decision for publisher documents. Every response carrying it is terminally +`private, no-store`: + +```text +TraceAuctionTransportV1 + schema_version: 1 + evidence?: TraceAuctionEvidenceV1 + unavailable_reason?: evidence_projection_failed +``` + +Exactly one of `evidence` and `unavailable_reason` is present. This small +transport envelope lets core report a safe projection failure without exposing +the raw error. A malformed envelope is rejected as a whole. A network failure +before an envelope arrives is recorded separately by the browser as +`evidence_transport_failed`. + +Initial-navigation and SPA slot definitions carry their token on the exact slot +object TSJS already consumes. This is a two-sided addition: add optional `ext?` +to the TypeScript `AuctionSlot` interface in `core/types.ts` and the matching +nested key to Rust's `build_slot_json` in `publisher.rs`. Neither has this member +today; Rust builds free-form JSON, and TSJS currently retains the slot objects: + +```text +AuctionSlot.ext.trusted_server.trace_slot_ref: string +``` + +Core adds that optional nested member only under the applicable trace gate. It +assigns the token while constructing the request-scoped slot definitions and +threads the same token into the corresponding `AuctionRequest` observation, so +neither side needs to recover the relationship from an ordinal or raw slot ID. +The ordinary `AuctionSlot.id`, `gam_unit_path`, `div_id`, formats, targeting, +ordering, and bid-map keys remain unchanged. The extension is absent when the +gate is false and is never copied into `TraceAuctionEvidenceV1` except as its +already-allowlisted opaque `slot_ref`. + +When valid auction evidence is supplied, TSJS accepts a slot extension only +when its canonical token occurs exactly once +in both the delivered slot list and the matching auction evidence. A missing, +malformed, duplicate, or conflicting token prevents only that sidecar join, +adds `correlation_unavailable`, and does not +drop, reorder, or mutate the ordinary slot or bid. It does not add +`evidence_validation_failed`: the evidence member itself validated, so this is +a correlation-layer limit that leaves `capture_status` unchanged per section +9.3. `evidence_validation_failed` is reserved for a supplied evidence member +that fails strict validation. TSJS reads no other extension +property. This validation occurs before the slot is handed to the existing GPT +initialization path. An absent optional transport member does not trigger +missing-token validation; no sidecar is emitted and no capture issue is added. + +The three transport call shapes are exact v1 contracts: + +```text +Initial seam: + scheduleInitialAdInit(bids, slots?, traceAuctionTransport?) + +Initial seam when ordinary ad-slot injection is withheld: + traceGpt.observeTransport(undefined, traceAuctionTransport, 'initial_navigation_ssat') + +SPA JSON: + { slots, bids, trace_auction?: TraceAuctionTransportV1 } + +/auction request unit: + adUnits[].ext.trusted_server.trace_slot_ref?: string + +/auction OpenRTB response: + ext.trusted_server.trace_auction?: TraceAuctionTransportV1 +``` + +The initial scheduler validates/records the optional third argument before it +runs `adInit`; cached older bundles may ignore the extra argument without +affecting ads, in which case evidence remains `not_observed`. The SPA parser +accepts only the exact optional top-level member and preserves its existing +`slots` and `bids` behavior. These trace members never become required for a +successful advertising response. + +When the ordinary initial page would not inject ad slots, deliver available +trace evidence through the existing gated core bridge only. Queue that one +observation until core initialization if necessary and retain the initial +navigation-generation guard. This trace-only seam must not call the ad +scheduler or `adInit`, set its latch, or assign bids or slots. Skipped-page +evidence cannot activate an otherwise withheld ad stack. + +For initial-navigation SSAT and SPA page-bids only, when the existing GPT +recorder consumes the matching Trusted Server opportunity for a concrete request +cycle, it emits this trace-owned sidecar: + +```text +TraceSlotCorrelationV1 + schema_version: 1 + diagnostic_auction_id: string + slot_ref: string + runtime_slot_number: safe positive integer + request_number: safe positive integer +``` + +The recorder already decides which pending opportunity belongs to which GPT +slot/request cycle. The sidecar records that exact decision; it does not rerun +attribution or read a private store during export. It is emitted only when both +opaque server tokens and the concrete GPT cycle are present. The current +`GptDiagnosticsExportV1` remains unchanged and the sidecar contains no slot +element ID or ad-unit path. + +Version one does not correlate either `/auction` caller to a GPT cycle. Prebid +refresh records only `prebid_refresh` intent and has no existing token-bearing +opportunity binding; passing its tokens through `recordTrustedServerOpportunity` +would incorrectly introduce `trusted_server_direct` attribution. The direct +`requestAds` caller renders outside GPT and supplies no GPT request cycle. Both +callers retain server evidence and the request-unit mapping, but emit no +`TraceSlotCorrelationV1`. The viewer displays API evidence independently with +`correlation_unavailable`; this interpretation limit does not downgrade an +otherwise complete server capture. A sidecar referencing an `auction_api` +record is invalid in v1. A future Prebid join requires a separately designed +association between request-unit tokens and exact GPT slot/request identities +that preserves existing request-path attribution. + +- **Initial-navigation SSAT:** core builds the evidence when the split auction + is collected at the held body tail. It injects the script-safe public model + beside the winning-bid map before initial ad initialization; the corresponding + request-scoped slot definitions already carry + `ext.trusted_server.trace_slot_ref`. Failed, abandoned, skipped, and zero-bid + outcomes still inject their bounded evidence when the publisher document can + be delivered. +- **SPA page-bids:** `/_ts/page-bids` and its still-live deprecated alias + `/__ts/page-bids` both add an optional, namespaced + `trace_auction` transport envelope beside its existing bid result. TSJS + validates and records it and consumes each returned slot's + `ext.trusted_server.trace_slot_ref` before triggering ad initialization. Both + the envelope and slot extensions are absent when the trace gate is inactive. +- **Trusted Server `/auction` API:** the existing OpenRTB response adds a + namespaced `ext.trusted_server.trace_auction` transport envelope only for a + request satisfying the base trace gate in section 5.2. After producing the + final grouped `AdRequest`, + both TSJS callers check `window.__tsjs_trace_active === true` before + assigning one fresh token to each outgoing unit and retaining that exact + request-scoped mapping. An inactive caller creates neither tokens nor pending + transport records and installs no trace-specific timeout/error hooks. Active + callers validate the echoed evidence and record it + before parsing bids. A converted or skipped unit therefore cannot shift + another request unit's server-evidence association. This mapping does not + imply a GPT-cycle join. The response member does not replace or expose + the existing orchestrator extension, and the trace projection must not copy + that extension's provider names, bidder names, metadata, price, creative IDs, + domains, or markup. HTTP/transport failures with no readable response are + browser-observed failures only; no successful server evidence is + manufactured. + +The direct TSJS caller records `evidence_transport_failed` from its existing +non-OK, unreadable-JSON, and rejected-`fetch` paths. The Prebid adapter +creates one bounded pending transport record after `buildRequests`, keyed by the +request's original bid IDs and its normalized unit tokens. `interpretResponse` +consumes it on a readable response. Add `onTimeout` and `onBidderError` to the +existing bidder-spec object passed to +`pbjs.registerBidAdapter(undefined, ADAPTER_CODE, spec)`; neither hook exists in +the TSJS adapter today. Confirm against the pinned Prebid build that this +registration wraps the spec through `newBidder` and routes both callbacks. +The new hooks consume the pending record and record +`evidence_transport_failed` otherwise. Repeated hooks are idempotent. Pending +records are capped at 128. At record creation, read the effective browser +`pbjs.getConfig('bidderTimeout')` value in milliseconds; use it only when it is +a finite integer from 0 through `2^31 - 1 - 5000`, otherwise use the pinned +Prebid 10.26.0 default of 3000 ms (`DEFAULT_BIDDER_TIMEOUT` in its +`src/config.ts`). Each record +expires after that captured timeout plus 5000 ms, so the delay fits the browser +timer's signed 32-bit limit. Compute the expiry with checked safe-integer +addition to the record's finite nonnegative safe-integer creation timestamp. +If the creation clock or the resulting expiry is invalid or unrepresentable, +decline the trace-only pending record without adding a capture issue or changing +previously collected evidence; that attempt remains `not_observed` and ordinary +bidding continues unchanged. +The TSJS integration's +`merged.timeout`, including the injected `[integrations.prebid].timeout_ms`, +sets this browser `bidderTimeout` when supplied; neither `[auction].timeout_ms` +nor `[auction].auction_timeout_ms` is its source. Expiry without any supported +success/error/timeout hook proves no transport outcome, so it removes the +marker and leaves evidence `not_observed` rather than inventing a failure. +These hooks collect only bounded categories and +opaque tokens, never XHR error text or response bodies. + +For SSAT and SPA page-bids, the diagnostic auction token is also attached to +the existing GPT opportunity marker, and the opaque slot token is carried +through the corresponding +winning-bid/slot initialization path. The numeric ordinal is never a +correlation key. The viewer joins a server slot to a GPT cycle only when one validated +`TraceSlotCorrelationV1` exactly matches both tokens and the exported +runtime/request numbers. It displays unmatched, duplicate, or conflicting +records independently, preserves competing paths, and never joins by +timestamps, implicit array position, ad-unit path, or a best-effort heuristic. + +TSJS retains at most the newest 16 validated server-auction records and 128 +correlation sidecars in memory. It increments checked eviction counters for +older records; the snapshot adds those counts to the matching truncation fields. +Evicting a server-auction record emits `record_evicted`, with `partial` if +server records remain or +`unavailable` if none remain after snapshot truncation. Evicting a correlation +sidecar increments `omitted_slot_correlations` and adds +`correlation_unavailable` only; it never emits `record_evicted` and never +changes `capture_status`, because server capture is unaffected. This distinction +also applies to sidecars removed during snapshot size truncation. It performs +no storage write until the explicit snapshot action. +Requests that fail the applicable trace gate do not mint +trace tokens, build trace evidence, add response members, emit sidecars, or +install auction-evidence listeners. + +No database, server-side report store, Tinybird query, beacon, or follow-up +network request is required. Evidence already available at the live auction +boundary is projected and carried forward in the response that the browser is +already receiving. + +### 9.5 Storage limits and expiry + +- Storage key: a namespaced, versioned constant owned by the diagnostics + module. +- Stored value: `{ stored_at_ms, report }`, where `stored_at_ms` is generated by + the capture code and is not taken from report content. +- Maximum encoded size: 512 KiB, defined as the byte length of the complete + compact UTF-8 `{ stored_at_ms, report }` JSON measured with `TextEncoder` + before storage. Formatted download size and JavaScript UTF-16 string length + are not used for enforcement. +- Maximum age: 15 minutes from `stored_at_ms`. Non-finite, negative, malformed, + more than 60 seconds in the future, or older values are rejected. A backward + wall-clock jump that places the timestamp beyond the tolerated future skew + also invalidates the entry. Expiry is exposure reduction, not a security + guarantee. +- One supported report for the current browsing context; a new explicit + snapshot replaces the old report. Browser opener cloning and session restore + may copy or retain it. +- The runtime validator accepts only the exact outer, server-auction, + slot-correlation, auction-coverage, and GPT-projection v1 schemas, rejects + unknown fields, applies the limits below, and checks compact UTF-8 size before + rendering. +- Invalid, oversized, expired, unsupported, or hostile reports are removed when + possible and otherwise ignored. Rendering uses DOM properties and + `textContent`, never report-derived HTML. +- After confirmation, `Clear report and end tracing` always attempts local + deletion, the validated end POST, and state verification as independent + retry-safe steps. Offline or server failure cannot prevent local deletion. + The UI reports server-observed cookie state and local-report state separately. + A distinct `Delete local report` action remains available whenever a report + is displayed, including after an earlier local-deletion failure. + +These are product limits, not assumptions about browser quota. A storage write +failure is handled even when the report is below the application limit. + +Runtime limits are part of the v1 contract: + +| Value | Limit | +| ----------------------------------------------- | ------------------------------------------------------------ | +| Container nesting | 10 levels | +| Server auctions | 16 | +| Slot correlations | 128 | +| Provider calls | 16 per server auction | +| Auction slots | 64 per server auction | +| Auction coverage issues | 16 | +| Slots | 64 | +| Request cycles | 10 per slot before total-size truncation | +| Callback issues | 128 | +| Attribution issues | 128 | +| Requested slot sizes | 16 per cycle | +| Creative-failure enums | 16 per cycle | +| Origin | 255 UTF-8 bytes | +| GPT pathname in trace projection | Exact literal `/[redacted]` | +| Slot element ID and ad-unit path | Forbidden, including callback/attribution issue copies | +| Trusted Server auction ID | Exact diagnostic auction-token shape | +| Callback reason | Exact documented value only | +| Diagnostic auction ID and opaque slot ref | 128 UTF-8 bytes each | +| Any other string | 128 UTF-8 bytes | +| Enum | Exact documented value only | +| Identifier, sequence, or counter | Finite safe integer from 0 through `Number.MAX_SAFE_INTEGER` | +| Browser-relative timestamp or duration | Finite number from 0 through `Number.MAX_SAFE_INTEGER` | +| Visibility percentage | Finite number from 0 through 100 | +| Requested or selected creative dimension | Finite integer from 1 through 100,000 | +| GPT-reported fill dimension (`size`) | Finite integer from 1 through 100,000 | +| Observed CSS box dimension (`observedSlotSize`) | Finite integer from 0 through 100,000 | + +The depth cap includes two levels of headroom above the current deepest valid +GPT path: report (1), GPT projection (2), slots array (3), slot (4), requests +array (5), cycle (6), `requestedSlotSizes` array (7), size tuple (8). Headroom +does not permit unknown fields; future schema additions still require explicit +compatibility review. Test the complete current projection and over-depth input. + +`observedSlotSize` preserves zero dimensions, including `[0, 0]` for a +hidden or collapsed element after a filled GPT render. Zero is a measured box +size, not missing data or evidence of an empty GPT response; do not omit it or +change `isEmpty`. Positive dimensions remain required for requested and +selected creative sizes and GPT-reported fill sizes. + +Every accepted string must be valid Unicode and must not contain C0/C1 control +characters or bidirectional override/isolate controls. This applies to browser +source fields as well as platform fields and precedes rendering or export. + +The snapshot builder creates a new field-by-field projection and rejects an +invalid value consumed by that public projection rather than stringifying it. +Source containers must have the exact current keys and own data properties; +unknown fields and accessors are rejected. Contents explicitly excluded in +section 9.3 are never accepted, copied, traversed or validated. The required +source `page.pathname` is checked only as an own string property before literal +replacement; its contents and raw length never enter the report. +Server auctions are already +bounded by core; the browser rejects an invalid inner model rather than +truncating it. The builder retains the newest 16 server auctions in observation +order, the newest 128 correlations in recorder emission order, and only the +first documented number of GPT requested sizes and creative failure enums. It records each discard in +`omitted_server_auctions`, `omitted_slot_correlations`, or +`omitted_nested_values`; strings are never silently shortened. It then measures +the complete compact UTF-8 storage wrapper. If it exceeds 512 KiB, it removes +the globally oldest GPT +request cycles first while retaining the newest cycle for each GPT slot, then +the oldest callback issues, then the oldest attribution issues, then the oldest +uncorrelated server auctions, and finally the oldest eligible correlated +server auctions until the report fits. The newest cycle for each GPT slot is a +floor for the whole procedure, not only its first stage: no stage removes a +slot's last remaining cycle. When removing a correlated auction in the final +stage, remove every retained GPT cycle that references it together with the +auction; an auction referenced by any floor cycle is therefore protected from +removal. A correlated auction is eligible for removal only when removing all +its referencing cycles preserves every slot's floor. It records every removal +in `truncation`; every sidecar referencing a removed auction or cycle is removed +and counted. The report must not retain a dangling token while claiming a join. +A report that still cannot fit after this bounded procedure fails snapshot +creation, following the oversized case in section 13. The protected floor and +its correlated auctions can exceed the budget on their own. The implementation +must include a worst-case fixture proving successful reports are bounded, and +one proving the floor-exceeds-budget case fails snapshot creation rather than +emptying the GPT section. + +All omission counters use checked addition. If any source collection would +make a counter exceed `u16::MAX`, projection rejects the source instead of +wrapping or saturating the count. + +For depth accounting, the `TraceReportV1` object—not its storage wrapper—is +level 1; entering either an object or an array increments the level by one; +primitives do not. No accepted report value may enter an eleventh container level. +The storage wrapper is validated separately as the exact two-field object +`{ stored_at_ms, report }`. + +For deterministic ordering, retained server auctions remain in observation +order, provider calls and auction slots retain their server-assigned numeric +order, and correlations retain recorder emission order. A request cycle with no +`requestedAtMs` sorts before a cycle with a timestamp; otherwise cycles sort by +`requestedAtMs`, then `runtimeSlotNumber`, then `requestNumber`. Callback and +attribution issues sort by `timestampMs`, then their original array index. The +builder preserves the relative order of all retained records. + +## 10. Auction and rendering evidence + +The report combines, but never conflates, the server model from section 9.4 and +TS Console's browser model. It must preserve the distinction between: + +- A server-observed auction and its source. +- An auction-wide provider call and response. +- A server-selected candidate for one opaque slot reference. +- A browser-observed Trusted Server opportunity. +- A GPT request and response. +- A non-empty GPT render. +- Trusted Server creative-bridge evidence. +- Creative load and viewability. +- A publisher or client-side refresh. + +The diagnostic auction token is created before dispatch rather than only on a +delivered winner, so zero-bid and terminal failure states have an identity when +a response can carry evidence. The viewer still must not infer that Trusted +Server rendered an ad merely because the server selected a candidate or GPT +reported a filled slot. Ambiguous, competing, unmatched, and unattributed +cycles remain explicit. + +Provider-call telemetry is auction-wide, while bid rows exist only for returned +bids. Exact `provider X was asked for slot Y` and exact no-bid causality require +a future provider-impression disposition model. That instrumentation is not +silently assumed by this design. + +The UI groups timing into three labeled clocks: + +1. **Server auction-local:** v1 `total_time_ms` and provider + `response_time_ms`, measured from the live orchestration result. +2. **Request-relative server milestones:** dispatched, resolved, and committed + milestones from #1076, unavailable in this schema and adoptable later. +3. **Browser/GPT:** TS Console request, response, render, load, and viewability + timings. + +The report never queries Tinybird and never subtracts or combines values from +different clocks. #1074/#1076 may add request-relative fields only through a +separately reviewed compatibility change. + +Bidder identity, provider identity, winning price, currency, creative numbering, +and a final `SSAT/TS/client-side winner` label are not added by version one. A +later version may consume them only if #1081 approves their meaning and public +disclosure policy. #1050 does not independently weaken the existing privacy +policy. Version one can nevertheless answer the narrower, evidence-based +questions: which Trusted Server entry point ran, what bounded server outcome it +reported, what GPT did afterward, and where correlation is missing. + +## 11. Network scope + +The report is inspired by Fastly Debug, not a clone. Version one exposes only +facts with a defined source and privacy boundary. Every field is optional; an +adapter must omit a value it cannot obtain directly and safely. + +Supported categories: + +- Masked client address. +- Country, region, and ASN when available. +- HTTP version. +- TLS protocol and cipher. +- Edge hostname, region, and POP. +- Capture time. + +Initial provenance and adapter support are: + +| Public field | Source | Fastly | Axum | Cloudflare | Spin | +| ------------------------------ | ---------------------------------------------------------------------------------------------- | --------------------------- | ---------------------- | ------------------------------------ | ----------- | +| `masked_client_ip` | `RuntimeServices.client_info.client_ip`, after trusted-client-IP resolution, then core masking | expected | expected | expected | expected | +| `country`, `region` | `RuntimeServices.geo.lookup(client_info.client_ip)` projected to `GeoInfo.country/region` | expected | unavailable by default | country expected, region unavailable | unavailable | +| `asn` | `GeoInfo.asn` | unavailable until populated | unavailable | unavailable until populated | unavailable | +| `http_version` | new bounded adapter mapping from inbound protocol metadata | optional | expected | optional | optional | +| `tls_protocol`, `tls_cipher` | `ClientInfo.tls_protocol/tls_cipher` | expected | unavailable | unavailable | unavailable | +| `edge_hostname`, `edge_region` | `ClientInfo.server_hostname/server_region` | expected | unavailable | unavailable | unavailable | +| `edge_pop` | new bounded adapter mapping from documented runtime metadata | optional | unavailable | optional | unavailable | + +`expected` means the implementation plan must map and test an existing source; +`optional` means the adapter includes it only when its supported SDK exposes a +stable value; `unavailable` means v1 intentionally omits it. In particular, +ASN is currently not populated by the Fastly or Cloudflare geo adapters and +must not be claimed until a concrete source is implemented. New HTTP-version or +POP mappings must be confirmed against the pinned adapter SDK before addition. + +Explicitly excluded: + +- DNS resolver address and resolver ASN. +- Active bandwidth or speed tests. +- TCP congestion window, next hop, RTT, and retransmit counters. +- DDoS/internal Fastly classifications. +- Arbitrary request headers. +- Full client IP in HTML or export. +- JA4, H2, or other probabilistic client fingerprints. + +Unsupported optional fields are omitted rather than populated with fabricated +fallbacks. + +## 12. Security and privacy + +### 12.1 Allowlist boundary + +The report serializer constructs a new public model field by field. It never +serializes request structs, cookie parsers, auction requests, telemetry rows, or +browser objects wholesale. + +The deserializer is an equally strict boundary. It validates the complete +outer and nested schema at runtime before any display, export, copy, or share +operation. Unknown properties, overlong strings, non-finite numbers, excessive +arrays, excessive depth, unsupported versions, and invalid timestamps reject +the report. Validation errors expose only bounded categories. + +Forbidden data includes: + +- Raw `Cookie` and `Set-Cookie` headers. +- EC IDs, EIDs, bidder user IDs, provider/bidder/seat names, and consent + strings. +- Unmasked client IP. +- Exact page paths, slot element IDs, ad-unit paths (including issue copies), + query strings, and fragments. +- The entire GPT `adManager` identity object and `previousCreativeId`. +- Fastly or internal request identifiers that can join to user-bearing logs. +- Internal `AuctionRequest.id`. +- Bid requests/responses, bid prices/currency, losing-bid payloads, provider + metadata, targeting, creative IDs/domains/markup, cache URLs, and stack + traces. + +### 12.2 Same-origin script visibility + +Publisher and third-party scripts running on the publisher origin can access +`sessionStorage`. They can also replace it, opener-created tabs may receive a +copy, browser session restore may preserve it, and a same-origin service worker +may intercept navigation. Therefore the stored model must be safe even if read +or forged by any same-origin code. A random storage key, closed shadow root, or +public endpoint does not change this requirement. + +Every report view and exported artifact is labeled `Browser-carried, +unverified diagnostic data`. Individual server entries retain their +server-produced provenance, but support documentation says the browser-carried +copy helps troubleshoot rendering and is not cryptographic proof of a server +event, user identity, or security incident. + +### 12.3 Response hardening + +For application-generated trace handling within section 8's boundary, the +HTML shell, enable/end responses, every active diagnostic publisher +response, and every dynamic page-bids or `/auction` response carrying trace +evidence are terminally `private, no-store`. The fixed versioned JS/CSS assets +are the sole exception and may be publicly cached because they contain no +request or report data. HTML and JSON endpoint responses, including state +results and all local authentication, routing, validation, and disabled-route errors, also send the +following headers. Every such error is `private, no-store`; only successful +fixed-asset responses qualify for the cache exception: + +- Path-appropriate `Content-Type`: `text/html; charset=utf-8` for the shell and + `application/json; charset=utf-8` for enable, end, and state results. +- `X-Content-Type-Options: nosniff` +- `Referrer-Policy: no-referrer` +- The Content Security Policy specified below. +- `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), +usb=()` + +```text +default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'; +object-src 'none'; frame-ancestors 'none'; form-action 'none'; connect-src 'self'; +img-src data: +``` + +The shell declares a fixed data-URL favicon with `` to suppress +the implicit publisher `/favicon.ico` fallback; `img-src data:` permits that +declared data URL to load despite `default-src 'none'`. All styles live in the +fixed CSS asset: toggle classes or the `hidden` attribute, with no inline style attributes, +style blocks, or JavaScript style-property writes. JSON download uses a Blob +object URL assigned directly to an `` followed by a click, then +schedules `URL.revokeObjectURL` with `setTimeout` for 1000 ms after the click. +Never revoke synchronously after `click()`; the delay gives the browser time +to acquire the Blob and is not a download-completion signal. Each download +schedules cleanup of its own object URL. Do not fetch the blob URL or +embed it in a frame. This fixes the download mechanism without widening +`connect-src` or enabling frames; browser tests exercise it under this exact CSP. + +The endpoint makes no third-party requests. Its script and stylesheet are fixed +same-origin static assets. Setup-request values are server-rendered as escaped +text nodes, and the viewer obtains the report only from browser storage. Active +publisher-page context continues to use the repository's script-safe serializer +and is never concatenated into executable JavaScript. If inline executable +assets become necessary, they require a per-response nonce or fixed build-time +hash and a corresponding CSP change. Validated report strings enter the +document through `textContent` or equivalent DOM properties, never `innerHTML`. + +The JS asset uses `application/javascript; charset=utf-8`; the CSS asset uses +`text/css; charset=utf-8`. Both send `X-Content-Type-Options: nosniff` and a strong ETag derived +from their build bytes. They use +`Cache-Control: public, max-age=31536000, immutable` only when no configured +authentication rule covers the asset; otherwise they use `private, no-store` +as specified in section 12.5. They accept no dynamic input. `script-src 'self'` is an +origin-level CSP permission, not a path restriction; same-origin script +interference remains inside the stated trust limitation. + +### 12.4 Shared templates and ESI + +Per-request trace context must never enter a shared template or ESI fragment. +This includes diagnostic auction/slot tokens and the optional `AuctionSlot` +extension; active responses add them only in request-scoped injection or the +request-scoped body seam. +The existing diagnostics private/no-store decision remains a load-bearing gate. +Tests must prove that late response-header handlers cannot make traced content +publicly cacheable. Reuse +`apply_response_headers_with_cache_privacy` in `response_privacy.rs`, which +skips operator cache-header overrides on already uncacheable responses. Fastly +also re-runs privacy guards in `apply_terminal_response_effects` after late EC +and filter effects; retain its +`late_filter_effects_cannot_make_an_assembled_response_public` regression. +`apply_finalize_headers` is terminal on Axum, Cloudflare, and Spin, but not on +Fastly; trace handling must preserve the appropriate terminal protection. + +### 12.5 Operator authentication policy + +Trace routing preserves existing first-match-wins operator authentication: +`Settings::handler_for_path` selects one handler, and trace handling enforces +that handler's Basic Authentication policy. Rules do not compose; a preceding +narrow rule can shadow `^/_ts` or `^/`. With no matching handler, trace remains +public: the fail-closed unmatched-path backstop in `enforce_basic_auth` applies +only to `/_ts/admin`, not trace. Call the synchronous `enforce_basic_auth` with +settings and the request before serving any trace response. Do not copy the +Fastly `/_ts/debug/ja4` early return, which bypasses `AuthMiddleware`. +Classification may run early, but does not authorize a request. Only +authentication is factored ahead of trace handling; ordinary event, identity, +filter, and auction processing stays outside trace routes. A challenge exposes +no setup context, changes no cookie, and is terminally private/no-store. + +The credential-free mobile journey requires deployment rules that leave the +trace namespace public. Document this beside `trace_page_enabled`; do not +silently narrow an operator rule. On origin requests, authentication precedes +disabled-route `404` and unsupported-method `405` responses as well. Asset +responses covered by an authentication rule must use `private, no-store` +instead of public immutable caching. Previously public cached inert assets may +remain available, but contain no report data and cannot bypass the uncached +shell or actions. + +## 13. Failure handling + +Local status/hardening rules below apply to application-visible trace handling +as defined in section 8; earlier runtime and adapter failures are separate. + +- Configured authentication failure: local private/no-store `401` challenge + before trace handling, including on disabled routes. +- Disabled route after authentication: local privacy-safe `404`. +- Unsupported method: local `405`; never publisher fallback. +- Runtime rejects before invocation, or adapter bootstrap/conversion fails + before trace dispatch: record the separate outcome defined in section 8; + do not invent a local hardened trace response or cookie-health report. +- Reserved-namespace path that is a trailing slash, extra segment, unsupported + asset name, repeated separator, or lookalike: local `404`; an encoded + namespace alias, encoded separator, or ambiguous dot segment still visible + at dispatch: local `400` after authentication and the enabled-feature check. + Never publisher fallback for a classified reserved request. A runtime-normalized + path outside the namespace retains ordinary behavior. +- Non-empty or unreadable activation/end body: local `413` for any body bytes, + positive/invalid `Content-Length`, or `Transfer-Encoding`, and local `400` + for a stream read error, both with no cookie mutation. +- Rejected activation/end POST: local `403` with no state mutation. +- Optional platform fact unavailable: omit the field and continue. +- Bounded cookie inspection failure: report the contract-defined invalid or + unavailable state without a value or parser message. Runtime ambiguity uses + `runtime_header_ambiguous`, disables trace capture and leaves ordinary + advertising/console behavior intact; valid end actions remain available. +- Diagnostics context serialization failure: omit the context, log a bounded + server error, and preserve publisher delivery. +- Server-auction evidence construction or serialization failure: omit only the + affected evidence, retain a bounded `unavailable` coverage marker when safe, + log no report content, and preserve the normal auction/result path. +- Initial-navigation evidence cannot be injected because the body tail is not + reached: preserve publisher delivery. Because the browser received no safe + marker, display `not_observed` rather than claiming a known server failure. +- A successful page-bids or `/auction` response omits the optional evidence + member: parse ordinary bids unchanged and add no capture issue. With no other + records or known capture issues, coverage is `not_observed`; absence does not + reset previously collected evidence or failures. +- A supplied evidence member fails strict validation: discard that member, + parse ordinary bids unchanged, and add `evidence_validation_failed`. Coverage + is `partial` when valid records remain or `unavailable` when none remain, + following section 9.3. +- Valid evidence cannot join a missing, malformed, duplicate, or conflicting + slot token, or a correlation sidecar is evicted: add only + `correlation_unavailable`, preserve ordinary slots and bids, and leave server + `capture_status` unchanged. Count evicted sidecars in + `omitted_slot_correlations`. +- TS Console capture failure: fail open for advertising and show incomplete + coverage in diagnostics. +- Storage unavailable or quota exceeded after a valid bounded report exists: + remain on the publisher page, announce the error, and offer direct download + of that same combined `TraceReportV1`. +- Invalid projection or a report that remains oversized after deterministic + truncation: remain on the publisher page, show a bounded capture-failure + category, and do not claim that a combined trace report exists. +- Missing snapshot on endpoint: show setup state, not an empty successful + report. +- Expired, malformed, or unknown report schema: clear it and explain that the + user must reproduce again. +- Clipboard or Web Share unavailable: keep JSON download available. +- Export failure: retain the on-screen report and show an accessible error. +- End POST failure: report that tracing may remain active and offer an + idempotent server retry; do not undo or block the independent local-deletion + attempt. +- State verification failure or mismatch: use `unconfirmed` wording and offer + an idempotent server retry independently of local report state. +- Local deletion failure: report separately that saved browser data could not be + removed and retain the always-available deletion retry, regardless of the + server end result. + +Diagnostic failures must never suppress, delay, add, remove, reorder, or change +the success status of GPT requests, auctions, bid responses, targeting, or +creative rendering. The evidence projection is a side effect of already-known +results, never a prerequisite for returning them. + +## 14. Testing strategy + +### 14.1 Core unit tests + +- Configuration defaults off and rejects trace-page enablement without GPT + diagnostics, with `enabled` both explicitly false and omitted, on both + deploy and runtime validation paths. +- With GPT diagnostics enabled but `trace_page_enabled = false`, a valid + console cookie still enables the existing console but never mints trace + tokens, builds auction evidence, adds response extensions, or emits + correlation sidecars. +- With a valid incoming cookie, `?ts_console=0`, invalid/duplicate directives, + prefetches, bots, and other ineligible navigations suppress all document trace + capture through the effective diagnostics decision. Query enable without a + cookie activates only the console until the next eligible cookie-bearing + reload. Page-bids and `/auction` use the base gate without navigation checks. +- Exact reserved-route classification, canonical-path, query, method, encoded + path, and fallback behavior. +- Exact versioned asset routes are local and contain no dynamic data; lookalike + asset paths never reach the publisher origin. +- Same-origin POST validation, cross-site/missing signal rejection, cookie + set/clear attributes, shared `Max-Age=1800` for endpoint and query activation + without ordinary-request refresh, and idempotent enable/end behavior. Cover + enable then query activation, query then enable, repeated explicit activation, + clearing through either surface, and the pre-existing session-cookie caveat. +- Enable/end success requires a separate state request to observe the resulting + cookie; failed and mismatched verification never displays confirmed state. + Absent, invalid, duplicate, and uninspectable cookies all report inactive, + with no claim that inactive proves the cookie is absent. +- Empty-body enforcement rejects positive/invalid lengths, transfer encoding, + nonempty bodies even with absent/zero lengths, and verifies no mutation on + rejection. Cover both `Body::Once` and `Body::Stream`, including clean EOF, + empty chunks before EOF or a non-empty chunk, and stream read errors. Assert + local `413` for body bytes and local `400` for read errors, with section 12.3 + hardening and no cookie mutation. Tests must not claim a transport bound or + timeout that the pinned adapters cannot enforce. +- Endpoint skips EC generation/finalization, EID ingestion, auction, telemetry, + configured filters, ordinary event context, and origin fetch. +- Cookie-health scanner covers multiple header fields; zero, one, and duplicate + occurrences; mixed valid/invalid duplicates; non-UTF-8; malformed pairs; and + per-value and total-header limits without retaining values. + Cover actual invalid bytes separately from valid non-ASCII UTF-8; missing and + every non-preserved fidelity status; independent per-Cookie octet/multiplicity + overrides; U+FFFD in unrelated and quoted values; commas bordering reserved + names versus commas inside unrelated JSON-like values; marker-free normal + sessions under Unknown; explicit Preserved marker semantics; and aggregate + size > actual invalid UTF-8 > runtime-ambiguity precedence. Ambiguity makes + all four states unavailable and suppresses tokens, evidence and trace sidecars. +- Request-context serializer masks IPv4/IPv6; enforces every string bound; and + omits page paths, fingerprints, query, raw headers, IDs, and unsupported + fields. +- Server-auction projection maps initial navigation, SPA page-bids, and auction + API call sites to the exact public source enums without using a browser hint. + Canonical and legacy page-bids routes produce equivalent gated evidence and + slot extensions, including the TSJS retry against the legacy alias. +- The diagnostic auction token is minted before dispatch and remains identical + across completed, zero-bid, skipped, failed, and abandoned evidence and the + corresponding SSAT/SPA browser opportunity marker. API tokens remain + server-evidence identifiers without GPT sidecars. No token equals or contains the + internal auction ID or telemetry UUID. +- Server-auction projection covers every terminal status/reason mapping, + auction-local total duration, provider role/status/duration/count, per-slot + requested sizes/bid count/candidate disposition, and checked numeric + conversion. +- Provider numbering and opaque slot references are deterministic and bounded; + provider names, bidder/seat names, prices, currency, publisher slot IDs, + creative identifiers/domains/markup, metadata, raw errors, and internal IDs + are absent from serialized fixtures. +- Auction-wide provider no-bid evidence is never projected as a per-slot + disposition, and `provider_to_slot_no_bid` remains `unavailable`. +- Evidence projection/serialization failure leaves the ordinary bid map or + OpenRTB response unchanged apart from the bounded unavailable transport + envelope. +- `/auction` request tokens survive the exact AdRequest-to-AuctionRequest slot + conversion, are stripped before provider dispatch, and remain correctly + associated across grouped multi-bidder units, duplicate codes, skipped + non-banner units, and mixed accepted/filtered inputs. Numeric ordinals are + never used for client correlation. +- Token tests cover the existing simple auction UUID-v4 shape and hyphenated + slot UUID-v4 shape, exact producer-to-sidecar joins, missing Web Crypto, malformed or + duplicate request extensions, the disabled trace gate, and proof that invalid + tokens neither fail nor otherwise alter the ordinary auction. +- Initial and SPA slot JSON attaches the exact + `ext.trusted_server.trace_slot_ref` token that appears in server evidence; + inactive responses omit it. With valid matching auction evidence supplied, + missing, duplicate, conflicting, malformed, and evidence-mismatched slot + tokens suppress only correlation and produce the + specified coverage issues without changing slot/bid order or contents. +- Active responses remain terminally private/no-store under hostile late header + overrides. +- Dynamic HTML/JSON values cannot close elements or create executable script. + +### 14.2 Adapter parity tests + +- Fastly early-route ordering and optional field mapping from documented + sources. +- Axum, Cloudflare, and Spin return the common route/schema with unavailable + fields omitted. +- Application-visible trace-route failures never fall through to publisher origin, including HEAD + on each exact path, visible malformed reserved paths, and arbitrary unsupported + methods intercepted before router dispatch. Assert path-specific `Allow`, + bodyless HEAD errors, and hardening headers on local errors. + Separately exercise real runtime method/parser rejection, adapter conversion + failure and transformed application handling. Cloudflare wire 501 is not a + successful local 405; Spin invalid-byte rejection is not cookie-health output. + Paths normalized into trace routes require auth; paths normalized to health, + JA4 or publisher paths retain ordinary behavior. Missing/transformed/oversize + original-target metadata must not reject an inspectable normal pathname. +- Broad `^/_ts` and `^/` authentication rules challenge every trace path + (shell/state/actions/assets, disabled routes, and unsupported methods); valid + credentials proceed to trace handling, while `^/_ts/admin` leaves trace + routes public. Challenges expose no context or cookie mutation, and + protected assets remain private/no-store. Also test an earlier narrow handler + shadowing a broad rule to pin first-match-wins behavior. + For encoded namespace aliases, pin literal visible-path auth selection: + `/%5Fts/trace` challenges under `^/`, but under a lone `^/_ts` rule is locally + rejected without serving setup data or performing an action. +- GET, HEAD, state-changing POST, and unsupported methods obey the same + lifecycle contract across adapters. +- Enable/end POSTs without `Content-Type` accept empty bodies and reject actual + body bytes without cookie mutation, including Axum's streaming path. +- Every adapter omits JA4/H2 and rejects control characters or overlong platform + strings. + +### 14.3 JavaScript unit tests + +- Explicit snapshot only; no continuous `sessionStorage` writes. +- Both TSJS auction callers require the literal `__tsjs_trace_active === true` + before token generation or trace collection. Cover missing/false/non-boolean + flags with GPT diagnostics active: no tokens, mappings, listeners, pending + transport records, sidecars, or handoff action are created. +- Compact UTF-8 size measurement; exact outer/nested schema validation; unknown + fields; per-string/array/numeric/depth caps; hostile mutation; expiry; + future-clock skew; wall-clock rollback; replacement; clearing; and storage + exceptions. +- A filled GPT cycle with `observedSlotSize: [0, 0]`, `[0, 250]`, or `[300, 0]` + survives projection, storage validation, rendering, and export unchanged. + Negative, fractional, non-finite, and over-limit observed dimensions fail + validation; requested/selected creative and GPT fill dimensions retain their + positive bounds. +- Omission counters use checked arithmetic and reject overflow. +- Strict validation of every server-auction enum, token, numeric bound, array + bound, nesting level, and unknown property; invalid evidence is discarded + without changing ordinary bid parsing. +- Transport envelopes require exactly one of evidence/unavailable reason; + projection, transport, validation, eviction, correlation, and external-client + coverage states produce the specified complete/partial/unavailable/not-observed + result without treating absence as proof that no auction ran. A fixture that + receives valid evidence and then evicts every server record during size + truncation must yield `unavailable` with `record_evicted` and exact omission + counts; partial eviction remains `partial`. +- Missing, malformed, duplicate, and conflicting slot extensions with valid + evidence add only `correlation_unavailable` and preserve complete server + capture. Evicting correlation sidecars at the 128-record memory cap or during + snapshot truncation increments `omitted_slot_correlations` exactly and never + adds `record_evicted` or changes server `capture_status`. +- Successful responses without an optional transport member add no validation + issue; malformed supplied members add `evidence_validation_failed`. Cover + both an empty collector and one with retained evidence or earlier failures. +- Direct fetch failures and Prebid `interpretResponse`, `onTimeout`, and + `onBidderError` paths consume their pending transport record exactly once; + capped/expired records and absent hooks follow the specified `not_observed` + behavior without retaining error text or bodies. Exercise the newly added + hooks through the pinned Prebid `registerBidAdapter`/`newBidder` registration + path, not only by calling the spec functions directly. Verify expiry uses the + captured browser `bidderTimeout` plus 5000 ms, including configured values, + the 3000 ms default, missing/invalid-value fallback, the maximum accepted + timeout, and fallback for values that would overflow the browser timer. + Invalid creation clocks and unsafe timestamp addition decline the pending + record without changing bids or inventing a transport failure. Later + configuration changes do not alter an existing record's expiry. +- Exact-token correlation joins matching SSAT/SPA server auctions, GPT opportunities, + and slot references; unmatched, duplicated, conflicting, missing, and + forged tokens stay separate and produce explicit coverage states. No + timestamp, index, or ad-unit-path heuristic is used. +- `TraceSlotCorrelationV1` is emitted only at the existing recorder's exact + opportunity-to-cycle binding, is capped and evicted deterministically, + contains only opaque tokens plus runtime/request numbers, and does not alter + `GptDiagnosticsExportV1`. +- Prebid `/auction` evidence remains independent of its `prebid_refresh` GPT + cycle, and direct `requestAds` evidence remains independent of GPT. Neither + path emits a sidecar or introduces `trusted_server_direct`/`competing` + attribution. Both show `correlation_unavailable` without downgrading complete + server capture; supplied API sidecars are rejected by the viewer. +- Source presentation distinguishes server-owned SSAT/page-bids/auction API + facts from browser-observed publisher refresh, Prebid refresh, competing, and + unattributed request paths. No fixture turns intent or a GPT fill into a + winner assertion. +- Server auction-local, request-relative unavailable, and browser/GPT timings + render in separate labeled groups and are never combined arithmetically. +- Trace projection replaces the nested GPT pathname with `/[redacted]`, rejects + any other stored value, emits `TraceGptDiagnosticsV1`, applies deterministic + ordering/truncation, and records exact omission counts in a worst-case 512 KiB + fixture. An oversized newest-cycle-per-slot floor, including its protected + correlated auctions, fails snapshot creation without dropping a slot's last + cycle or offering a combined-report export. +- Same-tab navigation occurs only after a successful write. +- Viewer handles absent optional network facts and every cookie-health state, + including the exact unavailable/runtime_header_ambiguous detail. Storage, + download, copy, share and direct export retain the same reason; invalid + state/detail pairs and unknown reasons reject. Worst-case size fixtures + include the new detail without expanding the existing report bounds. +- Populate every excluded `adManager` field, `previousCreativeId`, slot + `slotElementId`/`adUnitPath`, and callback/attribution issue `slotElementId` + with distinct sentinel values, including a synthetic secret-bearing path. + Verify none enter trace HTML, storage, copy/share, or direct export; hostile + stored copies containing any excluded property are rejected. Numbered + slot/cycle correlation still joins after redaction. +- Download filename and MIME type are deterministic. +- Fake timers verify that each download clicks its Blob-backed anchor before + scheduling cleanup, never revokes synchronously, and revokes its own URL + when the 1000 ms timer fires, including repeated downloads. +- Formatted-JSON copy and JSON-file Web Share success, rejection, absence, and + download/copy fallback behavior. +- 320-pixel layout, keyboard navigation, focus handling, and accessible status + announcements. + +### 14.4 Browser integration tests + +- Under the exact section 12.3 CSP, JSON downloads contain the complete report + with deferred URL cleanup, including the storage-failure recovery path and + clipboard/Web Share fallback. The declared data-URL favicon loads without + an implicit `/favicon.ico` request. +- First endpoint GET is read-only and shows setup state; a user-initiated, + same-origin enable POST sets the session. +- Successful in-page activation adds no history entry, so Back can return to + the article when it was the prior same-tab page. +- Cross-site top-level GET, form POST, and fetch attempts cannot enable or end + tracing. +- A real fixture reload activates diagnostics and captures multiple slots. +- Initial-navigation SSAT fixtures cover selected, no-candidate, + selected-unrenderable, skipped, dispatch-failed, execution-failed, and + abandoned outcomes without requiring a winning bid. +- SPA page-bids and both TSJS `/auction` callers consume the optional + `trace_auction` member before ad initialization/bid parsing; inactive + responses have no member, and malformed members do not affect bids. +- Initial seams pass the optional transport as the scheduler's third argument, + SPA JSON uses the exact optional top-level member, and old-scheduler/absent + transport fixtures preserve ad initialization while reporting `not_observed`. +- `View trace results` navigates in the same tab and renders the captured + request context and slot evidence. +- A correlated SSAT/SPA fixture renders the chain `server auction -> GPT -> creative`, + while unmatched server, client-side refresh, competing, and transport-failure + fixtures show honest independent evidence and `Unknown` where appropriate. +- Empty, filled, ambiguous, no-candidate, and unattributed slot states remain + distinct. +- Reloading the trace page retains an unexpired same-tab report. +- Opener-cloned tabs and browser session restore never bypass validation or + application expiry; the UI does not promise tab isolation. +- Ending tracing covers successful clearing, offline POST failure, idempotent + retry, verification mismatch, successful local deletion while offline, and + local-storage deletion failure without false success messaging. +- Back-forward-cache restoration is not described as a fresh traced request; + the setup page tells the user to reload. +- Export JSON matches the displayed versioned model. +- A storage-failure direct export matches the combined displayed model rather + than the GPT-only export. +- Hostname changes between apex, `www`, or another subdomain show the recovery + guidance rather than claiming the session followed the user. +- A fixture service worker interception is recognized as a same-origin trust + limitation, and the server endpoint remains correct when the request reaches + it. +- The delivered CSP blocks inline injection, framing, third-party connections, + and report-derived executable HTML. +- A committed digest of each published v1 asset is asserted against the bytes + the build produces, so changing those bytes fails the check. Changed bytes + require a new asset-set URL and its + committed digest, preserving the published URL/digest pairs; a fixture also + asserts the shell references the current asset-set URL. This pins the byte + contract at build time rather than claiming a test can observe future releases. +- Inactive publisher traffic has no trace assets, storage access, listeners, or + cache-policy change, diagnostic token generation, or trace-auction response + extension. +- After disabling `trace_page_enabled`, a fresh publisher load removes every + new capture behavior even when a technical `?ts_console=1` session leaves a + valid diagnostics cookie present. Already-loaded documents cannot be remotely + deactivated, but subsequent server responses contain no trace evidence. + +### 14.5 Manual acceptance + +Verify on current iOS Safari and Android Chrome using a representative publisher +fixture: + +- A layperson can follow the page instructions without developer tools. +- Touch targets, scrolling, zoom, safe areas, download, copy, and native share + behavior are usable. +- The user can distinguish setup information from captured-page information. +- The user can distinguish `SSAT/Trusted Server auction ran`, `browser refresh +observed`, `GPT filled/rendered`, and `Unknown` without understanding internal + request-path names. +- A failed share or download does not lose the visible report. +- Record the all-adapter normal setup → enable → state observation → eligible + reload → capture → view/export → end → state observation journey on suitable + same-origin fixtures. Include marker-free cookies with Unknown fidelity. + Do not treat malformed-wire rejection or synthetic converter-only tests as + evidence that this browser journey succeeded. Mobile Safari/Chrome acceptance + uses the supported deployment origin and retains Secure cookie attributes. + +## 15. Rollout and observability + +- Ship default-off. +- Enable first in a controlled staging publisher configuration. +- Validate response cache headers and CDN behavior before production use. +- Validate with redacted fixtures before real publisher traffic. +- Log only server-observable route outcome, served shell/schema version, and + bounded error category. The server cannot know whether a browser-local report + exists and must not add an upload or beacon merely to learn that fact. Never + log report contents or cookie/network values. +- Roll back by disabling `trace_page_enabled`; existing `?ts_console=1` + diagnostics remain independently configurable. + +## 16. Acceptance criteria + +1. With the feature disabled, trace-route origin requests that pass configured + authentication return local `404` + and ordinary requests without explicit diagnostics activation are unchanged. + Query activation adopts the shared cookie lifetime in section 5.2. + Previously cached inert versioned assets + may remain until cache eviction, but cannot activate tracing or load a shell. +2. On a deployment whose authentication rules leave trace routes public, a + mobile user can enable tracing by opening only `/_ts/trace` and selecting + one prominent action; no target URL, credentials, or trace ID is required, + and a cross-site GET cannot activate tracing. Matching operator auth rules + remain enforced on all trace paths. +3. The setup page accurately explains that the problem must be reproduced after + activation. +4. A subsequent real publisher-page reload captures redacted request context, + live server-auction evidence, and existing TS Console evidence without + altering ad behavior. +5. `View trace results` transfers one bounded, runtime-validated snapshot in + the supported same-tab journey and opens the report page without server-side + storage or claims of browser-storage isolation. +6. The report separates network, cookie health, server auction, GPT delivery, + creative rendering, and coverage/unknowns. +7. JSON export contains the same versioned allowlisted information shown on the + page. +8. No raw cookies, user IDs, full IPs, consent strings, exact page paths, query + strings, fingerprints, internal auction IDs, provider/bidder/seat names, + prices, targeting, or creative identifiers/payloads appear in trace HTML, + browser storage, trace-specific logs, or export. +9. Trace HTML and active publisher pages remain terminally private/no-store. +10. Missing platform fields, failed evidence projection/transport, incomplete + auction correlation, storage failure, and unavailable share APIs degrade + honestly without affecting advertising. +11. The full report is usable at 320 CSS pixels and with keyboard/screen-reader + navigation. +12. Version one accepts exactly `TraceReportV1` with server-produced + `TraceAuctionEvidenceV1`, browser-produced `TraceSlotCorrelationV1`, and + `TraceGptDiagnosticsV1`; only the latter is sourced from + `GptDiagnosticsExportV1`. #1081 and #1074/#1076 are optional additive + follow-ups rather than release gates. +13. Every rendered and exported report is identified as browser-carried and + unverified; server-produced and browser-observed entries retain distinct + provenance, and hostile storage content cannot create executable HTML or + unbounded DOM output. +14. Enable/end operations expose partial failure honestly and are safe to + retry; the UI does not claim server-observed cookie state without the + follow-up state request or claim that local data was cleared when deletion + fails. +15. A report can show that initial-navigation SSAT, SPA page-bids, or the + Trusted Server auction API ran; show its bounded provider and per-slot + outcome; show subsequent GPT/creative evidence; and display `Unknown` + rather than inventing a client-side winner or an unsupported correlation. +16. The normal accepted journey works on all four adapters within the documented + application boundary. Visible reserved paths terminate locally with the + required authentication/hardening; runtime rejection, conversion failure and + normalized non-trace paths are recorded separately. No original-target SDK + accessor or byte-exact reconstruction is a v1 dependency. +17. Cookie ambiguity is reported as unavailable/runtime_header_ambiguous and + cannot authorize capture. Unknown fidelity alone does not disable a normal + readable session. The reason survives strict Rust/JS validation, the viewer, + storage and every export path without exposing raw data or changing + ordinary advertising. + +## 17. Implementation sequencing + +This design has one implementation plan with four reviewable phases. The first +three together form the complete mandatory v1 release; phase four is optional. +Keep one spec and one plan on the existing branch. Phase checkpoints do not +authorize publishing a partial endpoint or creating separate plans: + +1. **Reserved route and privacy foundation:** configuration, shared early-route + classification with operator authentication, same-origin enable/end lifecycle, + shared 30-minute endpoint/query cookie policy, bounded cookie-health + inspection, base request-context schema, projection of already populated + `ClientInfo`/`GeoInfo` fields, response hardening, and adapter parity. Do not + add speculative new platform fields in this change. Include the raw-config + validation hook and method-independent dispatch on all four adapters in + this first phase; Fastly-only route registration does not satisfy the contract. +2. **Live server-auction evidence:** introduce the public diagnostic auction and + slot tokens, project `TraceAuctionEvidenceV1` at the live observation + boundary, transport it through initial navigation, page-bids, and both + TSJS `/auction` callers, emit `TraceSlotCorrelationV1` from the existing GPT + recorder binding, and prove ordinary bid behavior is unchanged on + absent/invalid evidence. +3. **Browser handoff and viewer:** integrate current + `GptDiagnosticsExportV1`, project `TraceGptDiagnosticsV1`, construct and + strictly validate `TraceReportV1`, implement the same-tab workflow, combined + server/GPT/creative correlation, direct/storage exports, mobile viewer, + copy/share, expiry, clearing, and browser/accessibility tests. +4. **Optional network enrichment and future schemas:** add HTTP-version, POP, + ASN, or other fields only from SDK-verified platform sources with explicit + bounds. Adopt #1081 or #1074/#1076 later through a separately reviewed + versioned compatibility change. + +Each phase must include its relevant adapter, privacy, cache, and failure tests. The +implementation must not claim completion of #1081 or request-relative timing as +part of #1050. Version one ships with minimal live server-auction evidence plus +current GPT/render evidence and labels unavailable fields honestly. + +## 18. Rejected alternatives + +### Automatic activation on `GET /_ts/trace` + +Rejected because a cross-site top-level navigation can trigger a public GET and +`SameSite=Lax` does not make that activation intentional. A same-origin fetch +POST after one explicit button press preserves the simple mobile journey and +the useful Back history entry without requiring server-side session storage. + +### `/_ts/admin/trace?target=/article` + +Rejected because it requires the user to supply the affected URL twice, adds +target validation and open-redirect risk, and is unsuitable for a layperson. + +### Basic Authentication + +Rejected as a new mandatory prerequisite for the mobile end-user workflow. +Existing operator authentication rules remain enforced; deployment configuration +must leave trace routes public to offer the credential-free journey. +Authentication also would not make it safe to inject raw secrets into a +publisher page containing third-party JavaScript. + +### Server-managed trace sessions + +Rejected because they require shared storage, report authorization, expiry, +deletion, and operational infrastructure beyond the issue's needs. + +### Synthetic auction on the endpoint + +Rejected because it does not reproduce the real page's DOM, GPT lifecycle, +consent context, refresh path, or auction timing and could produce misleading +results. + +### Endpoint-only report with no publisher-page integration + +Rejected because a request to `/_ts/trace` cannot observe rendering that +occurred in another document. + +### Query-only in-page console + +The existing `?ts_console=1` flow remains supported, but it is not the complete +answer to #1050: the issue asks for a memorable mobile endpoint and a +Fastly-style consolidated HTML report. The endpoint/viewer builds on rather +than replaces the console. + +### Cross-page URL payload + +Rejected because fragments or query strings containing the report create URL +length, history, logging, referrer, and accidental-sharing risks. + +## 19. Known limitations + +- Original wire information is outside the v1 guarantee. Runtime/SDK path + normalization can turn an originally trace-like spelling into an ordinary + request, including Fastly health/JA4 behavior. Runtime rejection and adapter + conversion failures cannot provide application trace responses. +- Cookie bounds and counts concern visible fields. Comma/replacement markers + conservatively suppress tracing when the applicable fidelity axis is not + Preserved, including valid unrelated values that happen to contain markers. + Transformations that erase ambiguity without a marker cannot be inferred. + Do not present an unavailable result as cookie absence or original-wire proof. + Resolved dependency/runtime changes require rerunning the semantic probes. +- The user must reproduce the problem after enabling tracing. +- Same-tab navigation is the supported workflow, but opener-created tabs and + browser session restore may copy or retain session storage. JSON export is + the intentional support handoff artifact. +- Publisher-origin scripts and service workers can read or forge the stored + public-safe report. A same-origin service worker can also intercept or fake + the shell, enable/end/state requests, assets, and report navigation. The + experience is diagnostic evidence, not an authenticity boundary. +- The activation cookie is host-only and session storage is origin-scoped, so + the workflow does not follow the user across apex, `www`, or other + subdomains. +- Browser privacy settings may disable storage, clipboard, download, or share + capabilities. +- A server can report an auction failure only when it can still deliver a + response containing evidence. Network termination or an unreadable `/auction` + response remains a browser-observed transport failure with no server outcome. +- Provider calls are auction-wide. Version one cannot attribute a provider + no-bid, timeout, or error to a specific slot. +- Third-party client-side auction participants, bids, and winners are not + observable. Publisher/Prebid refresh is browser intent, not proof that a + client-side bidder won. +- `/auction` evidence requires the TSJS request to reach the same Trusted Server + host with the active diagnostics cookie. A custom cross-origin auction + endpoint does not inherit this trace session and is shown as unavailable. +- Version one has no GPT correlation for `/auction`: the Prebid caller lacks + a token-bearing GPT binding, and direct `requestAds` renders outside GPT. + Their server evidence is displayed independently with correlation unavailable. +- Exact-token correlation can remain unavailable for hidden, unresolved, + competing, or independently initiated GPT cycles. The report preserves both + sides instead of guessing. +- Version one has auction-local server durations and browser/GPT timings, but + request-relative dispatched/resolved/committed milestones await #1076. +- Fastly-only transport details do not exist on every adapter. +- The current 30-pixel TS Console controls are not sufficient for this mobile + report; the endpoint uses independent 44-pixel touch targets. +- `fastly-debug.com` fields that require resolver, TCP, or active speed probes + remain out of scope. + +These limitations are displayed in operator documentation and, where relevant, +in the report itself. They are not hidden behind apparently successful empty +states. diff --git a/docs/superpowers/specs/2026-09-16-edgezero-fastly-store-selectors-design.md b/docs/superpowers/specs/2026-09-16-edgezero-fastly-store-selectors-design.md new file mode 100644 index 000000000..0dfa91648 --- /dev/null +++ b/docs/superpowers/specs/2026-09-16-edgezero-fastly-store-selectors-design.md @@ -0,0 +1,67 @@ +# EdgeZero Fastly Store Selector Alignment + +## Problem + +Trusted Server currently depends on EdgeZero `v0.0.8`, whose Fastly staging +deployment flow persists and reads service-scoped runtime store selectors. +Staging can therefore fall back to the logical secret-store name +`trusted_server_secrets` even when the selected GitHub Environment sets the +canonical selector to the physical store `ts_secrets`. Runtime settings then +fail while resolving required secret references such as +`publisher.proxy_secret`. + +EdgeZero PR 381 restores canonical deploy-time selectors and makes managed +deployments link each selected physical Config, KV, and Secret Store to the +target service version under its logical ID. The runtime opens stores by +logical ID and no longer reads selectors from an `edgezero_runtime_env` Config +Store; that store is legacy. Trusted Server needs to consume that behavior and +remove guidance for the superseded service-scoped and runtime-selector models. + +## Design + +All EdgeZero workspace dependencies are pinned with `tag = "v0.0.9"`, the +release that carries the merged PR 381 ("Bind Fastly stores per deployment +environment"). Pinning every crate to one tag ensures the adapter, CLI, core, +and platform-specific crates resolve to the same revision. This replaces the +interim `rev =` pin on the unmerged upstream branch and closes issue #1195. + +The Fastly entry point will stop reading `edgezero_runtime_env`. It will open +the config and secret stores by their logical IDs, `trusted_server_config` and +`trusted_server_secrets`, and read the config entry under +`trusted_server_config` for every target. Staging isolation comes from the +physical store the staging environment selects, not from a different key. + +Local Viceroy configuration will expose the secret store under the logical ID +`trusted_server_secrets` and drop the `edgezero_runtime_env` Config Store, so +local runs exercise the same store-opening path as a deployed version. + +The Fastly and CLI guides will describe GitHub Environment or deploy-process +variables as deployment inputs. They will no longer instruct operators to +construct a selector containing a Fastly service ID or to persist that key +manually. EdgeZero remains responsible for linking the selected physical stores +to the production or staged version during deployment. + +## Failure Handling + +Trusted Server will keep failing startup when a required secret reference +cannot be resolved. This change fixes the deployment mapping and resource links +rather than weakening runtime validation or falling back to another store. + +The pull request will state that its branch dependency is temporary and link to +EdgeZero PR 381. If upstream changes its CLI API before merge, Trusted Server's +compile and CLI tests will expose the mismatch before deployment. + +## Validation + +Validation will cover: + +- lockfile resolution of every EdgeZero crate to one upstream revision; +- Trusted Server CLI parsing and behavior tests on the host target; +- Fastly adapter compilation and tests where supported by the local toolchain; +- Rust formatting and target-matched Clippy for changed Rust surfaces; +- searches confirming service-scoped store-selector instructions and + `edgezero_runtime_env` references are removed from current operator + documentation and local Fastly configuration. + +The issue will record the staging failure, expected canonical selector behavior, +and the dependency on EdgeZero PR 381. The pull request will close that issue. diff --git a/docs/superpowers/specs/2026-10-01-docs-proposal-automation-design.md b/docs/superpowers/specs/2026-10-01-docs-proposal-automation-design.md new file mode 100644 index 000000000..abf52e016 --- /dev/null +++ b/docs/superpowers/specs/2026-10-01-docs-proposal-automation-design.md @@ -0,0 +1,238 @@ +# Documentation Proposal Automation Design + +- Status: proposed +- Design date: 2026-10-01 +- Issue: #1105 +- Target branch: `main` + +## Purpose + +When a merge to `main` changes behavior that the public documentation +describes, a reviewable pull request with the proposed documentation diff +should appear automatically. Today `deploy-docs.yml` only builds and deploys +the VitePress site after documentation changes land; nothing proposes edits +after a code merge. + +This design adds a proposal path that never writes to `main`. It is a +deliberate, scoped addition to the documentation refresh design +(`2026-08-19-documentation-refresh-design.md`), which excluded automated +tracked-file write paths. The exclusion still holds for `main`: automation +only ever writes to a dedicated proposal branch and a pull request. + +## Decisions + +| Question | Decision | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| How is prose produced? | GitHub Copilot CLI, run headless in GitHub Actions with the workflow `GITHUB_TOKEN` (`copilot-requests: write`), billed to the org. | +| Which code changes are examined? | Pushes to `main` touching `crates/**`, `trusted-server.example.toml`, `edgezero.toml`, `fastly.toml`, `.cargo/config.toml`, `scripts/**`. | +| Which pages may change? | `docs/guide/**` and `docs/index.md` only — the public site validated by the docs gates. | +| How is prose checked against merged code? | Copilot cites the merged source for every hunk; a per-hunk review exposes the evidence and a one-click revert; a human approves. | +| How do sequential or overlapping merges map? | One pull request per merge commit on branch `docs/auto/`; runs are serialized. | +| What happens on an empty diff? | No pull request. An existing proposal pull request for that SHA is closed. | +| What prevents duplicates on retry? | The branch name is derived from the merge SHA; a retry updates that branch and pull request, but never over maintainer commits. | + +## Workflow shape + +`.github/workflows/docs-proposal.yml` has three jobs. Copilot never holds a +token that can push or open pull requests, and proposal content is never built +or executed in a job that can. + +Every job checks out the workflow's own revision into `tools/` and the merge +commit into `source/`, then runs the scripts from `tools/` against `source/`. +Retrying a merge that predates this tooling therefore works, and a retry runs +the current scripts rather than historical ones. + +### Trigger + +- `push` to `main`, filtered to the source paths above. `docs/**` and + `scripts/docs-proposal/**` are not included, so a merged documentation + proposal or a change to this tooling never retriggers the workflow. +- `workflow_dispatch` with a required `sha` input and an optional `base` + input, for retries and for the first end-to-end validation. All three + scripts require a full 40-character SHA reachable from `origin/main`. +- `concurrency` with `group: docs-proposal`, `cancel-in-progress: false`, and + `queue: max` so sequential merges are processed one at a time, in order. + The default queue keeps one pending run and cancels the rest, which would + skip merges. + +### Job `propose` + +Permissions: `contents: read`, `copilot-requests: write`. + +1. Check out the tools and the merge commit with full history. +2. Install Copilot CLI (`@github/copilot`) at a pinned version. +3. `scripts/docs-proposal/propose.sh .docs-proposal `: + 1. Renders `.docs-proposal/prompt.md` from `scripts/docs-proposal/prompt.md`, + the merge SHA, its base, its subject, and the Sources-of-truth table from + the refresh design. The base is the push's previous `main` head, or the + manual dispatch's optional `base` input, falling back to the first + parent when it is missing or not an ancestor, so Copilot inspects + `git diff `: a multi-commit push is covered in full, and a + clean merge commit is not hidden by `git show`'s empty combined diff. + 2. Runs `copilot -p` with `--no-ask-user`, `--allow-tool=write`, and + `git show`, `git diff`, and `git log` shell tools; `git push` and + `git commit` are denied, and file reads need no permission. `git grep` + is not allowed because `--open-files-in-pager` runs a command. Copilot + edits documentation only when the merged change alters reader-facing + behavior, and writes `.docs-proposal/rationale.md`. + 3. Runs `npm ci` and `npm run format:write` in `docs/` so Prettier + normalizes the edits before any hunk is identified. + 4. Writes `proposal.patch` (`git diff --cached --binary -- docs`). An + empty patch ends the job here. + 5. Runs `node scripts/docs-proposal/hunks.mjs list` to enumerate the hunks. + A hunk id is the changed path plus a short hash of the hunk's removed + and added lines, so it is stable across reruns. A second Copilot run + writes `evidence.json`: for each hunk id, the merged source `path:line` + that justifies it and a one-sentence reason. Evidence is advisory, so a + failed evidence run only logs a warning and keeps the proposal. +4. Upload the `.docs-proposal/` directory as an artifact. + +### Job `validate` + +Permissions: `contents: read`; checkouts do not persist credentials. +VitePress executes Vue in Markdown during the build, so generated content runs +only here. + +`scripts/docs-proposal/validate.sh .docs-proposal`: + +1. Empty diff: exit successfully. +2. Apply `proposal.patch` to the merge commit and reject the proposal if any + changed path is outside `docs/guide/**` or `docs/index.md`, or any changed + entry is not a regular file. A symlink would otherwise pass the path check + and publish its target. +3. In `docs/`: `npm ci`, `npm run lint`, `npm run format`, `npm run build`. + Any failure fails the workflow; no pull request is offered. + +### Job `publish` + +Permissions: `contents: write`, `pull-requests: write`. Copilot does not run, +and nothing installs, builds, or executes proposal content. + +`scripts/docs-proposal/publish.sh .docs-proposal`: + +1. If a closed or merged pull request already exists for + `docs/auto/`, leave it alone and exit successfully: a + maintainer's decision is never reopened or recreated. Pull requests from + forks that reuse the branch name are ignored. +2. If an open pull request records a different base in its body, fail: the + retry inspected another range, such as a manual dispatch without the base + of a multi-commit push, and must not narrow or close the proposal. The + error names the recorded base to rerun with. +3. Look up the remote branch with `git ls-remote --exit-code`. Only a + verified absent branch counts as new; any other lookup or fetch failure + fails the run, so an error never hides maintainer commits. If the remote + branch's head is not the generated proposal commit (its + parent is not the merge commit), a maintainer has committed there, for + example by applying a review suggestion. Warn and exit successfully + without pushing, closing, or deleting anything. +4. Empty diff: if a pull request is open, delete its branch with + `git push --force-with-lease=refs/heads/: --delete`, + so a maintainer push after the inspection makes the deletion fail. Only + after the deletion succeeds, comment on the pull request and then close + it; the deletion may already have closed it, and `gh pr close` skips its + `--comment` on a closed pull request. + Then exit successfully. Otherwise apply `proposal.patch` to a fresh + checkout of the merge commit. +5. Reject the proposal again if any changed path is outside `docs/guide/**` or + `docs/index.md`, or any changed entry is not a regular file. +6. Commit as `github-actions[bot]` on `docs/auto/`. If the remote + branch already exists and its tree equals the new tree, reuse the remote + commit and skip the push, so a retry resumes any later step that failed. + Otherwise push with a lease on the fetched head. +7. Find the originating pull request with + `gh api repos/{owner}/{repo}/commits//pulls`. Create the pull request, + or edit the existing one, with a body that links the merge commit and the + originating pull request, records the inspected base, includes `rationale.md` as a + fenced `text` block with its `@` mentions neutralized, so links, HTML, + issue references, and closing keywords in it stay inert, and states that + a human must verify the prose against the code before merging. +8. If `github-actions[bot]` already reviewed the pushed commit, stop: nothing + is re-posted. Otherwise `node scripts/docs-proposal/hunks.mjs review` + builds review comments and posts one `COMMENT` review through `gh api` + against the pushed commit. + +## Per-hunk review + +Each hunk in the pull request receives one review comment containing the +Copilot evidence for that hunk and a `suggestion` block that **reverts** the +hunk. Reviewers accept the prose by leaving it, and reject a hunk by applying +its suggestion. Suggestions are built from the diff, never from model output, +so the revert is exact: + +| Hunk kind | Comment anchor | Suggestion body | +| ------------------------- | --------------------------------------- | ---------------------------------------- | +| Changed or inserted lines | The new lines (right side) | The original lines (empty for insertion) | +| Pure deletion | Adjacent context line (prefer previous) | That context line plus deleted lines | +| New file | The new lines (right side) | None; the comment asks to delete it | +| Deleted file or binary | Listed in the review body | None | + +Hunks without evidence still receive a revert suggestion, marked "none +cited". Copilot output that names unknown hunk ids is ignored, and evidence +text is stripped of backticks, newlines, and `@` mentions before it is posted. + +GitHub only allows suggestions on lines inside the pull request diff, which is +why the edits are commits and suggestions are reverts. + +## Repository prerequisites + +Documented in `scripts/README.md`: + +- The organization Copilot policy "Allow use of Copilot CLI billed to the + organization" is enabled. +- Repository setting "Allow GitHub Actions to create and approve pull + requests" is enabled. +- Pull requests opened with `GITHUB_TOKEN` do not trigger other workflows. + The `validate` job runs the docs gates itself. The checks `main` requires + never start on a proposal, so maintainers close and reopen it, or push to + its branch, before merging. + +## Security + +- Copilot runs with a read-only `contents` token and an allowlist of git + shell tools; it has no network shell, no push, and no pull request + permissions. +- The tool allowlist is not the boundary. Copilot can write any file in the + checkout, and `propose.sh` then runs `npm` in `docs/`, so the `propose` job + is treated as able to run arbitrary code. Its token is read-only, neither it + nor `validate` restores or saves a dependency cache, and its artifact is + re-checked by `validate.sh` and `publish.sh`. +- Both jobs run on the `main` ref, and code running in a job can save cache + entries scoped to `main` without declaring a cache. `deploy-docs.yml`, the + only privileged workflow that restored a cache, therefore installs docs + dependencies without one. +- Input is code already merged to `main`, so no untrusted fork content reaches + the agent. +- The path allowlist is enforced by `validate.sh` and again by `publish.sh`, + not by the prompt. +- Generated Markdown is built only in the `validate` job, which has a + read-only token and no persisted credentials. +- Proposed prose must use fictional `example.com` values, per `CLAUDE.md`; + the reviewer verifies this before merging. + +## Testing + +- `node --test scripts/docs-proposal/hunks.test.mjs` covers hunk listing, + every row of the per-hunk table, unknown evidence ids, and the review payload. +- `scripts/docs-proposal/test.sh` stubs `gh` and `npm` and pushes to a + temporary bare repository to cover validation of empty, disallowed, symlinked, + and allowed proposals, the empty diff, fork pull requests that reuse the + proposal branch name, closing a stale proposal, a disallowed + path, first publish without a build, an unchanged retry, an updated proposal + whose review fails and is resumed without a second push, a retry or empty + rerun that leaves maintainer commits in place, an empty rerun that races a + maintainer push, a failed branch fetch on both the empty and push paths, + deleting an unmaintained branch before closing its proposal, retries over a different or + unrecorded range, a closed proposal, PR body rendering, and how + `propose.sh` resolves the base of a multi-commit push, renders the work + dir into prompts, and runs, or survives a failure of, the evidence half. +- `shellcheck` passes for every script. +- The `docs-proposal-scripts` job in `format.yml` runs all three on every + pull request. +- Copilot cannot run locally; the first `workflow_dispatch` run against a + recent merge SHA is the end-to-end test. + +## Out of scope + +- Crate READMEs, `docs/internal/**`, and `docs/superpowers/**`. +- Rolling or merged proposals across several merges. +- A GitHub App token to trigger regular CI on proposal pull requests. diff --git a/docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md b/docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md new file mode 100644 index 000000000..3efea3bdd --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-1231-creative-asset-host-allowlist-design.md @@ -0,0 +1,430 @@ +# Creative asset host allowlist + +**Issue:** [IABTechLab/trusted-server#1231](https://github.com/IABTechLab/trusted-server/issues/1231) + +**Date:** 2026-10-07 + +**Status:** Draft (design validated by an exploratory implementation; see the plan, `docs/superpowers/plans/2026-10-07-1231-creative-asset-host-allowlist.md`) + +**Related:** [#1234](https://github.com/IABTechLab/trusted-server/issues/1234) (separate click-rewriting switch). Both issues replace `to_abs`; see [Coordination with #1234](#coordination-with-1234). + +Line numbers below are against `main` at `7a0ecb4c`. + +## Problem + +The creative rewrite pass proxies every absolute HTTP(S) asset URL in a creative through `/first-party/proxy` unless its host matches `[rewrite] exclude_domains`. An operator cannot say "proxy only these hosts". + +That matters for more than egress control. A proxied fetch appends the visitor's EC ID to the upstream URL (`append_ec_id`, `crates/trusted-server-core/src/proxy.rs:1204`, called at `proxy.rs:793`), so today every third-party asset host that appears in a winning creative receives `ts-ec`. A deny list cannot bound that set, because creatives name new hosts all the time. + +`proxy.allowed_domains` does not solve it. It is the fetch-time SSRF control, shared with the Prebid external bundle fetch. With it set, the rewriter still turns off-list URLs into `/first-party/proxy` links, and the fetch then fails with `403`. The asset breaks instead of loading directly. + +A second, smaller defect sits in the same code: `Rewrite::is_excluded` compares raw config entries against a host the `url` crate has already lowercased, so an entry written with an uppercase letter never matches. + +## Goals + +- Add an asset host allowlist, `[rewrite] include_domains`. Empty (the default) keeps today's behavior. +- When non-empty, rewrite only asset URLs whose host matches the list. Every other asset URL keeps its original bytes. +- `exclude_domains` wins when a host matches both lists. +- Apply the allowlist to every asset handler in the pass, on all three entry points, including `srcset` candidates and CSS `url()`, `image-set()` strings and `@import`. +- Never apply the allowlist to anchors (``, ``). +- Apply the same policy at `/first-party/sign` for absolute and protocol-relative input, returning the same non-`403` error excluded hosts already get. +- Use one case-insensitive host matcher for both rewrite lists, which also fixes `exclude_domains` case sensitivity. +- Keep older binaries able to read config blobs written with the default. +- Fix the inline path's double rewrite of ``, so each candidate is proxied exactly once on every path. + +## Non-goals + +- Click rewriting policy or a click switch. That is #1234. +- Domain checks at `/first-party/proxy`, `/first-party/click` or `/first-party/proxy-rebuild`. Fetch-time SSRF stays with `proxy.allowed_domains`, and the click endpoints redirect any validly signed target by design (`proxy.rs:1898-1907`). +- Narrowing EC forwarding on click redirects (`handle_first_party_click`, `proxy.rs:1545`) or on proxy redirect hops. The allowlist only decides which asset URLs become proxy URLs. +- Cross-validating `include_domains` against `proxy.allowed_domains`. The docs recommend keeping the allowlist inside the proxy allowlist; a load-time warning can be a follow-up. +- Client-side changes. The render guard asks `/first-party/sign` and follows its answer. +- Environment-overlay support for the list. EdgeZero overlays only replace scalar leaves that already exist in the TOML, so the list is set in `trusted-server.toml` and published with `ts config push`, like `exclude_domains`. + +## Current behavior + +### The rewrite pass + +`rewrite_creative_html_impl` (`crates/trusted-server-core/src/creative.rs:1125`) is one lol_html pass. Its asset handlers live at `creative.rs:1180-1339`: + +| Handler | Lines | Per-URL helper | +| ------------------------------------------------------------------ | --------- | -------------------------------------- | +| `img` `src`, `data-src` | 1180-1192 | `proxy_if_abs` | +| `script[src]` | 1194-1201 | `proxied_attr_value` → `proxy_if_abs` | +| `link[href]` (stylesheet, preload, prefetch) and its `imagesrcset` | 1203-1225 | `proxied_attr_value`, `rewrite_srcset` | +| `video`, `audio`, `source` `src` | 1227-1234 | `proxied_attr_value` | +| `object[data]`, `embed[src]` | 1236-1251 | `proxied_attr_value` | +| `input[type=image]` | 1253-1267 | `proxied_attr_value` | +| SVG `image`/`use` `href`, `xlink:href` | 1269-1281 | `proxied_attr_value` | +| `[style]` attribute | 1294-1302 | `CssUrlRewriter::rewrite` | +| `