diff --git a/.github/workflows/aps-real-gam.yml b/.github/workflows/aps-real-gam.yml new file mode 100644 index 000000000..71348d0e5 --- /dev/null +++ b/.github/workflows/aps-real-gam.yml @@ -0,0 +1,106 @@ +name: "APS real-GAM attestation" +run-name: >- + APS real-GAM / ${{ inputs.evidence_id }} / ${{ inputs.release_id }} + +permissions: + contents: read + +on: + workflow_call: + inputs: + release_id: + description: Exact TSJS release id deployed to the protected test network + required: true + type: string + evidence_id: + description: Unique cutover evidence identifier + required: true + type: string + previous_artifact_id: + description: Immutable artifact identifier used for rollback + required: true + type: string + workflow_dispatch: + inputs: + release_id: + description: Exact TSJS release id deployed to the protected test network + required: true + type: string + evidence_id: + description: Unique cutover evidence identifier + required: true + type: string + previous_artifact_id: + description: Immutable artifact identifier used for rollback + required: true + type: string + +jobs: + attest: + name: Chromium, Firefox, and WebKit attestation + runs-on: ubuntu-latest + timeout-minutes: 90 + environment: aps-real-gam + env: + TS_REAL_GAM_PAGE_URL: ${{ secrets.TS_REAL_GAM_PAGE_URL }} + TS_REAL_GAM_AUTH_HEADER: ${{ secrets.TS_REAL_GAM_AUTH_HEADER }} + TS_REAL_GAM_EXPECTED_RELEASE_ID: ${{ vars.TS_REAL_GAM_EXPECTED_RELEASE_ID }} + steps: + - uses: actions/checkout@v4 + + - name: Validate protected inputs and release binding + env: + DISPATCH_EVIDENCE_ID: ${{ inputs.evidence_id }} + DISPATCH_PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }} + DISPATCH_RELEASE_ID: ${{ inputs.release_id }} + run: bash scripts/ci/aps-real-gam.sh validate-inputs + + - name: Read repository toolchain pins + id: toolchains + run: bash scripts/ci/read-toolchains.sh + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.toolchains.outputs.node }} + cache: npm + cache-dependency-path: crates/trusted-server-integration-tests/browser/package-lock.json + + - name: Install isolated browser-test dependencies + working-directory: crates/trusted-server-integration-tests/browser + run: npm ci + + - name: Install all required browsers + working-directory: crates/trusted-server-integration-tests/browser + run: npx playwright install --with-deps chromium firefox webkit + + - name: Run protected real-GAM contract + id: real-gam + run: bash scripts/ci/aps-real-gam.sh run + + - name: Write release attestation + if: always() + env: + EVIDENCE_ID: ${{ inputs.evidence_id }} + PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }} + RELEASE_ID: ${{ inputs.release_id }} + TEST_OUTCOME: ${{ steps.real-gam.outcome }} + run: node scripts/ci/aps-tsjs-evidence.mjs write-real-gam + + - name: Scrub browser evidence before upload + if: always() + env: + TEST_OUTCOME: ${{ steps.real-gam.outcome }} + run: node scripts/ci/aps-tsjs-evidence.mjs scrub-real-gam + + - name: Upload real-GAM evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: aps-real-gam-${{ github.run_id }} + path: | + crates/trusted-server-integration-tests/browser/real-gam-evidence/ + crates/trusted-server-integration-tests/browser/playwright-report/ + crates/trusted-server-integration-tests/browser/test-results/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 4973afe44..4761fd311 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,4 +1,6 @@ name: "Integration Tests" +run-name: >- + Integration Tests / ${{ inputs.evidence_id || format('PR {0}', github.event.pull_request.number) }} permissions: contents: read @@ -9,6 +11,23 @@ on: pull_request: types: [opened, synchronize, reopened] workflow_dispatch: + inputs: + base_sha: + description: Exact rc/202608 base commit for performance comparison + required: true + type: string + evidence_id: + description: Unique identifier used to bind this run to an evidence artifact + required: true + type: string + release_id: + description: Exact generated TSJS release id + required: true + type: string + previous_artifact_id: + description: Immutable artifact identifier used for rollback + required: true + type: string env: ORIGIN_PORT: 8888 @@ -19,8 +38,32 @@ env: CF_BUILD_ARTIFACT_PATH: /tmp/integration-test-artifacts/cloudflare/build jobs: + tsjs-performance-gate: + name: TSJS first-display performance evidence + if: >- + github.event_name == 'workflow_dispatch' && + (startsWith(inputs.evidence_id, 'aps-tsjs-preswitch-') || + startsWith(inputs.evidence_id, 'aps-tsjs-postswitch-')) + uses: ./.github/workflows/tsjs-performance-gate.yml + with: + base_sha: ${{ inputs.base_sha }} + evidence_id: ${{ inputs.evidence_id }} + mode: ${{ startsWith(inputs.evidence_id, 'aps-tsjs-postswitch-') && 'postswitch' || 'preswitch' }} + + real-gam-attestation: + name: protected real-GAM attestation + if: >- + github.event_name == 'workflow_dispatch' && + startsWith(inputs.evidence_id, 'aps-tsjs-cutover-') + uses: ./.github/workflows/aps-real-gam.yml + with: + evidence_id: ${{ inputs.evidence_id }} + release_id: ${{ inputs.release_id }} + previous_artifact_id: ${{ inputs.previous_artifact_id }} + prepare-artifacts: name: prepare integration artifacts + if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -57,6 +100,7 @@ jobs: integration-tests: name: integration tests + if: github.event_name == 'pull_request' needs: prepare-artifacts runs-on: ubuntu-latest timeout-minutes: 20 @@ -116,6 +160,7 @@ jobs: integration-tests-fastly-ec: name: integration tests (Fastly EC lifecycle) + if: github.event_name == 'pull_request' needs: prepare-artifacts runs-on: ubuntu-latest timeout-minutes: 15 @@ -152,10 +197,60 @@ jobs: VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml RUST_LOG: info + aps-runner-proxy: + name: APS runner proxy (${{ matrix.runtime }}) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + runtime: [axum, fastly, cloudflare, spin] + steps: + - uses: actions/checkout@v4 + + - name: Set up APS proxy test environment + id: shared-setup + uses: ./.github/actions/setup-integration-test-env + with: + origin-port: ${{ env.ORIGIN_PORT }} + install-viceroy: ${{ matrix.runtime == 'fastly' && 'true' || 'false' }} + build-wasm: "false" + build-axum: "false" + build-test-images: "false" + build-cloudflare: "false" + + - name: Add Cloudflare wasm target + if: matrix.runtime == 'cloudflare' + run: rustup target add wasm32-unknown-unknown + + - name: Set up Node.js for Wrangler + if: matrix.runtime == 'cloudflare' + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.shared-setup.outputs.node-version }} + + - name: Install Wrangler + if: matrix.runtime == 'cloudflare' + run: npm install -g wrangler@4.64.0 + + - name: Install Spin + if: matrix.runtime == 'spin' + uses: fermyon/actions/spin/setup@v1 + with: + version: "v4.0.2" + + - name: Run actual-adapter APS runner-proxy corpus + run: ./scripts/integration-tests-aps-runner-proxy.sh --runtime ${{ matrix.runtime }} + env: + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + RUST_LOG: info + browser-tests: name: browser integration tests + if: github.event_name == 'pull_request' needs: prepare-artifacts - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -175,6 +270,12 @@ jobs: name: integration-test-artifacts path: ${{ env.ARTIFACTS_DIR }} + - name: Generate browser Viceroy configs with APS enabled + run: ./scripts/generate-integration-viceroy-configs.sh + env: + INTEGRATION_ENABLE_AUCTION: "true" + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + - name: Load integration test Docker images run: docker load --input "$DOCKER_ARTIFACT_PATH" @@ -244,3 +345,143 @@ jobs: name: playwright-traces path: crates/trusted-server-integration-tests/browser/test-results/ retention-days: 7 + + browser-tests-aps-tsjs-conformance: + name: browser integration tests (APS/TSJS conformance) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Set up APS/TSJS browser test runtime + id: shared-setup + uses: ./.github/actions/setup-integration-test-env + with: + origin-port: ${{ env.ORIGIN_PORT }} + install-viceroy: "true" + build-wasm: "false" + build-axum: "false" + build-test-images: "false" + build-cloudflare: "false" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.shared-setup.outputs.node-version }} + cache: npm + cache-dependency-path: | + crates/trusted-server-integration-tests/browser/package-lock.json + crates/trusted-server-js/lib/package-lock.json + + - name: Run focused APS/TSJS three-browser conformance matrix + env: + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + TS_BROWSER_FRAMEWORKS: nextjs + TS_BROWSER_PROJECTS: chromium,firefox,webkit + run: ./scripts/integration-tests-browser.sh tests/shared/aps-renderer.spec.ts tests/shared/aps-puc-lifecycle.spec.ts tests/shared/tsjs-runtime.spec.ts tests/shared/tsjs-policy.spec.ts tests/shared/creative-sandbox.spec.ts tests/nextjs/gpt-diagnostics.spec.ts tests/nextjs/navigation.spec.ts --project=chromium --project=firefox --project=webkit + + - name: Upload APS/TSJS Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-aps-tsjs-conformance + path: crates/trusted-server-integration-tests/browser/playwright-report/ + retention-days: 7 + + cutover-suite: + name: exact APS/TSJS integration evidence + if: github.event_name == 'workflow_dispatch' && startsWith(inputs.evidence_id, 'aps-tsjs-cutover-') + runs-on: ubuntu-24.04 + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up the complete integration environment + id: shared-setup + uses: ./.github/actions/setup-integration-test-env + with: + origin-port: ${{ env.ORIGIN_PORT }} + install-viceroy: "true" + build-cloudflare: "true" + + - name: Set up pinned Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.shared-setup.outputs.node-version }} + cache: npm + cache-dependency-path: | + crates/trusted-server-js/lib/package-lock.json + crates/trusted-server-integration-tests/browser/package-lock.json + + - name: Install exact integration runtimes + run: npm install -g wrangler@4.64.0 + + - name: Install pinned Spin + uses: fermyon/actions/spin/setup@v1 + with: + version: "v4.0.2" + + - name: Generate integration Viceroy configs + run: ./scripts/generate-integration-viceroy-configs.sh + env: + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + + - name: Build and validate the exact TSJS release + env: + EXPECTED_RELEASE_ID: ${{ inputs.release_id }} + shell: bash + run: bash scripts/ci/aps-tsjs-cutover.sh build-release + + - name: Run route parity and the full adapter integration suite + env: + WASM_BINARY_PATH: ${{ github.workspace }}/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm + AXUM_BINARY_PATH: ${{ github.workspace }}/target/debug/trusted-server-axum + CLOUDFLARE_WRANGLER_DIR: ${{ github.workspace }}/crates/trusted-server-adapter-cloudflare + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml + RUST_LOG: info + shell: bash + run: bash scripts/ci/aps-tsjs-cutover.sh run-adapters + + - name: Install Chromium, Firefox, and WebKit + run: bash scripts/ci/aps-tsjs-cutover.sh install-browsers + + - name: Run the focused three-browser APS/TSJS matrix + env: + WASM_BINARY_PATH: ${{ github.workspace }}/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml + TEST_FRAMEWORK: nextjs + TS_BROWSER_PROJECTS: chromium,firefox,webkit + shell: bash + run: bash scripts/ci/aps-tsjs-cutover.sh run-browser + + - name: Run the APS runner-proxy corpus on every adapter + shell: bash + run: bash scripts/ci/aps-tsjs-cutover.sh run-proxies + env: + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + RUST_LOG: info + + - name: Write exact integration evidence manifest + env: + EVIDENCE_ID: ${{ inputs.evidence_id }} + RELEASE_ID: ${{ inputs.release_id }} + PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }} + run: node scripts/ci/aps-tsjs-evidence.mjs write-integration + + - name: Scrub all integration evidence before upload + env: + INTEGRATION_AUTHORIZATION: integration-test-proxy-secret + run: node scripts/ci/aps-tsjs-evidence.mjs scrub-integration + + - name: Upload exact integration evidence + uses: actions/upload-artifact@v4 + with: + name: aps-tsjs-cutover-${{ github.sha }} + path: target/aps-tsjs-cutover-evidence/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f1bbe27a..cc46491d6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,6 @@ name: "Run Tests" +run-name: >- + Run Tests / ${{ inputs.evidence_id || format('PR {0}', github.event.pull_request.number) }} permissions: contents: read @@ -7,6 +9,16 @@ on: push: branches: [main] pull_request: + workflow_dispatch: + inputs: + evidence_id: + description: Unique cutover evidence identifier + required: true + type: string + release_id: + description: Exact generated TSJS release id + required: true + type: string jobs: test-rust: @@ -248,6 +260,8 @@ jobs: working-directory: crates/trusted-server-js/lib steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Retrieve Node.js version id: node-version @@ -268,5 +282,98 @@ jobs: - name: Build bundle run: npm run build + - name: Build pure external Prebid artifact + run: npm run build:prebid-external + + - name: Verify release inventory + run: npm run test:release + + - name: Enforce bundle budgets + run: npm run check:bundle + + - name: Typecheck full TSJS package + run: npm run typecheck + + - name: Lint full TSJS package + run: npm run lint + + - name: Verify generated APS renderer contract + run: npm run check:aps-contract + + - name: Verify APS/TSJS gate scripts + working-directory: ${{ github.workspace }} + run: bash scripts/ci/aps-tsjs-quality.sh contracts + + - name: Enforce hard-cutover absence + run: npm run check:hard-cutover-absence + + - name: Run embedded APS renderer contract + run: node --test test/contract/aps-renderer-es5.test.mjs + + - name: Verify retired concept audit + run: npm run check:concept-audit + - name: Run unit tests run: npm test -- --run + + cutover-quality-evidence: + name: APS/TSJS cutover quality evidence + if: github.event_name == 'workflow_dispatch' + needs: + - test-rust + - test-axum + - test-cloudflare + - test-spin + - test-parity + - test-cli + - test-typescript + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read repository toolchain pins + id: toolchains + shell: bash + run: bash scripts/ci/read-toolchains.sh + + - name: Set up pinned Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.toolchains.outputs.node }} + cache: npm + cache-dependency-path: | + crates/trusted-server-js/lib/package-lock.json + docs/package-lock.json + + - name: Set up pinned Rust quality targets + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.toolchains.outputs.rust }} + components: clippy, rustfmt + target: wasm32-wasip1,wasm32-unknown-unknown + cache-shared-key: cargo-${{ runner.os }}-aps-tsjs-quality + + - name: Install exact JavaScript dependencies + run: bash scripts/ci/aps-tsjs-quality.sh install + + - name: Validate release id and run final quality gates + env: + EXPECTED_RELEASE_ID: ${{ inputs.release_id }} + shell: bash + run: bash scripts/ci/aps-tsjs-quality.sh run + + - name: Write exact quality evidence manifest + env: + EVIDENCE_ID: ${{ inputs.evidence_id }} + RELEASE_ID: ${{ inputs.release_id }} + run: node scripts/ci/aps-tsjs-evidence.mjs write-quality + + - name: Upload exact quality evidence + uses: actions/upload-artifact@v4 + with: + name: aps-tsjs-quality-${{ github.run_id }} + path: target/aps-tsjs-quality-evidence/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/tsjs-performance-gate.yml b/.github/workflows/tsjs-performance-gate.yml new file mode 100644 index 000000000..f2e9535ec --- /dev/null +++ b/.github/workflows/tsjs-performance-gate.yml @@ -0,0 +1,137 @@ +name: "TSJS Performance Gate" +run-name: >- + TSJS Performance Gate / ${{ inputs.evidence_id || format('tsjs-pr-{0}', github.run_id) }} / ${{ inputs.mode || 'pull-request' }} + +permissions: + contents: read + +on: + pull_request: + paths: + - ".github/workflows/tsjs-performance-gate.yml" + - ".tool-versions" + - "Cargo.toml" + - "Cargo.lock" + - "crates/trusted-server-core/**" + - "crates/trusted-server-core/src/auction/**" + - "crates/trusted-server-core/src/html_processor.rs" + - "crates/trusted-server-core/src/publisher.rs" + - "crates/trusted-server-core/src/tsjs.rs" + - "crates/trusted-server-integration-tests/Cargo.toml" + - "crates/trusted-server-integration-tests/browser/**" + - "crates/trusted-server-integration-tests/src/bin/generate-tsjs-fixture.rs" + - "crates/trusted-server-js/**" + - "scripts/ci/read-toolchains.sh" + - "scripts/ci/tsjs-performance.sh" + - "scripts/validate-tsjs-performance-evidence.mjs" + workflow_dispatch: + inputs: + base_sha: + description: Exact pull-request base commit reachable from origin/rc/202608 + required: true + type: string + evidence_id: + description: Unique identifier bound to the uploaded evidence + required: true + type: string + mode: + description: Cutover side measured by this run + required: true + type: choice + options: + - preswitch + - postswitch + workflow_call: + inputs: + base_sha: + description: Exact pull-request base commit reachable from origin/rc/202608 + required: true + type: string + evidence_id: + description: Unique identifier bound to the uploaded evidence + required: true + type: string + mode: + description: Cutover side measured by this run (preswitch or postswitch) + required: true + type: string + +env: + TSJS_PERF_MACHINE_CLASS: github-hosted:ubuntu-24.04 + TSJS_PERF_RUNNER_IMAGE: ubuntu-24.04 + TSJS_PERF_WORKFLOW_NAME: TSJS Performance Gate + TSJS_PERF_WORKFLOW_FILE: .github/workflows/tsjs-performance-gate.yml + TSJS_PERF_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + TSJS_PERF_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || inputs.base_sha }} + +jobs: + measure: + name: measure ${{ inputs.mode || 'pull-request' }} (${{ inputs.evidence_id || format('tsjs-pr-{0}', github.run_id) }}) + runs-on: ubuntu-24.04 + timeout-minutes: 50 + env: + TSJS_EVIDENCE_ID: ${{ inputs.evidence_id || format('tsjs-pr-{0}', github.run_id) }} + TSJS_PERF_MODE: ${{ inputs.mode || 'pull-request' }} + TSJS_PERF_ARTIFACT_NAME: tsjs-performance-${{ inputs.evidence_id || format('tsjs-pr-{0}', github.run_id) }} + TSJS_PERF_OUTPUT: crates/trusted-server-integration-tests/browser/test-results/tsjs-performance-${{ inputs.mode || 'pull-request' }}.json + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Validate immutable measurement inputs + shell: bash + run: bash scripts/ci/tsjs-performance.sh validate-inputs + + - name: Read repository toolchain pins + id: toolchains + shell: bash + run: bash scripts/ci/read-toolchains.sh + + - name: Set up pinned Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.toolchains.outputs.node }} + cache: npm + cache-dependency-path: | + crates/trusted-server-js/lib/package-lock.json + crates/trusted-server-integration-tests/browser/package-lock.json + + - name: Set up pinned Rust for the generated controller fixture + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.toolchains.outputs.rust }} + cache-shared-key: cargo-${{ runner.os }}-tsjs-performance + + - name: Verify installed toolchain pins + shell: bash + run: bash scripts/ci/tsjs-performance.sh verify-toolchains + + - name: Build the real TSJS artifacts once + run: bash scripts/ci/tsjs-performance.sh build-candidate + + - name: Build the exact rc baseline artifacts + shell: bash + run: bash scripts/ci/tsjs-performance.sh build-baseline + + - name: Install the lockfile-pinned Chromium setup + run: bash scripts/ci/tsjs-performance.sh install-browser + + - name: Run the complete TSJS performance sample exactly once + env: + CI: "true" + run: bash scripts/ci/tsjs-performance.sh run-sample + + - name: Validate the generated evidence before upload + if: always() + run: bash scripts/ci/tsjs-performance.sh validate-evidence + + - name: Upload immutable TSJS performance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ env.TSJS_PERF_ARTIFACT_NAME }} + path: ${{ env.TSJS_PERF_OUTPUT }} + if-no-files-found: error + retention-days: 30 diff --git a/.tool-versions b/.tool-versions index 758146800..5330e3de6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,5 +1,5 @@ fastly 15.1.0 rust 1.95.0 nodejs 24.12.0 -viceroy 0.17.0 +viceroy 0.19.0 wasmtime 44.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index 546a3bf52..dd1c4041f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Supporting files: `edgezero.toml`, `fastly.toml`, | WASM target | `wasm32-wasip1` | | Node | 24.12.0 (from `.tool-versions`) | | Fastly CLI | 15.1.0 (from `.tool-versions`) | -| Viceroy | 0.17.0 (from `.tool-versions`) | +| Viceroy | 0.19.0 (from `.tool-versions`) | | Wasmtime | 44.0.1 (from `.tool-versions`) | --- @@ -139,7 +139,7 @@ cd crates/trusted-server-js/lib && node build-all.mjs ### Install prerequisites ```bash -cargo install viceroy --version 0.17.0 --locked --force +cargo install viceroy --version 0.19.0 --locked --force ``` --- diff --git a/Cargo.lock b/Cargo.lock index ab29df9fa..ce4593339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5402,6 +5402,7 @@ dependencies = [ "trusted-server-core", "url", "urlencoding", + "web-time", ] [[package]] @@ -5537,6 +5538,7 @@ dependencies = [ "reqwest 0.12.28", "scraper", "serde_json", + "tempfile", "testcontainers", "tokio", "toml", @@ -5554,6 +5556,8 @@ version = "0.1.0" dependencies = [ "build-print", "hex", + "serde", + "serde_json", "sha2 0.10.9", "which", ] diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 15b6ee59d..ab9a72942 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -18,8 +18,13 @@ path = "src/lib.rs" name = "trusted-server-axum" path = "src/main.rs" +[features] +default = [] +aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"] + [dependencies] async-trait = { workspace = true } +axum = { workspace = true } edgezero-adapter-axum = { workspace = true, features = ["axum"] } edgezero-core = { workspace = true } error-stack = { workspace = true } @@ -31,7 +36,6 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "ti trusted-server-core = { workspace = true } [dev-dependencies] -axum = { workspace = true } base64 = { workspace = true } temp-env = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 1deddcab5..03ecf5704 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -26,8 +26,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, buffer_publisher_response_async, - handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_PATH, buffer_publisher_response_async, handle_page_bids, + handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -36,6 +36,7 @@ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::{ default_config_key, default_config_store_name, get_settings_from_config_store, }; +use trusted_server_core::trace_cookie::handle_trace_mode; use trusted_server_core::platform::RuntimeServices; @@ -93,6 +94,91 @@ fn build_state_with_settings( })) } +async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { + if !state.registry.has_reserved_path(req.uri().path()) { + return None; + } + let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default()); + let services = build_runtime_services(&ctx); + Some( + state + .registry + .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) + .await + .expect("reserved path should have a hard-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + +#[derive(Clone)] +/// Dispatcher that owns one startup-built registry for hard-cutover route families. +pub struct ReservedApsDispatcher { + state: Arc, +} + +impl ReservedApsDispatcher { + /// Build the dispatcher from the adapter's startup settings. + /// + /// # Errors + /// + /// Returns an error when settings, the orchestrator, or the integration + /// registry cannot be initialized. + pub fn from_startup_settings() -> Result> { + // The outer Axum router cannot share EdgeZero's private application + // state, so the dev adapter builds one additional immutable startup + // snapshot for only the two reserved APS browser resources. Production + // adapters do not take this native development path. + Ok(Self { + state: build_state()?, + }) + } + + /// Build the dispatcher from explicit settings. + /// + /// # Errors + /// + /// Returns an error when the orchestrator or integration registry cannot be + /// initialized from `settings`. + pub fn from_settings(settings: Settings) -> Result> { + Ok(Self { + state: build_state_with_settings(settings)?, + }) + } + + /// Dispatch a request when it belongs to the reserved APS family. + pub async fn dispatch(&self, req: Request) -> Option { + dispatch_reserved_for_state(&self.state, req).await + } +} + +/// Dispatch a reserved APS request using explicit settings. +/// +/// # Errors +/// +/// Returns an error when the dispatcher cannot be initialized. +pub async fn dispatch_reserved_with_settings( + settings: Settings, + req: Request, +) -> Result, Report> { + Ok(ReservedApsDispatcher::from_settings(settings)? + .dispatch(req) + .await) +} + +/// Dispatch a reserved APS request using startup settings. +/// +/// # Errors +/// +/// Returns an error when startup settings or the dispatcher +/// cannot be initialized. +pub async fn dispatch_reserved( + req: Request, +) -> Result, Report> { + Ok(ReservedApsDispatcher::from_startup_settings()? + .dispatch(req) + .await) +} + // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -200,7 +286,7 @@ async fn dispatch_fallback( let path = req.uri().path().to_string(); let method = req.method().clone(); - if method == Method::GET && path.starts_with("/static/tsjs=") { + if path.starts_with("/static/tsjs=") { return handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SMaxageFallback); } @@ -283,6 +369,7 @@ enum NamedRouteHandler { /// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never /// reach the publisher fallback (which would leak admin credentials). LegacyAdminDenied, + TraceMode, Auction, PageBids, FirstPartyProxy, @@ -307,7 +394,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 16] { +fn named_routes() -> [NamedRoute; 17] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -368,6 +455,11 @@ fn named_routes() -> [NamedRoute; 16] { primary_methods: LEGACY_ADMIN_DENY_METHODS, handler: NamedRouteHandler::LegacyAdminDenied, }, + NamedRoute { + path: "/_ts/trace", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TraceMode, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -380,13 +472,12 @@ fn named_routes() -> [NamedRoute; 16] { primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, }, - // Deprecated double-underscore alias, kept so tsjs bundles served before - // the `/_ts/page-bids` rename keep getting ads on SPA navigations until - // they age out of browser caches. See `PAGE_BIDS_LEGACY_PATH`. + // This removed route must never reach the publisher fallback, which + // would make the hard cutover depend on the origin response. NamedRoute { - path: PAGE_BIDS_LEGACY_PATH, - primary_methods: &[Method::GET, Method::OPTIONS], - handler: NamedRouteHandler::PageBids, + path: "/__ts/page-bids", + primary_methods: LEGACY_ADMIN_DENY_METHODS, + handler: NamedRouteHandler::LegacyAdminDenied, }, NamedRoute { path: "/first-party/proxy", @@ -459,6 +550,9 @@ fn named_route_handler( handle_admin_eids_lookup(&partner_registry, &req) } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), + NamedRouteHandler::TraceMode => { + handle_trace_mode(&state.settings, req.uri().query()) + } NamedRouteHandler::Auction => { // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 960982176..2899a22ac 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,9 +1,14 @@ -use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; +use edgezero_adapter_axum::dev_server::AxumDevServerConfig; use edgezero_core::app::Hooks as _; use trusted_server_adapter_axum::app::TrustedServerApp; +#[tokio::main] #[allow(clippy::print_stderr)] -fn main() { +async fn main() { + use axum::Router; + use axum::routing::any; + use edgezero_adapter_axum::service::EdgeZeroAxumService; + if let Err(e) = simple_logger::SimpleLogger::new().init() { eprintln!("warning: logger init failed: {e}"); } @@ -19,11 +24,65 @@ fn main() { None => AxumDevServerConfig::default(), }; + let dispatcher = + trusted_server_adapter_axum::app::ReservedApsDispatcher::from_startup_settings() + .expect("should build the reserved APS dispatcher"); + let reserved = any(move |request: axum::http::Request| { + let dispatcher = dispatcher.clone(); + async move { + // The core reserved dispatcher is intentionally `?Send`, while this + // native-only development adapter runs on Tokio's multi-threaded + // executor. Keep that bridge explicit: a runner request can occupy + // this blocking-pool thread for its bounded five-second budget. + let response = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async move { + let request = + match edgezero_adapter_axum::request::into_core_request(request).await { + Ok(request) => request, + Err(error) => { + log::warn!("reserved APS request conversion failed: {error:?}"); + return Err(axum::http::StatusCode::BAD_REQUEST); + } + }; + match dispatcher.dispatch(request).await { + Some(response) => Ok(response), + None => { + log::error!( + "reserved APS entry route reached a request outside its family" + ); + Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + } + } + }) + }); + match response { + Ok(response) => edgezero_adapter_axum::response::into_axum_response(response), + Err(status) => axum::response::IntoResponse::into_response(status), + } + } + }); + let app = Router::new() + .route("/integrations/aps", reserved.clone()) + .route("/integrations/aps/{*rest}", reserved) + .fallback_service(EdgeZeroAxumService::new(TrustedServerApp::routes())); + let listener = tokio::net::TcpListener::bind(config.addr) + .await + .expect("should bind the configured address"); log::info!("Listening on http://{}", config.addr); - let router = TrustedServerApp::routes(); - if let Err(err) = AxumDevServer::with_config(router, config).run() { - log::error!("trusted-server-adapter-axum failed: {err}"); - std::process::exit(1); + let server = axum::serve(listener, app); + let result = if config.enable_ctrl_c { + server + .with_graceful_shutdown(async { + if let Err(error) = tokio::signal::ctrl_c().await { + log::error!("failed to install Ctrl-C handler: {error}"); + } + }) + .await + } else { + server.await + }; + if let Err(error) = result { + log::error!("trusted-server-adapter-axum failed: {error}"); } } diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 7dcdd53d8..24c5c360e 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -12,6 +12,7 @@ use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, + ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1, RuntimeServices, StoreId, StoreName, }; @@ -277,6 +278,9 @@ pub struct AxumPlatformHttpClient { client: reqwest::Client, } +#[cfg(feature = "aps-runner-proxy-integration-test")] +const APS_RUNNER_PROXY_TEST_ENDPOINT_ENV: &str = "TS_APS_RUNNER_PROXY_TEST_ENDPOINT"; + impl AxumPlatformHttpClient { /// Create a new client with sensible dev-server timeouts. /// @@ -299,6 +303,38 @@ impl AxumPlatformHttpClient { } } + #[cfg(feature = "aps-runner-proxy-integration-test")] + fn aps_runner_proxy_test_transport_uri( + logical_uri: &str, + ) -> Result, Report> { + use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL; + + if logical_uri != APS_RUNNER_UPSTREAM_URL { + return Ok(None); + } + let endpoint = std::env::var(APS_RUNNER_PROXY_TEST_ENDPOINT_ENV).map_err(|_| { + Report::new(PlatformError::HttpClient).attach( + "APS runner proxy integration artifact requires its loopback fixture endpoint", + ) + })?; + let parsed = reqwest::Url::parse(&endpoint) + .change_context(PlatformError::HttpClient) + .attach("invalid APS runner proxy integration fixture endpoint")?; + if parsed.scheme() != "http" + || !matches!(parsed.host_str(), Some("127.0.0.1" | "::1")) + || parsed.port().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(Report::new(PlatformError::HttpClient).attach( + "APS runner proxy integration fixture endpoint must be an explicit loopback HTTP URL", + )); + } + Ok(Some(parsed.into())) + } + /// Drain `body` to a `Vec`. /// /// For `Body::Stream` this awaits every chunk in the current async context @@ -372,6 +408,127 @@ impl AxumPlatformHttpClient { Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) } + + fn raw_header_evidence( + headers: &reqwest::header::HeaderMap, + name: reqwest::header::HeaderName, + ) -> ProxyHeaderEvidenceV1 { + ProxyHeaderEvidenceV1::Occurrences( + headers + .get_all(name) + .iter() + .map(|value| value.as_bytes().to_vec()) + .collect(), + ) + } + + fn canonical_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option { + let ProxyHeaderEvidenceV1::Occurrences(values) = evidence else { + return None; + }; + let [value] = values.as_slice() else { + return None; + }; + if value.is_empty() + || !value.iter().all(u8::is_ascii_digit) + || (value.len() > 1 && value[0] == b'0') + { + return None; + } + std::str::from_utf8(value).ok()?.parse().ok() + } + + async fn execute_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + if request.image_optimizer.is_some() || request.stream_response { + return Err(Report::new(PlatformError::HttpClient) + .attach("unsupported option on Axum raw proxy request")); + } + + let logical_uri = request.request.uri().to_string(); + #[cfg(feature = "aps-runner-proxy-integration-test")] + let transport_uri = Self::aps_runner_proxy_test_transport_uri(&logical_uri)?; + #[cfg(feature = "aps-runner-proxy-integration-test")] + let uri = transport_uri.as_deref().unwrap_or(&logical_uri); + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] + let uri = logical_uri.as_str(); + let method = reqwest::Method::from_bytes(request.request.method().as_str().as_bytes()) + .change_context(PlatformError::HttpClient)?; + let mut builder = self.client.request(method, uri); + for (name, value) in request.request.headers() { + builder = builder.header(name.as_str(), value.as_bytes()); + } + #[cfg(feature = "aps-runner-proxy-integration-test")] + if transport_uri.is_some() { + builder = builder + .header(reqwest::header::HOST, "client.aps.amazon-adsystem.com") + .header("x-ts-aps-logical-url", logical_uri.as_str()); + } + let (_, request_body) = request.request.into_parts(); + let request_body = Self::buffer_body(request_body).await?; + if !request_body.is_empty() { + builder = builder.body(request_body); + } + + tokio::time::timeout(policy.total_timeout, async move { + let mut response = tokio::time::timeout(policy.first_byte_timeout, builder.send()) + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy first-byte deadline exceeded") + })? + .change_context(PlatformError::HttpClient)?; + let evidence = ProxyResponseEvidenceV1 { + status: response.status().as_u16(), + content_type: Self::raw_header_evidence( + response.headers(), + reqwest::header::CONTENT_TYPE, + ), + content_encoding: Self::raw_header_evidence( + response.headers(), + reqwest::header::CONTENT_ENCODING, + ), + content_length: Self::raw_header_evidence( + response.headers(), + reqwest::header::CONTENT_LENGTH, + ), + }; + if Self::canonical_declared_length(&evidence.content_length) + .is_some_and(|length| length > policy.max_response_bytes) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy declared body exceeds configured cap")); + } + + let mut body = Vec::new(); + loop { + let chunk = tokio::time::timeout(policy.blocking_read_timeout, response.chunk()) + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy blocking-read deadline exceeded") + })? + .change_context(PlatformError::HttpClient)?; + let Some(chunk) = chunk else { break }; + let next_len = body.len().checked_add(chunk.len()).ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow") + })?; + if next_len > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy body exceeds configured cap")); + } + body.extend_from_slice(&chunk); + } + Ok(RawProxyResponseV1 { evidence, body }) + }) + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient).attach("raw proxy total deadline exceeded") + })? + } } impl Default for AxumPlatformHttpClient { @@ -389,6 +546,14 @@ impl PlatformHttpClient for AxumPlatformHttpClient { self.execute(request).await } + async fn send_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + self.execute_raw_proxy_v1(request, policy).await + } + async fn send_async( &self, request: PlatformHttpRequest, @@ -790,6 +955,223 @@ mod tests { ); } + fn raw_proxy_request(url: &str) -> PlatformHttpRequest { + PlatformHttpRequest::new( + edgezero_core::http::request_builder() + .uri(url) + .header(header::ACCEPT_ENCODING, "identity") + .body(EdgeBody::empty()) + .expect("should build raw proxy request"), + "test_backend", + ) + } + + fn raw_proxy_policy(timeout: Duration, max_response_bytes: usize) -> RawProxyPolicyV1 { + RawProxyPolicyV1 { + total_timeout: timeout, + first_byte_timeout: timeout, + blocking_read_timeout: timeout, + max_response_bytes, + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_preserves_header_occurrences_and_exact_bytes() { + let url = serve_raw_response( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/javascript\r\n\ + Content-Encoding: identity\r\n\ + Content-Length: 2\r\n\ + Set-Cookie: must-not-enter-core=1\r\n\ + \r\n\ + ok", + ) + .await; + + let response = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&url), + raw_proxy_policy(Duration::from_secs(1), 2), + ) + .await + .expect("valid raw response should be collected"); + + assert_eq!(response.evidence.status, 200); + assert_eq!( + response.evidence.content_type, + ProxyHeaderEvidenceV1::one("application/javascript") + ); + assert_eq!( + response.evidence.content_encoding, + ProxyHeaderEvidenceV1::one("identity") + ); + assert_eq!( + response.evidence.content_length, + ProxyHeaderEvidenceV1::one("2") + ); + assert_eq!(response.body, b"ok"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_preserves_duplicate_security_headers_for_core_rejection() { + let url = serve_raw_response( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/javascript\r\n\ + Content-Type: text/javascript\r\n\ + Content-Length: 2\r\n\ + \r\n\ + ok", + ) + .await; + + let response = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&url), + raw_proxy_policy(Duration::from_secs(1), 2), + ) + .await + .expect("transport should preserve duplicate evidence"); + + assert_eq!( + response.evidence.content_type, + ProxyHeaderEvidenceV1::Occurrences(vec![ + b"application/javascript".to_vec(), + b"text/javascript".to_vec(), + ]) + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_cancels_on_body_overflow_and_total_deadline() { + let overflow_url = serve_raw_response( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/javascript\r\n\ + Transfer-Encoding: chunked\r\n\ + \r\n\ + 2\r\n\ + ok\r\n\ + 0\r\n\ + \r\n", + ) + .await; + let overflow = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&overflow_url), + raw_proxy_policy(Duration::from_secs(1), 1), + ) + .await; + assert!(overflow.is_err(), "one byte over the cap must fail"); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind deadline test server"); + let addr = listener.local_addr().expect("should read local address"); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("should accept request"); + let mut request = [0; 1024]; + let _ = stream + .read(&mut request) + .await + .expect("should read request"); + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: 2\r\n\r\nok", + ) + .await; + }); + let deadline = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&format!("http://{addr}/")), + raw_proxy_policy(Duration::from_millis(20), 2), + ) + .await; + assert!(deadline.is_err(), "total deadline must cover first byte"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_enforces_first_byte_and_blocking_read_deadlines() { + let first_byte_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind first-byte deadline server"); + let first_byte_addr = first_byte_listener + .local_addr() + .expect("should read first-byte server address"); + tokio::spawn(async move { + let (mut stream, _) = first_byte_listener + .accept() + .await + .expect("should accept first-byte request"); + let mut request = [0; 1024]; + let _ = stream + .read(&mut request) + .await + .expect("should read first-byte request"); + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: 2\r\n\r\nok", + ) + .await; + }); + let first_byte = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&format!("http://{first_byte_addr}/")), + RawProxyPolicyV1 { + total_timeout: Duration::from_secs(1), + first_byte_timeout: Duration::from_millis(20), + blocking_read_timeout: Duration::from_secs(1), + max_response_bytes: 2, + }, + ) + .await; + assert!( + first_byte.is_err(), + "response headers after the first-byte deadline must fail" + ); + + let body_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind blocking-read deadline server"); + let body_addr = body_listener + .local_addr() + .expect("should read blocking-read server address"); + tokio::spawn(async move { + let (mut stream, _) = body_listener + .accept() + .await + .expect("should accept blocking-read request"); + let mut request = [0; 1024]; + let _ = stream + .read(&mut request) + .await + .expect("should read blocking-read request"); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nTransfer-Encoding: chunked\r\n\r\n1\r\no\r\n", + ) + .await + .expect("should write first body chunk"); + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = stream.write_all(b"1\r\nk\r\n0\r\n\r\n").await; + }); + let blocking_read = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&format!("http://{body_addr}/")), + RawProxyPolicyV1 { + total_timeout: Duration::from_secs(1), + first_byte_timeout: Duration::from_secs(1), + blocking_read_timeout: Duration::from_millis(20), + max_response_bytes: 2, + }, + ) + .await; + assert!( + blocking_read.is_err(), + "a body read blocked past its deadline must fail" + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn select_attributes_failed_backend_name() { // Bind and immediately drop a listener so the port is closed — the diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 6812b7421..458ed9217 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -26,6 +26,11 @@ fn test_settings() -> trusted_server_core::settings::Settings { username = "admin" password = "admin-pass" + [[handlers]] + path = "^/integrations/aps" + username = "aps-user" + password = "aps-pass" + [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -34,6 +39,20 @@ fn test_settings() -> trusted_server_core::settings::Settings { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [auction] + enabled = true + timeout_ms = 1000 + + [auction.providers.aps-main] + protocol = "openrtb-2.6" + profile = "aps" + endpoint = "https://aps.example/e/pb/bid" + routing = "all_eligible" + + [auction.providers.aps-main.profile_config] + account_id = "route-test-aps-account" + allow_script_creatives = true "#, ) .expect("should parse route test settings") @@ -44,6 +63,18 @@ fn test_router() -> edgezero_core::router::RouterService { .expect("should build router from test settings") } +async fn route_reserved(request: Request) -> axum::http::Response { + let request = edgezero_adapter_axum::request::into_core_request(request) + .await + .expect("should convert reserved APS request"); + let response = + trusted_server_adapter_axum::app::dispatch_reserved_with_settings(test_settings(), request) + .await + .expect("should build APS dispatcher") + .expect("APS family should be reserved"); + edgezero_adapter_axum::response::into_axum_response(response) +} + fn make_service() -> EdgeZeroAxumService { EdgeZeroAxumService::new(test_router()) } @@ -65,7 +96,7 @@ fn assert_route_registered(method: &str, path: &str) { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn aps_profile_serves_renderer_through_adapter_fallback() { +async fn aps_profile_serves_canonical_renderer_through_reserved_dispatcher() { let mut settings = test_settings(); settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), @@ -81,22 +112,19 @@ async fn aps_profile_serves_renderer_through_adapter_fallback() { .expect("should parse APS profile config"), }, ); - let router = TrustedServerApp::routes_with_settings(settings) - .expect("should build router with APS profile"); - let mut service = EdgeZeroAxumService::new(router); let request = Request::builder() .method("GET") - .uri("/integrations/aps/renderer") + .uri("/integrations/aps/renderer/v2") .body(AxumBody::empty()) .expect("should build APS renderer request"); - - let response = service - .ready() + let request = edgezero_adapter_axum::request::into_core_request(request) .await - .expect("should be ready") - .call(request) - .await - .expect("should serve APS renderer"); + .expect("should convert APS renderer request"); + let response = + trusted_server_adapter_axum::app::dispatch_reserved_with_settings(settings, request) + .await + .expect("should build reserved APS dispatcher") + .expect("APS renderer should be reserved"); assert_eq!(response.status().as_u16(), 200); } @@ -118,16 +146,9 @@ fn all_explicit_routes_are_registered() { ("POST", "/admin/keys/rotate"), ("POST", "/admin/keys/deactivate"), ("POST", "/auction"), - // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both - // paths are spelled out as literals rather than referencing - // `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH` so this test pins the - // actual URL the tsjs client fetches — asserting a const against itself - // would still pass if the const's value changed out from under the - // client. + // Pin the canonical literal fetched by the hard-cutover client. ("GET", "/_ts/page-bids"), ("OPTIONS", "/_ts/page-bids"), - ("GET", "/__ts/page-bids"), - ("OPTIONS", "/__ts/page-bids"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), ("GET", "/first-party/sign"), @@ -139,6 +160,9 @@ fn all_explicit_routes_are_registered() { for (method, path) in expected { assert_route_registered(method, path); } + for method in LEGACY_ADMIN_DENY_METHODS { + assert_route_registered(method, "/__ts/page-bids"); + } } /// Verify the legacy non-`/_ts` admin aliases ARE registered — to the local @@ -250,39 +274,133 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tsjs_route_matching_hash_uses_s_maxage_fallback() { - let mut svc = make_service(); - let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]); - let req = Request::builder() - .method("GET") - .uri(src) - .body(AxumBody::empty()) - .expect("should build request"); +async fn tsjs_wrong_methods_are_local_no_store_404s() { + for method in ["HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] { + let req = Request::builder() + .method(method) + .uri(format!( + "/static/tsjs=tsjs-unified.min.js?v={}", + "0".repeat(64) + )) + .body(AxumBody::empty()) + .expect("should build wrong-method TSJS request"); + let response = make_service() + .oneshot(req) + .await + .expect("should reject TSJS request locally"); - let resp = svc - .ready() - .await - .expect("should be ready") - .call(req) - .await - .expect("should respond"); + assert_eq!(response.status().as_u16(), 404, "method {method}"); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "method {method}" + ); + assert!( + !response.headers().contains_key("location"), + "method {method}" + ); + } +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn aps_cutover_renderer_and_family_failures_are_local() { + let renderer = Request::builder() + .method("GET") + .uri("/integrations/aps/renderer/v2") + .header("authorization", "Bearer must-not-reach-publisher") + .body(AxumBody::empty()) + .expect("should build APS renderer request"); + let response = route_reserved(renderer).await; + assert_eq!(response.status().as_u16(), 200); assert_eq!( - resp.status().as_u16(), - 200, - "matching TSJS hash should serve OK" + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()), + Some("text/html; charset=utf-8") ); assert_eq!( - resp.headers() + response + .headers() .get("cache-control") .and_then(|value| value.to_str().ok()), - Some("public, max-age=31536000, s-maxage=31536000, immutable"), - "Axum adapter should render the portable s-maxage fallback" - ); - assert!( - resp.headers().get("surrogate-control").is_none(), - "s-maxage fallback must not emit Fastly Surrogate-Control" + Some("public, max-age=31536000, immutable") ); + assert!(response.headers().get("x-frame-options").is_none()); + let body = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("renderer body should be bounded"); + let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); + assert!(body.contains("TS APS Bootstrap Ready")); + assert!(body.contains("TS APS Bootstrap Configure")); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(body.contains("data:text/html;charset=utf-8,")); + assert!(!body.contains("client.aps.amazon-adsystem.com")); + + for (method, path, expected) in [ + ("POST", "/integrations/aps/runner.js", 405), + ("TRACE", "/integrations/aps/renderer/v2", 405), + ("CONNECT", "/integrations/aps/renderer/v2", 405), + ("PROPFIND", "/integrations/aps/renderer/v2", 405), + ("GET", "/integrations/aps/renderer", 404), + ("GET", "/integrations/aps/renderer/v1", 404), + ("GET", "/integrations/aps/runner/v1.js", 404), + ("GET", "/integrations/aps", 404), + ] { + let request = Request::builder() + .method(method) + .uri(path) + .header("authorization", "Bearer must-not-reach-publisher") + .body(AxumBody::empty()) + .expect("should build APS family request"); + let response = route_reserved(request).await; + assert_eq!(response.status().as_u16(), expected, "{method} {path}"); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "{method} {path}" + ); + assert!( + response.headers().get("x-geo-info-available").is_none(), + "{method} {path} must not receive generic finalizer headers" + ); + if expected == 405 { + assert_eq!( + response + .headers() + .get("allow") + .and_then(|v| v.to_str().ok()), + Some("GET") + ); + assert_eq!(response.headers().len(), 2, "{method} {path}"); + } else { + assert_eq!(response.headers().len(), 1, "{method} {path}"); + } + let body = axum::body::to_bytes(response.into_body(), 1) + .await + .expect("local APS failure body should be empty"); + assert!(body.is_empty(), "{method} {path}"); + } + + let protected_control = Request::builder() + .method("GET") + .uri("/integrations/apsx") + .body(AxumBody::empty()) + .expect("should build protected non-APS boundary request"); + let response = make_service() + .ready() + .await + .expect("should be ready") + .call(protected_control) + .await + .expect("should auth-gate non-APS boundary request"); + assert_eq!(response.status().as_u16(), 401); } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 097844012..e4e5e4ca7 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -19,6 +19,7 @@ crate-type = ["cdylib", "rlib"] default = [] # Keep for explicit `cargo check --features cloudflare --target wasm32-unknown-unknown` cloudflare = ["edgezero-adapter-cloudflare/cloudflare", "dep:worker"] +aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"] [dependencies] async-trait = { workspace = true } diff --git a/crates/trusted-server-adapter-cloudflare/build.sh b/crates/trusted-server-adapter-cloudflare/build.sh index dcabdee8e..cbd78c23a 100644 --- a/crates/trusted-server-adapter-cloudflare/build.sh +++ b/crates/trusted-server-adapter-cloudflare/build.sh @@ -33,4 +33,9 @@ if [ -z "$WORKER_VERSION" ]; then echo "error: could not determine the worker crate version from Cargo.lock" >&2 exit 1 fi -cargo install -q --force --version "=$WORKER_VERSION" worker-build && worker-build --release +cargo install -q --force --version "=$WORKER_VERSION" worker-build +if [ -n "${TS_WORKER_BUILD_FEATURES:-}" ]; then + worker-build --release . --features "$TS_WORKER_BUILD_FEATURES" +else + worker-build --release +fi diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index b57c4a6b8..7625ce597 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -31,14 +31,14 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, PublisherResponse, - buffer_publisher_response_async, handle_page_bids, handle_publisher_request, - handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_PATH, PublisherResponse, buffer_publisher_response_async, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; use crate::platform::build_runtime_services; @@ -154,6 +154,47 @@ fn build_state_with_settings( })) } +async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { + if !state.registry.has_reserved_path(req.uri().path()) { + return None; + } + let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default()); + let services = build_runtime_services(&ctx); + Some( + state + .registry + .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) + .await + .expect("reserved path should have a hard-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + +/// Dispatch a reserved request using explicit settings. +/// +/// # Errors +/// +/// Returns an error when the adapter state cannot be built from `settings`. +pub async fn dispatch_reserved_with_settings( + settings: Settings, + req: Request, +) -> Result, Report> { + let state = build_state_with_settings(settings)?; + Ok(dispatch_reserved_for_state(&state, req).await) +} + +/// Dispatch a reserved request using the configured adapter state. +/// +/// # Errors +/// +/// Returns an error when the configured adapter state cannot be built. +pub async fn dispatch_reserved( + req: Request, +) -> Result, Report> { + let state = build_state()?; + Ok(dispatch_reserved_for_state(&state, req).await) +} + // --------------------------------------------------------------------------- // Per-request RuntimeServices // --------------------------------------------------------------------------- @@ -402,7 +443,7 @@ fn build_router(state: &Arc) -> RouterService { { let state = Arc::clone(state); - // Shared fallback dispatch: routes to tsjs (GET only), integration proxy, or publisher. + // Shared fallback dispatch: routes to tsjs (GET/HEAD), integration proxy, or publisher. async fn dispatch( state: Arc, ctx: RequestContext, @@ -420,10 +461,7 @@ fn build_router(state: &Arc) -> RouterService { } let path = req.uri().path().to_owned(); let method = req.method().clone(); - // tsjs assets are served for GET only, matching the Axum/Fastly adapters. - let allow_tsjs = method == Method::GET; - - let result = if allow_tsjs && path.starts_with("/static/tsjs=") { + let result = if path.starts_with("/static/tsjs=") { handle_tsjs_dynamic( &req, &state.registry, @@ -528,19 +566,21 @@ fn build_router(state: &Arc) -> RouterService { .post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async { Ok::(admin_key_management_not_supported()) }) - // Admin EC lookup routes. Registered explicitly (like the key - // routes above) so they never fall through to the publisher - // fallback, and they match `Settings::ADMIN_ENDPOINTS` for auth - // coverage. The EC identity graph is Fastly KV backed, so this - // adapter has no store to read. .get("/_ts/admin/ec", |_ctx: RequestContext| async { Ok::(admin_ec_lookup_not_supported()) }) .get("/_ts/admin/ec/{id}", |_ctx: RequestContext| async { Ok::(admin_ec_lookup_not_supported()) }) - // Admin EIDs echo: pure request inspection (no KV), so this - // adapter serves the real handler. + .get( + "/_ts/trace", + make_handler(Arc::clone(&state), |s, _services, req| async move { + handle_trace_mode(&s.settings, req.uri().query()) + }), + ) + // Render-trace toggle: arms/disarms the ts-trace cookie and + // redirects to `/`. Gated by [debug] trace_route_enabled (404 when + // off). .get( "/_ts/admin/eids", make_handler(Arc::clone(&state), |s, _services, req| async move { @@ -608,15 +648,8 @@ fn build_router(state: &Arc) -> RouterService { }), ); - // SPA re-auction endpoint, registered on the canonical path and on the - // deprecated `PAGE_BIDS_LEGACY_PATH` double-underscore alias. The alias - // keeps tsjs bundles served before the `/_ts/page-bids` rename getting - // ads on SPA navigations until they age out of browser caches. - // - // The OPTIONS preflight is denied on both so the GET handler's - // `X-TSJS-Page-Bids` gate stays trustworthy — an alias that let the - // preflight fall through to a permissive origin would reopen exactly - // the cross-site hole the canonical path closes. + // SPA re-auction endpoint. OPTIONS is denied so the GET handler's + // `X-TSJS-Page-Bids` gate stays trustworthy. let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move { let ec_context = build_ec_context(&s.settings, &services, &req); let auction = AuctionDispatch { @@ -630,10 +663,8 @@ fn build_router(state: &Arc) -> RouterService { make_handler(Arc::clone(&state), |_s, _services, _req| async move { Ok(page_bids_preflight_denied()) }); - for path in [PAGE_BIDS_PATH, PAGE_BIDS_LEGACY_PATH] { - router = router.route(path, Method::GET, page_bids.clone()); - router = router.route(path, Method::OPTIONS, page_bids_preflight.clone()); - } + router = router.route(PAGE_BIDS_PATH, Method::GET, page_bids); + router = router.route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_preflight); let legacy_admin_deny = make_handler(Arc::clone(&state), |_s, _services, _req| async move { @@ -647,6 +678,9 @@ fn build_router(state: &Arc) -> RouterService { ); router = router.route("/admin/keys/deactivate", method, legacy_admin_deny.clone()); } + for method in publisher_fallback_methods() { + router = router.route("/__ts/page-bids", method, legacy_admin_deny.clone()); + } for method in publisher_fallback_methods() { router = router.route("/", method.clone(), fallback.clone()); @@ -700,11 +734,10 @@ mod tests { let state = build_state_with_settings(aps_profile_settings()) .expect("Cloudflare startup should register APS renderer"); assert!( - state.registry.has_route( - &edgezero_core::http::Method::GET, - "/integrations/aps/renderer" - ), - "Cloudflare startup registry should expose the APS renderer" + state + .registry + .has_reserved_path("/integrations/aps/renderer/v2"), + "Cloudflare startup registry should reserve the APS v2 renderer" ); } diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs index 980a9b209..4f7eb687f 100644 --- a/crates/trusted-server-adapter-cloudflare/src/lib.rs +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -15,6 +15,11 @@ pub mod platform; #[cfg(target_arch = "wasm32")] use worker::{Context, Env, Request, Response, Result, event}; +#[cfg(any(target_arch = "wasm32", test))] +fn preserved_reserved_method(value: &str) -> Option { + edgezero_core::http::Method::from_bytes(value.as_bytes()).ok() +} + #[cfg(target_arch = "wasm32")] #[event(fetch)] /// Dispatches an incoming Cloudflare Worker fetch event. @@ -29,6 +34,31 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { } app::set_cloudflare_env(env.clone()); + let is_reserved = req + .url() + .is_ok_and(|url| trusted_server_core::integrations::aps::is_aps_family_path(url.path())); + if is_reserved { + // workers-rs maps unknown methods to GET; the underlying Fetch request + // preserves the original method token, so capture it before conversion. + let method = preserved_reserved_method(&req.inner().method()).ok_or_else(|| { + worker::Error::RustError("reserved APS request method is invalid".to_string()) + })?; + let mut request = edgezero_adapter_cloudflare::request::into_core_request(req, env, ctx) + .await + .map_err(|error| worker::Error::RustError(error.to_string()))?; + *request.method_mut() = method; + let response = app::dispatch_reserved(request) + .await + .map_err(|error| worker::Error::RustError(error.to_string()))? + .ok_or_else(|| { + worker::Error::RustError( + "reserved APS path has no hard-cutover handler".to_string(), + ) + })?; + return edgezero_adapter_cloudflare::response::from_core_response(response) + .map_err(|error| worker::Error::RustError(error.to_string())); + } + match edgezero_adapter_cloudflare::run_app::(req, env, ctx).await { Ok(resp) => Ok(resp), Err(e) => { @@ -37,3 +67,16 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { } } } + +#[cfg(test)] +mod tests { + use super::preserved_reserved_method; + + #[test] + fn reserved_method_parser_preserves_extension_methods() { + let method = preserved_reserved_method("PROPFIND") + .expect("should preserve a syntactically valid extension method"); + + assert_eq!(method.as_str(), "PROPFIND"); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index cded42a0d..d9dfeabb1 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -18,6 +18,7 @@ use trusted_server_core::platform::UnavailableHttpClient; #[cfg(target_arch = "wasm32")] use trusted_server_core::platform::{ PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, + ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1, }; // --------------------------------------------------------------------------- @@ -190,7 +191,13 @@ struct CloudflarePendingResponse { /// fetch layer; the Workers runtime's global CPU budget (~30 s on paid plans) /// is the only implicit deadline. #[cfg(target_arch = "wasm32")] -pub struct CloudflareHttpClient; +pub struct CloudflareHttpClient { + #[cfg(feature = "aps-runner-proxy-integration-test")] + aps_runner_proxy_test_fetcher: Option, +} + +#[cfg(all(target_arch = "wasm32", feature = "aps-runner-proxy-integration-test"))] +const APS_RUNNER_PROXY_TEST_SERVICE_BINDING: &str = "APS_RUNNER_PROXY_FIXTURE"; /// Maximum buffered upstream response body, mirroring the Fastly adapter's cap. /// @@ -281,6 +288,27 @@ fn outbound_request_init(method: worker::Method, headers: worker::Headers) -> wo #[cfg(target_arch = "wasm32")] impl CloudflareHttpClient { + fn new(request_context: &edgezero_core::context::RequestContext) -> Self { + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] + let _ = request_context; + #[cfg(feature = "aps-runner-proxy-integration-test")] + let aps_runner_proxy_test_fetcher = + edgezero_adapter_cloudflare::context::CloudflareRequestContext::get( + request_context.request(), + ) + .and_then(|cloudflare_context| { + cloudflare_context + .env() + .service(APS_RUNNER_PROXY_TEST_SERVICE_BINDING) + .ok() + }); + + Self { + #[cfg(feature = "aps-runner-proxy-integration-test")] + aps_runner_proxy_test_fetcher, + } + } + async fn execute( &self, request: PlatformHttpRequest, @@ -436,6 +464,219 @@ impl CloudflareHttpClient { Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) } + + fn raw_header_evidence(headers: &worker::Headers, name: &str) -> ProxyHeaderEvidenceV1 { + match headers.get(name) { + Ok(Some(value)) => ProxyHeaderEvidenceV1::Combined(value.into_bytes()), + Ok(None) => ProxyHeaderEvidenceV1::absent(), + Err(_) => ProxyHeaderEvidenceV1::Unavailable, + } + } + + fn canonical_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option { + let value = match evidence { + ProxyHeaderEvidenceV1::Occurrences(values) => { + let [value] = values.as_slice() else { + return None; + }; + value.as_slice() + } + ProxyHeaderEvidenceV1::Combined(value) => value.as_slice(), + ProxyHeaderEvidenceV1::Unavailable => return None, + }; + if value.is_empty() + || !value.iter().all(u8::is_ascii_digit) + || (value.len() > 1 && value[0] == b'0') + { + return None; + } + std::str::from_utf8(value).ok()?.parse().ok() + } + + async fn execute_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + use futures::{FutureExt as _, StreamExt as _, future::Either}; + use worker::{ + AbortController, CacheMode, Fetch, Headers, Method, Request, RequestInit, + RequestRedirect, ResponseBody, + }; + + if request.image_optimizer.is_some() || request.stream_response { + return Err(Report::new(PlatformError::HttpClient) + .attach("unsupported option on Cloudflare raw proxy request")); + } + + let cache_mode = outbound_cache_mode(request.bypass_cache); + let uri = request.request.uri().to_string(); + #[cfg(feature = "aps-runner-proxy-integration-test")] + let use_test_service_binding = { + use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL; + + uri == APS_RUNNER_UPSTREAM_URL + }; + let method = Method::from(request.request.method().to_string()); + let headers = Headers::new(); + for (name, value) in request.request.headers() { + let value = + std::str::from_utf8(value.as_bytes()).change_context(PlatformError::HttpClient)?; + headers + .append(name.as_str(), value) + .change_context(PlatformError::HttpClient)?; + } + #[cfg(feature = "aps-runner-proxy-integration-test")] + if use_test_service_binding { + headers + .set("x-ts-aps-logical-url", &uri) + .change_context(PlatformError::HttpClient)?; + } + + let (_, body) = request.request.into_parts(); + let body = match body { + edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), + edgezero_core::body::Body::Stream(_) => { + return Err(Report::new(PlatformError::HttpClient) + .attach("streaming request bodies are not supported on Cloudflare raw proxy")); + } + }; + let mut init = RequestInit::new(); + init.with_method(method) + .with_headers(headers) + .with_redirect(RequestRedirect::Manual); + if cache_mode == OutboundCacheMode::NoStore { + init.with_cache(CacheMode::NoStore); + } + if !body.is_empty() { + init.with_body(Some(js_sys::Uint8Array::from(body.as_slice()).into())); + } + let worker_request = + Request::new_with_init(&uri, &init).change_context(PlatformError::HttpClient)?; + + let controller = AbortController::default(); + let signal = controller.signal(); + #[cfg(feature = "aps-runner-proxy-integration-test")] + let test_fetcher = if use_test_service_binding { + Some(self.aps_runner_proxy_test_fetcher.clone().ok_or_else(|| { + Report::new(PlatformError::HttpClient) + .attach("APS runner proxy integration service binding is unavailable") + })?) + } else { + None + }; + let operation = async { + let fetch_operation = async { + #[cfg(feature = "aps-runner-proxy-integration-test")] + let response = if let Some(fetcher) = test_fetcher { + let mut bound_request: worker::HttpRequest = worker_request + .try_into() + .change_context(PlatformError::HttpClient)?; + bound_request.extensions_mut().insert(signal.clone()); + let bound_response = fetcher + .fetch_request(bound_request) + .await + .change_context(PlatformError::HttpClient)?; + worker::Response::try_from(bound_response) + .change_context(PlatformError::HttpClient)? + } else { + let fetch = Fetch::Request(worker_request); + fetch + .send_with_signal(&signal) + .await + .change_context(PlatformError::HttpClient)? + }; + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] + let response = { + let fetch = Fetch::Request(worker_request); + fetch + .send_with_signal(&signal) + .await + .change_context(PlatformError::HttpClient)? + }; + Ok::>(response) + } + .boxed_local(); + let first_byte_deadline = worker::Delay::from(policy.first_byte_timeout).boxed_local(); + let mut response = + match futures::future::select(fetch_operation, first_byte_deadline).await { + Either::Left((response, _)) => response?, + Either::Right(((), _)) => { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy first-byte deadline exceeded")); + } + }; + let evidence = ProxyResponseEvidenceV1 { + status: response.status_code(), + content_type: Self::raw_header_evidence(response.headers(), "content-type"), + content_encoding: Self::raw_header_evidence(response.headers(), "content-encoding"), + content_length: Self::raw_header_evidence(response.headers(), "content-length"), + }; + if Self::canonical_declared_length(&evidence.content_length) + .is_some_and(|length| length > policy.max_response_bytes) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy declared body exceeds configured cap")); + } + + let mut body = match response.body().clone() { + ResponseBody::Empty => Vec::new(), + ResponseBody::Body(bytes) => bytes, + ResponseBody::Stream(_) => { + let mut stream = response + .stream() + .change_context(PlatformError::HttpClient)?; + let mut body = Vec::new(); + loop { + let read = stream.next().boxed_local(); + let read_deadline = + worker::Delay::from(policy.blocking_read_timeout).boxed_local(); + let chunk = match futures::future::select(read, read_deadline).await { + Either::Left((chunk, _)) => chunk, + Either::Right(((), _)) => { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy blocking-read deadline exceeded")); + } + }; + let Some(chunk) = chunk else { break }; + let chunk = chunk.change_context(PlatformError::HttpClient)?; + let next_len = body.len().checked_add(chunk.len()).ok_or_else(|| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy body length overflow") + })?; + if next_len > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy body exceeds configured cap")); + } + body.extend_from_slice(&chunk); + } + body + } + }; + if body.len() > policy.max_response_bytes { + body.clear(); + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy buffered body exceeds configured cap")); + } + Ok(RawProxyResponseV1 { evidence, body }) + } + .boxed_local(); + let deadline = worker::Delay::from(policy.total_timeout).boxed_local(); + + match futures::future::select(operation, deadline).await { + Either::Left((result, _)) => { + if result.is_err() { + controller.abort(); + } + result + } + Either::Right(((), _)) => { + controller.abort(); + Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded")) + } + } + } } #[cfg(target_arch = "wasm32")] @@ -448,6 +689,14 @@ impl PlatformHttpClient for CloudflareHttpClient { self.execute(request).await } + async fn send_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + self.execute_raw_proxy_v1(request, policy).await + } + fn supports_concurrent_fanout(&self) -> bool { // `send_async` executes each request eagerly, so multiple pending // requests run sequentially. The auction orchestrator checks this @@ -594,7 +843,7 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R let client_ip = extract_client_ip(ctx); #[cfg(target_arch = "wasm32")] - let http_client: Arc = Arc::new(CloudflareHttpClient); + let http_client: Arc = Arc::new(CloudflareHttpClient::new(ctx)); #[cfg(not(target_arch = "wasm32"))] let http_client: Arc = Arc::new(UnavailableHttpClient); diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index fb498ce4e..8499199af 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -21,14 +21,19 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// The handler regex is the production-shaped `^/_ts/admin`, matching /// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical /// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. -fn test_router() -> RouterService { - let settings = Settings::from_toml( +fn test_settings() -> Settings { + Settings::from_toml( r#" [[handlers]] path = "^/_ts/admin" username = "admin" password = "admin-pass" + [[handlers]] + path = "^/integrations/aps" + username = "aps-user" + password = "aps-pass" + [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -37,11 +42,27 @@ fn test_router() -> RouterService { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [auction] + enabled = true + timeout_ms = 1000 + + [auction.providers.aps-main] + protocol = "openrtb-2.6" + profile = "aps" + endpoint = "https://aps.example/e/pb/bid" + routing = "all_eligible" + + [auction.providers.aps-main.profile_config] + account_id = "route-test-aps-account" + allow_script_creatives = true "#, ) - .expect("should parse route test settings"); + .expect("should parse route test settings") +} - TrustedServerApp::routes_with_settings(settings) +fn test_router() -> RouterService { + TrustedServerApp::routes_with_settings(test_settings()) .expect("should build router from test settings") } @@ -58,6 +79,13 @@ async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } +async fn route_reserved(req: Request) -> Response { + trusted_server_adapter_cloudflare::app::dispatch_reserved_with_settings(test_settings(), req) + .await + .expect("should build APS dispatcher") + .expect("APS family should be reserved") +} + fn assert_route_registered(method: &str, path: &str) { let routes = registered_routes(); assert!( @@ -101,6 +129,77 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn aps_cutover_renderer_and_family_failures_are_local() { + let renderer = request_builder() + .method("GET") + .uri("/integrations/aps/renderer/v2") + .header("authorization", "Bearer must-not-reach-publisher") + .body(edgezero_core::body::Body::empty()) + .expect("should build APS renderer request"); + let response = route_reserved(renderer).await; + assert_eq!(response.status().as_u16(), 200); + assert_eq!( + response.headers()["content-type"], + "text/html; charset=utf-8" + ); + assert_eq!( + response.headers()["cache-control"], + "public, max-age=31536000, immutable" + ); + assert!(!response.headers().contains_key("x-frame-options")); + let body = response.into_body().into_bytes().unwrap_or_default(); + let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); + assert!(body.contains("TS APS Bootstrap Ready")); + assert!(body.contains("TS APS Bootstrap Configure")); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(body.contains("data:text/html;charset=utf-8,")); + assert!(!body.contains("client.aps.amazon-adsystem.com")); + + for (method, path, expected) in [ + ("POST", "/integrations/aps/runner.js", 405), + ("TRACE", "/integrations/aps/renderer/v2", 405), + ("CONNECT", "/integrations/aps/renderer/v2", 405), + ("PROPFIND", "/integrations/aps/renderer/v2", 405), + ("GET", "/integrations/aps/renderer", 404), + ("GET", "/integrations/aps/renderer/v1", 404), + ("GET", "/integrations/aps/runner/v1.js", 404), + ("GET", "/integrations/aps", 404), + ] { + let request = request_builder() + .method(method) + .uri(path) + .header("authorization", "Bearer must-not-reach-publisher") + .body(edgezero_core::body::Body::empty()) + .expect("should build APS family request"); + let response = route_reserved(request).await; + assert_eq!(response.status().as_u16(), expected, "{method} {path}"); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response.headers().contains_key("x-geo-info-available")); + if expected == 405 { + assert_eq!(response.headers()["allow"], "GET"); + assert_eq!(response.headers().len(), 2, "{method} {path}"); + } else { + assert_eq!(response.headers().len(), 1, "{method} {path}"); + } + assert!( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .is_empty() + ); + } + + let protected_control = request_builder() + .method("GET") + .uri("/integrations/apsx") + .body(edgezero_core::body::Body::empty()) + .expect("should build protected non-APS boundary request"); + let response = route(test_router(), protected_control).await; + assert_eq!(response.status().as_u16(), 401); +} + // --------------------------------------------------------------------------- // Middleware regression tests — verify FinalizeResponseMiddleware and // AuthMiddleware are wired so they cannot be removed silently. @@ -204,40 +303,32 @@ async fn tsjs_route_is_routed_not_5xx() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tsjs_route_emits_cloudflare_cache_header_for_matching_hash() { - let router = test_router(); - let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]); - let req = request_builder() - .method("GET") - .uri(src) - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - - let resp = route(router, req).await; +async fn tsjs_wrong_methods_are_local_no_store_404s() { + for method in ["HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] { + let req = request_builder() + .method(method) + .uri(format!( + "/static/tsjs=tsjs-unified.min.js?v={}", + "0".repeat(64) + )) + .body(edgezero_core::body::Body::empty()) + .expect("should build wrong-method TSJS request"); + let response = route(test_router(), req).await; - assert_eq!( - resp.status().as_u16(), - 200, - "matching TSJS hash should serve OK" - ); - assert_eq!( - resp.headers() - .get("cache-control") - .and_then(|value| value.to_str().ok()), - Some("public, max-age=31536000, immutable"), - "browser cache policy should be immutable for matching TSJS hash" - ); - assert_eq!( - resp.headers() - .get("cloudflare-cdn-cache-control") - .and_then(|value| value.to_str().ok()), - Some("max-age=31536000"), - "Cloudflare adapter should emit the Cloudflare-specific edge header" - ); - assert!( - resp.headers().get("surrogate-control").is_none(), - "Cloudflare adapter must not emit Fastly Surrogate-Control" - ); + assert_eq!(response.status().as_u16(), 404, "method {method}"); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "method {method}" + ); + assert!( + !response.headers().contains_key("location"), + "method {method}" + ); + } } /// Verify that every expected explicit route is registered in the route table. @@ -256,16 +347,9 @@ fn all_explicit_routes_are_registered() { ("GET", "/_ts/admin/ec/{id}"), ("GET", "/_ts/admin/eids"), ("POST", "/auction"), - // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both - // paths are spelled out as literals rather than referencing - // `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH` so this test pins the - // actual URL the tsjs client fetches — asserting a const against itself - // would still pass if the const's value changed out from under the - // client. + // Pin the canonical literal fetched by the hard-cutover client. ("GET", "/_ts/page-bids"), ("OPTIONS", "/_ts/page-bids"), - ("GET", "/__ts/page-bids"), - ("OPTIONS", "/__ts/page-bids"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), ("GET", "/first-party/sign"), @@ -277,6 +361,9 @@ fn all_explicit_routes_are_registered() { for (method, path) in expected { assert_route_registered(method, path); } + for method in LEGACY_ADMIN_DENY_METHODS { + assert_route_registered(method, "/__ts/page-bids"); + } for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { for method in LEGACY_ADMIN_DENY_METHODS { diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml b/crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml new file mode 100644 index 000000000..cdf6e4b07 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml @@ -0,0 +1,24 @@ +name = "trusted-server-aps-runner-proxy-integration" +main = "build/index.js" +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat", "cache_option_enabled"] + +[[kv_namespaces]] +binding = "TRUSTED_SERVER_KV" +id = "aps-runner-proxy-local-kv" + +[[services]] +binding = "APS_RUNNER_PROXY_FIXTURE" +service = "aps-runner-proxy-fixture" + +[vars] +# Replaced in a temporary copy by the integration-test controller. +TRUSTED_SERVER_CONFIG = "{}" + +# Fictitious integration-only values for the secret references in the shared +# app-config fixture. Production values are provisioned with Worker 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" diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 65320faa6..8b50f4006 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -10,6 +10,10 @@ version = { workspace = true } [lints] workspace = true +[features] +default = [] +aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"] + [dependencies] async-trait = { workspace = true } base64 = { workspace = true } @@ -30,6 +34,7 @@ serde_json = { workspace = true } trusted-server-core = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } +web-time = { workspace = true } [dev-dependencies] bytes = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index c1ba5ed6b..77e5367a3 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -132,9 +132,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, handle_page_bids, - handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, - publisher_response_into_streaming_response, + AuctionDispatch, PAGE_BIDS_PATH, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, publisher_response_into_streaming_response, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -143,6 +142,7 @@ use trusted_server_core::request_signing::{ use trusted_server_core::settings::{ProxyAssetRoute, Settings}; use trusted_server_core::settings_data::{DEFAULT_CONFIG_STORE_ID, get_settings_from_config_store}; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ @@ -195,6 +195,25 @@ pub(crate) fn build_state( build_state_from_settings(load_settings_from_config_store(stores)?) } +pub(crate) async fn dispatch_reserved_for_state( + state: &Arc, + req: Request, +) -> Option { + if !state.registry.has_reserved_path(req.uri().path()) { + return None; + } + let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default()); + let services = build_per_request_services(state, &ctx); + Some( + state + .registry + .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) + .await + .expect("reserved path should have a hard-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + pub(crate) fn load_settings_from_config_store( stores: &RuntimeStoreConfig, ) -> Result> { @@ -322,8 +341,8 @@ fn publisher_fallback_methods() -> [Method; 7] { ] } -fn uses_dynamic_tsjs_fallback(method: &Method, path: &str) -> bool { - *method == Method::GET && path.starts_with("/static/tsjs=") +fn uses_dynamic_tsjs_fallback(_method: &Method, path: &str) -> bool { + path.starts_with("/static/tsjs=") } // --------------------------------------------------------------------------- @@ -667,6 +686,7 @@ async fn run_named_route( } NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), + NamedRouteHandler::TraceMode => handle_trace_mode(&state.settings, req.uri().query()), NamedRouteHandler::Auction => { // The auction reads consent data, so the consent KV store must be // available — fail closed with 503 when it is configured but @@ -1086,6 +1106,7 @@ enum NamedRouteHandler { Identify, SetTester, ClearTester, + TraceMode, Auction, PageBids, FirstPartyProxy, @@ -1186,6 +1207,11 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET], handler: NamedRouteHandler::ClearTester, }, + NamedRoute { + path: "/_ts/trace", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TraceMode, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -1198,15 +1224,12 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, }, - // Deprecated double-underscore alias. tsjs bundles served before the - // `/_ts/page-bids` rename keep requesting this path from already-loaded - // pages and browser caches; dropping it would strand SPA navigations - // without ads until those bundles age out. See `PAGE_BIDS_LEGACY_PATH`; - // removal is tracked by IABTechLab/trusted-server#970. + // A removed route must be denied here, before the publisher fallback, so + // its response is always a local unknown-route result rather than an alias. NamedRoute { - path: PAGE_BIDS_LEGACY_PATH, - primary_methods: &[Method::GET, Method::OPTIONS], - handler: NamedRouteHandler::PageBids, + path: "/__ts/page-bids", + primary_methods: LEGACY_ADMIN_DENY_METHODS, + handler: NamedRouteHandler::LegacyAdminDenied, }, NamedRoute { path: "/first-party/proxy", @@ -1358,19 +1381,20 @@ mod tests { use std::sync::{Arc, Mutex}; use std::time::Duration; + #[cfg(feature = "aps-runner-proxy-integration-test")] + use super::dispatch_reserved_for_state; use super::{ - AppState, AuctionDispatch, EcContext, EdgeCacheHeader, HandlerFuture, NAMED_ROUTES, - NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, RuntimeStoreConfig, - TrustedServerApp, build_orchestrator_with_plan, build_per_request_services, - build_state_from_settings, compile_auction_plan, handle_publisher_request, - publisher_response_into_streaming_response, startup_error_router, + AppState, AuctionDispatch, EcContext, EdgeCacheHeader, EnvConfig, HandlerFuture, + NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_PATH, RuntimeStoreConfig, TrustedServerApp, + build_orchestrator_with_plan, build_per_request_services, build_state_from_settings, + compile_auction_plan, handle_publisher_request, publisher_response_into_streaming_response, + startup_error_router, }; use base64::Engine as _; use bytes::Bytes; use edgezero_core::app::Hooks as _; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; - use edgezero_core::env_config::EnvConfig; use edgezero_core::http::{Method, Response, StatusCode, header, request_builder}; use edgezero_core::key_value_store::NoopKvStore; use edgezero_core::params::PathParams; @@ -1551,6 +1575,11 @@ mod tests { username = "admin" password = "admin-pass" + [[handlers]] + path = "^/integrations/aps" + username = "aps-user" + password = "aps-pass" + [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -1572,6 +1601,11 @@ mod tests { enabled = true external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" + [integrations.aps] + enabled = true + account_id = "route-test-aps-account" + allow_script_creatives = true + [auction] enabled = true [auction.providers.prebid] @@ -1619,6 +1653,103 @@ mod tests { ); } + #[cfg(feature = "aps-runner-proxy-integration-test")] + fn route_reserved(request: edgezero_core::http::Request) -> Response { + let state = build_state_from_settings(test_settings()).expect("should build test state"); + block_on(dispatch_reserved_for_state(&state, request)) + .expect("APS family should be reserved") + } + + #[cfg(feature = "aps-runner-proxy-integration-test")] + #[test] + fn aps_cutover_renderer_and_family_failures_are_local() { + let response = route_reserved(empty_request(Method::GET, "/integrations/aps/renderer/v2")); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "public, max-age=31536000, immutable" + ); + assert!(!response.headers().contains_key("x-frame-options")); + let body = response.into_body().into_bytes().unwrap_or_default(); + let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); + assert!(body.contains("TS APS Bootstrap Ready")); + assert!(body.contains("TS APS Bootstrap Configure")); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(body.contains("data:text/html;charset=utf-8,")); + assert!(!body.contains("client.aps.amazon-adsystem.com")); + + for (method, path, expected) in [ + ( + Method::POST, + "/integrations/aps/runner.js", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + Method::TRACE, + "/integrations/aps/renderer/v2", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + Method::CONNECT, + "/integrations/aps/renderer/v2", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + Method::from_bytes(b"PROPFIND").expect("PROPFIND should be a valid method"), + "/integrations/aps/renderer/v2", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + Method::GET, + "/integrations/aps/renderer", + StatusCode::NOT_FOUND, + ), + ( + Method::GET, + "/integrations/aps/renderer/v1", + StatusCode::NOT_FOUND, + ), + ( + Method::GET, + "/integrations/aps/runner/v1.js", + StatusCode::NOT_FOUND, + ), + (Method::GET, "/integrations/aps", StatusCode::NOT_FOUND), + ] { + let mut request = empty_request(method.clone(), path); + request.headers_mut().insert( + header::AUTHORIZATION, + "Bearer must-not-reach-publisher" + .parse() + .expect("should parse authorization header"), + ); + let response = route_reserved(request); + assert_eq!(response.status(), expected, "{method} {path}"); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert!(!response.headers().contains_key(HEADER_X_GEO_INFO_AVAILABLE)); + if expected == StatusCode::METHOD_NOT_ALLOWED { + assert_eq!(response.headers()[header::ALLOW], "GET"); + } + assert!( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .is_empty() + ); + } + + let response = route( + &test_router(), + empty_request(Method::GET, "/integrations/apsx"), + ); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + /// Builds a router whose `AppState` uses a registry containing the given /// request filters (and no routes), so dispatch-level request-filter /// behavior can be exercised without a real integration. @@ -1720,8 +1851,27 @@ mod tests { #[test] fn startup_registers_aps_renderer_route() { - let mut settings = test_settings(); - settings.auction.providers.clear(); + let mut settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-password" + + [publisher] + domain = "publisher.example" + cookie_domain = ".publisher.example" + origin_url = "https://origin.publisher.example" + proxy_secret = "fictional-proxy-secret" + + [ec] + passphrase = "fictional-secret-key-32-bytes-minimum" + + [auction] + enabled = true + "#, + ) + .expect("should parse APS startup settings"); settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), trusted_server_core::auction::ProviderConfig { @@ -1738,11 +1888,10 @@ mod tests { let state = build_state_from_settings(settings) .expect("Fastly startup should register APS renderer"); assert!( - state.registry.has_route( - &edgezero_core::http::Method::GET, - "/integrations/aps/renderer" - ), - "Fastly startup registry should expose the APS renderer" + state + .registry + .has_reserved_path("/integrations/aps/renderer/v2"), + "Fastly startup registry should reserve the APS v2 renderer" ); } @@ -1785,18 +1934,18 @@ mod tests { } #[test] - fn dynamic_tsjs_fallback_is_get_only() { + fn dynamic_tsjs_fallback_rejects_every_wrong_method_locally() { assert!( super::uses_dynamic_tsjs_fallback(&Method::GET, "/static/tsjs=tsjs-unified.js"), "GET should use the dynamic tsjs shortcut" ); assert!( - !super::uses_dynamic_tsjs_fallback(&Method::HEAD, "/static/tsjs=tsjs-unified.js"), - "HEAD should fall through to the publisher/integration fallback" + super::uses_dynamic_tsjs_fallback(&Method::HEAD, "/static/tsjs=tsjs-unified.js"), + "HEAD should use the local TSJS rejection path" ); assert!( - !super::uses_dynamic_tsjs_fallback(&Method::OPTIONS, "/static/tsjs=tsjs-unified.js"), - "OPTIONS should fall through to the publisher/integration fallback" + super::uses_dynamic_tsjs_fallback(&Method::OPTIONS, "/static/tsjs=tsjs-unified.js"), + "OPTIONS should use the local TSJS rejection path" ); } @@ -1925,83 +2074,29 @@ mod tests { } #[test] - fn admin_ec_lookup_routes_are_registered() { - // Both lookup shapes must be explicitly routed to the admin EC - // handler: the bare cookie-based route and the parameterized route. - // Leaving either unrouted would fall through to the publisher - // fallback, forwarding the caller's `Authorization` header to the - // origin. - for path in ["/_ts/admin/ec", "/_ts/admin/ec/{id}"] { - let route = NAMED_ROUTES - .iter() - .find(|route| route.path == path) - .unwrap_or_else(|| panic!("{path} must be a named route")); - assert!( - matches!(route.handler, NamedRouteHandler::AdminEcLookup), - "{path} must map to the admin EC lookup handler" - ); - assert_eq!( - route.primary_methods, - &[Method::GET], - "{path} must have GET as its only primary method" - ); - } - - let eids_route = NAMED_ROUTES - .iter() - .find(|route| route.path == "/_ts/admin/eids") - .expect("should register /_ts/admin/eids as a named route"); - assert!( - matches!(eids_route.handler, NamedRouteHandler::AdminEidsLookup), - "/_ts/admin/eids must map to the admin EIDs lookup handler" - ); - assert_eq!( - eids_route.primary_methods, - &[Method::GET], - "/_ts/admin/eids must have GET as its only primary method" - ); - } - - #[test] - fn page_bids_serves_canonical_path_and_deprecated_alias() { - // The SPA re-auction endpoint lives at the canonical single-underscore - // `/_ts/page-bids`, matching every other internal route. The deprecated - // `/__ts/page-bids` alias must stay registered to the same handler with - // the same methods until pre-rename tsjs bundles age out of browser - // caches — dropping it would leave those clients without ads on SPA - // navigations. - // - // The paths are literals, not `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`. - // Looking a route up by the same const it was registered with is - // tautological: it keeps passing if the const's value changes, which is - // exactly the break that would silently desync the server from the tsjs - // client's hardcoded fetch path. Pin the consts to their literals too so - // a rename has to be deliberate. + fn page_bids_keeps_the_canonical_handler_and_denies_the_removed_alias_locally() { + // The hard cutover exposes only the canonical single-underscore page-bids + // handler. The removed path is an explicit local 404, never an alias or + // publisher-fallback route. assert_eq!( PAGE_BIDS_PATH, "/_ts/page-bids", "canonical page-bids path must match the path tsjs fetches" ); - assert_eq!( - PAGE_BIDS_LEGACY_PATH, "/__ts/page-bids", - "legacy alias must match the path pre-rename tsjs bundles fetch" - ); - - for path in ["/_ts/page-bids", "/__ts/page-bids"] { - let route = NAMED_ROUTES - .iter() - .find(|route| route.path == path) - .unwrap_or_else(|| panic!("{path} should be registered")); - - assert!( - matches!(route.handler, NamedRouteHandler::PageBids), - "{path} must map to the page-bids handler" - ); - assert_eq!( - route.primary_methods, - &[Method::GET, Method::OPTIONS], - "{path} must handle GET and OPTIONS directly, not fall through to the publisher" - ); - } + let route = NAMED_ROUTES + .iter() + .find(|route| route.path == "/_ts/page-bids") + .expect("canonical page-bids path should be registered"); + assert!(matches!(route.handler, NamedRouteHandler::PageBids)); + assert_eq!(route.primary_methods, &[Method::GET, Method::OPTIONS]); + let removed = NAMED_ROUTES + .iter() + .find(|route| route.path == "/__ts/page-bids") + .expect("removed page-bids path should be denied locally"); + assert!(matches!( + removed.handler, + NamedRouteHandler::LegacyAdminDenied + )); + assert_eq!(removed.primary_methods, super::LEGACY_ADMIN_DENY_METHODS); } #[test] @@ -2950,6 +3045,7 @@ mod tests { enabled = true [creative_opportunities] + enabled = true gam_network_id = "99999" assembly_mode = "esi" diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index b086467e3..46b631e4b 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -178,7 +178,20 @@ fn edgezero_main(mut req: FastlyRequest) { core_req.extensions_mut().insert(config_store); core_req.extensions_mut().insert(device_signals); core_req.extensions_mut().insert(client_info); - match futures::executor::block_on(app.router().oneshot(core_req)) { + let routed = if let Some(state) = app_state + .as_ref() + .filter(|state| state.registry.has_reserved_path(core_req.uri().path())) + { + Ok( + futures::executor::block_on(crate::app::dispatch_reserved_for_state( + state, core_req, + )) + .expect("reserved path should dispatch before RouterService"), + ) + } else { + futures::executor::block_on(app.router().oneshot(core_req)) + }; + match routed { Ok(response) => response, Err(error) => edge_error_response(error), } @@ -197,7 +210,12 @@ fn edgezero_main(mut req: FastlyRequest) { let asset_cache_policy = response.extensions_mut().remove::(); let request_filter_effects = response.extensions_mut().remove::(); - if !take_finalize_sentinel(&mut response) { + let should_finalize = response + .extensions() + .get::() + .is_none() + && !take_finalize_sentinel(&mut response); + if should_finalize { if let Some(settings) = settings_snapshot.as_deref() { apply_entry_point_finalize_headers(settings, &mut response, client_ip); } else { @@ -339,7 +357,7 @@ fn send_edgezero_response( mut response: HttpResponse, request_filter_effects: Option<&RequestFilterEffects>, ) { - apply_terminal_response_effects(&mut response, request_filter_effects); + apply_send_response_effects(&mut response, request_filter_effects); let (parts, body) = response.into_parts(); @@ -368,6 +386,21 @@ fn send_edgezero_response( } } +fn apply_send_response_effects( + response: &mut HttpResponse, + request_filter_effects: Option<&RequestFilterEffects>, +) { + if response + .extensions() + .get::() + .is_some() + { + return; + } + apply_terminal_response_effects(response, request_filter_effects); + crate::middleware::enforce_uncacheable_cache_privacy(response); +} + /// Apply every late response mutation, then restore privacy invariants before headers commit. fn apply_terminal_response_effects( response: &mut HttpResponse, @@ -585,6 +618,21 @@ mod tests { ); } + #[test] + fn exact_route_headers_bypass_terminal_cache_rewriting() { + let mut response = response_builder() + .header("cache-control", "no-store") + .body(EdgeBody::empty()) + .expect("should build exact route response"); + response + .extensions_mut() + .insert(trusted_server_core::platform::ExactResponseHeadersV1); + + apply_send_response_effects(&mut response, None); + + assert_eq!(response.headers()["cache-control"], "no-store"); + } + #[test] fn late_filter_effects_cannot_make_an_assembled_response_public() { let mut response = response_builder() diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index e612830b9..822cba246 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -13,13 +13,18 @@ use fastly::geo::{Geo, geo_lookup}; use fastly::{ConfigStore, Request, SecretStore}; use crate::backend::BackendConfig; +#[cfg(feature = "aps-runner-proxy-integration-test")] +use trusted_server_core::integrations::aps::{ + APS_RUNNER_BLOCKING_READ_TIMEOUT, APS_RUNNER_FIRST_BYTE_TIMEOUT, APS_RUNNER_UPSTREAM_URL, +}; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, - PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, + PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, + ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1, StoreId, StoreName, }; use trusted_server_core::settings::TrustedClientIpConfig; @@ -448,6 +453,31 @@ fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool) } } +fn fastly_raw_header_evidence(response: &fastly::Response, name: &str) -> ProxyHeaderEvidenceV1 { + ProxyHeaderEvidenceV1::Occurrences( + response + .get_header_all(name) + .map(|value| value.as_bytes().to_vec()) + .collect(), + ) +} + +fn canonical_fastly_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option { + let ProxyHeaderEvidenceV1::Occurrences(values) = evidence else { + return None; + }; + let [value] = values.as_slice() else { + return None; + }; + if value.is_empty() + || !value.iter().all(u8::is_ascii_digit) + || (value.len() > 1 && value[0] == b'0') + { + return None; + } + std::str::from_utf8(value).ok()?.parse().ok() +} + /// Fastly implementation of [`PlatformHttpClient`]. /// /// - [`send`](PlatformHttpClient::send) converts the platform request to a @@ -470,6 +500,51 @@ fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool) /// tests cover request conversion and the single-send boundary. pub struct FastlyPlatformHttpClient; +#[cfg(feature = "aps-runner-proxy-integration-test")] +const APS_RUNNER_PROXY_TEST_BACKEND: &str = "aps_runner_proxy_fixture"; + +#[cfg(feature = "aps-runner-proxy-integration-test")] +fn aps_runner_proxy_test_backend( + policy: RawProxyPolicyV1, +) -> Result> { + if policy.first_byte_timeout != APS_RUNNER_FIRST_BYTE_TIMEOUT + || policy.blocking_read_timeout != APS_RUNNER_BLOCKING_READ_TIMEOUT + { + return Err(Report::new(PlatformError::HttpClient) + .attach("APS runner raw proxy policy does not match the static fixture timeouts")); + } + let fixture = fastly::Backend::from_name(APS_RUNNER_PROXY_TEST_BACKEND) + .change_context(PlatformError::HttpClient)?; + if !fixture.exists() || fixture.is_ssl() { + return Err(Report::new(PlatformError::HttpClient) + .attach("APS runner fixture backend must exist as plain HTTP")); + } + let fixture_host = fixture.get_host(); + let fixture_address = fixture_host.parse::().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("APS runner fixture backend host must be a literal IP address") + })?; + if !fixture_address.is_loopback() { + return Err(Report::new(PlatformError::HttpClient) + .attach("APS runner fixture backend host must be loopback")); + } + let logical_url = + url::Url::parse(APS_RUNNER_UPSTREAM_URL).change_context(PlatformError::HttpClient)?; + let logical_host = logical_url.host_str().ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("APS runner logical URL must contain a host") + })?; + if fixture + .get_host_override() + .as_ref() + .and_then(|host| host.to_str().ok()) + != Some(logical_host) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("APS runner fixture backend must preserve the logical host")); + } + Ok(APS_RUNNER_PROXY_TEST_BACKEND.to_string()) +} + #[async_trait::async_trait(?Send)] impl PlatformHttpClient for FastlyPlatformHttpClient { fn supports_streaming_responses(&self) -> bool { @@ -496,6 +571,88 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { fastly_response_to_platform(fastly_resp, backend_name, stream_response, request_is_head) } + async fn send_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + if request.image_optimizer.is_some() || request.stream_response { + return Err(Report::new(PlatformError::HttpClient) + .attach("unsupported option on Fastly raw proxy request")); + } + + let started = web_time::Instant::now(); + if policy.first_byte_timeout > policy.total_timeout { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy first-byte timeout exceeds total deadline")); + } + let backend_name = request.backend_name; + let mut fastly_request = edge_request_to_fastly(request.request)?; + #[cfg(feature = "aps-runner-proxy-integration-test")] + let backend_name = { + if fastly_request.get_url_str() == APS_RUNNER_UPSTREAM_URL { + fastly_request.set_header("x-ts-aps-logical-url", APS_RUNNER_UPSTREAM_URL); + aps_runner_proxy_test_backend(policy)? + } else { + backend_name + } + }; + apply_fastly_cache_bypass(&mut fastly_request, request.bypass_cache); + let pending = fastly_request + .send_async(&backend_name) + .change_context(PlatformError::HttpClient)?; + // The backend carries the requested first-byte timeout. Waiting in the + // SDK lets the host suspend the guest instead of guest-side polling. + let mut response = pending.wait().change_context(PlatformError::HttpClient)?; + if started.elapsed() >= policy.total_timeout { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded before response headers")); + } + + let evidence = ProxyResponseEvidenceV1 { + status: response.get_status().as_u16(), + content_type: fastly_raw_header_evidence(&response, "content-type"), + content_encoding: fastly_raw_header_evidence(&response, "content-encoding"), + content_length: fastly_raw_header_evidence(&response, "content-length"), + }; + if canonical_fastly_declared_length(&evidence.content_length) + .is_some_and(|length| length > policy.max_response_bytes) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy declared body exceeds configured cap")); + } + + let mut reader = response.take_body(); + let mut body = Vec::new(); + let mut chunk = [0_u8; 64 * 1024]; + loop { + if started.elapsed() >= policy.total_timeout { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded before blocking body read")); + } + let read = reader + .read(&mut chunk) + .change_context(PlatformError::HttpClient)?; + if started.elapsed() >= policy.total_timeout { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded while reading body")); + } + if read == 0 { + break; + } + let next_len = body.len().checked_add(read).ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow") + })?; + if next_len > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy body exceeds configured cap")); + } + body.extend_from_slice(&chunk[..read]); + } + + Ok(RawProxyResponseV1 { evidence, body }) + } + async fn send_async( &self, request: PlatformHttpRequest, @@ -909,6 +1066,33 @@ mod tests { ); } + #[test] + fn raw_proxy_waits_with_the_sdk_without_sleep_polling() { + let source = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/platform.rs")); + let raw_proxy = source + .split("async fn send_raw_proxy_v1(") + .nth(1) + .and_then(|source| source.split("fn supports_concurrent_fanout(").next()) + .expect("should locate the Fastly raw proxy implementation"); + + assert!( + raw_proxy.contains("pending.wait()"), + "raw proxy should block in the Fastly SDK instead of guest-side polling" + ); + assert!(!raw_proxy.contains("pending.poll()")); + assert!(!raw_proxy.contains("std::thread::sleep")); + assert!( + raw_proxy.contains("policy.total_timeout"), + "raw proxy should preserve the complete policy-owned total timeout" + ); + assert!( + !raw_proxy.contains("call_start_deadline") + && !raw_proxy.contains("reduced deadline") + && !raw_proxy.contains("SAFETY_MARGIN"), + "raw proxy must not reserve time outside the exact transport window" + ); + } + // --- FastlyPlatformBackend::predict_name -------------------------------- #[test] diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 77c4139bc..43ba8741f 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -18,6 +18,7 @@ crate-type = ["cdylib", "rlib"] [features] default = [] spin = ["edgezero-adapter-spin/spin"] +aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"] [dependencies] anyhow = { workspace = true } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index e6abe75ea..073a3eb8e 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1,11 +1,19 @@ use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[cfg(all( + feature = "spin", + target_arch = "wasm32", + not(feature = "aps-runner-proxy-integration-test") +))] use edgezero_adapter_spin::config_store::SpinConfigStore; use edgezero_adapter_spin::context::SpinRequestContext; use edgezero_core::app::Hooks; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[cfg(all( + feature = "spin", + target_arch = "wasm32", + not(feature = "aps-runner-proxy-integration-test") +))] use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; @@ -29,37 +37,57 @@ use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; use trusted_server_core::platform::RuntimeServices; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[cfg(all( + feature = "spin", + target_arch = "wasm32", + not(feature = "aps-runner-proxy-integration-test") +))] use trusted_server_core::platform::{PlatformConfigStore, StoreName}; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, PublisherResponse, - buffer_publisher_response_async, handle_page_bids, handle_publisher_request, - handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_PATH, PublisherResponse, buffer_publisher_response_async, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +#[cfg(all( + feature = "spin", + target_arch = "wasm32", + not(feature = "aps-runner-proxy-integration-test") +))] +use trusted_server_core::settings_data::default_config_key; #[cfg(all(feature = "spin", target_arch = "wasm32"))] -use trusted_server_core::settings_data::{default_config_key, default_secret_store_name}; +use trusted_server_core::settings_data::default_secret_store_name; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{ AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware, SanitizeRequestMiddleware, }; -use crate::platform::build_runtime_services; +#[cfg(all( + feature = "spin", + target_arch = "wasm32", + not(feature = "aps-runner-proxy-integration-test") +))] +use crate::platform::ConfigStoreHandleAdapter; #[cfg(all(feature = "spin", target_arch = "wasm32"))] -use crate::platform::{ConfigStoreHandleAdapter, SpinSecretStoreAdapter}; +use crate::platform::SpinSecretStoreAdapter; +use crate::platform::build_runtime_services; // --------------------------------------------------------------------------- // AppState // --------------------------------------------------------------------------- /// Spin auto-provides this key-value store label without runtime configuration. -#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[cfg(all( + feature = "spin", + target_arch = "wasm32", + not(feature = "aps-runner-proxy-integration-test") +))] const SPIN_DEFAULT_CONFIG_STORE: &str = "default"; /// Application state built once at startup and shared across all requests. @@ -75,12 +103,17 @@ pub struct AppState { /// /// Returns an error when settings, the auction orchestrator, or the integration /// registry fail to initialise. +#[cfg(not(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32")))] fn build_state() -> Result, Report> { let settings = load_startup_settings()?; build_state_with_settings(settings) } -#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[cfg(all( + feature = "spin", + target_arch = "wasm32", + not(feature = "aps-runner-proxy-integration-test") +))] fn load_startup_settings() -> Result> { let config_store_name = StoreName::from(SPIN_DEFAULT_CONFIG_STORE); let config_key = default_config_key(); @@ -114,6 +147,22 @@ fn load_startup_settings() -> Result> { .attach("use TrustedServerApp::routes_with_settings for host tests")) } +#[cfg(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32"))] +fn build_state() -> Result, Report> { + let envelope = + futures::executor::block_on(spin_sdk::variables::get("v_trusted_x5fserver_x5fconfig")) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "failed to read the Spin APS proxy test app config".to_string(), + }) + .attach(error.to_string()) + })?; + let secret_store = SpinSecretStoreAdapter; + let settings = + settings_from_config_blob(&envelope, &secret_store, &default_secret_store_name())?; + build_state_with_settings(settings) +} + /// Build the application state from explicit settings. /// /// # Errors @@ -135,6 +184,49 @@ fn build_state_with_settings( })) } +async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { + if !state.registry.has_reserved_path(req.uri().path()) { + return None; + } + let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default()); + let services = build_runtime_services(&ctx); + Some( + state + .registry + .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) + .await + .expect("reserved path should have a hard-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + +/// Dispatch a reserved APS request using explicit settings. +/// +/// # Errors +/// +/// Returns an error when the application state cannot be +/// initialized from `settings`. +pub async fn dispatch_reserved_with_settings( + settings: Settings, + req: Request, +) -> Result, Report> { + let state = build_state_with_settings(settings)?; + Ok(dispatch_reserved_for_state(&state, req).await) +} + +/// Dispatch a reserved APS request using startup settings. +/// +/// # Errors +/// +/// Returns an error when startup settings or the application +/// state cannot be initialized. +pub async fn dispatch_reserved( + req: Request, +) -> Result, Report> { + let state = build_state()?; + Ok(dispatch_reserved_for_state(&state, req).await) +} + // --------------------------------------------------------------------------- // Publisher response helper // --------------------------------------------------------------------------- @@ -204,7 +296,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 16] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 17] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -215,9 +307,10 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 16] { ("/_ts/admin/eids", &[Method::GET]), ("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS), ("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS), + ("/_ts/trace", &[Method::GET]), ("/auction", &[Method::POST]), (PAGE_BIDS_PATH, &[Method::GET, Method::OPTIONS]), - (PAGE_BIDS_LEGACY_PATH, &[Method::GET, Method::OPTIONS]), + ("/__ts/page-bids", LEGACY_ADMIN_DENY_METHODS), ("/first-party/proxy", &[Method::GET]), ("/first-party/click", &[Method::GET]), ("/first-party/sign", &[Method::GET, Method::POST]), @@ -751,9 +844,7 @@ fn build_router(state: &Arc) -> RouterService { let path = req.uri().path().to_owned(); let method = req.method().clone(); - // Dynamic tsjs serving is GET-only; other methods fall through to the - // integration/publisher fallback. - let result = if method == Method::GET && path.starts_with("/static/tsjs=") { + let result = if path.starts_with("/static/tsjs=") { handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SMaxageFallback) } else if state.registry.has_route(&method, &path) { let mut ec_context = EcContext::default(); @@ -820,6 +911,17 @@ fn build_router(state: &Arc) -> RouterService { }; let legacy_admin_deny = |_ctx: RequestContext| async { Ok::(legacy_admin_alias_denied()) }; + let s = Arc::clone(&state); + let trace_mode_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let req = ctx.into_request(); + Ok::( + handle_trace_mode(&s.settings, req.uri().query()) + .unwrap_or_else(|error| http_error(&error)), + ) + } + }; let mut builder = RouterService::builder() // Outermost middleware: strips the configured trusted-client-IP @@ -853,27 +955,13 @@ fn build_router(state: &Arc) -> RouterService { // credentials and key-management payloads to the origin. .post("/_ts/admin/keys/rotate", admin_not_supported_handler) .post("/_ts/admin/keys/deactivate", admin_not_supported_handler) - // Admin EC lookup routes. Registered explicitly (like the key - // routes above) so they never fall through to the publisher - // fallback, and they match `Settings::ADMIN_ENDPOINTS` for auth - // coverage. The EC identity graph is Fastly KV backed, so this - // adapter has no store to read. .get("/_ts/admin/ec", admin_ec_not_supported_handler) .get("/_ts/admin/ec/{id}", admin_ec_not_supported_handler) .get("/_ts/admin/eids", admin_eids_handler) + .get("/_ts/trace", trace_mode_handler) .post("/auction", auction_handler) - .get(PAGE_BIDS_PATH, page_bids_handler.clone()) + .get(PAGE_BIDS_PATH, page_bids_handler) .route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_options_handler) - // Deprecated double-underscore alias, kept so tsjs bundles served - // before the `/_ts/page-bids` rename keep getting ads on SPA - // navigations until they age out of browser caches. See - // `PAGE_BIDS_LEGACY_PATH`. - .get(PAGE_BIDS_LEGACY_PATH, page_bids_handler) - .route( - PAGE_BIDS_LEGACY_PATH, - Method::OPTIONS, - page_bids_options_handler, - ) .get("/first-party/proxy", fp_proxy_handler) .get("/first-party/click", fp_click_handler) .get("/first-party/sign", fp_sign_handler) @@ -884,6 +972,7 @@ fn build_router(state: &Arc) -> RouterService { for method in LEGACY_ADMIN_DENY_METHODS { builder = builder.route("/admin/keys/rotate", method.clone(), legacy_admin_deny); builder = builder.route("/admin/keys/deactivate", method.clone(), legacy_admin_deny); + builder = builder.route("/__ts/page-bids", method.clone(), legacy_admin_deny); } // Mirror the Fastly/Axum publisher fallback: every supported method that is @@ -975,11 +1064,10 @@ mod tests { let state = build_state_with_settings(settings).expect("Spin startup should register APS renderer"); assert!( - state.registry.has_route( - &edgezero_core::http::Method::GET, - "/integrations/aps/renderer" - ), - "Spin startup registry should expose the APS renderer" + state + .registry + .has_reserved_path("/integrations/aps/renderer/v2"), + "Spin startup registry should reserve the APS v2 renderer" ); } diff --git a/crates/trusted-server-adapter-spin/src/lib.rs b/crates/trusted-server-adapter-spin/src/lib.rs index f47877ff2..5a6b20bc1 100644 --- a/crates/trusted-server-adapter-spin/src/lib.rs +++ b/crates/trusted-server-adapter-spin/src/lib.rs @@ -13,5 +13,15 @@ use spin_sdk::http_service; #[http_service] // FORCED: edgezero_adapter_spin::run_app returns anyhow::Result — EdgeZero SDK constraint, not a project choice. async fn handle(req: Request) -> anyhow::Result { + if trusted_server_core::integrations::aps::is_aps_family_path(req.uri().path()) { + let request = edgezero_adapter_spin::request::into_core_request(req).await?; + let response = app::dispatch_reserved(request) + .await + .map_err(|error| anyhow::anyhow!("{error:?}"))? + .expect("reserved APS path should dispatch before RouterService"); + return edgezero_adapter_spin::response::from_core_response(response) + .await + .map_err(Into::into); + } edgezero_adapter_spin::run_app::(req).await } diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 8e7cb4bf3..d239cc4a1 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -23,7 +23,8 @@ use std::io::Read as _; use trusted_server_core::platform::PlatformHttpRequest; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use trusted_server_core::platform::{ - PlatformPendingRequest, PlatformResponse, PlatformSelectResult, + PlatformPendingRequest, PlatformResponse, PlatformSelectResult, ProxyHeaderEvidenceV1, + ProxyResponseEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1, }; // 8 MiB ceiling: conservative for ad-server responses while leaving headroom in @@ -455,18 +456,61 @@ struct SpinPendingResponse { /// `StubHttpClient` driver records the one-send 3xx behavior while adapter tests /// cover request/response policy around that single boundary. /// -/// # Known MVP limits -/// -/// **No configurable outbound timeout.** `spin_sdk::http::send` does not -/// expose per-request timeout control, and [`PlatformBackendSpec::first_byte_timeout`] -/// is ignored by [`NoopBackend`]. A slow or hung origin will block the Spin -/// invocation for whatever default the Spin runtime imposes. Operators requiring -/// deterministic timeout behaviour should use the Fastly adapter. +/// Per-request raw-proxy calls lower the core connect, first-byte, and +/// between-byte deadlines into WASI HTTP [`RequestOptions`]. #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub struct SpinPlatformHttpClient; +#[cfg(all( + feature = "aps-runner-proxy-integration-test", + any(test, all(feature = "spin", target_arch = "wasm32")) +))] +fn aps_runner_proxy_transport_uri( + logical_uri: &str, + endpoint: &str, +) -> Result, Report> { + use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL; + + if logical_uri != APS_RUNNER_UPSTREAM_URL { + return Ok(None); + } + let parsed: edgezero_core::http::Uri = endpoint.parse().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("invalid APS runner proxy integration fixture endpoint") + })?; + if parsed.scheme_str() != Some("http") + || !matches!(parsed.host(), Some("127.0.0.1" | "::1")) + || parsed.port_u16().is_none() + || parsed.path().is_empty() + || parsed.query().is_some() + { + return Err(Report::new(PlatformError::HttpClient).attach( + "APS runner proxy integration fixture endpoint must be an explicit loopback HTTP URL", + )); + } + Ok(Some(endpoint.to_owned())) +} + #[cfg(all(feature = "spin", target_arch = "wasm32"))] impl SpinPlatformHttpClient { + #[cfg(all( + feature = "aps-runner-proxy-integration-test", + feature = "spin", + target_arch = "wasm32" + ))] + async fn aps_runner_proxy_test_transport_uri( + logical_uri: &str, + ) -> Result, Report> { + let endpoint = spin_sdk::variables::get("aps_runner_proxy_test_endpoint") + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient).attach( + "APS runner proxy integration artifact requires its loopback fixture endpoint", + ) + })?; + aps_runner_proxy_transport_uri(logical_uri, &endpoint) + } + async fn execute( &self, request: PlatformHttpRequest, @@ -552,6 +596,190 @@ impl SpinPlatformHttpClient { Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) } + + fn raw_header_evidence( + headers: &spin_sdk::wasip3::http::types::Headers, + name: &str, + ) -> ProxyHeaderEvidenceV1 { + ProxyHeaderEvidenceV1::Occurrences(headers.get(name)) + } + + fn canonical_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option { + let ProxyHeaderEvidenceV1::Occurrences(values) = evidence else { + return None; + }; + let [value] = values.as_slice() else { + return None; + }; + if value.is_empty() + || !value.iter().all(u8::is_ascii_digit) + || (value.len() > 1 && value[0] == b'0') + { + return None; + } + std::str::from_utf8(value).ok()?.parse().ok() + } + + async fn execute_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + use futures::{FutureExt as _, future::Either}; + use spin_sdk::http::IntoRequest as _; + use spin_sdk::wasip3::http::types::RequestOptions; + use spin_sdk::wasip3::http_compat::{IncomingResponseBody, RequestOptionsExtension}; + + reject_unsupported_request_contracts(&request)?; + let method = request.request.method().clone(); + let logical_uri = request.request.uri().to_string(); + #[cfg(feature = "aps-runner-proxy-integration-test")] + let transport_uri = Self::aps_runner_proxy_test_transport_uri(&logical_uri).await?; + let mut builder = spin_sdk::http::Request::builder() + .method(into_spin_method(&method)) + .uri(&logical_uri); + for (name, value) in request.request.headers() { + if is_wasi_forbidden_outbound_header(name.as_str()) { + continue; + } + builder = builder.header(name.as_str(), value.as_bytes()); + } + #[cfg(feature = "aps-runner-proxy-integration-test")] + if transport_uri.is_some() { + builder = builder.header("x-ts-aps-logical-url", logical_uri); + } + + let (_, request_body) = request.request.into_parts(); + let request_body = match request_body { + edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), + edgezero_core::body::Body::Stream(_) => { + return Err(Report::new(PlatformError::HttpClient) + .attach("streaming request bodies are not supported on Spin raw proxy")); + } + }; + let mut spin_request = builder + .body(spin_sdk::http::FullBody::new(Bytes::from(request_body))) + .map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to build Spin raw proxy request: {error}")) + })?; + + // Spin/Wasmtime owns the wire `Host` header and forbids guests from + // setting it. Keep the fixed APS URL through the core→adapter contract, + // then apply the loopback-only integration target at the final lowering + // boundary. Production builds have no transport override constructor. + #[cfg(feature = "aps-runner-proxy-integration-test")] + if let Some(transport_uri) = transport_uri { + *spin_request.uri_mut() = transport_uri.parse().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("failed to lower APS loopback transport URI") + })?; + } + + let connect_timeout_nanos = policy.total_timeout.as_nanos().try_into().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy connect timeout exceeds WASI HTTP duration range") + })?; + let first_byte_timeout_nanos = + policy + .first_byte_timeout + .as_nanos() + .try_into() + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy first-byte timeout exceeds WASI HTTP duration range") + })?; + let between_bytes_timeout_nanos = policy + .blocking_read_timeout + .as_nanos() + .try_into() + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy between-bytes timeout exceeds WASI HTTP duration range") + })?; + let options = RequestOptions::new(); + options + .set_connect_timeout(Some(connect_timeout_nanos)) + .map_err(|_| { + Report::new(PlatformError::Unsupported) + .attach("Spin raw proxy connect timeout is unavailable") + })?; + options + .set_first_byte_timeout(Some(first_byte_timeout_nanos)) + .map_err(|_| { + Report::new(PlatformError::Unsupported) + .attach("Spin raw proxy first-byte timeout is unavailable") + })?; + options + .set_between_bytes_timeout(Some(between_bytes_timeout_nanos)) + .map_err(|_| { + Report::new(PlatformError::Unsupported) + .attach("Spin raw proxy between-bytes timeout is unavailable") + })?; + spin_request + .extensions_mut() + .insert(RequestOptionsExtension(options)); + let wasi_request = spin_request.into_request().map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to lower Spin raw proxy request: {error}")) + })?; + + let operation = async move { + let response = spin_sdk::wasip3::http::client::send(wasi_request) + .await + .map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("Spin raw proxy request failed: {error}")) + })?; + let status = response.get_status_code(); + let headers = response.get_headers(); + let evidence = ProxyResponseEvidenceV1 { + status, + content_type: Self::raw_header_evidence(&headers, "content-type"), + content_encoding: Self::raw_header_evidence(&headers, "content-encoding"), + content_length: Self::raw_header_evidence(&headers, "content-length"), + }; + if Self::canonical_declared_length(&evidence.content_length) + .is_some_and(|length| length > policy.max_response_bytes) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy declared body exceeds configured cap")); + } + + let mut incoming = IncomingResponseBody::new(response).map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to open Spin raw proxy body: {error}")) + })?; + let mut body = Vec::new(); + while let Some(frame) = incoming.frame().await { + let frame = frame.map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to read Spin raw proxy body: {error}")) + })?; + let Ok(data) = frame.into_data() else { + continue; + }; + let next_len = body.len().checked_add(data.len()).ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow") + })?; + if next_len > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy body exceeds configured cap")); + } + body.extend_from_slice(&data); + } + Ok(RawProxyResponseV1 { evidence, body }) + } + .boxed_local(); + let deadline = spin_sdk::time::sleep(policy.total_timeout).boxed_local(); + match futures::future::select(operation, deadline).await { + Either::Left((result, _)) => result, + Either::Right(((), _)) => { + Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded")) + } + } + } } #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -571,6 +799,14 @@ impl PlatformHttpClient for SpinPlatformHttpClient { self.execute(request).await } + async fn send_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + self.execute_raw_proxy_v1(request, policy).await + } + async fn send_async( &self, request: PlatformHttpRequest, @@ -815,6 +1051,22 @@ mod tests { } } + #[cfg(feature = "aps-runner-proxy-integration-test")] + #[test] + fn aps_test_transport_mapping_preserves_logical_authority_until_lowering() { + use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL; + + let endpoint = "http://127.0.0.1:49152/prebid-creative.js"; + let transport = aps_runner_proxy_transport_uri(APS_RUNNER_UPSTREAM_URL, endpoint) + .expect("loopback integration endpoint should be accepted") + .expect("fixed APS URL should select the integration transport"); + assert_eq!(transport.to_string(), endpoint); + let logical: edgezero_core::http::Uri = APS_RUNNER_UPSTREAM_URL + .parse() + .expect("fixed APS URL should parse"); + assert_eq!(logical.host(), Some("client.aps.amazon-adsystem.com")); + } + fn make_ctx_without_spin_context() -> RequestContext { let req = request_builder() .method("GET") diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index f75ea687e..d9cfa64c7 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -20,14 +20,19 @@ use trusted_server_core::settings::Settings; /// The handler regex is the production-shaped `^/_ts/admin`, matching /// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical /// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. -fn test_router() -> RouterService { - let settings = Settings::from_toml( +fn test_settings() -> Settings { + Settings::from_toml( r#" [[handlers]] path = "^/_ts/admin" username = "admin" password = "admin-pass" + [[handlers]] + path = "^/integrations/aps" + username = "aps-user" + password = "aps-pass" + [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -36,11 +41,27 @@ fn test_router() -> RouterService { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [auction] + enabled = true + timeout_ms = 1000 + + [auction.providers.aps-main] + protocol = "openrtb-2.6" + profile = "aps" + endpoint = "https://aps.example/e/pb/bid" + routing = "all_eligible" + + [auction.providers.aps-main.profile_config] + account_id = "route-test-aps-account" + allow_script_creatives = true "#, ) - .expect("should parse route test settings"); + .expect("should parse route test settings") +} - TrustedServerApp::routes_with_settings(settings) +fn test_router() -> RouterService { + TrustedServerApp::routes_with_settings(test_settings()) .expect("should build router from test settings") } @@ -48,6 +69,13 @@ async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } +async fn route_reserved(req: Request) -> Response { + trusted_server_adapter_spin::app::dispatch_reserved_with_settings(test_settings(), req) + .await + .expect("should build APS dispatcher") + .expect("APS family should be reserved") +} + #[test] fn routes_build_without_panic() { // build_state() may fail (no real settings in CI) — startup_error_router @@ -55,6 +83,77 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn aps_cutover_renderer_and_family_failures_are_local() { + let renderer = request_builder() + .method("GET") + .uri("/integrations/aps/renderer/v2") + .header("authorization", "Bearer must-not-reach-publisher") + .body(edgezero_core::body::Body::empty()) + .expect("should build APS renderer request"); + let response = route_reserved(renderer).await; + assert_eq!(response.status().as_u16(), 200); + assert_eq!( + response.headers()["content-type"], + "text/html; charset=utf-8" + ); + assert_eq!( + response.headers()["cache-control"], + "public, max-age=31536000, immutable" + ); + assert!(!response.headers().contains_key("x-frame-options")); + let body = response.into_body().into_bytes().unwrap_or_default(); + let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); + assert!(body.contains("TS APS Bootstrap Ready")); + assert!(body.contains("TS APS Bootstrap Configure")); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(body.contains("data:text/html;charset=utf-8,")); + assert!(!body.contains("client.aps.amazon-adsystem.com")); + + for (method, path, expected) in [ + ("POST", "/integrations/aps/runner.js", 405), + ("TRACE", "/integrations/aps/renderer/v2", 405), + ("CONNECT", "/integrations/aps/renderer/v2", 405), + ("PROPFIND", "/integrations/aps/renderer/v2", 405), + ("GET", "/integrations/aps/renderer", 404), + ("GET", "/integrations/aps/renderer/v1", 404), + ("GET", "/integrations/aps/runner/v1.js", 404), + ("GET", "/integrations/aps", 404), + ] { + let request = request_builder() + .method(method) + .uri(path) + .header("authorization", "Bearer must-not-reach-publisher") + .body(edgezero_core::body::Body::empty()) + .expect("should build APS family request"); + let response = route_reserved(request).await; + assert_eq!(response.status().as_u16(), expected, "{method} {path}"); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response.headers().contains_key("x-geo-info-available")); + if expected == 405 { + assert_eq!(response.headers()["allow"], "GET"); + assert_eq!(response.headers().len(), 2, "{method} {path}"); + } else { + assert_eq!(response.headers().len(), 1, "{method} {path}"); + } + assert!( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .is_empty() + ); + } + + let protected_control = request_builder() + .method("GET") + .uri("/integrations/apsx") + .body(edgezero_core::body::Body::empty()) + .expect("should build protected non-APS boundary request"); + let response = route(test_router(), protected_control).await; + assert_eq!(response.status().as_u16(), 401); +} + #[test] fn edgezero_manifest_loads_and_resolves_spin_stores() { let loader = edgezero_core::manifest::ManifestLoader::load_from_str(include_str!( @@ -366,33 +465,32 @@ async fn tsjs_route_is_routed_not_5xx() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tsjs_route_matching_hash_uses_s_maxage_fallback() { - let router = test_router(); - let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]); - let req = request_builder() - .method("GET") - .uri(src) - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - - let resp = route(router, req).await; +async fn tsjs_wrong_methods_are_local_no_store_404s() { + for method in ["HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] { + let req = request_builder() + .method(method) + .uri(format!( + "/static/tsjs=tsjs-unified.min.js?v={}", + "0".repeat(64) + )) + .body(edgezero_core::body::Body::empty()) + .expect("should build wrong-method TSJS request"); + let response = route(test_router(), req).await; - assert_eq!( - resp.status().as_u16(), - 200, - "matching TSJS hash should serve OK" - ); - assert_eq!( - resp.headers() - .get("cache-control") - .and_then(|value| value.to_str().ok()), - Some("public, max-age=31536000, s-maxage=31536000, immutable"), - "Spin adapter should render the portable s-maxage fallback" - ); - assert!( - resp.headers().get("surrogate-control").is_none(), - "s-maxage fallback must not emit Fastly Surrogate-Control" - ); + assert_eq!(response.status().as_u16(), 404, "method {method}"); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "method {method}" + ); + assert!( + !response.headers().contains_key("location"), + "method {method}" + ); + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -516,54 +614,30 @@ async fn auction_is_routed() { assert_ne!(resp.status().as_u16(), 404, "/auction must be routed"); } -/// `GET` on the SPA re-auction endpoint must reach the page-bids handler on -/// both the canonical path and its deprecated `/__ts/` alias. -/// -/// The alias is what pre-rename tsjs bundles still request, and on a SPA that -/// path is what delivers ads for in-session navigations — so a dropped or -/// misspelled registration silently costs revenue rather than erroring loudly. -/// Spin registers `GET` and `OPTIONS` separately, so the preflight-denial parity -/// test does not imply the `GET` side is wired. -/// -/// Paths are literals rather than `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`: -/// this pins the actual URL the client fetches, which asserting a const against -/// itself would not. -/// -/// These test settings configure no creative opportunities, so the handler's own -/// deterministic answer is a 404 `Creative opportunities not configured`. That -/// body is the anchor: an unregistered path would instead fall through to the -/// publisher fallback and attempt an outbound fetch to the (nonexistent) test -/// origin, which cannot produce this message. A bare `!= 404` check would be -/// wrong here — the handler legitimately returns 404 under this config. +/// The canonical SPA re-auction path reaches page-bids, while the removed +/// double-underscore alias is denied locally with 404. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn page_bids_get_is_routed_on_canonical_path_and_alias() { - let mut responses = Vec::new(); - - for path in ["/_ts/page-bids", "/__ts/page-bids"] { - let req = request_builder() - .method("GET") - .uri(path) - .header("sec-fetch-site", "same-origin") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let resp = route(test_router(), req).await; - let status = resp.status().as_u16(); - let body = String::from_utf8_lossy(&resp.into_body().into_bytes().unwrap_or_default()) +async fn page_bids_get_is_routed_only_on_the_canonical_path() { + let canonical = request_builder() + .method("GET") + .uri("/_ts/page-bids") + .header("sec-fetch-site", "same-origin") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let canonical = route(test_router(), canonical).await; + let canonical_body = + String::from_utf8_lossy(&canonical.into_body().into_bytes().unwrap_or_default()) .into_owned(); + assert!(canonical_body.contains("Creative opportunities not configured")); - assert!( - body.contains("Creative opportunities not configured"), - "GET {path} must reach the page-bids handler, \ - got status {status} body {body:?}" - ); - - responses.push((status, body)); - } - - assert_eq!( - responses[0], responses[1], - "the deprecated alias must answer identically to the canonical path" - ); + let former_alias = request_builder() + .method("GET") + .uri("/__ts/page-bids") + .header("sec-fetch-site", "same-origin") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let former_alias = route(test_router(), former_alias).await; + assert_eq!(former_alias.status().as_u16(), 404); } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-cli/src/ad_templates/expected.rs b/crates/trusted-server-cli/src/ad_templates/expected.rs index 9392963ff..1f210dda6 100644 --- a/crates/trusted-server-cli/src/ad_templates/expected.rs +++ b/crates/trusted-server-cli/src/ad_templates/expected.rs @@ -152,7 +152,8 @@ mod tests { .collect::>() .join(", "); let toml = format!( - "gam_network_id = \"123\"\n\ + "enabled = true\n\ + gam_network_id = \"123\"\n\ \n\ [[slot]]\n\ id = \"atf\"\n\ @@ -202,7 +203,8 @@ mod tests { #[test] fn expected_slots_default_resolution_without_overrides() { - let toml = "gam_network_id = \"42\"\n\ + let toml = "enabled = true\n\ + gam_network_id = \"42\"\n\ \n\ [[slot]]\n\ id = \"footer\"\n\ @@ -223,7 +225,8 @@ mod tests { #[test] fn expected_slots_render_section_templates_per_path() { - let toml = "gam_network_id = \"99999\"\n\ + let toml = "enabled = true\n\ + gam_network_id = \"99999\"\n\ section_root = \"homepage\"\n\ \n\ [[slot]]\n\ @@ -256,9 +259,10 @@ mod tests { #[test] fn expected_slots_omit_dynamic_template_the_runtime_cannot_render() { // A `{section}` template that renders past GAM's 100-byte unit-path - // limit. The runtime omits this slot for the request path, so diagnostics - // must not match it against a truncated or otherwise different path. - let toml = "gam_network_id = \"99999\"\n\ + // limit. `validate_runtime` rejects this config, so the verifier reports + // the slot as unconfirmable rather than matching a truncated path. + let toml = "enabled = true\n\ + gam_network_id = \"99999\"\n\ section_root = \"homepage\"\n\ \n\ [[slot]]\n\ diff --git a/crates/trusted-server-cli/src/commands/audit/ad_templates.rs b/crates/trusted-server-cli/src/commands/audit/ad_templates.rs index 0e2b51371..8155e2724 100644 --- a/crates/trusted-server-cli/src/commands/audit/ad_templates.rs +++ b/crates/trusted-server-cli/src/commands/audit/ad_templates.rs @@ -632,7 +632,8 @@ mod tests { } fn news_config() -> CreativeOpportunitiesConfig { - let toml = "gam_network_id = \"123\"\n\ + let toml = "enabled = true\n\ + gam_network_id = \"123\"\n\ \n\ [[slot]]\n\ id = \"atf\"\n\ diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs index 490ccba80..61952c1a8 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs @@ -1724,7 +1724,7 @@ mod tests { #[test] fn merge_refuses_to_change_policy_used_by_preserved_templates() { let existing: CreativeOpportunitiesConfig = toml::from_str( - "gam_network_id = \"123\"\nsection_root = \"home\"\nsection_segment = 0\n\ + "enabled = true\ngam_network_id = \"123\"\nsection_root = \"home\"\nsection_segment = 0\n\ [[slot]]\nid = \"header\"\ndiv_id = \"ad-header\"\n\ gam_unit_path = \"/{network_id}/site/{section}\"\npage_patterns = [\"/\"]\n\ formats = [{ width = 728, height = 90 }]\n", @@ -1804,7 +1804,7 @@ mod tests { // rather than demand `--replace` (which would discard the hand-tuned // slots it is preserving). let existing: CreativeOpportunitiesConfig = toml::from_str( - "gam_network_id = \"123\"\n\ + "enabled = true\ngam_network_id = \"123\"\n\ [[slot]]\nid = \"header\"\ndiv_id = \"ad-header\"\n\ gam_unit_path = \"/{network_id}/site/{section}\"\npage_patterns = [\"/\"]\n\ formats = [{ width = 728, height = 90 }]\n", @@ -1822,7 +1822,7 @@ mod tests { #[test] fn merge_preserves_an_explicit_segment_when_section_root_is_unset() { let existing: CreativeOpportunitiesConfig = toml::from_str( - "gam_network_id = \"123\"\nsection_segment = 1\n\ + "enabled = true\ngam_network_id = \"123\"\nsection_segment = 1\n\ [[slot]]\nid = \"header\"\ndiv_id = \"ad-header\"\n\ gam_unit_path = \"/{network_id}/site/{section}\"\npage_patterns = [\"/\"]\n\ formats = [{ width = 728, height = 90 }]\n", @@ -2143,7 +2143,7 @@ mod tests { let config_path = temp.path().join("trusted-server.toml"); fs::write( &config_path, - "[creative_opportunities]\ngam_network_id = \"111\"\n", + "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n", ) .expect("should write config"); // The requested URL redirects; slots are scraped from the final page. @@ -2862,7 +2862,7 @@ mod tests { fn update_slots_rejects_invalid_page_pattern_without_touching_config() { let temp = TempDir::new().expect("should create temp dir"); let config_path = temp.path().join("trusted-server.toml"); - let original = "[creative_opportunities]\ngam_network_id = \"111\"\n"; + let original = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n"; fs::write(&config_path, original).expect("should write config"); let collector = FakeCollector::new(collected_page_with_header_slot()); let mut out = Vec::new(); @@ -2971,7 +2971,7 @@ mod tests { let config_path = temp.path().join("trusted-server.toml"); fs::write( &config_path, - "[creative_opportunities]\ngam_network_id = \"111\"\n", + "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n", ) .expect("should write config"); let collector = FakeCollector::new(collected_page_with_header_slot()); @@ -3009,7 +3009,7 @@ mod tests { let config_path = temp.path().join("trusted-server.toml"); fs::write( &config_path, - "[creative_opportunities]\ngam_network_id = \"111\"\n", + "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n", ) .expect("should write config"); let collector = FakeCollector::new(collected_page_with_header_slot()); diff --git a/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs b/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs index f795ea41b..2215010cf 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs @@ -673,6 +673,9 @@ pub(super) fn splice_creative_slots( // retain positions from their source document, so anchor the whole subtree // here to keep the parent, slots, and provider tables together. creative.set_position(section_position); + if !had_section { + creative["enabled"] = toml_edit::value(true); + } if let Some(network_id) = keys.network_id { creative["gam_network_id"] = toml_edit::value(network_id); } @@ -688,13 +691,17 @@ pub(super) fn splice_creative_slots( if uses_crlf(existing) { result = convert_document_lf_to_crlf(&result); } - ensure_only_managed_fields_changed(existing, &result)?; + ensure_only_managed_fields_changed(existing, &result, !had_section)?; Ok(result) } /// Verifies that the structural update changed only generator-managed fields. -fn ensure_only_managed_fields_changed(before: &str, after: &str) -> CliResult<()> { - fn unmanaged(document: &str) -> CliResult { +fn ensure_only_managed_fields_changed( + before: &str, + after: &str, + generated_enabled: bool, +) -> CliResult<()> { + fn unmanaged(document: &str, remove_enabled: bool) -> CliResult { let mut value = toml::from_str::(document) .map_err(|error| report_error(format!("failed to validate updated config: {error}")))?; if let Some(root) = value.as_table_mut() { @@ -705,6 +712,9 @@ fn ensure_only_managed_fields_changed(before: &str, after: &str) -> CliResult<() for key in ["slot", "gam_network_id", "section_root", "section_segment"] { creative.remove(key); } + if remove_enabled { + creative.remove("enabled"); + } creative.is_empty() } else { false @@ -716,7 +726,7 @@ fn ensure_only_managed_fields_changed(before: &str, after: &str) -> CliResult<() Ok(value) } - if unmanaged(before)? != unmanaged(after)? { + if unmanaged(before, false)? != unmanaged(after, generated_enabled)? { return cli_error( "refusing to update config because fields outside the managed \ creative-opportunities keys would change", @@ -955,13 +965,14 @@ slot_id = "sidebar" } fn existing_config(toml_str: &str) -> CreativeOpportunitiesConfig { - toml::from_str::(toml_str).expect("valid creative config") + toml::from_str::(&format!("enabled = true\n{toml_str}")) + .expect("valid creative config") } #[test] fn splice_replaces_slots_and_preserves_other_sections() { let existing = "[publisher]\ndomain = \"x\"\n\n\ - [creative_opportunities]\ngam_network_id = \"111\"\nprice_granularity = \"dense\"\n\n\ + [creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\nprice_granularity = \"dense\"\n\n\ [[creative_opportunities.slot]]\nid = \"old\"\ndiv_id = \"old\"\n\ gam_unit_path = \"/111/old\"\npage_patterns = [\"/\"]\n\ formats = [{ width = 300, height = 250 }]\n\n\ @@ -974,6 +985,10 @@ slot_id = "sidebar" out.contains("gam_network_id = \"222\""), "network id updated" ); + assert!( + out.contains("enabled = true"), + "hard-cutover enablement should be preserved" + ); assert!(!out.contains("id = \"old\""), "old slot removed"); assert!( out.contains("gam_unit_path = \"/222/homepage/header\""), @@ -991,8 +1006,11 @@ slot_id = "sidebar" } #[test] - fn splice_updates_a_quoted_section_header_structurally() { - let existing = "[\"creative_opportunities\"]\ngam_network_id = \"111\"\n"; + fn splice_rejects_quoted_section_header_instead_of_duplicating_it() { + // A quoted header is valid TOML but the line-based splice does not + // recognise it; appending a second `[creative_opportunities]` would + // produce a document that no longer parses. + let existing = "[\"creative_opportunities\"]\nenabled = true\ngam_network_id = \"111\"\n"; let updated = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) .expect("should update quoted table structurally"); @@ -1094,7 +1112,7 @@ slot_id = "sidebar" #[test] fn splice_rejects_top_level_inline_creative_opportunities_table() { - let existing = "creative_opportunities = { gam_network_id = \"111\" }\n"; + let existing = "creative_opportunities = { enabled = true, gam_network_id = \"111\" }\n"; let error = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) .expect_err("should refuse a top-level inline table"); @@ -1122,7 +1140,7 @@ slot_id = "sidebar" fn splice_inserts_section_policy_keys_a_config_does_not_have_yet() { // The whole point of `upsert`: every config predating templating lacks // these keys, so a replace-only writer could never add them. - let existing = "[creative_opportunities]\ngam_network_id = \"111\"\n\n\ + let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\n\ [auction]\nenabled = true\n"; let out = splice_creative_slots( @@ -1146,7 +1164,7 @@ slot_id = "sidebar" #[test] fn splice_replaces_section_policy_keys_that_are_already_present() { - let existing = "[creative_opportunities]\ngam_network_id = \"111\"\n\ + let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\ section_root = \"old\"\nsection_segment = 2\n"; let out = splice_creative_slots( @@ -1172,7 +1190,7 @@ slot_id = "sidebar" // `section_root`/`section_segment` are `deny_unknown_fields` additions: // writing them into a config that does not need them would make it // unloadable by an older binary for no benefit. - let existing = "[creative_opportunities]\ngam_network_id = \"111\"\n"; + let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n"; let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) .expect("should splice"); @@ -1196,11 +1214,35 @@ slot_id = "sidebar" let value = toml::from_str::(&out).expect("valid TOML"); let creative = &value["creative_opportunities"]; + assert_eq!(creative["enabled"].as_bool(), Some(true)); assert_eq!(creative["gam_network_id"].as_str(), Some("222")); assert_eq!(creative["section_root"].as_str(), Some("homepage")); assert_eq!(creative["section_segment"].as_integer(), Some(0)); } + #[test] + fn splice_keeps_an_inserted_key_inside_the_section_scalar_block() { + // Appending at the end of the section would land the key after a + // subtable, where TOML reads it as part of that subtable instead. + let document = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\n\ + [[creative_opportunities.slot]]\nid = \"a\"\n\ + page_patterns = [\"/\"]\nformats = [{ width = 1, height = 1 }]\n"; + + let out = splice_creative_slots( + document, + &template_keys("111", "homepage", 0), + &header_rendered(), + ) + .expect("should insert"); + + let value = toml::from_str::(&out).expect("valid TOML"); + assert_eq!( + value["creative_opportunities"]["section_root"].as_str(), + Some("homepage"), + "the key must belong to the section, not the slot subtable" + ); + } + #[test] fn splice_refuses_fresh_section_without_a_network_id() { // Reachable whenever the scraped unit path has no all-digit leading @@ -1234,6 +1276,10 @@ slot_id = "sidebar" value["creative_opportunities"]["gam_network_id"].as_str(), Some("222") ); + assert_eq!( + value["creative_opportunities"]["enabled"].as_bool(), + Some(true) + ); } #[test] @@ -1241,6 +1287,7 @@ slot_id = "sidebar" // Mirrors the templated operator shape: section policy scalars in the // head block and a per-slot prebid provider subtable. let existing = "[creative_opportunities]\n\ + enabled = true\n\ gam_network_id = \"111\"\n\ auction_timeout_ms = 2000\n\ section_root = \"homepage\"\n\n\ @@ -1256,6 +1303,7 @@ slot_id = "sidebar" let existing_config = existing_config( &existing .replace("[creative_opportunities]\n", "") + .replacen("enabled = true\n", "", 1) .replace("[[creative_opportunities.slot]]", "[[slot]]") .replace("[creative_opportunities.slot.", "[slot.") .replace("\n[auction]\nenabled = true\n", ""), @@ -1297,7 +1345,7 @@ slot_id = "sidebar" #[test] fn splice_preserves_crlf_line_endings() { - let existing = "[creative_opportunities]\r\ngam_network_id = \"111\"\r\n\r\n\ + let existing = "[creative_opportunities]\r\nenabled = true\r\ngam_network_id = \"111\"\r\n\r\n\ [auction]\r\nenabled = true\r\n"; let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) @@ -1487,7 +1535,7 @@ slot_id = "sidebar" fn splice_recognizes_inline_commented_section_header() { // `[creative_opportunities] # comment` is valid TOML; the splice must // update it in place instead of appending a duplicate section. - let existing = "[creative_opportunities] # ad templates\ngam_network_id = \"111\"\n\n\ + let existing = "[creative_opportunities] # ad templates\nenabled = true\ngam_network_id = \"111\"\n\n\ [auction] # flags\nenabled = true\n"; let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) @@ -1519,8 +1567,7 @@ slot_id = "sidebar" #[test] fn splice_inserts_when_no_existing_slots() { - let existing = - "[creative_opportunities]\ngam_network_id = \"111\"\n\n[auction]\nenabled = true\n"; + let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\n[auction]\nenabled = true\n"; let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) .expect("should splice"); @@ -1545,6 +1592,7 @@ slot_id = "sidebar" #[test] fn splice_replaces_inline_slot_array() { let existing = "[creative_opportunities]\n\ + enabled = true\n\ gam_network_id = \"111\"\n\ slot = [{ id = \"old\", div_id = \"old\", gam_unit_path = \"/111/old\", page_patterns = [\"/\"], formats = [{ width = 300, height = 250 }] }]\n\n\ [auction]\nenabled = true\n"; @@ -1568,6 +1616,7 @@ slot_id = "sidebar" #[test] fn splice_replaces_inline_slot_map() { let existing = "[creative_opportunities]\n\ + enabled = true\n\ gam_network_id = \"111\"\n\ slot = { \"0\" = { id = \"old\", div_id = \"old\", gam_unit_path = \"/111/old\", page_patterns = [\"/\"], formats = [{ width = 300, height = 250 }] } }\n"; @@ -2205,7 +2254,7 @@ slot_id = "sidebar" #[test] fn replace_key_handles_inline_commented_headers() { - let document = "[creative_opportunities] # managed\ngam_network_id = \"111\"\n\n\ + let document = "[creative_opportunities] # managed\nenabled = true\ngam_network_id = \"111\"\n\n\ [auction] # flags\nenabled = true\n"; let updated = replace_key_in_section( @@ -2242,7 +2291,7 @@ slot_id = "sidebar" false, ); let doc = format!( - "[creative_opportunities]\ngam_network_id = \"1\"\n{}", + "[creative_opportunities]\nenabled = true\ngam_network_id = \"1\"\n{}", render_slots(&merged) ); diff --git a/crates/trusted-server-cli/src/commands/audit/mod.rs b/crates/trusted-server-cli/src/commands/audit/mod.rs index 211060662..d2f549f96 100644 --- a/crates/trusted-server-cli/src/commands/audit/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/mod.rs @@ -484,7 +484,7 @@ mod tests { #[test] fn invalid_setting_outside_the_section_still_yields_creative_config() { let document = "unknown_runtime_key = true\n\ - [creative_opportunities]\ngam_network_id = \"123\"\n"; + [creative_opportunities]\nenabled = true\ngam_network_id = \"123\"\n"; let creative = creative_config(document, std::path::Path::new("trusted-server.toml")) .expect("an unrelated invalid setting must not hide creative config") diff --git a/crates/trusted-server-cli/tests/config_env_overlay.rs b/crates/trusted-server-cli/tests/config_env_overlay.rs index 45eab5cc9..abe0006cb 100644 --- a/crates/trusted-server-cli/tests/config_env_overlay.rs +++ b/crates/trusted-server-cli/tests/config_env_overlay.rs @@ -47,11 +47,12 @@ fn migrated_project() -> MigratedProject { let mut document = LEGACY_CONFIG .parse::() .expect("should parse legacy integration config"); - // EdgeZero environment overlays cannot create missing TOML leaves, - // so a migrated config must carry every leaf whose environment override is + // EdgeZero environment overlays cannot create missing TOML leaves, so a + // migrated config must carry every leaf whose environment override is // expected to take effect. document["auction"]["rewrite_creatives"] = value(true); document["auction"]["sanitize_creatives"] = value(false); + document["integrations"]["gpt"]["gam_attribution_enabled"] = value(false); document["creative_opportunities"]["enabled"] = value(true); document["creative_opportunities"]["gam_network_id"] = value("123456789"); document["auction"]["providers"]["pbs-main"] = toml_edit::table(); @@ -165,7 +166,7 @@ fn migrated_config_applies_boolean_environment_overrides() { assert_eq!( envelope["data"]["integrations"]["gpt"]["gam_attribution_enabled"], serde_json::Value::Bool(true), - "pushed config should contain the GAM attribution environment override" + "pushed config should contain the GAM attribution environment override: {envelope}" ); assert_eq!( envelope["data"]["creative_opportunities"]["enabled"], diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 19aa0b82f..ead5be30f 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -8,6 +8,7 @@ use trusted_server_core::streaming_processor::StreamProcessor as _; fn make_config() -> HtmlProcessorConfig { HtmlProcessorConfig { csp_nonce_observed: None, + csp_nonce: None, origin_host: "origin.bench.example.com".to_string(), request_host: "proxy.bench.example.com".to_string(), request_scheme: "https".to_string(), @@ -16,11 +17,9 @@ fn make_config() -> HtmlProcessorConfig { ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, - // The benchmark measures URL rewriting, not ad injection, and - // `ad_slots_script` is `None` here — matching the previous behaviour, - // which inferred no body-close work from that. - body_close: BodyCloseInjection::None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, + body_close: BodyCloseInjection::None, } } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index de05dfdd8..1966ce9ee 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -1,6 +1,6 @@ //! HTTP endpoint handlers for auction requests. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; @@ -20,20 +20,21 @@ use crate::ec::log_id; use crate::ec::prebid_eids::parse_prebid_eids_cookie; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; +use crate::http_util::RequestInfo; use crate::openrtb::{Eid, Uid}; use crate::platform::RuntimeServices; use crate::settings::Settings; use super::AuctionOrchestrator; -use super::formats::{ - convert_to_openrtb_response, convert_to_openrtb_response_with_report, - convert_tsjs_to_auction_request, -}; +use super::formats::{attach_auction_response_headers, convert_tsjs_to_auction_request}; use super::telemetry::{ AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events, emit_auction_events_best_effort_lazy, }; -use super::types::AuctionContext; +use super::types::{ + AuctionContext, AuctionDecisionSetV1, AuctionRequest, AuctionSlotFailureReason, + SlotAuctionDecisionV1, SystemAuctionIdentityGenerator, +}; const MAX_CLIENT_EID_SOURCES: usize = 64; const MAX_CLIENT_UIDS_PER_SOURCE: usize = 32; @@ -45,6 +46,66 @@ const MAX_CLIENT_EID_SOURCE_BYTES: usize = 255; /// arbitrary WASM linear memory. const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; +struct ExactAuctionResponseV1 { + response: Response, + delivered_winner_slots: HashSet, + dropped_winner_count: usize, +} + +fn exact_auction_response_v1( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + request_origin: &str, + ec_allowed: bool, +) -> Result> { + let price_granularity = settings + .creative_opportunities + .as_ref() + .map(|config| config.price_granularity) + .unwrap_or_default(); + let canonical = crate::publisher::coordinated_cutover_v1::build_browser_auction_projection_v1( + result, + price_granularity, + settings, + request_origin, + None, + &SystemAuctionIdentityGenerator, + )?; + let body = crate::auction::formats::coordinated_cutover_v1::serialize_trusted_server_auction_response_v1( + &canonical, + )?; + let delivered_winner_slots: HashSet = canonical + .projection + .auction + .results + .iter() + .filter_map(|decision| match decision { + SlotAuctionDecisionV1::Winner { slot, .. } => Some(slot.clone()), + _ => None, + }) + .collect(); + let projected_winner_count = result + .decision_set + .results + .iter() + .filter(|decision| matches!(decision, SlotAuctionDecisionV1::Winner { .. })) + .count(); + let mut response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from(body)) + .change_context(TrustedServerError::Auction { + message: "Failed to build exact auction response".to_string(), + })?; + attach_auction_response_headers(&mut response, auction_request, ec_allowed)?; + Ok(ExactAuctionResponseV1 { + response, + dropped_winner_count: projected_winner_count.saturating_sub(delivered_winner_slots.len()), + delivered_winner_slots, + }) +} + /// Handle auction request from `POST /auction`. /// /// Accepts a JSON body matching [`AdRequest`][`super::formats::AdRequest`]. @@ -168,6 +229,22 @@ pub async fn handle_auction( ); let http_req = Request::from_parts(parts, EdgeBody::empty()); + let request_info = RequestInfo::from_request(&http_req, services.client_info()); + let request_scheme = if request_info.scheme.is_empty() { + http_req.uri().scheme_str().unwrap_or("https") + } else { + &request_info.scheme + }; + let request_host = if request_info.host.is_empty() { + http_req + .uri() + .authority() + .map(http::uri::Authority::as_str) + .unwrap_or(&settings.publisher.domain) + } else { + &request_info.host + }; + let request_origin = format!("{request_scheme}://{request_host}"); // Story 5 middleware contract: auction is a read-only EC route. // It must not generate EC IDs; it only consumes pre-routed context. @@ -196,13 +273,19 @@ pub async fn handle_auction( winning_bids: HashMap::new(), total_time_ms: 0, metadata: HashMap::new(), + decision_set: AuctionDecisionSetV1::failed( + &auction_request, + AuctionSlotFailureReason::AuctionDisabled, + ), }; - return convert_to_openrtb_response( + return Ok(exact_auction_response_v1( &empty_result, settings, &auction_request, + &request_origin, ec_context.ec_allowed(), - ); + )? + .response); } // Server-side auction consent gate. The publisher-navigation and @@ -247,15 +330,21 @@ pub async fn handle_auction( provider_responses: Vec::new(), mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1::failed( + &auction_request, + AuctionSlotFailureReason::ConsentDenied, + ), total_time_ms: 0, metadata: HashMap::new(), }; - return convert_to_openrtb_response( + return Ok(exact_auction_response_v1( &empty_result, settings, &auction_request, + &request_origin, ec_context.ec_allowed(), - ); + )? + .response); } // Parse client-provided EIDs from the current request body. When the @@ -353,10 +442,11 @@ pub async fn handle_auction( } }; - let conversion = match convert_to_openrtb_response_with_report( + let conversion = match exact_auction_response_v1( &result, settings, &auction_request, + &request_origin, ec_context.ec_allowed(), ) { Ok(conversion) => conversion, @@ -384,7 +474,7 @@ pub async fn handle_auction( AuctionTerminalOutcome::Completed { request: &auction_request, result: &result, - delivered_winner_slots: Some(&conversion.delivery.delivered_winner_slots), + delivered_winner_slots: Some(&conversion.delivered_winner_slots), }, ) }) @@ -393,8 +483,8 @@ pub async fn handle_auction( log::info!( "Auction completed: {} providers, {} delivered winning bids, {} dropped winners, {}ms total", result.provider_responses.len(), - conversion.delivery.delivered_winner_slots.len(), - conversion.delivery.dropped_winner_count, + conversion.delivered_winner_slots.len(), + conversion.dropped_winner_count, result.total_time_ms ); @@ -599,10 +689,9 @@ mod tests { use crate::consent::types::ConsentContext; use crate::openrtb::Uid; use crate::platform::test_support::{ - NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, StubHttpClient, - noop_services, + NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, noop_services, }; - use crate::platform::{ClientInfo, PlatformHttpClient, PlatformHttpRequest, PlatformResponse}; + use crate::platform::{ClientInfo, PlatformResponse}; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; @@ -688,14 +777,12 @@ mod tests { } } - /// Provider used to prove that direct `/auction` remains available when - /// publisher server-side ad templates are disabled. - struct TemplateSwitchProbeProvider { - calls: Arc>, + struct CallRecordingProvider { + called: Arc>, } #[async_trait::async_trait(?Send)] - impl AuctionProvider for TemplateSwitchProbeProvider { + impl AuctionProvider for CallRecordingProvider { fn provider_name(&self) -> &'static str { "template-switch-probe" } @@ -703,26 +790,12 @@ mod tests { async fn request_bids( &self, _request: &AuctionRequest, - context: &AuctionContext<'_>, + _context: &AuctionContext<'_>, ) -> Result> { - *self.calls.lock().expect("should lock provider call count") += 1; - let request = Request::builder() - .method("POST") - .uri("https://bidder.example/auction") - .body(EdgeBody::empty()) - .expect("should build probe provider request"); - context - .services - .http_client() - .send_async(PlatformHttpRequest::new( - request, - "template-switch-probe-backend", - )) - .await - .change_context(TrustedServerError::Auction { - message: "probe provider launch failed".to_string(), - }) - .map(ProviderRequestOutcome::pending) + *self.called.lock().expect("should lock provider call flag") = true; + Err(Report::new(TrustedServerError::Auction { + message: "call recorded".to_string(), + })) } async fn parse_response( @@ -730,11 +803,7 @@ mod tests { _response: PlatformResponse, _response_time_ms: u64, ) -> Result> { - Ok(AuctionResponse::success( - self.provider_name(), - Vec::new(), - 0, - )) + panic!("parse_response must not run when the launch returns an error"); } fn timeout_ms(&self) -> u32 { @@ -742,7 +811,7 @@ mod tests { } fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { - Some("template-switch-probe-backend".to_string()) + Some("call-recording-backend".to_string()) } } @@ -754,23 +823,12 @@ mod tests { ); let settings = Settings::from_toml(&settings_toml) .expect("should parse settings with disabled templates"); - let calls = Arc::new(Mutex::new(0)); + let called = Arc::new(Mutex::new(false)); let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - orchestrator.register_provider(Arc::new(TemplateSwitchProbeProvider { - calls: Arc::clone(&calls), + orchestrator.register_provider(Arc::new(CallRecordingProvider { + called: Arc::clone(&called), })); - - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"probe response".to_vec()); - let services = RuntimeServices::builder() - .config_store(Arc::new(NoopConfigStore)) - .secret_store(Arc::new(NoopSecretStore)) - .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) - .backend(Arc::new(NoopBackend)) - .http_client(Arc::clone(&stub) as Arc) - .geo(Arc::new(NoopGeo)) - .client_info(ClientInfo::default()) - .build(); + let services = noop_services(); let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); let body = json!({ "adUnits": [{ @@ -786,7 +844,7 @@ mod tests { )) .expect("should build auction request"); - let response = handle_auction( + let _ = handle_auction( &settings, &orchestrator, None, @@ -795,15 +853,12 @@ mod tests { &services, req, ) - .await - .expect("direct auction should remain available"); + .await; - assert_eq!( - *calls.lock().expect("should lock provider call count"), - 1, - "disabling publisher templates must not disable direct /auction" + assert!( + *called.lock().expect("should lock provider call flag"), + "direct /auction must remain live when publisher template delivery is disabled" ); - assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] @@ -871,6 +926,20 @@ mod tests { seatbid_empty, "gated auction must return no bids, got: {parsed}" ); + assert_eq!(parsed["cur"], "USD"); + assert_eq!( + parsed["ext"]["trusted_server"]["slot_results"]["results"][0], + json!({ + "slot": "div-gpt-ad-1", + "outcome": "failed", + "reason": "consent_denied" + }), + "the production endpoint must emit the exact decision-set extension" + ); + assert!( + parsed["ext"].get("orchestrator").is_none(), + "the removed legacy response extension must not survive the hard cutover" + ); let batches = telemetry_sink .batches diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 571d9d484..b3474abc7 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -7,7 +7,7 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt, ensure}; use http::{HeaderValue, Request, Response, StatusCode, header}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use std::collections::{BTreeMap, HashMap, HashSet}; use url::Url; @@ -29,8 +29,12 @@ use crate::settings::Settings; use super::orchestrator::OrchestrationResult; use super::types::{ - AdFormat, AdSlot, AuctionRequest, BidRenderer, DeviceInfo, MediaType, OrchestratorExt, - ProviderSummary, PublisherInfo, SiteInfo, UserInfo, + AdFormat, AdSlot, AuctionDecisionSetV1, AuctionDropReason, AuctionDropReasons, AuctionRequest, + AuctionSlotFailureReason, BidRenderSourceV1, BrowserAuctionBidV1, BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, DeviceInfo, MAX_BROWSER_AUCTION_PROJECTION_BYTES, + MAX_BROWSER_AUCTION_RESULTS, MAX_BROWSER_AUCTION_TARGETING_ENTRIES, MediaType, OrchestratorExt, + ProviderSummary, PublisherInfo, RENDER_DIMENSION_MAX, RENDER_DIMENSION_MIN, SiteInfo, + SlotAuctionDecisionV1, UserInfo, classify_aps_renderer_v1, record_auction_drop, }; /// Request body for `POST /auction` (tsjs / Prebid.js wire format). @@ -281,6 +285,499 @@ pub fn convert_tsjs_to_auction_request( }) } +/// Attach the consent/EID headers shared by every `/auction` response wire. +pub(crate) fn attach_auction_response_headers( + response: &mut Response, + auction_request: &AuctionRequest, + ec_allowed: bool, +) -> Result<(), Report> { + if ec_allowed { + response + .headers_mut() + .insert(HEADER_X_TS_EC_CONSENT, HeaderValue::from_static("ok")); + } + + if let Some(ref eids) = auction_request.user.eids { + let (encoded, truncated) = encode_eids_header(eids)?; + let header_val = + HeaderValue::from_str(&encoded).change_context(TrustedServerError::Auction { + message: "Failed to encode EIDs header value".to_string(), + })?; + response.headers_mut().insert(HEADER_X_TS_EIDS, header_val); + if truncated { + response + .headers_mut() + .insert(HEADER_X_TS_EIDS_TRUNCATED, HeaderValue::from_static("true")); + } + } + + Ok(()) +} + +#[allow( + dead_code, + reason = "pure coordinated-cutover contract is exercised directly until Task 19 wires endpoints" +)] +pub(crate) mod coordinated_cutover_v1 { + use super::*; + + /// Validated projection plus its exact canonical UTF-8 representation. + #[derive(Debug, Clone)] + pub(crate) struct CanonicalBrowserAuctionProjectionV1 { + /// Deep-owned, validated projection in canonical result/bid/targeting order. + pub projection: BrowserAuctionProjectionV1, + /// Whitespace-free JSON using schema field order. + pub json: Vec, + /// Whether the exact aggregate overflow rule replaced every winner. + pub reduced_for_size: bool, + } + + fn projection_contract_error(message: impl Into) -> Report { + Report::new(TrustedServerError::Auction { + message: message.into(), + }) + } + + fn is_base64url_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') + } + + fn valid_auction_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') + }) + } + + fn valid_candidate_id(value: &str) -> bool { + value.len() == 12 && value.bytes().all(is_base64url_byte) + } + + pub(crate) fn valid_renderer_reservation_id(value: &str) -> bool { + value + .strip_prefix("r1_") + .is_some_and(|token| token.len() == 22 && token.bytes().all(is_base64url_byte)) + } + + fn valid_provider_name(value: &str) -> bool { + let bytes = value.as_bytes(); + (1..=64).contains(&bytes.len()) + && bytes[0].is_ascii_alphanumeric() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b'-')) + } + + fn valid_bounded_text(value: &str, maximum_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= maximum_bytes + && !value + .chars() + .any(|character| matches!(character, '\0'..='\u{1f}' | '\u{7f}')) + } + + fn valid_targeting_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= 20 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + } + + fn valid_targeting(targeting: &BTreeMap) -> bool { + targeting.len() <= MAX_BROWSER_AUCTION_TARGETING_ENTRIES + && targeting.iter().all(|(key, value)| { + key != "hb_adid" + && valid_targeting_key(key) + && valid_bounded_text(value, 160) + && value.chars().count() <= 40 + }) + } + + fn valid_render_dimension(value: u32) -> bool { + (RENDER_DIMENSION_MIN..=RENDER_DIMENSION_MAX).contains(&u64::from(value)) + } + + fn render_source_dimensions(source: &BidRenderSourceV1) -> (u32, u32) { + match source { + BidRenderSourceV1::Aps(source) => (source.width, source.height), + BidRenderSourceV1::Adm(source) => (source.width, source.height), + BidRenderSourceV1::PbsCache(source) => (source.width, source.height), + } + } + + fn valid_render_source(source: &BidRenderSourceV1, publisher_origin: &str) -> bool { + match source { + BidRenderSourceV1::Aps(source) => { + valid_render_dimension(source.width) + && valid_render_dimension(source.height) + && source.version == 1 + && serde_json::to_value(BidRenderSourceV1::Aps(source.clone())).is_ok_and( + |value| { + classify_aps_renderer_v1(&value, publisher_origin) + == crate::auction::types::ApsRendererValidationResult::Accepted + }, + ) + } + BidRenderSourceV1::Adm(source) => { + valid_render_dimension(source.width) + && valid_render_dimension(source.height) + && source.version == 1 + && !source.adm.is_empty() + && source.adm.len() <= 512 * 1024 + } + BidRenderSourceV1::PbsCache(source) => { + source.version == 1 + && !source.cache_id.is_empty() + && !source.cache_host.is_empty() + && !source.cache_path.is_empty() + } + } + } + + fn valid_browser_bid(bid: &BrowserAuctionBidV1, publisher_origin: &str) -> bool { + valid_candidate_id(&bid.candidate_id) + && valid_bounded_text(&bid.slot, 256) + && valid_provider_name(&bid.provider) + && valid_bounded_text(&bid.upstream_bid_id, 64) + && bid.cpm.is_finite() + && bid.cpm >= 0.0 + && bid.currency == "USD" + && valid_targeting(&bid.targeting) + && valid_render_source(&bid.render_source, publisher_origin) + && match &bid.render_source { + BidRenderSourceV1::Aps(_) | BidRenderSourceV1::Adm(_) => bid + .renderer_reservation_id + .as_deref() + .is_some_and(valid_renderer_reservation_id), + BidRenderSourceV1::PbsCache(_) => bid.renderer_reservation_id.is_none(), + } + } + + fn valid_browser_slot(slot: &BrowserAuctionSlotV1) -> bool { + valid_bounded_text(&slot.slot, 256) + && valid_bounded_text(&slot.gam_unit_path, 256) + && valid_bounded_text(&slot.div_id, 256) + && !slot.formats.is_empty() + && slot.formats.len() <= 64 + && slot.formats.iter().all(|[width, height]| { + valid_render_dimension(*width) && valid_render_dimension(*height) + }) + && valid_targeting(&slot.targeting) + } + + fn validate_decision_set( + decision_set: &AuctionDecisionSetV1, + ) -> Result<(), Report> { + ensure!( + decision_set.version == 1, + projection_contract_error("Browser auction decision version must be 1") + ); + ensure!( + valid_auction_id(&decision_set.auction_id), + projection_contract_error("Browser auction id violates the version-1 grammar") + ); + ensure!( + decision_set.results.len() <= MAX_BROWSER_AUCTION_RESULTS, + projection_contract_error("Browser auction result count exceeds 256") + ); + + let mut slots = HashSet::new(); + let mut candidates = HashSet::new(); + for result in &decision_set.results { + ensure!( + valid_bounded_text(result.slot(), 256) && slots.insert(result.slot()), + projection_contract_error("Browser auction result slots must be valid and unique") + ); + if let SlotAuctionDecisionV1::Winner { candidate_id, .. } = result { + ensure!( + valid_candidate_id(candidate_id) && candidates.insert(candidate_id), + projection_contract_error( + "Browser auction winner candidates must be valid and unique" + ) + ); + } + } + Ok(()) + } + + /// Validate, reorder, and canonically serialize a complete browser auction projection. + /// + /// Winner-local projection failures become `winner_not_renderable`. Aggregate + /// overflow applies the contract's all-winners reduction; it never selects a + /// response-order-dependent subset. + pub(crate) fn canonicalize_browser_auction_projection_v1( + input: BrowserAuctionProjectionV1, + publisher_origin: &str, + ) -> Result> { + ensure!( + input.version == 1, + projection_contract_error("Browser auction projection version must be 1") + ); + validate_decision_set(&input.auction)?; + ensure!( + input.slots.len() <= MAX_BROWSER_AUCTION_RESULTS, + projection_contract_error("Browser auction slot count exceeds 256") + ); + if !input.slots.is_empty() { + ensure!( + input.slots.len() == input.auction.results.len(), + projection_contract_error( + "Browser auction slots must cover every decision or be empty for direct serialization" + ) + ); + let mut slot_ids = HashSet::with_capacity(input.slots.len()); + for (index, slot) in input.slots.iter().enumerate() { + ensure!( + valid_browser_slot(slot) + && slot_ids.insert(slot.slot.as_str()) + && input.auction.results[index].slot() == slot.slot, + projection_contract_error( + "Browser auction slots must be valid, unique, and follow decision order" + ) + ); + } + } + ensure!( + input.bids.len() <= MAX_BROWSER_AUCTION_RESULTS, + projection_contract_error("Browser auction bid count exceeds 256") + ); + + let publisher_origin = Url::parse(publisher_origin) + .ok() + .filter(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some()) + .map(|url| url.origin().ascii_serialization()) + .ok_or_else(|| projection_contract_error("Publisher origin is invalid"))?; + + let mut bids_by_candidate = HashMap::with_capacity(input.bids.len()); + for bid in input.bids { + let candidate_id = bid.candidate_id.clone(); + ensure!( + bids_by_candidate.insert(candidate_id, bid).is_none(), + projection_contract_error("Browser auction candidate bids must be unique") + ); + } + + let mut reservation_ids = HashSet::new(); + let mut canonical_bids = Vec::new(); + let mut canonical_results = Vec::with_capacity(input.auction.results.len()); + for result in input.auction.results { + match result { + SlotAuctionDecisionV1::Winner { slot, candidate_id } => { + let bid = bids_by_candidate.remove(&candidate_id); + if let Some(bid) = bid.filter(|bid| { + bid.slot == slot + && valid_browser_bid(bid, &publisher_origin) + && bid + .renderer_reservation_id + .as_ref() + .is_none_or(|id| reservation_ids.insert(id.clone())) + }) { + canonical_results + .push(SlotAuctionDecisionV1::Winner { slot, candidate_id }); + canonical_bids.push(bid); + } else { + canonical_results.push(SlotAuctionDecisionV1::Failed { + slot, + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }); + } + } + non_winner => canonical_results.push(non_winner), + } + } + ensure!( + bids_by_candidate.is_empty(), + projection_contract_error("Browser auction contains a bid without a winner decision") + ); + + let mut projection = BrowserAuctionProjectionV1 { + version: 1, + auction: AuctionDecisionSetV1 { + version: 1, + auction_id: input.auction.auction_id, + results: canonical_results, + }, + slots: input.slots, + bids: canonical_bids, + }; + let mut json = + serde_json::to_vec(&projection).change_context(TrustedServerError::Auction { + message: "Failed to serialize browser auction projection".to_string(), + })?; + let reduced_for_size = json.len() > MAX_BROWSER_AUCTION_PROJECTION_BYTES; + if reduced_for_size { + projection.auction.results = projection + .auction + .results + .into_iter() + .map(|result| match result { + SlotAuctionDecisionV1::Winner { slot, .. } => SlotAuctionDecisionV1::Failed { + slot, + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }, + non_winner => non_winner, + }) + .collect(); + projection.bids.clear(); + json = serde_json::to_vec(&projection).change_context(TrustedServerError::Auction { + message: "Failed to serialize reduced browser auction projection".to_string(), + })?; + ensure!( + json.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES, + projection_contract_error("Reduced browser auction projection exceeds 8 MiB") + ); + } + + Ok(CanonicalBrowserAuctionProjectionV1 { + projection, + json, + reduced_for_size, + }) + } + + /// Parse and validate one browser-boot projection before it enters HTML. + /// + /// Browser boot requires full slot coverage, unlike the direct `/auction` + /// serializer that may carry an empty slot vector. The result is the exact + /// canonical JSON produced by the shared production validator. + pub(crate) fn canonicalize_browser_auction_projection_json_v1( + json: &str, + publisher_origin: &str, + ) -> Result> { + ensure!( + json.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES, + projection_contract_error("Browser auction projection exceeds 8 MiB") + ); + let projection = + serde_json::from_str::(json).map_err(|_| { + projection_contract_error( + "Browser auction projection violates the version-1 schema", + ) + })?; + let canonical = + canonicalize_browser_auction_projection_v1(projection.clone(), publisher_origin)?; + ensure!( + !canonical.reduced_for_size && canonical.projection == projection, + projection_contract_error("Browser auction projection violates the version-1 contract") + ); + String::from_utf8(canonical.json).map_err(|_| { + projection_contract_error("Browser auction projection serialization is not UTF-8") + }) + } + + #[derive(Serialize)] + struct TrustedServerOpenRtbBidExtV1<'a> { + candidate_id: &'a str, + slot_id: &'a str, + render_source: &'a BidRenderSourceV1, + } + + #[derive(Serialize)] + struct OpenRtbBidExtV1<'a> { + trusted_server: TrustedServerOpenRtbBidExtV1<'a>, + } + + #[derive(Serialize)] + struct TrustedServerOpenRtbBidV1<'a> { + id: &'a str, + impid: &'a str, + price: f64, + #[serde(skip_serializing_if = "Option::is_none")] + adm: Option<&'a str>, + w: u32, + h: u32, + ext: OpenRtbBidExtV1<'a>, + } + + #[derive(Serialize)] + struct TrustedServerSeatBidV1<'a> { + seat: &'a str, + bid: Vec>, + } + + #[derive(Serialize)] + struct TrustedServerResponseExtInnerV1<'a> { + slot_results: &'a AuctionDecisionSetV1, + } + + #[derive(Serialize)] + struct TrustedServerResponseExtV1<'a> { + trusted_server: TrustedServerResponseExtInnerV1<'a>, + } + + #[derive(Serialize)] + struct TrustedServerAuctionResponseWireV1<'a> { + id: &'a str, + seatbid: Vec>, + cur: &'static str, + ext: TrustedServerResponseExtV1<'a>, + } + + /// Serialize the coordinated-cutover exact `/auction` winner wire. + /// + /// This remains a pure contract function until Task 19 switches the endpoint. + pub(crate) fn serialize_trusted_server_auction_response_v1( + canonical: &CanonicalBrowserAuctionProjectionV1, + ) -> Result, Report> { + let seatbid = canonical + .projection + .bids + .iter() + .map(|bid| { + let (width, height) = render_source_dimensions(&bid.render_source); + let wire_id = match &bid.render_source { + BidRenderSourceV1::Aps(_) | BidRenderSourceV1::Adm(_) => bid + .renderer_reservation_id + .as_deref() + .expect("should retain the validated APS/ADM reservation"), + BidRenderSourceV1::PbsCache(source) => source.cache_id.as_str(), + }; + TrustedServerSeatBidV1 { + seat: &bid.provider, + bid: vec![TrustedServerOpenRtbBidV1 { + id: wire_id, + impid: &bid.slot, + price: bid.cpm, + // `render_source` is the sole browser authority. Standard + // `adm` is optional on the exact wire and omitted by the + // producer to avoid duplicating up to 512 KiB per winner. + adm: None, + w: width, + h: height, + ext: OpenRtbBidExtV1 { + trusted_server: TrustedServerOpenRtbBidExtV1 { + candidate_id: &bid.candidate_id, + slot_id: &bid.slot, + render_source: &bid.render_source, + }, + }, + }], + } + }) + .collect(); + let response = TrustedServerAuctionResponseWireV1 { + id: &canonical.projection.auction.auction_id, + seatbid, + cur: "USD", + ext: TrustedServerResponseExtV1 { + trusted_server: TrustedServerResponseExtInnerV1 { + slot_results: &canonical.projection.auction, + }, + }, + }; + serde_json::to_vec(&response).change_context(TrustedServerError::Auction { + message: "Failed to serialize exact trusted-server auction response".to_string(), + }) + } +} + +#[cfg(test)] +use coordinated_cutover_v1::{ + canonicalize_browser_auction_projection_v1, serialize_trusted_server_auction_response_v1, +}; + /// Delivery facts produced while serializing winning bids. #[derive(Debug, Default)] pub(crate) struct AuctionDeliveryReport { @@ -289,20 +786,11 @@ pub(crate) struct AuctionDeliveryReport { /// Winners omitted because they could not be delivered safely. pub dropped_winner_count: usize, /// Machine-readable reasons for omitted winners. - pub dropped_winner_reasons: BTreeMap, -} - -impl AuctionDeliveryReport { - fn record_drop(&mut self, reason: &str) { - self.dropped_winner_count += 1; - *self - .dropped_winner_reasons - .entry(reason.to_string()) - .or_default() += 1; - } + pub dropped_winner_reasons: AuctionDropReasons, } /// Serialized response and the delivery facts used to produce it. +#[cfg(test)] pub(crate) struct OpenRtbResponseConversion { /// HTTP response returned to the auction client. pub response: Response, @@ -317,38 +805,63 @@ pub(crate) struct OpenRtbResponseConversion { /// ([`AuctionConfig::sanitize_creatives`], opt-in, and /// [`AuctionConfig::rewrite_creatives`], default-on); with both disabled the /// creative ships exactly as the bidder returned it, subject to the 1 MiB -/// per-creative cap. Typed renderers are serialized in the response extension -/// instead of entering that pipeline at all. +/// per-creative cap. /// /// [`AuctionConfig::sanitize_creatives`]: crate::auction_config_types::AuctionConfig::sanitize_creatives /// [`AuctionConfig::rewrite_creatives`]: crate::auction_config_types::AuctionConfig::rewrite_creatives /// /// # Errors /// -/// Returns an error if response serialization fails. -/// -/// Winners without a decoded price or a deliverable creative are omitted and -/// recorded in the returned delivery report so other slots can still render. +/// Returns an error if: +/// - A winning bid is missing a price or render source +/// - The response serialization fails pub fn convert_to_openrtb_response( result: &OrchestrationResult, settings: &Settings, auction_request: &AuctionRequest, ec_allowed: bool, ) -> Result, Report> { - Ok( - convert_to_openrtb_response_with_report(result, settings, auction_request, ec_allowed)? - .response, - ) + convert_to_openrtb_response_impl(result, settings, auction_request, ec_allowed) } +#[cfg(test)] pub(crate) fn convert_to_openrtb_response_with_report( result: &OrchestrationResult, settings: &Settings, auction_request: &AuctionRequest, ec_allowed: bool, ) -> Result> { + let (response, delivery) = convert_to_openrtb_response_impl_with_report( + result, + settings, + auction_request, + ec_allowed, + )?; + Ok(OpenRtbResponseConversion { response, delivery }) +} + +fn convert_to_openrtb_response_impl( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + ec_allowed: bool, +) -> Result, Report> { + let (response, _) = convert_to_openrtb_response_impl_with_report( + result, + settings, + auction_request, + ec_allowed, + )?; + Ok(response) +} + +fn convert_to_openrtb_response_impl_with_report( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + ec_allowed: bool, +) -> Result<(Response, AuctionDeliveryReport), Report> { let mut seatbids = Vec::with_capacity(result.winning_bids.len()); - let rewrite_creatives = settings.auction.rewrite_creatives; let mut delivery = AuctionDeliveryReport::default(); for (slot_id, bid) in &result.winning_bids { @@ -359,7 +872,11 @@ pub(crate) fn convert_to_openrtb_response_with_report( slot_id, bid.bidder ); - delivery.record_drop("no_decoded_price"); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::InvalidPrice, + ); continue; }; @@ -370,29 +887,29 @@ pub(crate) fn convert_to_openrtb_response_with_report( let width = to_openrtb_i32(bid.width, "width", &bid_context); let height = to_openrtb_i32(bid.height, "height", &bid_context); - // Ordinary markup goes through the configured creative processing: - // sanitization is opt-in, rewriting is on by default, and with both - // disabled the creative ships exactly as the bidder returned it. A typed - // renderer is serialized separately and never enters that pipeline. - let serialize_renderer = |renderer: &BidRenderer| { - (BidExt { - trusted_server: BidTrustedServerExt { renderer }, - }) - .to_ext() - }; - let (adm, ext) = if let Some(raw_creative) = bid + let creative = bid .creative .as_deref() - .filter(|creative| !creative.trim().is_empty()) - { - if bid.renderer.is_some() { - log::warn!( - "Auction {}: winning bid for slot '{}' from '{}' has both creative markup and a renderer; using creative markup when it remains renderable", - auction_request.id, - slot_id, - bid.bidder - ); - } + .filter(|creative| !creative.trim().is_empty()); + if creative.is_some() && bid.renderer.is_some() { + log::warn!( + "Auction {}: skipping winning bid for slot '{}' from '{}' because it has multiple render sources", + auction_request.id, + slot_id, + bid.bidder + ); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::MultipleRenderSources, + ); + continue; + } + + // Ordinary markup follows the independently configured processing + // path: sanitization is opt-in and rewriting is default-on. A typed + // render source is serialized separately and never enters either pass. + let (adm, ext) = if let Some(raw_creative) = creative { let processed = creative::process_auction_creative(settings, raw_creative); log::debug!( @@ -401,45 +918,43 @@ pub(crate) fn convert_to_openrtb_response_with_report( slot_id, bid.bidder, settings.auction.sanitize_creatives, - rewrite_creatives, + settings.auction.rewrite_creatives, raw_creative.len(), processed.len() ); if processed.trim().is_empty() { - let Some(renderer) = bid.renderer.as_ref() else { - log::warn!( - "Auction {}: skipping winning bid for slot '{}' from '{}' because creative processing rejected its only render source", - auction_request.id, - slot_id, - bid.bidder - ); - delivery.record_drop("creative_processing_rejected"); - continue; - }; - let Some(ext) = serialize_renderer(renderer) else { - log::warn!( - "Auction {}: skipping winning bid for slot '{}' from '{}' because its renderer extension could not be serialized", - auction_request.id, - slot_id, - bid.bidder - ); - delivery.record_drop("renderer_extension_serialization_failed"); - continue; - }; - (None, Some(ext)) - } else { - (Some(processed), None) + log::warn!( + "Auction {}: skipping winning bid for slot '{}' from '{}' because creative processing rejected its only render source", + auction_request.id, + slot_id, + bid.bidder + ); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::CreativeProcessingRejected, + ); + continue; } + + (Some(processed), None) } else if let Some(renderer) = bid.renderer.as_ref() { - let Some(ext) = serialize_renderer(renderer) else { + let Some(ext) = (BidExt { + trusted_server: BidTrustedServerExt { renderer }, + }) + .to_ext() else { log::warn!( "Auction {}: skipping winning bid for slot '{}' from '{}' because its renderer extension could not be serialized", auction_request.id, slot_id, bid.bidder ); - delivery.record_drop("renderer_extension_serialization_failed"); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::RendererExtensionSerializationFailed, + ); continue; }; (None, Some(ext)) @@ -450,7 +965,11 @@ pub(crate) fn convert_to_openrtb_response_with_report( slot_id, bid.bidder ); - delivery.record_drop("no_render_source"); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::NoRenderSource, + ); continue; }; @@ -524,29 +1043,9 @@ pub(crate) fn convert_to_openrtb_response_with_report( message: "Failed to build auction response".to_string(), })?; - // Signal consent status independently of whether EIDs were resolved. - if ec_allowed { - response - .headers_mut() - .insert(HEADER_X_TS_EC_CONSENT, HeaderValue::from_static("ok")); - } - - // Attach EID response headers when consent-gated EIDs are available. - if let Some(ref eids) = auction_request.user.eids { - let (encoded, truncated) = encode_eids_header(eids)?; - let header_val = - HeaderValue::from_str(&encoded).change_context(TrustedServerError::Auction { - message: "Failed to encode EIDs header value".to_string(), - })?; - response.headers_mut().insert(HEADER_X_TS_EIDS, header_val); - if truncated { - response - .headers_mut() - .insert(HEADER_X_TS_EIDS_TRUNCATED, HeaderValue::from_static("true")); - } - } + attach_auction_response_headers(&mut response, auction_request, ec_allowed)?; - Ok(OpenRtbResponseConversion { response, delivery }) + Ok((response, delivery)) } #[cfg(test)] @@ -557,7 +1056,8 @@ mod tests { }; use crate::auction::routing::route_auction; use crate::auction::types::{ - ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderer, BidStatus, + ApsRendererV1, ApsTagType, AuctionDecisionSetV1, AuctionResponse, Bid, BidRenderSourceV1, + BidStatus, }; use crate::openrtb::{Eid, Uid}; use crate::platform::test_support::noop_services; @@ -636,6 +1136,11 @@ mod tests { provider_responses: Vec::new(), mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results: Vec::new(), + }, total_time_ms: 10, metadata: HashMap::new(), } @@ -644,6 +1149,9 @@ mod tests { fn make_bid(slot_id: &str, bidder: &str, price: Option) -> Bid { Bid { slot_id: slot_id.to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price, currency: "USD".to_string(), creative: Some("
Ad
".to_string()), @@ -685,6 +1193,11 @@ mod tests { }], mediator_response: None, winning_bids: HashMap::from([(bid.slot_id.clone(), bid)]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results: Vec::new(), + }, total_time_ms: 50, metadata: HashMap::new(), } @@ -1212,7 +1725,8 @@ mod tests { #[test] fn convert_to_openrtb_response_serializes_winning_bid_and_orchestrator_ext() { - let settings = make_settings(); + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75))); @@ -1252,18 +1766,7 @@ mod tests { assert_eq!(bid["id"], json!("appnexus-div-gpt-top")); assert_eq!(bid["impid"], json!("div-gpt-top")); assert_eq!(bid["price"], json!(2.75)); - // Rewriting is on by default, and a body-less fragment still receives - // the creative runtime (prepended), so the markup is carried rather - // than returned verbatim. - let adm = bid["adm"].as_str().expect("should serialize adm"); - assert!( - adm.contains("
Ad
"), - "should carry the creative: {adm}" - ); - assert!( - adm.contains("/static/tsjs=tsjs-unified.min.js"), - "should inject the creative runtime into a body-less fragment: {adm}" - ); + assert_eq!(bid["adm"], json!("
Ad
")); assert_eq!(bid["crid"], json!("appnexus-creative")); assert_eq!(bid["w"], json!(300)); assert_eq!(bid["h"], json!(250)); @@ -1328,7 +1831,7 @@ mod tests { "should remove malicious script content before rewriting: {adm}" ); assert!( - !adm.contains("auction-handler-marker") && !adm.contains("onerror"), + !adm.contains("auction-handler-marker") && !adm.contains(r#" onerror=""#), "should remove event handlers before rewriting: {adm}" ); } @@ -1341,7 +1844,6 @@ mod tests { // markup cannot reach the publisher origin — can opt out and deliver the // creative exactly as the bidder returned it. let mut settings = make_settings(); - settings.auction.sanitize_creatives = false; settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let result = make_result(make_complete_creative_bid()); @@ -1351,7 +1853,7 @@ mod tests { .expect("should have a creative fixture"); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) - .expect("should convert creative with sanitization disabled"); + .expect("should convert creative with rewriting disabled"); let adm = response_adm(response); assert_eq!( @@ -1394,7 +1896,7 @@ mod tests { } #[test] - fn sanitize_creatives_defaults_to_disabled() { + fn rewrite_creatives_defaults_to_enabled() { let config = crate::auction_config_types::AuctionConfig::default(); assert!( !config.sanitize_creatives, @@ -1408,8 +1910,6 @@ mod tests { #[test] fn convert_to_openrtb_response_can_skip_rewriting_while_sanitizing() { - // The two controls are independent: sanitization can stay on while URL - // rewriting is off. let mut settings = make_settings(); settings.auction.rewrite_creatives = false; settings.auction.sanitize_creatives = true; @@ -1453,7 +1953,7 @@ mod tests { "should still remove malicious script content: {adm}" ); assert!( - !adm.contains("auction-handler-marker") && !adm.contains("onerror"), + !adm.contains("auction-handler-marker") && !adm.contains(r#" onerror=""#), "should still remove event handlers: {adm}" ); } @@ -1485,6 +1985,11 @@ mod tests { }], mediator_response: None, winning_bids: HashMap::from([(bid.slot_id.clone(), bid)]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results: Vec::new(), + }, total_time_ms: 50, metadata: HashMap::new(), }; @@ -1501,26 +2006,19 @@ mod tests { #[test] fn convert_to_openrtb_response_skips_invalid_winners_without_dropping_valid_slots() { - // Sanitization is opt-in, so enable it here: script-only markup is what - // makes the `rejected` and `renderer` fixtures below reach the - // processing-rejected path. Left at the default they would survive - // processing as ordinary (script-bearing) creatives. let mut settings = make_settings(); - settings.auction.sanitize_creatives = true; + settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let mut missing = make_bid("missing", "invalid", Some(3.0)); missing.creative = None; let mut whitespace = make_bid("whitespace", "invalid", Some(2.9)); whitespace.creative = Some(" \n\t ".to_string()); - let mut rejected = make_bid("rejected", "invalid", Some(2.8)); - rejected.creative = Some("".to_string()); - let unpriced = make_bid("unpriced", "invalid", None); let ordinary = make_bid("ordinary", "appnexus", Some(2.75)); let mut renderer = make_bid("renderer", "aps", Some(2.5)); - renderer.creative = Some("".to_string()); + renderer.creative = Some(" ".to_string()); renderer.bid_id = Some("upstream-renderer-bid".to_string()); renderer.creative_id = None; - renderer.renderer = Some(BidRenderer::Aps(ApsRendererV1 { + renderer.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "upstream-renderer-bid".to_string(), @@ -1537,37 +2035,21 @@ mod tests { winning_bids: HashMap::from([ (missing.slot_id.clone(), missing), (whitespace.slot_id.clone(), whitespace), - (rejected.slot_id.clone(), rejected), - (unpriced.slot_id.clone(), unpriced), (ordinary.slot_id.clone(), ordinary), (renderer.slot_id.clone(), renderer), ]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results: Vec::new(), + }, total_time_ms: 50, metadata: HashMap::new(), }; - let conversion = - convert_to_openrtb_response_with_report(&result, &settings, &auction_request, false) - .expect("should omit invalid winners and preserve valid slots"); - assert_eq!( - conversion.delivery.delivered_winner_slots, - HashSet::from(["ordinary".to_string(), "renderer".to_string()]), - "should report only serialized winners as delivered" - ); - assert_eq!(conversion.delivery.dropped_winner_count, 4); - assert_eq!( - conversion.delivery.dropped_winner_reasons["no_render_source"], - 2 - ); - assert_eq!( - conversion.delivery.dropped_winner_reasons["no_decoded_price"], - 1 - ); - assert_eq!( - conversion.delivery.dropped_winner_reasons["creative_processing_rejected"], - 1 - ); - let json = response_json(conversion.response); + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should omit invalid winners and preserve valid slots"); + let json = response_json(response); let bids: Vec<&JsonValue> = json["seatbid"] .as_array() .expect("should include valid seatbids") @@ -1576,30 +2058,16 @@ mod tests { .collect(); assert_eq!(bids.len(), 2, "should omit only invalid winners"); - assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 4); + assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 2); assert_eq!( json["ext"]["orchestrator"]["dropped_winner_reasons"]["no_render_source"], 2 ); - assert_eq!( - json["ext"]["orchestrator"]["dropped_winner_reasons"]["no_decoded_price"], - 1 - ); - assert_eq!( - json["ext"]["orchestrator"]["dropped_winner_reasons"]["creative_processing_rejected"], - 1 - ); let ordinary = bids .iter() .find(|bid| bid["impid"] == "ordinary") .expect("should preserve ordinary winner"); - assert!( - ordinary["adm"] - .as_str() - .is_some_and(|adm| adm.contains("
Ad
")), - "should preserve ordinary creative markup: {}", - ordinary["adm"] - ); + assert_eq!(ordinary["adm"], "
Ad
"); let renderer = bids .iter() .find(|bid| bid["impid"] == "renderer") @@ -1614,11 +2082,38 @@ mod tests { } #[test] - fn convert_to_openrtb_response_prefers_creative_when_both_render_sources_exist() { - let settings = make_settings(); + fn convert_to_openrtb_response_drops_creative_rejected_by_processing() { + let mut settings = make_settings(); + settings.auction.sanitize_creatives = true; + settings.auction.rewrite_creatives = false; + let auction_request = make_auction_request(); + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.creative = Some("".to_string()); + let result = make_result(bid); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should omit a creative rejected by configured processing"); + let json = response_json(response); + + assert!( + json["seatbid"].as_array().is_none_or(Vec::is_empty), + "should not serialize an empty adm" + ); + assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 1); + assert_eq!( + json["ext"]["orchestrator"]["dropped_winner_reasons"]["creative_processing_rejected"], + 1, + "should report the exact processing rejection" + ); + } + + #[test] + fn convert_to_openrtb_response_rejects_multiple_render_sources() { + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let mut bid = make_bid("div-gpt-top", "aps", Some(2.75)); - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { + bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "fictional-bid".to_string(), @@ -1632,20 +2127,16 @@ mod tests { let result = make_result(bid); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) - .expect("should prefer ordinary creative markup"); + .expect("should reject an ambiguous render source"); let json = response_json(response); - let bid = &json["seatbid"][0]["bid"][0]; - - // Rewriting is on by default and a body-less fragment still receives the - // creative runtime, so the markup is carried rather than returned verbatim. - let adm = bid["adm"].as_str().expect("should serialize adm"); assert!( - adm.contains("
Ad
"), - "should carry the creative markup: {adm}" + json["seatbid"].as_array().is_none_or(Vec::is_empty), + "should not serialize an ambiguous winner" ); - assert!( - bid.get("ext").is_none(), - "should omit renderer extension when creative markup wins precedence" + assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 1); + assert_eq!( + json["ext"]["orchestrator"]["dropped_winner_reasons"]["multiple_render_sources"], 1, + "should report the exact ambiguous-source reason" ); } @@ -1658,7 +2149,7 @@ mod tests { bid.bid_id = Some("fictional-bid".to_string()); bid.ad_id = Some("fictional-ad".to_string()); bid.creative_id = Some("fictional-creative".to_string()); - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { + bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "fictional-bid".to_string(), @@ -1726,6 +2217,11 @@ mod tests { provider_responses: vec![], mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results: Vec::new(), + }, total_time_ms: 50, metadata: HashMap::new(), }; @@ -1748,7 +2244,8 @@ mod tests { #[test] fn convert_to_openrtb_response_serializes_multiple_winning_bids() { - let settings = make_settings(); + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let top_bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); let mut sidebar_bid = make_bid("div-gpt-sidebar", "rubicon", Some(1.25)); @@ -1766,6 +2263,11 @@ mod tests { (top_bid.slot_id.clone(), top_bid), (sidebar_bid.slot_id.clone(), sidebar_bid), ]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results: Vec::new(), + }, total_time_ms: 50, metadata: HashMap::new(), }; @@ -1800,12 +2302,10 @@ mod tests { "should preserve top slot impid" ); assert_eq!(top_bid["price"], json!(2.75), "should preserve top price"); - assert!( - top_bid["adm"] - .as_str() - .is_some_and(|adm| adm.contains("
Ad
")), - "should preserve top creative: {}", - top_bid["adm"] + assert_eq!( + top_bid["adm"], + json!("
Ad
"), + "should preserve top creative" ); let sidebar_seatbid = seatbids @@ -1833,12 +2333,10 @@ mod tests { json!(1.25), "should preserve sidebar price" ); - assert!( - sidebar_bid["adm"] - .as_str() - .is_some_and(|adm| adm.contains("
Sidebar
")), - "should preserve sidebar creative: {}", - sidebar_bid["adm"] + assert_eq!( + sidebar_bid["adm"], + json!("
Sidebar
"), + "should preserve sidebar creative" ); assert_eq!( json["ext"]["orchestrator"]["total_bids"], @@ -1877,9 +2375,15 @@ mod tests { assert!(conversion.delivery.delivered_winner_slots.is_empty()); assert_eq!(conversion.delivery.dropped_winner_count, 1); assert_eq!( - conversion.delivery.dropped_winner_reasons["no_decoded_price"], 1, + conversion.delivery.dropped_winner_reasons[&AuctionDropReason::InvalidPrice], + 1, "should report the omitted malformed winner" ); + assert_eq!( + conversion.response.status(), + StatusCode::OK, + "should still return a successful partial auction response" + ); } #[test] @@ -1904,10 +2408,16 @@ mod tests { #[cfg(test)] mod convert_tests { use super::*; + use crate::auction::types::{ + AdmRenderSourceV1, AuctionDecisionSetV1, BidRenderSourceV1, BrowserAuctionBidV1, + BrowserAuctionProjectionV1, MAX_BROWSER_AUCTION_PROJECTION_BYTES, SlotAuctionDecisionV1, + }; use crate::consent::ConsentContext; use crate::platform::test_support::noop_services; use crate::test_support::tests::crate_test_settings_str; use http::Method; + use serde_json::json; + use std::collections::BTreeMap; fn make_settings() -> Settings { Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings") @@ -2080,4 +2590,273 @@ mod convert_tests { "3-element banner size should return an error" ); } + + fn projection_candidate_id(index: usize) -> String { + format!("{index:012x}") + } + + fn projection_reservation_id(index: usize) -> String { + format!("r1_{index:022x}") + } + + fn projection_adm_bid(index: usize, slot: &str, adm: String) -> BrowserAuctionBidV1 { + BrowserAuctionBidV1 { + candidate_id: projection_candidate_id(index), + slot: slot.to_string(), + provider: "prebid".to_string(), + upstream_bid_id: format!("upstream-{index}"), + cpm: index as f64, + currency: "USD".to_string(), + targeting: BTreeMap::from([ + ("z_key".to_string(), "last".to_string()), + ("a_key".to_string(), "first".to_string()), + ]), + renderer_reservation_id: Some(projection_reservation_id(index)), + render_source: BidRenderSourceV1::Adm(AdmRenderSourceV1 { + version: 1, + adm, + width: 300, + height: 250, + }), + } + } + + fn projection_with_adm_lengths(lengths: &[usize]) -> BrowserAuctionProjectionV1 { + let results = lengths + .iter() + .enumerate() + .map(|(index, _)| SlotAuctionDecisionV1::Winner { + slot: format!("slot-{index}"), + candidate_id: projection_candidate_id(index), + }) + .collect(); + let bids = lengths + .iter() + .enumerate() + .map(|(index, length)| { + projection_adm_bid(index, &format!("slot-{index}"), "x".repeat(*length)) + }) + .rev() + .collect(); + BrowserAuctionProjectionV1 { + version: 1, + auction: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results, + }, + slots: Vec::new(), + bids, + } + } + + #[test] + fn canonical_projection_orders_bids_and_targeting_by_contract() { + let input = projection_with_adm_lengths(&[1, 1]); + let mut permuted = input.clone(); + permuted.bids.reverse(); + + let canonical = + canonicalize_browser_auction_projection_v1(input, "https://publisher.example") + .expect("valid projection should canonicalize"); + let canonical_permuted = + canonicalize_browser_auction_projection_v1(permuted, "https://publisher.example") + .expect("response-order permutation should canonicalize"); + + assert!(!canonical.reduced_for_size); + assert_eq!(canonical.json, canonical_permuted.json); + assert_eq!(canonical.projection.bids[0].slot, "slot-0"); + assert_eq!(canonical.projection.bids[1].slot, "slot-1"); + let json = String::from_utf8(canonical.json).expect("canonical JSON should be UTF-8"); + assert!( + json.find("\"a_key\"") < json.find("\"z_key\""), + "targeting keys should be lexically sorted" + ); + assert!( + json.starts_with("{\"version\":1,\"auction\":{\"version\":1,\"auctionId\":"), + "top-level and decision-set fields should retain schema order: {json}" + ); + } + + #[test] + fn pbs_cache_wire_is_the_exact_thin_deny_unknown_carrier() { + let value = serde_json::json!({ + "type": "pbs_cache", + "version": 1, + "cacheId": "f47447a0-b759-4f2f-9887-af458b79b570", + "cacheHost": "cache.example:8443", + "cachePath": "/pbc/v1/cache/opaque%2Fpath", + "width": 0, + "height": u32::MAX + }); + let source: BidRenderSourceV1 = serde_json::from_value(value.clone()) + .expect("the final tagged union should admit the thin pbs_cache carrier"); + assert_eq!( + serde_json::to_value(source).expect("cache carrier should serialize"), + value + ); + + let mut unknown = value; + unknown["fetchUrl"] = serde_json::Value::String( + "https://cache.example/pbc/v1/cache?uuid=not-authoritative".to_string(), + ); + assert!(serde_json::from_value::(unknown).is_err()); + } + + #[test] + fn invalid_selected_winner_becomes_winner_not_renderable() { + let mut input = projection_with_adm_lengths(&[1]); + input.bids[0].renderer_reservation_id = Some("not-a-reservation".to_string()); + + let canonical = + canonicalize_browser_auction_projection_v1(input, "https://publisher.example") + .expect("selected projection failure should remain an explicit slot result"); + + assert!(canonical.projection.bids.is_empty()); + assert_eq!( + canonical.projection.auction.results, + vec![SlotAuctionDecisionV1::Failed { + slot: "slot-0".to_string(), + reason: crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable, + }] + ); + } + + #[test] + fn canonical_projection_enforces_exact_eight_mib_all_winner_reduction() { + let mut lengths = vec![512 * 1024; 15]; + lengths.push(1); + let baseline = projection_with_adm_lengths(&lengths); + let baseline_len = serde_json::to_vec(&baseline) + .expect("typed baseline should serialize") + .len(); + let exact_tail = 1 + MAX_BROWSER_AUCTION_PROJECTION_BYTES - baseline_len; + assert!( + exact_tail <= 512 * 1024, + "tail ADM should remain individually valid" + ); + + for (delta, should_reduce) in [(-1_isize, false), (0, false), (1, true)] { + lengths[15] = exact_tail + .checked_add_signed(delta) + .expect("positive exact tail"); + let input = projection_with_adm_lengths(&lengths); + let canonical = + canonicalize_browser_auction_projection_v1(input, "https://publisher.example") + .expect("boundary projection should canonicalize or reduce"); + assert_eq!(canonical.reduced_for_size, should_reduce, "delta {delta}"); + assert!(canonical.json.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES); + if should_reduce { + assert!(canonical.projection.bids.is_empty()); + assert!(canonical.projection.auction.results.iter().all(|result| matches!( + result, + SlotAuctionDecisionV1::Failed { + reason: crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable, + .. + } + ))); + let wire: JsonValue = serde_json::from_slice( + &serialize_trusted_server_auction_response_v1(&canonical) + .expect("reduced exact response should serialize"), + ) + .expect("reduced exact response should be JSON"); + assert_eq!(wire["seatbid"], json!([])); + } else { + assert_eq!( + canonical.json.len(), + MAX_BROWSER_AUCTION_PROJECTION_BYTES + .checked_add_signed(delta) + .expect("boundary size should remain positive") + ); + if delta == 0 { + let wire = serialize_trusted_server_auction_response_v1(&canonical) + .expect("exact-boundary response should serialize"); + assert!( + wire.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES, + "exact response should not exceed the admitted projection cap" + ); + } + } + } + } + + #[test] + fn exact_openrtb_serializer_uses_reservation_and_trusted_server_join_only() { + let canonical = canonicalize_browser_auction_projection_v1( + projection_with_adm_lengths(&[7]), + "https://publisher.example", + ) + .expect("projection should canonicalize"); + + let json: JsonValue = serde_json::from_slice( + &serialize_trusted_server_auction_response_v1(&canonical) + .expect("exact response should serialize"), + ) + .expect("exact response should be JSON"); + + let bid = &json["seatbid"][0]["bid"][0]; + assert_eq!(bid["id"], projection_reservation_id(0)); + assert_eq!(bid["impid"], "slot-0"); + assert!( + bid.get("adm").is_none(), + "tagged render_source should be the sole browser authority" + ); + assert_eq!(json["cur"], "USD"); + assert_eq!( + bid["ext"]["trusted_server"], + json!({ + "candidate_id": projection_candidate_id(0), + "slot_id": "slot-0", + "render_source": { + "type": "adm", + "version": 1, + "adm": "xxxxxxx", + "width": 300, + "height": 250, + } + }) + ); + assert_eq!( + json["ext"]["trusted_server"]["slot_results"], + serde_json::to_value(&canonical.projection.auction) + .expect("decision set should serialize") + ); + } + + #[test] + fn exact_openrtb_serializer_carries_identity_generation_failure_without_a_bid() { + let canonical = canonicalize_browser_auction_projection_v1( + BrowserAuctionProjectionV1 { + version: 1, + auction: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-identity-failure".to_string(), + results: vec![SlotAuctionDecisionV1::Failed { + slot: "slot-0".to_string(), + reason: crate::auction::types::AuctionSlotFailureReason::IdentityGenerationFailed, + }], + }, + slots: Vec::new(), + bids: Vec::new(), + }, + "https://publisher.example", + ) + .expect("identity failure decision should canonicalize"); + + let json: JsonValue = serde_json::from_slice( + &serialize_trusted_server_auction_response_v1(&canonical) + .expect("identity failure response should serialize"), + ) + .expect("identity failure response should be JSON"); + + assert_eq!(json["seatbid"], json!([])); + assert_eq!( + json["ext"]["trusted_server"]["slot_results"]["results"][0], + json!({ + "slot": "slot-0", + "outcome": "failed", + "reason": "identity_generation_failed", + }) + ); + } } diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 432303928..e2923d250 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -228,6 +228,6 @@ mod plan_sharing_tests { let registry = IntegrationRegistry::with_plan(&settings, plan) .expect("should build APS renderer registry"); - assert!(registry.has_route(&http::Method::GET, "/integrations/aps/renderer")); + assert!(registry.has_reserved_path("/integrations/aps/renderer/v2")); } } diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs index d9f20c42e..89ef5f426 100644 --- a/crates/trusted-server-core/src/auction/openrtb.rs +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -629,6 +629,9 @@ fn extract_standard_bid(value: &Value, returned_seat: Option<&str>) -> Option>; + +struct NormalizedProviderResponses { + outcomes: Vec, + candidates: HashMap, +} + /// In-flight auction requests dispatched to SSP backends. /// /// Created by [`AuctionOrchestrator::dispatch_auction`] and consumed by @@ -45,6 +59,7 @@ pub struct DispatchedAuction { planned_unused_bidder_params: HashMap, planned_unroutable_bidder_count: u32, planned_provider_order: HashMap, + eligible_slots_by_provider: EligibleSlotsByProvider, } struct ProviderLaunchState { @@ -75,11 +90,18 @@ pub enum DispatchAuctionOutcome { metadata: HashMap, /// Elapsed dispatch time. elapsed_ms: u64, + /// Exact provider-to-slot eligibility established during routing. + eligible_slots_by_provider: EligibleSlotsByProvider, }, /// One or more providers produced an immediate response or started a request. Dispatched(DispatchedAuction), } +/// A dispatch attempt either has in-flight transport to collect or is already terminal. +pub(crate) enum ResolvedDispatchOutcome { + InFlight(Box), + Complete(Box), +} impl DispatchedAuction { /// Consume the dispatch token without collecting provider responses. #[must_use] @@ -133,6 +155,7 @@ impl DispatchedAuction { planned_unused_bidder_params: HashMap::new(), planned_unroutable_bidder_count: 0, planned_provider_order: HashMap::new(), + eligible_slots_by_provider: HashMap::new(), } } } @@ -219,6 +242,23 @@ fn provider_skipped_response(provider_name: &str) -> AuctionResponse { ) } +fn canonical_provider_response( + expected_provider: &str, + response: AuctionResponse, +) -> AuctionResponse { + if response.provider == expected_provider { + response + } else { + log::warn!( + "Provider '{}' returned response identity '{}'; rejecting mismatched response", + expected_provider, + response.provider + ); + AuctionResponse::error(expected_provider, response.response_time_ms) + .with_drop_reason(AuctionDropReason::InvalidProviderResponse) + } +} + /// Compute the remaining time budget from a deadline. /// /// Returns the number of milliseconds left before `timeout_ms` is exceeded, @@ -259,6 +299,23 @@ fn routing_metadata(unroutable_bidder_count: u32) -> HashMap EligibleSlotsByProvider { + routed + .inputs() + .iter() + .map(|input| { + ( + input.provider_id().as_str().to_string(), + input + .slots() + .iter() + .map(|slot| slot.slot().id.clone()) + .collect(), + ) + }) + .collect() +} + /// Attach only the count derived from the routed provider input at dispatch. /// /// This is intentionally applied after every provider outcome is materialized, @@ -311,6 +368,7 @@ pub struct AuctionOrchestrator { config: AuctionConfig, #[cfg(test)] providers: HashMap>, + identity_generator: Arc, } /// Test harness for the live plan-backed orchestrator semantics. @@ -319,6 +377,7 @@ pub(crate) struct AuctionOrchestratorHarness { plan: Arc, providers: Vec>, mediator: Option>, + identity_generator: Arc, } struct PlannedLaunchState { @@ -349,9 +408,18 @@ impl AuctionOrchestratorHarness { plan, providers, mediator, + identity_generator: Arc::new(SystemAuctionIdentityGenerator), } } + pub(crate) fn with_identity_generator( + mut self, + identity_generator: Arc, + ) -> Self { + self.identity_generator = identity_generator; + self + } + pub(crate) fn provider_count(&self) -> usize { self.providers.len() } @@ -621,12 +689,21 @@ impl AuctionOrchestratorHarness { .unwrap_or(usize::MAX) }); + let helper = AuctionOrchestrator::with_identity_generator( + AuctionConfig::default(), + Arc::clone(&self.identity_generator), + ); + let eligible_slots_by_provider = routed_eligible_slots(routed); + let normalized = helper.normalize_provider_responses_with_eligibility( + original_request, + &mut responses, + &eligible_slots_by_provider, + ); let floor_prices = original_request .slots .iter() .filter_map(|slot| slot.floor_price.map(|floor| (slot.id.clone(), floor))) .collect::>(); - let helper = AuctionOrchestrator::new(AuctionConfig::default()); let local_winners = || helper.select_winning_bids(&responses, &floor_prices); let (mediator_response, winning_bids) = if let Some(mediator) = &self.mediator { let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); @@ -646,10 +723,17 @@ impl AuctionOrchestratorHarness { "Planned mediator transport budget canonicalized to zero; using local ranking" ); let winning_bids = local_winners(); + let decision_set = helper.build_decision_set( + original_request, + &normalized.outcomes, + &winning_bids, + true, + ); return Ok(OrchestrationResult { provider_responses: responses, mediator_response: None, winning_bids, + decision_set, total_time_ms: auction_start.elapsed().as_millis() as u64, metadata: routing_metadata(routed.diagnostics().unroutable_bidder_count()), }); @@ -752,10 +836,18 @@ impl AuctionOrchestratorHarness { "Auction routing diagnostics: unroutable_bidder_count={}", unroutable_bidder_count ); + let mediation_failed = self.mediator.is_some() && mediator_response.is_none(); + let decision_set = helper.build_decision_set( + original_request, + &normalized.outcomes, + &winning_bids, + mediation_failed, + ); Ok(OrchestrationResult { provider_responses: responses, mediator_response, winning_bids, + decision_set, total_time_ms: auction_start.elapsed().as_millis() as u64, metadata: routing_metadata(unroutable_bidder_count), }) @@ -786,6 +878,7 @@ impl AuctionOrchestrator { planned_providers: Vec::new(), mediator: None, providers: HashMap::new(), + identity_generator: Arc::new(SystemAuctionIdentityGenerator), } } @@ -809,15 +902,32 @@ impl AuctionOrchestrator { config: AuctionConfig::default(), #[cfg(test)] providers: HashMap::new(), + identity_generator: Arc::new(SystemAuctionIdentityGenerator), } } + #[cfg(test)] + fn with_identity_generator( + config: AuctionConfig, + identity_generator: Arc, + ) -> Self { + let mut orchestrator = Self::new(config); + orchestrator.identity_generator = identity_generator; + orchestrator + } + /// Return whether this orchestrator and another plan consumer share the same plan allocation. #[must_use] pub fn shares_plan(&self, plan: &Arc) -> bool { Arc::ptr_eq(&self.plan, plan) } + /// Return whether the compiled plan contains the named provider profile. + #[must_use] + pub fn has_profile(&self, profile_id: &str) -> bool { + self.plan.has_profile(profile_id) + } + /// Register an auction provider in the legacy parity harness. #[cfg(test)] pub(crate) fn register_provider(&mut self, provider: Arc) { @@ -837,15 +947,32 @@ impl AuctionOrchestrator { request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { - match self.dispatch_auction(request, context).await { - DispatchAuctionOutcome::Dispatched(dispatched) => Ok(self - .collect_dispatched_auction(dispatched, context.services, context) + match self + .resolve_dispatch_outcome(request, self.dispatch_auction(request, context).await)? + { + ResolvedDispatchOutcome::InFlight(dispatched) => Ok(self + .collect_dispatched_auction(*dispatched, context.services, context) .await), + ResolvedDispatchOutcome::Complete(result) => Ok(*result), + } + } + + /// Normalize a split dispatch result without starting any additional transport. + pub(crate) fn resolve_dispatch_outcome( + &self, + request: &AuctionRequest, + outcome: DispatchAuctionOutcome, + ) -> Result> { + match outcome { + DispatchAuctionOutcome::Dispatched(dispatched) => { + Ok(ResolvedDispatchOutcome::InFlight(Box::new(dispatched))) + } DispatchAuctionOutcome::DispatchFailed { - provider_responses, + mut provider_responses, fatal_admission_error, metadata, elapsed_ms, + eligible_slots_by_provider, .. } => { if let Some(error) = fatal_admission_error { @@ -853,17 +980,33 @@ impl AuctionOrchestrator { message: "Planned auction admission failed".to_string(), })); } - Ok(OrchestrationResult { - provider_responses, - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: elapsed_ms, - metadata, - }) + let normalized = self.normalize_provider_responses_with_eligibility( + request, + &mut provider_responses, + &eligible_slots_by_provider, + ); + let winning_bids = HashMap::new(); + let decision_set = + self.build_decision_set(request, &normalized.outcomes, &winning_bids, false); + Ok(ResolvedDispatchOutcome::Complete(Box::new( + OrchestrationResult { + provider_responses, + mediator_response: None, + winning_bids, + decision_set, + total_time_ms: elapsed_ms, + metadata, + }, + ))) } DispatchAuctionOutcome::NotStarted => { if self.planned_providers.is_empty() { - Ok(OrchestrationResult::no_bid()) + Ok(ResolvedDispatchOutcome::Complete(Box::new( + OrchestrationResult::no_bid( + request, + AuctionSlotFailureReason::SlotNotEligible, + ), + ))) } else { Err(Report::new(TrustedServerError::Auction { message: "No planned provider request was started".to_string(), @@ -873,7 +1016,395 @@ impl AuctionOrchestrator { } } - /// Execute an auction through the compiled plan. + #[cfg(test)] + fn provider_is_eligible_for_slot( + &self, + provider_name: &str, + slot: &super::types::AdSlot, + ) -> bool { + self.providers.get(provider_name).is_some_and(|provider| { + provider.is_enabled() + && slot + .formats + .iter() + .any(|format| provider.supports_media_type(&format.media_type)) + }) + } + + #[cfg(test)] + fn eligible_slot_ids(&self, provider_name: &str, request: &AuctionRequest) -> HashSet { + request + .slots + .iter() + .filter(|slot| self.provider_is_eligible_for_slot(provider_name, slot)) + .map(|slot| slot.id.clone()) + .collect() + } + + fn valid_upstream_bid_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_UPSTREAM_BID_ID_BYTES + && !value.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) + } + + fn mint_candidate_id(&self, issued: &mut HashSet) -> Option { + let candidate_id = mint_response_unique_base64url_identity( + self.identity_generator.as_ref(), + issued, + "", + CANDIDATE_ID_BYTES, + CANDIDATE_ID_COLLISION_RETRIES, + )?; + debug_assert_eq!(candidate_id.len(), 12); + Some(candidate_id) + } + + fn response_failure_reason(response: &AuctionResponse) -> Option { + if response.status == BidStatus::Error || response.status == BidStatus::Pending { + return match response + .metadata + .get("error_type") + .and_then(serde_json::Value::as_str) + { + Some(ERROR_TYPE_TIMEOUT) => Some(AuctionSlotFailureReason::ProviderTimeout), + Some(ERROR_TYPE_PARSE_RESPONSE) => { + Some(AuctionSlotFailureReason::InvalidProviderResponse) + } + _ => { + let invalid = response + .metadata + .get("drop_reasons") + .and_then(serde_json::Value::as_object) + .is_some_and(|reasons| reasons.contains_key("invalid_provider_response")); + Some(if invalid { + AuctionSlotFailureReason::InvalidProviderResponse + } else { + AuctionSlotFailureReason::ProviderError + }) + } + }; + } + + None + } + + #[cfg(test)] + fn normalize_provider_responses( + &self, + request: &AuctionRequest, + responses: &mut [AuctionResponse], + ) -> NormalizedProviderResponses { + let eligible_slots_by_provider = self + .config + .providers + .keys() + .map(super::plan::ProviderId::as_str) + .map(|provider| { + ( + provider.to_string(), + self.eligible_slot_ids(provider, request), + ) + }) + .collect(); + self.normalize_provider_responses_with_eligibility( + request, + responses, + &eligible_slots_by_provider, + ) + } + + fn normalize_provider_responses_with_eligibility( + &self, + request: &AuctionRequest, + responses: &mut [AuctionResponse], + eligible_slots_by_provider: &EligibleSlotsByProvider, + ) -> NormalizedProviderResponses { + let requested_slots: HashMap<&str, &super::types::AdSlot> = request + .slots + .iter() + .map(|slot| (slot.id.as_str(), slot)) + .collect(); + let mut issued_candidate_ids = HashSet::new(); + let mut candidates = HashMap::new(); + let mut outcomes = Vec::new(); + + for response in responses { + let eligible_slots = eligible_slots_by_provider + .get(&response.provider) + .cloned() + .unwrap_or_default(); + let response_failure = Self::response_failure_reason(response); + let mut upstream_counts = HashMap::::new(); + for bid in &response.bids { + if let Some(upstream_id) = bid.bid_id.as_deref() + && Self::valid_upstream_bid_id(upstream_id) + { + *upstream_counts.entry(upstream_id.to_string()).or_default() += 1; + } + } + + let mut invalid_slots = response + .metadata + .get("invalid_slots") + .and_then(serde_json::Value::as_object) + .map(|slots| { + slots + .iter() + .filter_map(|(slot, reason)| { + (reason.as_str() == Some("invalid_provider_response")).then_some(( + slot.clone(), + AuctionSlotFailureReason::InvalidProviderResponse, + )) + }) + .collect::>() + }) + .unwrap_or_default(); + let mut global_invalid = response + .metadata + .get("global_invalid_provider_response") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let mut accepted = Vec::new(); + for mut bid in core::mem::take(&mut response.bids) { + let requested_slot = requested_slots.get(bid.slot_id.as_str()).copied(); + let slot_is_eligible = eligible_slots.contains(&bid.slot_id); + let dimensions_match = requested_slot.is_some_and(|slot| { + slot.formats.iter().any(|format| { + format.width == bid.width + && format.height == bid.height + && format.media_type == super::types::MediaType::Banner + }) + }); + let upstream_id = bid.bid_id.as_deref(); + let upstream_is_valid = upstream_id.is_some_and(Self::valid_upstream_bid_id); + let upstream_is_unique = upstream_id.is_some_and(|upstream_id| { + upstream_counts.get(upstream_id).copied() == Some(1) + }); + let bid_is_valid = response.status == BidStatus::Success + && slot_is_eligible + && dimensions_match + && upstream_is_valid + && upstream_is_unique + && bid.currency == "USD" + && bid + .price + .is_some_and(|price| price.is_finite() && price >= 0.0); + + if !bid_is_valid { + if requested_slot.is_some() { + invalid_slots + .entry(bid.slot_id.clone()) + .or_insert(AuctionSlotFailureReason::InvalidProviderResponse); + } else { + global_invalid = true; + } + continue; + } + + let Some(candidate_id) = self.mint_candidate_id(&mut issued_candidate_ids) else { + invalid_slots + .insert(bid.slot_id.clone(), AuctionSlotFailureReason::InternalError); + continue; + }; + bid.candidate_id = Some(candidate_id.clone()); + bid.candidate_provider = Some(response.provider.clone()); + bid.renderer_reservation_id = None; + candidates.insert(candidate_id, bid.clone()); + accepted.push(bid); + } + let internally_failed_slots: HashSet<&str> = invalid_slots + .iter() + .filter_map(|(slot, reason)| { + (*reason == AuctionSlotFailureReason::InternalError).then_some(slot.as_str()) + }) + .collect(); + if !internally_failed_slots.is_empty() { + accepted.retain(|bid| !internally_failed_slots.contains(bid.slot_id.as_str())); + candidates.retain(|_, bid| { + bid.candidate_provider.as_deref() != Some(response.provider.as_str()) + || !internally_failed_slots.contains(bid.slot_id.as_str()) + }); + } + response.bids = accepted; + + for slot in &request.slots { + if !eligible_slots.contains(&slot.id) { + continue; + } + let slot_candidates: Vec = response + .bids + .iter() + .filter(|bid| bid.slot_id == slot.id) + .cloned() + .collect(); + let disposition = if !slot_candidates.is_empty() { + ProviderSlotDisposition::Candidates(slot_candidates) + } else if let Some(reason) = invalid_slots.get(&slot.id).copied() { + ProviderSlotDisposition::Failed(reason) + } else if global_invalid { + ProviderSlotDisposition::Failed( + AuctionSlotFailureReason::InvalidProviderResponse, + ) + } else if let Some(reason) = response_failure { + ProviderSlotDisposition::Failed(reason) + } else { + ProviderSlotDisposition::NoBid + }; + outcomes.push(ProviderSlotOutcome { + provider: response.provider.clone(), + slot: slot.id.clone(), + disposition, + }); + } + } + + NormalizedProviderResponses { + outcomes, + candidates, + } + } + + fn build_decision_set( + &self, + request: &AuctionRequest, + outcomes: &[ProviderSlotOutcome], + winning_bids: &HashMap, + mediation_failed: bool, + ) -> AuctionDecisionSetV1 { + let results = request + .slots + .iter() + .map(|slot| { + if let Some(winner) = winning_bids.get(&slot.id) { + return winner.candidate_id.as_ref().map_or_else( + || SlotAuctionDecisionV1::Failed { + slot: slot.id.clone(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }, + |candidate_id| SlotAuctionDecisionV1::Winner { + slot: slot.id.clone(), + candidate_id: candidate_id.clone(), + }, + ); + } + + let eligible_provider_count = outcomes + .iter() + .filter(|outcome| outcome.slot == slot.id) + .count(); + if eligible_provider_count == 0 { + return SlotAuctionDecisionV1::Failed { + slot: slot.id.clone(), + reason: AuctionSlotFailureReason::SlotNotEligible, + }; + } + + let mut failures: Vec = outcomes + .iter() + .filter(|outcome| outcome.slot == slot.id) + .filter_map(|outcome| match outcome.disposition { + ProviderSlotDisposition::Failed(reason) => Some(reason), + ProviderSlotDisposition::Candidates(_) | ProviderSlotDisposition::NoBid => { + None + } + }) + .collect(); + if mediation_failed { + failures.push(AuctionSlotFailureReason::MediationFailed); + } + failures.sort_by_key(|reason| reason.priority()); + failures.first().copied().map_or_else( + || SlotAuctionDecisionV1::NoBid { + slot: slot.id.clone(), + }, + |reason| SlotAuctionDecisionV1::Failed { + slot: slot.id.clone(), + reason, + }, + ) + }) + .collect(); + + AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results, + } + } + + fn resolve_mediator_candidates( + mediator_response: AuctionResponse, + candidates: &HashMap, + ) -> Result { + if mediator_response.status == BidStatus::Error + || mediator_response.status == BidStatus::Pending + { + return Err(()); + } + + let mut seen = HashSet::new(); + let mut seen_slots = HashSet::new(); + let mut resolved = Vec::with_capacity(mediator_response.bids.len()); + for selection in &mediator_response.bids { + let Some(candidate_id) = selection.candidate_id.as_deref() else { + return Err(()); + }; + if !seen.insert(candidate_id.to_string()) { + return Err(()); + } + let Some(source) = candidates.get(candidate_id) else { + return Err(()); + }; + let Some(selected_price) = selection + .price + .filter(|price| price.is_finite() && *price >= 0.0) + else { + return Err(()); + }; + let source_authority_matches = selection.slot_id == source.slot_id + && selection.candidate_provider == source.candidate_provider + && selection.currency == source.currency + && selection.creative == source.creative + && selection.adomain == source.adomain + && selection.bidder == source.bidder + && selection.width == source.width + && selection.height == source.height + && selection.nurl == source.nurl + && selection.burl == source.burl + && selection.bid_id == source.bid_id + && selection.ad_id == source.ad_id + && selection.creative_id == source.creative_id + && selection.renderer == source.renderer + && selection.cache_id == source.cache_id + && selection.cache_host == source.cache_host + && selection.cache_path == source.cache_path; + if !seen_slots.insert(source.slot_id.as_str()) || !source_authority_matches { + return Err(()); + } + + let mut restored = source.clone(); + restored.price = Some(selected_price); + resolved.push(restored); + } + + Ok(AuctionResponse { + provider: mediator_response.provider, + status: if resolved.is_empty() { + BidStatus::NoBid + } else { + BidStatus::Success + }, + bids: resolved, + response_time_ms: mediator_response.response_time_ms, + metadata: mediator_response.metadata, + }) + } + + /// Execute an auction using the auto-detected strategy. + /// + /// Strategy is determined by mediator configuration: + /// - If mediator is configured: runs parallel mediation (bidders → mediator decides) + /// - If no mediator: runs parallel only (bidders → highest CPM wins) /// /// # Errors /// @@ -885,7 +1416,10 @@ impl AuctionOrchestrator { context: &AuctionContext<'_>, ) -> Result> { if !self.enabled { - return Ok(OrchestrationResult::no_bid()); + return Ok(OrchestrationResult::no_bid( + request, + AuctionSlotFailureReason::AuctionDisabled, + )); } #[cfg(not(test))] return self.run_planned_auction(request, context).await; @@ -936,138 +1470,145 @@ impl AuctionOrchestrator { context: &AuctionContext<'_>, ) -> Result> { let mediation_start = Instant::now(); - let provider_responses = self.run_providers_parallel(request, context).await?; + let mut provider_responses = self.run_providers_parallel(request, context).await?; + let normalized = self.normalize_provider_responses(request, &mut provider_responses); let floor_prices = self.floor_prices_by_slot(request); - let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { - let mediator = self.get_provider(mediator_name)?; - - log::info!( - "Sending {} provider responses to mediator: {}", - provider_responses.len(), - mediator.provider_name() - ); - - // Give the mediator only the remaining time from the auction - // deadline, not the full timeout — the bidding phase already - // consumed part of it. Canonicalize the transport timeout so the - // backend name remains stable across equivalent budget values. - let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); - let mediator_timeout = context - .services - .backend() - .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()); - - if mediator_timeout == 0 { - log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); - let winning = self.select_winning_bids(&provider_responses, &floor_prices); - return Ok(OrchestrationResult { - provider_responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: 0, - metadata: HashMap::new(), - }); - } - - let mediator_context = AuctionContext { - settings: context.settings, - request: context.request, - timeout_ms: mediator_timeout, - transport_timeout_ms: mediator_timeout, - provider_responses: Some(&provider_responses), - services: context.services, - }; + let mut mediation_failed = false; + let mut mediator_response = None; + let mut winning_bids = None; - let start_time = Instant::now(); - let mediator_resp = match mediator - .request_bids(request, &mediator_context) - .await - .change_context(TrustedServerError::Auction { - message: format!("Mediator {} failed to launch", mediator.provider_name()), - })? { - ProviderRequestOutcome::Immediate(response) => response, - ProviderRequestOutcome::Pending { - request: pending, - parse_state, - } => { - let platform_resp = mediator_context - .services - .http_client() - .wait(pending) - .await - .change_context(TrustedServerError::Auction { - message: format!( - "Mediator {} request failed", - mediator.provider_name() - ), - })?; - let response_time_ms = start_time.elapsed().as_millis() as u64; - if AuctionDeadlinePolicy::for_runtime(context.services) - .rejects_late_completion(mediation_start, context.timeout_ms) + if let Some(mediator_name) = &self.config.mediator { + if let Some(mediator) = self.providers.get(mediator_name) { + log::info!( + "Sending {} provider responses to mediator: {}", + provider_responses.len(), + mediator.provider_name() + ); + let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); + let logical_budget_ms = remaining_ms.min(mediator.timeout_ms()); + let transport_timeout_ms = context + .services + .backend() + .canonicalize_transport_timeout_ms(logical_budget_ms, mediator.timeout_ms()); + if logical_budget_ms == 0 || transport_timeout_ms == 0 { + log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); + mediation_failed = true; + } else { + let mediator_context = AuctionContext { + settings: context.settings, + request: context.request, + timeout_ms: logical_budget_ms, + transport_timeout_ms, + provider_responses: Some(&provider_responses), + services: context.services, + }; + let start_time = Instant::now(); + let raw_response = match mediator.request_bids(request, &mediator_context).await { - log::warn!( - "Mediator '{}' completed after the hard auction deadline; using local ranking ({}ms)", - mediator.provider_name(), - response_time_ms - ); - let winning = self.select_winning_bids(&provider_responses, &floor_prices); - return Ok(OrchestrationResult { - provider_responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: 0, - metadata: HashMap::new(), - }); - } - - mediator - .parse_response_with_context_and_state( - platform_resp, - response_time_ms, - request, - &mediator_context, - parse_state.as_deref(), - ) - .await - .change_context(TrustedServerError::Auction { - message: format!("Mediator {} parse failed", mediator.provider_name()), - })? - } - }; + Ok(ProviderRequestOutcome::Immediate(response)) => Some( + canonical_provider_response(mediator.provider_name(), response), + ), + Ok(ProviderRequestOutcome::Pending { + request: pending, + parse_state, + }) => match mediator_context.services.http_client().wait(pending).await { + Ok(platform_response) => { + let response_time_ms = start_time.elapsed().as_millis() as u64; + if AuctionDeadlinePolicy::for_runtime(context.services) + .rejects_late_completion(mediation_start, context.timeout_ms) + { + None + } else { + mediator + .parse_response_with_context_and_state( + platform_response, + response_time_ms, + request, + &mediator_context, + parse_state.as_deref(), + ) + .await + .inspect_err(|error| { + log::warn!( + "Mediator '{}' parse failed: {error:?}", + mediator.provider_name() + ); + }) + .ok() + .map(|response| { + canonical_provider_response( + mediator.provider_name(), + response, + ) + }) + } + } + Err(error) => { + log::warn!( + "Mediator '{}' request failed: {error:?}", + mediator.provider_name() + ); + None + } + }, + Err(error) => { + log::warn!( + "Mediator '{}' failed to launch: {error:?}", + mediator.provider_name() + ); + None + } + }; - // Extract only mediator bids with comparable numeric prices. - let winning = mediator_resp - .bids - .iter() - .filter_map(|bid| { - if bid.price.is_none() { - log::warn!( - "Mediator '{}' returned bid for slot '{}' without a price - skipping", - mediator.provider_name(), - bid.slot_id - ); - None + if let Some(raw_response) = raw_response { + mediator_response = Some(raw_response.clone()); + match Self::resolve_mediator_candidates( + raw_response, + &normalized.candidates, + ) { + Ok(resolved) => { + let selected = resolved + .bids + .iter() + .map(|bid| (bid.slot_id.clone(), bid.clone())) + .collect(); + winning_bids = + Some(self.apply_floor_prices(selected, &floor_prices)); + mediator_response = Some(resolved); + } + Err(()) => { + log::warn!( + "Mediator '{}' returned invalid candidate provenance", + mediator.provider_name() + ); + mediation_failed = true; + } + } } else { - Some((bid.slot_id.clone(), bid.clone())) + mediation_failed = true; } - }) - .collect(); + } + } else { + log::warn!("Mediator '{}' not registered", mediator_name); + mediation_failed = true; + } + } - ( - Some(mediator_resp), - self.apply_floor_prices(winning, &floor_prices), - ) - } else { - // No mediator - select best bid per slot from bidder responses - let winning = self.select_winning_bids(&provider_responses, &floor_prices); - (None, winning) - }; + let winning_bids = winning_bids + .unwrap_or_else(|| self.select_winning_bids(&provider_responses, &floor_prices)); + let decision_set = self.build_decision_set( + request, + &normalized.outcomes, + &winning_bids, + mediation_failed, + ); Ok(OrchestrationResult { provider_responses, mediator_response, winning_bids, + decision_set, total_time_ms: 0, // Will be set by caller metadata: HashMap::new(), }) @@ -1080,14 +1621,18 @@ impl AuctionOrchestrator { request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { - let provider_responses = self.run_providers_parallel(request, context).await?; + let mut provider_responses = self.run_providers_parallel(request, context).await?; + let normalized = self.normalize_provider_responses(request, &mut provider_responses); let floor_prices = self.floor_prices_by_slot(request); let winning_bids = self.select_winning_bids(&provider_responses, &floor_prices); + let decision_set = + self.build_decision_set(request, &normalized.outcomes, &winning_bids, false); Ok(OrchestrationResult { provider_responses, mediator_response: None, winning_bids, + decision_set, total_time_ms: 0, metadata: HashMap::new(), }) @@ -1111,9 +1656,7 @@ impl AuctionOrchestrator { .collect::>(); if provider_names.is_empty() { - return Err(Report::new(TrustedServerError::Auction { - message: "No providers configured".to_string(), - })); + return Ok(Vec::new()); } // Reject multi-provider fan-out before any request launches when the @@ -1122,14 +1665,14 @@ impl AuctionOrchestrator { // blow the auction budget before a later `select` could reject it. if provider_names.len() > 1 && !context.services.http_client().supports_concurrent_fanout() { - return Err(Report::new(TrustedServerError::Auction { - message: format!( - "{} auction providers configured, but this platform's HTTP \ - client executes requests sequentially — configure a single \ - provider, or use an adapter with concurrent fan-out support", - provider_names.len(), - ), - })); + log::warn!( + "{} auction providers configured, but this platform's HTTP client executes requests sequentially", + provider_names.len(), + ); + return Ok(provider_names + .iter() + .map(|provider_name| provider_launch_failed_response(provider_name, 0)) + .collect()); } log::info!( @@ -1145,8 +1688,6 @@ impl AuctionOrchestrator { let mut backend_to_provider: HashMap = HashMap::new(); let mut pending_requests: Vec = Vec::new(); let mut responses = Vec::new(); - let mut immediate_response_count = 0usize; - for provider_name in &provider_names { let provider = match self.providers.get(*provider_name) { Some(p) => p, @@ -1177,20 +1718,23 @@ impl AuctionOrchestrator { // budget skips every provider, including one that might respond immediately. if effective_timeout == 0 { log::warn!("Auction timeout exhausted before launching provider request; skipping"); + responses.push(provider_timeout_response(provider.provider_name(), 0)); continue; } - // Immediate providers have no backend name and must remain eligible - // to return a synchronous result. Pending providers are still - // guarded before dispatch when their name can be predicted. - let predicted_backend_name = provider.backend_name(context.services, effective_timeout); - if let Some(backend_name) = predicted_backend_name.as_ref() - && backend_to_provider.contains_key(backend_name) + // Pre-launch guard: `request_bids` fires the outbound send, and + // discarding the returned pending handle afterwards does not retract + // it. If another provider this auction already claimed the predicted + // backend name, skip *before* dispatching so a duplicate never hits + // the wire. The post-launch check below stays as a defense for a + // provider that resolves to an unexpected name. + if let Some(predicted) = provider.backend_name(context.services, effective_timeout) + && backend_to_provider.contains_key(&predicted) { log::warn!( "Provider '{}' predicted backend name '{}' already belongs to another provider; skipping launch", provider.provider_name(), - backend_name, + predicted, ); responses.push(provider_launch_failed_response(provider.provider_name(), 0)); continue; @@ -1218,14 +1762,13 @@ impl AuctionOrchestrator { parse_state, }) => { let request_backend_name = pending.backend_name().map(str::to_string).or_else(|| { - if let Some(backend_name) = predicted_backend_name.as_ref() { + provider.backend_name(context.services, effective_timeout).inspect(|name| { log::warn!( "Provider '{}' pending request returned no backend name; using predicted name '{}'", provider.provider_name(), - backend_name, + name, ); - } - predicted_backend_name.clone() + }) }); let Some(request_backend_name) = request_backend_name else { log::warn!( @@ -1238,6 +1781,10 @@ impl AuctionOrchestrator { )); continue; }; + // Post-launch defense: a resolved backend name already + // claimed by another provider would misattribute that + // provider's response, so fail this launch attributably + // instead of overwriting the correlation entry. if backend_to_provider.contains_key(&request_backend_name) { log::warn!( "Provider '{}' resolved backend name '{}' already belongs to another provider; skipping launch", @@ -1267,12 +1814,14 @@ impl AuctionOrchestrator { ); } Ok(ProviderRequestOutcome::Immediate(response)) => { - immediate_response_count += 1; log::debug!( "Provider '{}' completed without an upstream request", provider.provider_name() ); - responses.push(response); + responses.push(canonical_provider_response( + provider.provider_name(), + response, + )); } Err(e) => { let response_time_ms = start_time.elapsed().as_millis() as u64; @@ -1290,18 +1839,7 @@ impl AuctionOrchestrator { } if pending_requests.is_empty() { - // An immediate response (for example, an APS-only Prebid no-bid) is - // a completed provider outcome. Launch failures alone remain a - // terminal auction error rather than being converted to a 200 no-bid. - if immediate_response_count > 0 { - return Ok(responses); - } - return Err(Report::new(TrustedServerError::Auction { - message: format!( - "All {} configured provider(s) skipped or failed to launch", - provider_names.len() - ), - })); + return Ok(responses); } let deadline_policy = AuctionDeadlinePolicy::for_runtime(context.services); @@ -1318,7 +1856,7 @@ impl AuctionOrchestrator { // some adapters, buffers the selected response body before returning. // Backend first-byte and between-bytes timeouts are capped to the // remaining auction budget in Phase 1. They are transport timers, not - // absolute wall-clock limits, so connection setup and byte-trickling + // absolute wall-clock limits, so connection setup and byte trickling // remain bounded operational risks rather than strict deadline proof. let mut remaining = pending_requests; @@ -1385,7 +1923,10 @@ impl AuctionOrchestrator { auction_response.status, auction_response.response_time_ms ); - responses.push(auction_response); + responses.push(canonical_provider_response( + &state.provider_name, + auction_response, + )); } Err(e) => { // lgtm[rust/cleartext-logging] @@ -1487,9 +2028,20 @@ impl AuctionOrchestrator { }; let should_replace = match winning_bids.get(&bid.slot_id) { - Some(current_winner) => current_winner - .price - .is_none_or(|current_price| bid_price > current_price), + Some(current_winner) => current_winner.price.is_none_or(|current_price| { + bid_price > current_price + || (bid_price == current_price + && ( + bid.candidate_provider.as_deref().unwrap_or(&bid.bidder), + bid.bid_id.as_deref().unwrap_or_default(), + ) < ( + current_winner + .candidate_provider + .as_deref() + .unwrap_or(¤t_winner.bidder), + current_winner.bid_id.as_deref().unwrap_or_default(), + )) + }), None => true, }; @@ -1556,24 +2108,6 @@ impl AuctionOrchestrator { .collect() } - /// Get a provider by name. - #[cfg(test)] - fn get_provider( - &self, - name: &str, - ) -> Result<&Arc, Report> { - self.providers.get(name).ok_or_else(|| { - log::warn!( - "Provider '{}' configured but not registered. Available providers: {:?}", - name, - self.providers.keys().collect::>() - ); - Report::new(TrustedServerError::Auction { - message: format!("Provider '{}' not registered", name), - }) - }) - } - async fn dispatch_planned_auction( &self, request: &AuctionRequest, @@ -1600,6 +2134,7 @@ impl AuctionOrchestrator { plan, context.services.client_info().client_ip, ); + let eligible_slots_by_provider = routed_eligible_slots(&routed); let planned_unused_bidder_params = routed .inputs() .iter() @@ -1666,6 +2201,7 @@ impl AuctionOrchestrator { fatal_admission_error: Some(error), metadata: routing_metadata(planned_unroutable_bidder_count), elapsed_ms: auction_start.elapsed().as_millis() as u64, + eligible_slots_by_provider, }; } }; @@ -1755,7 +2291,10 @@ impl AuctionOrchestrator { } Ok(ProviderRequestOutcome::Immediate(response)) => { immediate_response_count += 1; - completed_responses.push(response); + completed_responses.push(canonical_provider_response( + provider.provider_name(), + response, + )); } Err(error) => { log::warn!( @@ -1790,6 +2329,7 @@ impl AuctionOrchestrator { planned_unused_bidder_params, planned_unroutable_bidder_count, planned_provider_order, + eligible_slots_by_provider, }) } @@ -1800,10 +2340,10 @@ impl AuctionOrchestrator { /// [`DispatchedAuction`] token. The Fastly host begins the SSP round-trips /// while WASM continues to `pending_origin.wait()`. /// - /// Returns [`DispatchAuctionOutcome::NotStarted`] when no providers are configured or - /// all providers are disabled / over budget. Returns - /// [`DispatchAuctionOutcome::DispatchFailed`] when provider launch attempts - /// happened but none could be started. + /// The token is returned even when no transport starts. Collection then + /// routes zero-budget, launch-failure, disabled, and unconfigured-provider + /// cases through the same exhaustive terminal decision builder as ordinary + /// responses instead of silently dropping their slot outcomes. #[must_use] pub async fn dispatch_auction( &self, @@ -1827,6 +2367,18 @@ impl AuctionOrchestrator { if provider_names.is_empty() { return DispatchAuctionOutcome::NotStarted; } + #[cfg(test)] + let eligible_slots_by_provider = provider_names + .iter() + .map(|provider| { + ( + (*provider).to_string(), + self.eligible_slot_ids(provider, request), + ) + }) + .collect::(); + #[cfg(not(test))] + let eligible_slots_by_provider = EligibleSlotsByProvider::new(); // Mirror run_providers_parallel: reject multi-provider fan-out before // any request launches when the platform executes `send_async` eagerly @@ -1843,7 +2395,17 @@ impl AuctionOrchestrator { concurrent fan-out support", provider_names.len(), ); - return DispatchAuctionOutcome::NotStarted; + return DispatchAuctionOutcome::DispatchFailed { + request: request.clone(), + provider_responses: provider_names + .iter() + .map(|provider| provider_launch_failed_response(provider, 0)) + .collect(), + fatal_admission_error: None, + metadata: HashMap::new(), + elapsed_ms: 0, + eligible_slots_by_provider, + }; } let auction_start = Instant::now(); @@ -1872,6 +2434,7 @@ impl AuctionOrchestrator { // lgtm[rust/cleartext-logging] // The provider name is a static config identifier (e.g. "prebid"), not a secret. log::warn!("Provider '{}' not registered, skipping", provider_name); + completed_responses.push(provider_launch_failed_response(provider_name, 0)); continue; } }; @@ -1898,20 +2461,24 @@ impl AuctionOrchestrator { context.timeout_ms, provider.provider_name() ); + completed_responses.push(provider_timeout_response( + provider.provider_name(), + auction_start.elapsed().as_millis() as u64, + )); continue; } - // Do not require a backend name before dispatch: an immediate - // provider intentionally has none. Guard predicted names when - // available; pending requests without either name fail below. - let predicted_backend_name = provider.backend_name(context.services, effective_timeout); - if let Some(backend_name) = predicted_backend_name.as_ref() - && backend_to_provider.contains_key(backend_name) + // Pre-launch guard: skip before `request_bids` fires the outbound + // send when another provider this auction already claimed the + // predicted backend name (see the parallel path). Dropping the + // pending handle afterwards would not retract the request. + if let Some(predicted) = provider.backend_name(context.services, effective_timeout) + && backend_to_provider.contains_key(&predicted) { log::warn!( "Provider '{}' predicted backend name '{}' already belongs to another provider; skipping dispatch", provider.provider_name(), - backend_name, + predicted, ); completed_responses .push(provider_launch_failed_response(provider.provider_name(), 0)); @@ -1933,16 +2500,10 @@ impl AuctionOrchestrator { request: pending, parse_state, }) => { - let backend_name = pending.backend_name().map(str::to_string).or_else(|| { - if let Some(backend_name) = predicted_backend_name.as_ref() { - log::warn!( - "Provider '{}' pending request returned no backend name; using predicted name '{}'", - provider.provider_name(), - backend_name, - ); - } - predicted_backend_name.clone() - }); + let backend_name = pending + .backend_name() + .map(str::to_string) + .or_else(|| provider.backend_name(context.services, effective_timeout)); let Some(backend_name) = backend_name else { log::warn!( "Provider '{}' pending request has no backend name; response cannot be correlated", @@ -1954,9 +2515,14 @@ impl AuctionOrchestrator { )); continue; }; + // Post-launch defense: a resolved backend name already + // claimed by another provider would misattribute that + // provider's response, so fail this dispatch attributably + // instead of overwriting the correlation entry. if backend_to_provider.contains_key(&backend_name) { log::warn!( - "Provider '{}' resolved backend name '{}' already belongs to another provider; skipping dispatch", + "Provider '{}' resolved to backend name '{}' already claimed by another \ + provider this auction; skipping launch to avoid response misattribution", provider.provider_name(), backend_name, ); @@ -1986,7 +2552,10 @@ impl AuctionOrchestrator { } Ok(ProviderRequestOutcome::Immediate(response)) => { immediate_response_count += 1; - completed_responses.push(response); + completed_responses.push(canonical_provider_response( + provider.provider_name(), + response, + )); } Err(e) => { let response_time_ms = start_time.elapsed().as_millis() as u64; @@ -2013,6 +2582,7 @@ impl AuctionOrchestrator { fatal_admission_error: None, metadata: HashMap::new(), elapsed_ms: auction_start.elapsed().as_millis() as u64, + eligible_slots_by_provider, } }; } @@ -2037,6 +2607,7 @@ impl AuctionOrchestrator { planned_unused_bidder_params: HashMap::new(), planned_unroutable_bidder_count: 0, planned_provider_order: HashMap::new(), + eligible_slots_by_provider, }) } @@ -2069,6 +2640,7 @@ impl AuctionOrchestrator { planned_unused_bidder_params, planned_unroutable_bidder_count, planned_provider_order, + eligible_slots_by_provider, } = dispatched; log::info!( @@ -2160,7 +2732,10 @@ impl AuctionOrchestrator { ) .await { - Ok(auction_response) => responses.push(auction_response), + Ok(auction_response) => responses.push(canonical_provider_response( + &state.provider_name, + auction_response, + )), Err(error) => responses.push(provider_error_response( &state.provider_name, response_time_ms, @@ -2186,7 +2761,10 @@ impl AuctionOrchestrator { ) .await { - Ok(response) => responses.push(response), + Ok(response) => responses.push(canonical_provider_response( + state.provider.provider_name(), + response, + )), Err(error) => responses.push(provider_error_response( state.provider.provider_name(), response_time_ms, @@ -2286,6 +2864,12 @@ impl AuctionOrchestrator { }); } + let normalized = self.normalize_provider_responses_with_eligibility( + &request, + &mut responses, + &eligible_slots_by_provider, + ); + #[cfg(not(test))] let mediator = self.mediator.as_ref(); #[cfg(test)] @@ -2295,171 +2879,152 @@ impl AuctionOrchestrator { .as_ref() .and_then(|name| self.providers.get(name)) }); - let (mediator_response, winning_bids) = if let Some(mediator) = mediator { - { - // Cap the mediator at whichever is tighter: its own configured - // timeout or the remaining auction budget (A_deadline). Backend - // first-byte and between-bytes timeouts bound normal collection, but - // they are transport timers rather than absolute wall-clock limits: - // connection setup and byte-trickling can still consume more of the - // auction budget. Recomputing the remaining budget here prevents the - // mediator from extending that bounded response hold. - let remaining = remaining_budget_ms(auction_start, timeout_ms); - let logical_budget_ms = remaining.min(mediator.timeout_ms()); - if logical_budget_ms == 0 { - log::warn!( - "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", - mediator.provider_name(), - responses.len(), - ); - let winning = self.select_winning_bids(&responses, &floor_prices); - return OrchestrationResult { - provider_responses: responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: auction_start.elapsed().as_millis() as u64, - metadata: routing_metadata(planned_unroutable_bidder_count), - }; - } + let mut mediation_failed = false; + let mut mediator_response = None; + let mut mediated_winners = None; + + if let Some(mediator) = mediator { + let remaining = remaining_budget_ms(auction_start, timeout_ms); + let logical_budget_ms = remaining.min(mediator.timeout_ms()); + if logical_budget_ms == 0 { + log::warn!( + "A_deadline exhausted before mediator '{}' — using direct fallback", + mediator.provider_name(), + ); + mediation_failed = true; + } else { let transport_timeout_ms = services .backend() .canonicalize_transport_timeout_ms(logical_budget_ms, mediator.timeout_ms()); if transport_timeout_ms == 0 { log::warn!( - "Mediator '{}' transport budget canonicalized to zero — returning {} SSP bids without mediation", + "Mediator '{}' transport budget canonicalized to zero — using direct fallback", mediator.provider_name(), - responses.len(), ); - let winning = self.select_winning_bids(&responses, &floor_prices); - return OrchestrationResult { - provider_responses: responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: auction_start.elapsed().as_millis() as u64, - metadata: routing_metadata(planned_unroutable_bidder_count), + mediation_failed = true; + } else { + let mediator_start = Instant::now(); + let placeholder = http::Request::builder() + .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) + .body(edgezero_core::body::Body::empty()) + .unwrap_or_else(|_| http::Request::new(edgezero_core::body::Body::empty())); + let mediator_context = AuctionContext { + settings: context.settings, + request: &placeholder, + timeout_ms: logical_budget_ms, + transport_timeout_ms, + provider_responses: Some(&responses), + services: context.services, }; - } - let mediator_start = Instant::now(); - log::info!( - "Running mediator '{}' with {}ms logical budget and {}ms transport timeout (A_deadline remaining: {}ms, configured: {}ms)", - mediator.provider_name(), - logical_budget_ms, - transport_timeout_ms, - remaining, - mediator.timeout_ms(), - ); - // The mediator runs on the collect path. See the doc-comment on - // `AuctionContext::request`: the real client request was already - // consumed by `send_async` during dispatch, so we substitute a - // canonical placeholder URL. Any future mediator that needs real - // client headers must snapshot them at dispatch time onto - // `DispatchedAuction` rather than reading `context.request` here. - let placeholder = http::Request::builder() - .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) - .body(edgezero_core::body::Body::empty()) - .unwrap_or_else(|_| http::Request::new(edgezero_core::body::Body::empty())); - let mediator_context = AuctionContext { - settings: context.settings, - request: &placeholder, - timeout_ms: logical_budget_ms, - transport_timeout_ms, - provider_responses: Some(&responses), - services: context.services, - }; - let mediator_response = match mediator - .request_bids(&request, &mediator_context) - .await - { - Ok(ProviderRequestOutcome::Immediate(response)) => Some(response), - Ok(ProviderRequestOutcome::Pending { - request: pending, - parse_state, - }) => match services.http_client().wait(pending).await.change_context( - TrustedServerError::Auction { - message: format!( - "Mediator {} request failed", - mediator.provider_name() - ), - }, - ) { - Ok(platform_resp) => { - let response_time_ms = mediator_start.elapsed().as_millis() as u64; - if deadline_policy.rejects_late_completion(auction_start, timeout_ms) { + let raw_response = match mediator + .request_bids(&request, &mediator_context) + .await + { + Ok(ProviderRequestOutcome::Immediate(response)) => Some( + canonical_provider_response(mediator.provider_name(), response), + ), + Ok(ProviderRequestOutcome::Pending { + request: pending, + parse_state, + }) => match services.http_client().wait(pending).await { + Ok(platform_response) => { + let response_time_ms = mediator_start.elapsed().as_millis() as u64; + if deadline_policy + .rejects_late_completion(auction_start, timeout_ms) + { + log::warn!( + "Mediator '{}' completed after the hard auction deadline; using direct fallback ({}ms)", + mediator.provider_name(), + response_time_ms, + ); + None + } else { + mediator + .parse_response_with_context_and_state( + platform_response, + response_time_ms, + &request, + &mediator_context, + parse_state.as_deref(), + ) + .await + .inspect_err(|error| { + log::warn!( + "Mediator '{}' parse failed: {error:?}", + mediator.provider_name(), + ); + }) + .ok() + .map(|response| { + canonical_provider_response( + mediator.provider_name(), + response, + ) + }) + } + } + Err(error) => { log::warn!( - "Mediator '{}' completed after the hard auction deadline; using local ranking ({}ms)", + "Mediator '{}' request failed: {error:?}", mediator.provider_name(), - response_time_ms ); None - } else { - match mediator - .parse_response_with_context_and_state( - platform_resp, - response_time_ms, - &request, - &mediator_context, - parse_state.as_deref(), - ) - .await - { - Ok(response) => Some(response), - Err(error) => { - log::warn!( - "Mediator '{}' parse failed: {:?}", - mediator.provider_name(), - error - ); - None - } - } } - } + }, Err(error) => { - log::warn!("Mediator request failed: {:?}", error); + log::warn!( + "Mediator '{}' failed to dispatch: {error:?}", + mediator.provider_name(), + ); None } - }, - Err(error) => { - log::warn!( - "Mediator '{}' failed to dispatch: {:?}", - mediator.provider_name(), - error - ); - None - } - }; + }; - if let Some(mediator_response) = mediator_response { - let winning = mediator_response - .bids - .iter() - .filter_map(|bid| { - if bid.price.is_none() { - log::warn!( - "Mediator '{}' returned bid for slot '{}' without decoded price - skipping", - mediator.provider_name(), - bid.slot_id - ); - None - } else { - Some((bid.slot_id.clone(), bid.clone())) - } - }) - .collect(); - let winning = self.apply_floor_prices(winning, &floor_prices); - (Some(mediator_response), winning) - } else { - (None, self.select_winning_bids(&responses, &floor_prices)) + if let Some(raw_response) = raw_response { + mediator_response = Some(raw_response.clone()); + match Self::resolve_mediator_candidates( + raw_response, + &normalized.candidates, + ) { + Ok(resolved) => { + let selected = resolved + .bids + .iter() + .map(|bid| (bid.slot_id.clone(), bid.clone())) + .collect(); + mediated_winners = + Some(self.apply_floor_prices(selected, &floor_prices)); + mediator_response = Some(resolved); + } + Err(()) => { + log::warn!( + "Mediator '{}' returned invalid candidate provenance", + mediator.provider_name(), + ); + mediation_failed = true; + } + } + } else { + mediation_failed = true; + } } } - } else { - (None, self.select_winning_bids(&responses, &floor_prices)) - }; + } + + let winning_bids = + mediated_winners.unwrap_or_else(|| self.select_winning_bids(&responses, &floor_prices)); + let decision_set = self.build_decision_set( + &request, + &normalized.outcomes, + &winning_bids, + mediation_failed, + ); OrchestrationResult { provider_responses: responses, mediator_response, winning_bids, + decision_set, total_time_ms: auction_start.elapsed().as_millis() as u64, metadata: routing_metadata(planned_unroutable_bidder_count), } @@ -2481,6 +3046,8 @@ pub struct OrchestrationResult { pub mediator_response: Option, /// Winning bids per slot pub winning_bids: HashMap, + /// Exact ordered decision for every requested slot. + pub decision_set: AuctionDecisionSetV1, /// Total orchestration time in milliseconds pub total_time_ms: u64, /// Metadata about the auction @@ -2488,11 +3055,12 @@ pub struct OrchestrationResult { } impl OrchestrationResult { - fn no_bid() -> Self { + pub(crate) fn no_bid(request: &AuctionRequest, reason: AuctionSlotFailureReason) -> Self { Self { provider_responses: Vec::new(), mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1::failed(request, reason), total_time_ms: 0, metadata: HashMap::new(), } @@ -2535,13 +3103,14 @@ mod tests { AuctionPlan, AuctionPlanConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, }; use crate::auction::provider::{ - AuctionProvider, GenericOpenRtbProvider, ProviderRequestOutcome, + AuctionProvider, GenericOpenRtbProvider, ProviderRequestOutcome, ProviderSlotDisposition, }; use crate::auction::routing::{RoutingDiagnostics, route_auction}; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest, - AuctionResponse, Bid, BidRenderer, BidStatus, MediaType, PublisherInfo, UserInfo, + AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionDropReason, + AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, BidRenderSourceV1, + BidStatus, MediaType, PublisherInfo, SlotAuctionDecisionV1, UserInfo, }; use crate::error::TrustedServerError; use crate::integrations::adserver_mock::{AdServerMockConfig, AdServerMockProvider}; @@ -2562,10 +3131,20 @@ mod tests { use std::sync::{Arc, Mutex}; use super::{ - AuctionOrchestrator, AuctionOrchestratorHarness, DispatchedAuction, ERROR_TYPE_TIMEOUT, - OrchestrationResult, + AuctionIdentityGenerator, AuctionOrchestrator, AuctionOrchestratorHarness, + DispatchedAuction, ERROR_TYPE_TIMEOUT, OrchestrationResult, }; + fn expect_dispatched(outcome: DispatchAuctionOutcome) -> DispatchedAuction { + match outcome { + DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, + DispatchAuctionOutcome::NotStarted => panic!("auction should dispatch"), + DispatchAuctionOutcome::DispatchFailed { .. } => { + panic!("auction dispatch should not fail") + } + } + } + fn planned_config(providers: &[(&str, RoutingMode)], signing: bool) -> AuctionPlanConfig { AuctionPlanConfig { timeout_ms: 777, @@ -3097,6 +3676,46 @@ mod tests { struct StubAuctionProvider { name: &'static str, backend: &'static str, + configured_timeout_ms: u32, + predicted_timeouts: Option>>>, + request_timeouts: Option>>>, + } + + impl StubAuctionProvider { + fn new(name: &'static str, backend: &'static str) -> Self { + Self { + name, + backend, + configured_timeout_ms: 125, + predicted_timeouts: None, + request_timeouts: None, + } + } + + fn recording( + name: &'static str, + backend: &'static str, + configured_timeout_ms: u32, + predicted_timeouts: Arc>>, + request_timeouts: Arc>>, + ) -> Self { + Self { + name, + backend, + configured_timeout_ms, + predicted_timeouts: Some(predicted_timeouts), + request_timeouts: Some(request_timeouts), + } + } + + fn record(slot: &Option>>>, timeout_ms: u32) { + if let Some(observed) = slot { + observed + .lock() + .expect("should lock observed timeouts") + .push(timeout_ms); + } + } } #[async_trait::async_trait(?Send)] @@ -3110,6 +3729,7 @@ mod tests { _request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { + Self::record(&self.request_timeouts, context.transport_timeout_ms); let req = PlatformHttpRequest::new( http::Request::builder() .method("POST") @@ -3161,10 +3781,11 @@ mod tests { } fn timeout_ms(&self) -> u32 { - 125 + self.configured_timeout_ms } - fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + fn backend_name(&self, _services: &RuntimeServices, timeout_ms: u32) -> Option { + Self::record(&self.predicted_timeouts, timeout_ms); Some(self.backend.to_string()) } } @@ -3209,9 +3830,11 @@ mod tests { _response: PlatformResponse, response_time_ms: u64, ) -> Result> { + let mut bid = auction_bid(self.name, 3.0); + bid.bid_id = Some(format!("{}-bid", self.name)); Ok(AuctionResponse::success( self.name, - vec![auction_bid(self.name, 3.0)], + vec![bid], response_time_ms, )) } @@ -3245,7 +3868,7 @@ mod tests { _request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { - let request = PlatformHttpRequest::new( + let req = PlatformHttpRequest::new( http::Request::builder() .method("POST") .uri("https://example.com/mediate") @@ -3256,7 +3879,7 @@ mod tests { context .services .http_client() - .send_async(request) + .send_async(req) .await .change_context(TrustedServerError::Auction { message: "pending mediator launch failed".to_string(), @@ -3265,13 +3888,30 @@ mod tests { } async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("pending deadline mediator should parse with provider context") + } + + async fn parse_response_with_context( &self, _response: PlatformResponse, response_time_ms: u64, + _request: &AuctionRequest, + context: &AuctionContext<'_>, ) -> Result> { + let mut selection = context + .provider_responses + .and_then(|responses| responses.first()) + .and_then(|response| response.bids.first()) + .cloned() + .expect("pending deadline mediator should receive one candidate"); + selection.price = Some(9.0); Ok(AuctionResponse::success( self.provider_name(), - vec![auction_bid("mediated", 9.0)], + vec![selection], response_time_ms, )) } @@ -3322,73 +3962,6 @@ mod tests { } } - struct RecordingTimeoutProvider { - name: &'static str, - backend: &'static str, - configured_timeout_ms: u32, - predicted: Arc>>, - requested: Arc>>, - } - - #[async_trait::async_trait(?Send)] - impl AuctionProvider for RecordingTimeoutProvider { - fn provider_name(&self) -> &str { - self.name - } - - async fn request_bids( - &self, - _request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - self.requested - .lock() - .expect("should lock requested timeouts") - .push(context.transport_timeout_ms); - let request = PlatformHttpRequest::new( - http::Request::builder() - .method("POST") - .uri("https://example.com/bid") - .body(edgezero_core::body::Body::empty()) - .expect("should build recording request"), - self.backend, - ); - context - .services - .http_client() - .send_async(request) - .await - .change_context(TrustedServerError::Auction { - message: "recording launch failed".to_string(), - }) - .map(ProviderRequestOutcome::pending) - } - - async fn parse_response( - &self, - _response: PlatformResponse, - response_time_ms: u64, - ) -> Result> { - Ok(AuctionResponse::success( - self.name, - vec![], - response_time_ms, - )) - } - - fn timeout_ms(&self) -> u32 { - self.configured_timeout_ms - } - - fn backend_name(&self, _services: &RuntimeServices, timeout_ms: u32) -> Option { - self.predicted - .lock() - .expect("should lock predicted timeouts") - .push(timeout_ms); - Some(self.backend.to_string()) - } - } - struct DivergentBackendProvider { name: &'static str, predicted: &'static str, @@ -3482,14 +4055,14 @@ mod tests { configured_timeout_ms: u32, predicted: &Arc>>, requested: &Arc>>, - ) -> RecordingTimeoutProvider { - RecordingTimeoutProvider { + ) -> StubAuctionProvider { + StubAuctionProvider::recording( name, backend, configured_timeout_ms, - predicted: Arc::clone(predicted), - requested: Arc::clone(requested), - } + Arc::clone(predicted), + Arc::clone(requested), + ) } /// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring @@ -3499,7 +4072,7 @@ mod tests { fn auction_bid(bidder: &str, price: f64) -> Bid { let renderer = (bidder == "aps").then(|| { - BidRenderer::Aps(ApsRendererV1 { + BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "aps-selected-bid".to_string(), @@ -3513,6 +4086,9 @@ mod tests { }); Bid { slot_id: "slot-1".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(price), currency: "USD".to_string(), creative: renderer @@ -3536,9 +4112,112 @@ mod tests { } } + struct CounterIdentityGenerator { + draws: AtomicUsize, + } + + impl CounterIdentityGenerator { + fn new() -> Self { + Self { + draws: AtomicUsize::new(0), + } + } + } + + impl AuctionIdentityGenerator for CounterIdentityGenerator { + fn fill(&self, destination: &mut [u8]) -> Result<(), ()> { + destination.fill(0); + let draw = self.draws.fetch_add(1, Ordering::SeqCst) + 1; + let last = destination.last_mut().ok_or(())?; + *last = u8::try_from(draw).map_err(|_| ())?; + Ok(()) + } + } + + struct FixedIdentityGenerator { + draws: AtomicUsize, + } + + impl FixedIdentityGenerator { + fn new() -> Self { + Self { + draws: AtomicUsize::new(0), + } + } + } + + impl AuctionIdentityGenerator for FixedIdentityGenerator { + fn fill(&self, destination: &mut [u8]) -> Result<(), ()> { + self.draws.fetch_add(1, Ordering::SeqCst); + destination.fill(0); + Ok(()) + } + } + + struct SourceBidProvider { + nurl: &'static str, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for SourceBidProvider { + fn provider_name(&self) -> &'static str { + "bidder" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let request = PlatformHttpRequest::new( + http::Request::builder() + .method("POST") + .uri("https://example.com/bid") + .body(edgezero_core::body::Body::empty()) + .expect("should build source bid request"), + "bidder-backend", + ); + context + .services + .http_client() + .send_async(request) + .await + .change_context(TrustedServerError::Auction { + message: "source bidder launch failed".to_string(), + }) + .map(ProviderRequestOutcome::pending) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + let mut bid = mediated_bid(Some(self.nurl.to_string())); + bid.price = Some(1.0); + bid.bid_id = Some("source-bid-id".to_string()); + Ok(AuctionResponse::success( + self.provider_name(), + vec![bid], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some("bidder-backend".to_string()) + } + } + fn mediated_bid(nurl: Option) -> Bid { Bid { slot_id: "header-banner".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(2.5), currency: "USD".to_string(), creative: Some("
ad
".to_string()), @@ -3608,12 +4287,18 @@ mod tests { _response: PlatformResponse, response_time_ms: u64, _request: &AuctionRequest, - _context: &AuctionContext<'_>, + context: &AuctionContext<'_>, ) -> Result> { - // Context-aware path: restores nurl/ad_id from the collected SSP bids. + let mut selection = context + .provider_responses + .and_then(|responses| responses.first()) + .and_then(|response| response.bids.first()) + .cloned() + .expect("should provide one source candidate to mediator"); + selection.price = Some(2.5); Ok(AuctionResponse::success( "mediator", - vec![mediated_bid(Some("https://nurl.example/win".to_string()))], + vec![selection], response_time_ms, )) } @@ -3638,13 +4323,18 @@ mod tests { async fn request_bids( &self, _request: &AuctionRequest, - _context: &AuctionContext<'_>, + context: &AuctionContext<'_>, ) -> Result> { + let mut selection = context + .provider_responses + .and_then(|responses| responses.first()) + .and_then(|response| response.bids.first()) + .cloned() + .expect("should provide one source candidate to immediate mediator"); + selection.price = Some(2.5); Ok(ProviderRequestOutcome::Immediate(AuctionResponse::success( self.provider_name(), - vec![mediated_bid(Some( - "https://nurl.example/immediate".to_string(), - ))], + vec![selection], 0, ))) } @@ -3683,9 +4373,8 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "bidder", - backend: "bidder-backend", + orchestrator.register_provider(Arc::new(SourceBidProvider { + nurl: "https://nurl.example/win", })); orchestrator.register_provider(Arc::new(CacheRestoringMediator)); @@ -3740,9 +4429,8 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "bidder", - backend: "bidder-backend", + orchestrator.register_provider(Arc::new(SourceBidProvider { + nurl: "https://nurl.example/immediate", })); orchestrator.register_provider(Arc::new(ImmediateMediator)); let request = create_test_auction_request(); @@ -3758,11 +4446,8 @@ mod tests { }; let result = if split { - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("bidder request should dispatch"); - }; + let dispatched = + expect_dispatched(orchestrator.dispatch_auction(&request, &context).await); orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await @@ -3815,7 +4500,7 @@ mod tests { name: "late-two", backend: "late-two-backend", })); - let request = create_test_auction_request(); + let request = one_slot_request(); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = AuctionContext { @@ -3827,23 +4512,393 @@ mod tests { services: &services, }; - if split { - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("deadline test providers should dispatch"); - }; - orchestrator - .collect_dispatched_auction(dispatched, &services, &context) - .await - } else { - orchestrator - .run_auction(&request, &context) - .await - .expect("deadline test auction should complete") + if split { + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("deadline test providers should dispatch"); + }; + orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await + } else { + orchestrator + .run_auction(&request, &context) + .await + .expect("deadline test auction should complete") + } + } + + fn one_slot_request() -> AuctionRequest { + let mut request = create_test_auction_request(); + request.slots = vec![AdSlot { + id: "slot-1".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }]; + request + } + + fn enabled_config(providers: &[&str]) -> AuctionConfig { + AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(providers), + ..AuctionConfig::default() + } + } + + #[test] + fn normalized_provider_outcomes_cover_every_dispatched_slot() { + let generator = Arc::new(CounterIdentityGenerator::new()); + let mut orchestrator = + AuctionOrchestrator::with_identity_generator(enabled_config(&["alpha"]), generator); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + let request = one_slot_request(); + let mut candidate = auction_bid("aps", 2.0); + candidate.slot_id = "slot-1".to_string(); + let mut responses = vec![AuctionResponse::success("alpha", vec![candidate], 10)]; + + let normalized = orchestrator.normalize_provider_responses(&request, &mut responses); + + assert_eq!(normalized.outcomes.len(), 1); + assert_eq!(normalized.outcomes[0].provider, "alpha"); + assert_eq!(normalized.outcomes[0].slot, "slot-1"); + assert!(matches!( + &normalized.outcomes[0].disposition, + ProviderSlotDisposition::Candidates(candidates) + if candidates.len() == 1 + && candidates[0].candidate_id.as_deref().is_some_and(|id| id.len() == 12) + )); + + let mut no_bid = vec![AuctionResponse::no_bid("alpha", 10)]; + let normalized = orchestrator.normalize_provider_responses(&request, &mut no_bid); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::NoBid + )); + + let mut timeout = vec![super::provider_timeout_response("alpha", 10)]; + let normalized = orchestrator.normalize_provider_responses(&request, &mut timeout); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Failed(AuctionSlotFailureReason::ProviderTimeout) + )); + + let mut attributable_invalid = vec![AuctionResponse::no_bid("alpha", 10)]; + attributable_invalid[0].metadata.insert( + "invalid_slots".to_string(), + serde_json::json!({"slot-1": "invalid_provider_response"}), + ); + let normalized = + orchestrator.normalize_provider_responses(&request, &mut attributable_invalid); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Failed(AuctionSlotFailureReason::InvalidProviderResponse) + )); + } + + #[test] + fn provider_failure_classes_map_to_closed_slot_reasons() { + let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha"])); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + let request = one_slot_request(); + + for (error_type, expected) in [ + ( + super::ERROR_TYPE_LAUNCH_FAILED, + AuctionSlotFailureReason::ProviderError, + ), + ( + super::ERROR_TYPE_TRANSPORT, + AuctionSlotFailureReason::ProviderError, + ), + ( + super::ERROR_TYPE_HTTP_STATUS, + AuctionSlotFailureReason::ProviderError, + ), + ( + super::ERROR_TYPE_PARSE_RESPONSE, + AuctionSlotFailureReason::InvalidProviderResponse, + ), + ] { + let error = Report::new(TrustedServerError::Auction { + message: "provider failed".to_string(), + }); + let mut responses = vec![super::provider_error_response( + "alpha", 1, error_type, &error, + )]; + let normalized = orchestrator.normalize_provider_responses(&request, &mut responses); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Failed(reason) if reason == expected + )); + } + } + + #[test] + fn candidate_collision_exhaustion_fails_only_the_affected_slot() { + let generator = Arc::new(FixedIdentityGenerator::new()); + let mut orchestrator = AuctionOrchestrator::with_identity_generator( + enabled_config(&["alpha"]), + generator.clone(), + ); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + let mut request = one_slot_request(); + request.slots.push(AdSlot { + id: "slot-2".to_string(), + formats: request.slots[0].formats.clone(), + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }); + let mut first = auction_bid("aps", 2.0); + first.slot_id = "slot-1".to_string(); + first.bid_id = Some("upstream-1".to_string()); + let mut second = auction_bid("aps", 1.0); + second.slot_id = "slot-2".to_string(); + second.bid_id = Some("upstream-2".to_string()); + let mut responses = vec![AuctionResponse::success("alpha", vec![first, second], 10)]; + + let normalized = orchestrator.normalize_provider_responses(&request, &mut responses); + + assert_eq!(generator.draws.load(Ordering::SeqCst), 10); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Candidates(_) + )); + assert!(matches!( + normalized.outcomes[1].disposition, + ProviderSlotDisposition::Failed(AuctionSlotFailureReason::InternalError) + )); + } + + #[test] + fn candidate_collision_exhaustion_discards_earlier_sibling_for_same_slot() { + let generator = Arc::new(FixedIdentityGenerator::new()); + let mut orchestrator = AuctionOrchestrator::with_identity_generator( + enabled_config(&["alpha"]), + generator.clone(), + ); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + let request = one_slot_request(); + let mut first = auction_bid("aps", 2.0); + first.bid_id = Some("upstream-1".to_string()); + let mut second = auction_bid("aps", 1.0); + second.bid_id = Some("upstream-2".to_string()); + let mut responses = vec![AuctionResponse::success("alpha", vec![first, second], 10)]; + + let normalized = orchestrator.normalize_provider_responses(&request, &mut responses); + + assert_eq!(generator.draws.load(Ordering::SeqCst), 10); + assert!(responses[0].bids.is_empty()); + assert!(normalized.candidates.is_empty()); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Failed(AuctionSlotFailureReason::InternalError) + )); + } + + #[test] + fn per_bid_drop_does_not_poison_an_unrelated_missing_slot() { + let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha"])); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + let mut request = one_slot_request(); + request.slots.push(AdSlot { + id: "slot-2".to_string(), + formats: request.slots[0].formats.clone(), + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }); + let mut valid = auction_bid("aps", 2.0); + valid.bid_id = Some("upstream-1".to_string()); + let mut response = AuctionResponse::success("alpha", vec![valid], 10); + response = response.with_drop_reason(AuctionDropReason::InvalidDimensions); + let mut responses = vec![response]; + + let normalized = orchestrator.normalize_provider_responses(&request, &mut responses); + + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Candidates(_) + )); + assert!(matches!( + normalized.outcomes[1].disposition, + ProviderSlotDisposition::NoBid + )); + } + + #[test] + fn final_decisions_are_request_ordered_and_use_closed_failure_priority() { + let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha", "zeta"])); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("zeta", "zeta"))); + let request = one_slot_request(); + let outcomes = vec![ + crate::auction::provider::ProviderSlotOutcome { + provider: "alpha".to_string(), + slot: "slot-1".to_string(), + disposition: ProviderSlotDisposition::Failed( + AuctionSlotFailureReason::ProviderTimeout, + ), + }, + crate::auction::provider::ProviderSlotOutcome { + provider: "zeta".to_string(), + slot: "slot-1".to_string(), + disposition: ProviderSlotDisposition::Failed( + AuctionSlotFailureReason::InvalidProviderResponse, + ), + }, + ]; + + let decisions = orchestrator.build_decision_set(&request, &outcomes, &HashMap::new(), true); + + assert_eq!(decisions.results.len(), 1); + assert!(matches!( + &decisions.results[0], + SlotAuctionDecisionV1::Failed { slot, reason } + if slot == "slot-1" && *reason == AuctionSlotFailureReason::MediationFailed + )); + assert_eq!( + serde_json::to_string(&decisions).expect("decision set should serialize"), + r#"{"version":1,"auctionId":"test-auction-123","results":[{"slot":"slot-1","outcome":"failed","reason":"mediation_failed"}]}"# + ); + assert_eq!( + serde_json::to_string(&SlotAuctionDecisionV1::Failed { + slot: "slot-1".to_string(), + reason: AuctionSlotFailureReason::IdentityGenerationFailed, + }) + .expect("direct identity-generation failure should serialize"), + r#"{"slot":"slot-1","outcome":"failed","reason":"identity_generation_failed"}"# + ); + } + + #[test] + fn deliverable_winner_beats_a_sibling_provider_failure() { + let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha", "zeta"])); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("zeta", "zeta"))); + let request = one_slot_request(); + let mut winner = auction_bid("alpha-seat", 2.0); + winner.candidate_id = Some("AAAAAAAAAAAA".to_string()); + winner.candidate_provider = Some("alpha".to_string()); + winner.bid_id = Some("upstream-alpha".to_string()); + let outcomes = vec![crate::auction::provider::ProviderSlotOutcome { + provider: "zeta".to_string(), + slot: "slot-1".to_string(), + disposition: ProviderSlotDisposition::Failed(AuctionSlotFailureReason::ProviderTimeout), + }]; + + let decisions = orchestrator.build_decision_set( + &request, + &outcomes, + &HashMap::from([("slot-1".to_string(), winner)]), + true, + ); + + assert_eq!( + decisions.results, + vec![SlotAuctionDecisionV1::Winner { + slot: "slot-1".to_string(), + candidate_id: "AAAAAAAAAAAA".to_string(), + }] + ); + } + + #[test] + fn direct_ties_ignore_arrival_and_candidate_ids() { + let orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha", "zeta"])); + let mut alpha = auction_bid("seat-a", 2.0); + alpha.candidate_provider = Some("alpha".to_string()); + alpha.candidate_id = Some("zzzzzzzzzzzz".to_string()); + alpha.bid_id = Some("upstream-z".to_string()); + let mut zeta = auction_bid("seat-z", 2.0); + zeta.candidate_provider = Some("zeta".to_string()); + zeta.candidate_id = Some("AAAAAAAAAAAA".to_string()); + zeta.bid_id = Some("upstream-a".to_string()); + let left = AuctionResponse::success("alpha", vec![alpha], 1); + let right = AuctionResponse::success("zeta", vec![zeta], 1); + + for responses in [vec![left.clone(), right.clone()], vec![right, left]] { + let winners = orchestrator.select_winning_bids(&responses, &HashMap::new()); + assert_eq!( + winners["slot-1"].candidate_provider.as_deref(), + Some("alpha") + ); } } + #[test] + fn mediator_can_select_only_known_candidate_provenance() { + let mut source = auction_bid("aps", 1.0); + source.candidate_id = Some("AAAAAAAAAAAA".to_string()); + source.candidate_provider = Some("aps".to_string()); + source.nurl = Some("https://source.example/win".to_string()); + let candidates = HashMap::from([("AAAAAAAAAAAA".to_string(), source.clone())]); + let mut selection = source.clone(); + selection.price = Some(9.0); + + let resolved = AuctionOrchestrator::resolve_mediator_candidates( + AuctionResponse::success("mediator", vec![selection], 2), + &candidates, + ) + .expect("known candidate should resolve"); + assert_eq!(resolved.bids[0].price, Some(9.0)); + assert_eq!(resolved.bids[0].width, source.width); + assert_eq!(resolved.bids[0].height, source.height); + assert_eq!(resolved.bids[0].renderer, source.renderer); + assert_eq!(resolved.bids[0].nurl, source.nurl); + + let mut substituted = source.clone(); + substituted.price = Some(9.0); + substituted.width = 1; + assert!( + AuctionOrchestrator::resolve_mediator_candidates( + AuctionResponse::success("mediator", vec![substituted], 2), + &candidates, + ) + .is_err(), + "mediator source-field substitutions should fail provenance validation" + ); + + let mut second_source = source.clone(); + second_source.candidate_id = Some("BBBBBBBBBBBB".to_string()); + second_source.bid_id = Some("upstream-2".to_string()); + let same_slot_candidates = HashMap::from([ + ("AAAAAAAAAAAA".to_string(), source.clone()), + ("BBBBBBBBBBBB".to_string(), second_source.clone()), + ]); + assert!( + AuctionOrchestrator::resolve_mediator_candidates( + AuctionResponse::success("mediator", vec![source.clone(), second_source], 2), + &same_slot_candidates, + ) + .is_err(), + "a mediator may select at most one candidate for a slot" + ); + + let mut unknown = source; + unknown.candidate_id = Some("BBBBBBBBBBBB".to_string()); + assert!( + AuctionOrchestrator::resolve_mediator_candidates( + AuctionResponse::success("mediator", vec![unknown], 2), + &candidates, + ) + .is_err() + ); + } + + fn create_test_settings() -> crate::settings::Settings { + let settings_str = crate_test_settings_str(); + crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings") + } + #[tokio::test] async fn current_adapter_deadline_drains_late_responses_in_both_paths() { for split in [false, true] { @@ -3905,7 +4960,7 @@ mod tests { backend: "local-backend", })); orchestrator.register_provider(Arc::new(PendingDeadlineMediator)); - let request = create_test_auction_request(); + let request = one_slot_request(); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = AuctionContext { @@ -3946,11 +5001,13 @@ mod tests { current_mediator.response_time_ms >= 50, "mediator timing should preserve actual elapsed duration" ); - assert_eq!(current.winning_bids["slot-1"].bidder, "mediated"); + assert_eq!(current.winning_bids["slot-1"].bidder, "local"); + assert_eq!(current.winning_bids["slot-1"].price, Some(9.0)); let hard = pending_mediator_deadline_test_result(split, true).await; assert!(hard.mediator_response.is_none()); assert_eq!(hard.winning_bids["slot-1"].bidder, "local"); + assert_eq!(hard.winning_bids["slot-1"].price, Some(3.0)); assert!( hard.total_time_ms >= 50, "discarding a late mediator must retain actual total elapsed time" @@ -3981,7 +5038,7 @@ mod tests { launches: Arc::clone(&launches), budgets: None, })); - let request = create_test_auction_request(); + let request = one_slot_request(); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = AuctionContext { @@ -4029,7 +5086,7 @@ mod tests { launches: Arc::clone(&launches), budgets: None, })); - let request = create_test_auction_request(); + let request = one_slot_request(); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = AuctionContext { @@ -4092,11 +5149,6 @@ mod tests { } } - fn create_test_settings() -> crate::settings::Settings { - let settings_str = crate_test_settings_str(); - crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings") - } - struct ImmediateNoBidProvider; #[async_trait::async_trait(?Send)] @@ -4204,6 +5256,17 @@ mod tests { assert_eq!(result.provider_responses.len(), 1); assert_eq!(result.provider_responses[0].status, BidStatus::NoBid); assert!(result.winning_bids.is_empty()); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::NoBid { + slot: "header-banner".to_string(), + }, + SlotAuctionDecisionV1::NoBid { + slot: "sidebar".to_string(), + }, + ] + ); } #[tokio::test] @@ -4221,12 +5284,11 @@ mod tests { let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = immediate_test_context(&settings, &downstream, &services); - let DispatchAuctionOutcome::Dispatched(dispatched) = orchestrator - .dispatch_auction(&create_test_auction_request(), &context) - .await - else { - panic!("enabled immediate provider should dispatch"); - }; + let dispatched = expect_dispatched( + orchestrator + .dispatch_auction(&create_test_auction_request(), &context) + .await, + ); let result = orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; @@ -4234,6 +5296,17 @@ mod tests { assert_eq!(result.provider_responses.len(), 1); assert_eq!(result.provider_responses[0].status, BidStatus::NoBid); assert!(result.winning_bids.is_empty()); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::NoBid { + slot: "header-banner".to_string(), + }, + SlotAuctionDecisionV1::NoBid { + slot: "sidebar".to_string(), + }, + ] + ); } #[tokio::test] @@ -4247,10 +5320,10 @@ mod tests { }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(ImmediateNoBidProvider)); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "pending", - backend: "pending-backend", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "pending", + "pending-backend", + ))); let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); let services = build_services_with_http_client(stub); @@ -4260,11 +5333,8 @@ mod tests { let request = create_test_auction_request(); let result = if split { - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("mixed immediate/pending auction should dispatch"); - }; + let dispatched = + expect_dispatched(orchestrator.dispatch_auction(&request, &context).await); orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await @@ -4394,6 +5464,9 @@ mod tests { "slot-1".to_string(), Bid { slot_id: "slot-1".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(0.50), currency: "USD".to_string(), creative: Some("
Ad
".to_string()), @@ -4418,6 +5491,9 @@ mod tests { "slot-2".to_string(), Bid { slot_id: "slot-2".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(2.00), currency: "USD".to_string(), creative: Some("
Ad
".to_string()), @@ -4490,14 +5566,25 @@ mod tests { let result = orchestrator.run_auction(&request, &context).await; - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(format!("{}", err).contains("No providers configured")); + let result = result.expect("should return one decision per requested slot"); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::Failed { + slot: "header-banner".to_string(), + reason: AuctionSlotFailureReason::SlotNotEligible, + }, + SlotAuctionDecisionV1::Failed { + slot: "sidebar".to_string(), + reason: AuctionSlotFailureReason::SlotNotEligible, + }, + ] + ); }); } #[test] - fn provider_launch_failures_error_when_no_requests_launch() { + fn provider_launch_failures_are_explicit_when_no_requests_launch() { futures::executor::block_on(async { let config = AuctionConfig { enabled: true, @@ -4517,16 +5604,20 @@ mod tests { .expect("should build request"); let context = create_test_auction_context(&settings, &req, 2000); - let error = orchestrator - .run_auction(&request, &context) - .await - .expect_err("should fail when every provider launch fails"); - - assert!( - error - .to_string() - .contains("All 1 configured provider(s) skipped or failed to launch"), - "should explain that no configured provider request launched" + let result = orchestrator.run_auction(&request, &context).await; + let result = result.expect("should preserve launch failures as slot decisions"); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::Failed { + slot: "header-banner".to_string(), + reason: AuctionSlotFailureReason::ProviderError, + }, + SlotAuctionDecisionV1::Failed { + slot: "sidebar".to_string(), + reason: AuctionSlotFailureReason::ProviderError, + }, + ] ); }); } @@ -4541,14 +5632,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "shared-backend", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "shared-backend", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "shared-backend", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "shared-backend", + ))); let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); let services = build_services_with_http_client(stub); @@ -4558,11 +5649,8 @@ mod tests { let request = create_test_auction_request(); let result = if split { - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("should dispatch the first provider"); - }; + let dispatched = + expect_dispatched(orchestrator.dispatch_auction(&request, &context).await); orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await @@ -4683,13 +5771,17 @@ mod tests { #[test] fn zero_canonical_timeout_skips_parallel_launch() { futures::executor::block_on(async { + // A platform that canonicalizes to zero signals "budget exhausted"; + // the orchestrator must skip the launch and retain an attributable + // timeout decision for every eligible requested slot. + let stub = Arc::new(StubHttpClient::new()); let calls = Arc::new(Mutex::new(Vec::new())); let services = build_services_with_backend_and_http_client( Arc::new(CanonicalTimeoutBackend { canonical_ms: 0, calls, }), - Arc::new(StubHttpClient::new()), + stub, ); let predicted = Arc::new(Mutex::new(Vec::new())); let requested = Arc::new(Mutex::new(Vec::new())); @@ -4709,14 +5801,88 @@ mod tests { let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = immediate_test_context(&settings, &downstream, &services); + let request = create_test_auction_request(); let result = orchestrator - .run_auction(&create_test_auction_request(), &context) - .await; + .run_auction(&request, &context) + .await + .expect("should preserve an exhausted budget as slot decisions"); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::Failed { + slot: "header-banner".to_string(), + reason: AuctionSlotFailureReason::ProviderTimeout, + }, + SlotAuctionDecisionV1::Failed { + slot: "sidebar".to_string(), + reason: AuctionSlotFailureReason::ProviderTimeout, + }, + ] + ); + }); + } + + #[test] + fn zero_canonical_timeout_is_attributable_in_split_dispatch() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + let calls = Arc::new(Mutex::new(Vec::new())); + let services = build_services_with_backend_and_http_client( + Arc::new(CanonicalTimeoutBackend { + canonical_ms: 0, + calls, + }), + stub, + ); + let predicted = Arc::new(Mutex::new(Vec::new())); + let requested = Arc::new(Mutex::new(Vec::new())); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["bidder"]), + timeout_ms: 2000, + ..Default::default() + }); + orchestrator.register_provider(Arc::new(recording_provider( + "bidder", + "bidder-backend", + 1000, + &predicted, + &requested, + ))); + let settings = create_test_settings(); + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = immediate_test_context(&settings, &downstream, &services); + let request = create_test_auction_request(); + + let outcome = orchestrator.dispatch_auction(&request, &context).await; + let result = match orchestrator + .resolve_dispatch_outcome(&request, outcome) + .expect("zero canonical timeout should resolve attributably") + { + super::ResolvedDispatchOutcome::InFlight(dispatched) => { + orchestrator + .collect_dispatched_auction(*dispatched, &services, &context) + .await + } + super::ResolvedDispatchOutcome::Complete(result) => *result, + }; - assert!(result.is_err(), "zero budget should skip every provider"); assert!(predicted.lock().expect("should lock predicted").is_empty()); assert!(requested.lock().expect("should lock requested").is_empty()); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::Failed { + slot: "header-banner".to_string(), + reason: AuctionSlotFailureReason::ProviderTimeout, + }, + SlotAuctionDecisionV1::Failed { + slot: "sidebar".to_string(), + reason: AuctionSlotFailureReason::ProviderTimeout, + }, + ] + ); }); } @@ -4743,10 +5909,10 @@ mod tests { timeout_ms: 2000, ..Default::default() }); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "bidder", - backend: "bidder-backend", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "bidder", + "bidder-backend", + ))); orchestrator.register_provider(Arc::new(recording_provider( "mediator", "mediator-backend", @@ -4812,11 +5978,8 @@ mod tests { let context = immediate_test_context(&settings, &downstream, &services); let request = create_test_auction_request(); - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("should dispatch bidder request"); - }; + let dispatched = + expect_dispatched(orchestrator.dispatch_auction(&request, &context).await); orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; @@ -4922,18 +6085,22 @@ mod tests { let context = immediate_test_context(&settings, &downstream, &services); let request = create_test_auction_request(); - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("should dispatch provider"); - }; + let dispatched = + expect_dispatched(orchestrator.dispatch_auction(&request, &context).await); let result = orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; - assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-a" && response.status == BidStatus::Success - })); + let provider_a = result + .provider_responses + .iter() + .find(|response| response.provider == "provider-a") + .expect("should have provider-a response"); + assert_eq!( + provider_a.status, + BidStatus::Success, + "response should correlate by the resolved backend name, not the prediction" + ); }); } @@ -4965,21 +6132,24 @@ mod tests { let context = immediate_test_context(&settings, &downstream, &services); let request = create_test_auction_request(); - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("should dispatch first provider"); - }; + let dispatched = + expect_dispatched(orchestrator.dispatch_auction(&request, &context).await); let result = orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; - assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-a" && response.status == BidStatus::Success - })); - assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-b" && response.status == BidStatus::Error - })); + let provider_a = result + .provider_responses + .iter() + .find(|response| response.provider == "provider-a") + .expect("should have provider-a response"); + let provider_b = result + .provider_responses + .iter() + .find(|response| response.provider == "provider-b") + .expect("should have provider-b response"); + assert_eq!(provider_a.status, BidStatus::Success); + assert_eq!(provider_b.status, BidStatus::Error); }); } @@ -5007,14 +6177,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "backend-a", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "backend-b", + ))); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -5146,10 +6316,9 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); + let mut provider = StubAuctionProvider::new("provider-a", "backend-a"); + provider.configured_timeout_ms = 125; + orchestrator.register_provider(Arc::new(provider)); let request = create_test_auction_request(); let settings = create_test_settings(); let downstream = http::Request::builder() @@ -5165,13 +6334,11 @@ mod tests { provider_responses: None, services: &services, }; - let dispatched = match orchestrator - .dispatch_auction(&request, &dispatch_context) - .await - { - DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, - _ => panic!("should dispatch provider request"), - }; + let dispatched = expect_dispatched( + orchestrator + .dispatch_auction(&request, &dispatch_context) + .await, + ); let placeholder = http::Request::builder() .uri("https://placeholder.invalid/") .body(edgezero_core::body::Body::empty()) @@ -5226,14 +6393,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "backend-a", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "backend-b", + ))); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -5254,11 +6421,21 @@ mod tests { // Act let result = orchestrator.run_auction(&request, &context).await; - // Assert: rejected before any provider request launches. - let err = result.expect_err("should reject multi-provider fan-out"); - assert!( - format!("{err}").contains("sequentially"), - "should explain the sequential-execution limitation" + // Assert: every affected slot gets an explicit provider failure + // without launching either provider request. + let result = result.expect("should preserve sequential-platform failures"); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::Failed { + slot: "header-banner".to_string(), + reason: AuctionSlotFailureReason::ProviderError, + }, + SlotAuctionDecisionV1::Failed { + slot: "sidebar".to_string(), + reason: AuctionSlotFailureReason::ProviderError, + }, + ] ); assert!( stub_for_assertion.recorded_backend_names().is_empty(), @@ -5292,14 +6469,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "backend-a", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "backend-b", + ))); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -5318,17 +6495,53 @@ mod tests { }; // Act - let dispatched = orchestrator.dispatch_auction(&request, &context).await; + let DispatchAuctionOutcome::DispatchFailed { + mut provider_responses, + eligible_slots_by_provider, + .. + } = orchestrator.dispatch_auction(&request, &context).await + else { + panic!("sequential-platform rejection should preserve dispatch failures"); + }; - // Assert: no dispatch and no provider request launched. - assert!( - matches!(dispatched, DispatchAuctionOutcome::NotStarted), - "should skip initial-page dispatch on sequential platforms" - ); + // Assert: no network request launches, but every configured provider + // remains attributable through the normal terminal decision path. assert!( stub_for_assertion.recorded_backend_names().is_empty(), "should not launch any provider request on a sequential platform" ); + let normalized = orchestrator.normalize_provider_responses_with_eligibility( + &request, + &mut provider_responses, + &eligible_slots_by_provider, + ); + let winning_bids = HashMap::new(); + let decision_set = orchestrator.build_decision_set( + &request, + &normalized.outcomes, + &winning_bids, + false, + ); + let result = OrchestrationResult { + provider_responses, + mediator_response: None, + winning_bids, + decision_set, + total_time_ms: 0, + metadata: HashMap::new(), + }; + assert!(result.winning_bids.is_empty()); + assert!(result.provider_responses.iter().all(|response| { + response.status == BidStatus::Error + && response.metadata["error_type"] == "launch_failed" + })); + assert!(result.decision_set.results.iter().all(|decision| matches!( + decision, + SlotAuctionDecisionV1::Failed { + reason: AuctionSlotFailureReason::ProviderError, + .. + } + ))); }); } @@ -5468,14 +6681,10 @@ mod tests { .map(|response| response.provider.as_str()), Some("immediate-mediator") ); - assert_eq!( - result.winning_bids["header-banner"].nurl.as_deref(), - Some("https://nurl.example/immediate") - ); - assert!( - !result.winning_bids.contains_key("fictional-slot"), - "mediator output owns final selection" - ); + let winner = &result.winning_bids["fictional-slot"]; + assert_eq!(winner.bid_id.as_deref(), Some("provider")); + assert_eq!(winner.price, Some(2.5)); + assert!(!result.winning_bids.contains_key("header-banner")); } async fn planned_pending_mediator_deadline_result( @@ -5534,7 +6743,11 @@ mod tests { .as_ref() .expect("current adapters should accept completed late mediator responses"); assert!(current_mediator.response_time_ms >= 50); - assert_eq!(current.winning_bids["slot-1"].bidder, "mediated"); + assert_eq!( + current.winning_bids["fictional-slot"].bid_id.as_deref(), + Some("provider") + ); + assert_eq!(current.winning_bids["fictional-slot"].price, Some(9.0)); let hard = planned_pending_mediator_deadline_result(true).await; assert!(hard.mediator_response.is_none()); @@ -5760,7 +6973,8 @@ mod tests { serde_json::to_vec(&serde_json::json!({ "seatbid": [{"seat": "aps-instance", "bid": [{ "id": "mediated-aps", "impid": "fictional-slot", "price": 2.0, - "adm": "ignored", "w": 300, "h": 250, "crid": "aps-creative" + "w": 300, "h": 250, + "ext": {"trusted_server": {"candidate_id": "AAAAAAAAAAAA"}} }]}] })) .expect("should serialize mediator response"), @@ -5778,7 +6992,8 @@ mod tests { timeout_ms: 500, ..AdServerMockConfig::default() }); - let orchestrator = AuctionOrchestratorHarness::new(plan, Some(Arc::new(mediator))); + let orchestrator = AuctionOrchestratorHarness::new(plan, Some(Arc::new(mediator))) + .with_identity_generator(Arc::new(FixedIdentityGenerator::new())); let request = planned_request(); let settings = create_test_settings(); let inbound = http::Request::new(edgezero_core::body::Body::empty()); @@ -5956,7 +7171,7 @@ mod tests { let renderer = bid .renderer .as_ref() - .and_then(BidRenderer::as_aps) + .and_then(BidRenderSourceV1::as_aps) .expect("should construct typed APS renderer"); assert_eq!(renderer.account_id, "example-account"); let decoded = base64::engine::general_purpose::STANDARD @@ -5976,13 +7191,13 @@ mod tests { for reason in [ "lost_to_higher_bid", "script_rendering_disabled", - "unknown_impid", + "unknown_impression", "invalid_dimensions", "invalid_price", "unsupported_media_type", - "unsupported_tagtype", + "invalid_tag_type", "creative_id_too_large", - "missing_render_source", + "missing_upstream_bid_id", "empty_seatbid_bids", ] { assert_eq!(response.metadata["drop_reasons"][reason], 1, "{reason}"); @@ -6138,28 +7353,28 @@ mod tests { 200, b"not-json".to_vec(), BidStatus::Error, - Some("unexpected_response_shape"), + Some("invalid_provider_response"), Some("parse_response"), ), ( 200, b"[]".to_vec(), BidStatus::Error, - Some("unexpected_response_shape"), + Some("invalid_provider_response"), Some("parse_response"), ), ( 200, br#"{"contextual":true}"#.to_vec(), BidStatus::Error, - Some("unexpected_response_shape"), - Some("parse_response"), + Some("invalid_provider_response"), + None, ), ( 200, br#"{"cur":"EUR","seatbid":[]}"#.to_vec(), - BidStatus::NoBid, - Some("unsupported_currency"), + BidStatus::Error, + Some("invalid_provider_response"), None, ), ]; @@ -6311,7 +7526,7 @@ mod tests { let renderer = parsed.bids[0] .renderer .as_ref() - .and_then(BidRenderer::as_aps) + .and_then(BidRenderSourceV1::as_aps) .expect("should construct APS renderer"); let decoded = base64::engine::general_purpose::STANDARD .decode(&renderer.aax_response) @@ -7490,6 +8705,9 @@ mod tests { "slot-1".to_string(), Bid { slot_id: "slot-1".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: None, currency: "USD".to_string(), creative: Some("
Ad
".to_string()), @@ -7535,6 +8753,9 @@ mod tests { "atf".to_string(), Bid { slot_id: "atf".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(0.30), // decoded APS price — below $0.50 floor currency: "USD".to_string(), creative: Some("
APS Ad
".to_string()), @@ -7575,6 +8796,9 @@ mod tests { "atf".to_string(), Bid { slot_id: "atf".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(0.75), // decoded APS price — above floor currency: "USD".to_string(), creative: Some("
APS Ad
".to_string()), diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index 28f6dbd96..01380a070 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -25,7 +25,31 @@ use super::openrtb::{ use super::plan::ProviderPlan; use super::profile::CompiledOpenRtbProfile; use super::routing::{ProviderAuctionInput, RoutedAuction}; -use super::types::{AuctionContext, AuctionRequest, AuctionResponse}; +use super::types::{ + AuctionContext, AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, +}; + +/// Exactly one normalized outcome for a slot dispatched to one provider. +#[derive(Debug, Clone)] +pub struct ProviderSlotOutcome { + /// Provider integration that received the slot. + pub provider: String, + /// Exact dispatched slot identifier. + pub slot: String, + /// Candidate, successful no-bid, or typed failure. + pub disposition: ProviderSlotDisposition, +} + +/// Closed normalized provider result for one dispatched slot. +#[derive(Debug, Clone)] +pub enum ProviderSlotDisposition { + /// One or more independently validated candidates returned for the slot. + Candidates(Vec), + /// Provider completed successfully without a candidate for this slot. + NoBid, + /// Provider failed for this slot. + Failed(AuctionSlotFailureReason), +} const MAX_PLANNED_RESPONSE_BYTES: usize = 1024 * 1024; diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index 2c7c9cc45..7cc113285 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -944,7 +944,7 @@ mod tests { use serde_json::json; - use crate::auction::types::{AdFormat, AdSlot, PublisherInfo, UserInfo}; + use crate::auction::types::{AdFormat, AdSlot, AuctionDecisionSetV1, PublisherInfo, UserInfo}; use super::*; @@ -980,6 +980,9 @@ mod tests { fn bid(slot_id: &str, bidder: &str, ad_id: Option<&str>, price: Option) -> Bid { Bid { slot_id: slot_id.to_owned(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price, currency: "USD".to_owned(), creative: None, @@ -1061,6 +1064,11 @@ mod tests { provider_responses: vec![provider_success, provider_no_bid, provider_error], mediator_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), winning)]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 99, metadata: HashMap::new(), }; @@ -1122,6 +1130,10 @@ mod tests { provider_responses: vec![provider], mediator_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), aps_bid.clone())]), + decision_set: crate::auction::types::AuctionDecisionSetV1::failed( + &request, + crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable, + ), total_time_ms: 12, metadata: HashMap::new(), }; @@ -1158,6 +1170,10 @@ mod tests { )], mediator_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), fallback_bid)]), + decision_set: crate::auction::types::AuctionDecisionSetV1::failed( + &request, + crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable, + ), total_time_ms: 12, metadata: HashMap::new(), }; @@ -1190,6 +1206,10 @@ mod tests { provider_responses: vec![provider], mediator_response: Some(mediator), winning_bids: HashMap::from([("slot-1".to_owned(), aps_bid.clone())]), + decision_set: crate::auction::types::AuctionDecisionSetV1::failed( + &request, + crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable, + ), total_time_ms: 15, metadata: HashMap::new(), }; @@ -1230,6 +1250,11 @@ mod tests { provider_responses: vec![provider_success.clone()], mediator_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), provider_success.bids[0].clone())]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 42, metadata: HashMap::new(), }; @@ -1271,6 +1296,11 @@ mod tests { provider_responses: vec![provider_http_error], mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 12, metadata: HashMap::new(), }; @@ -1309,6 +1339,11 @@ mod tests { provider_responses: vec![provider_success], mediator_response: Some(mediator_response), winning_bids: HashMap::from([("slot-1".to_owned(), mediator_bid)]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 80, metadata: HashMap::new(), }; @@ -1349,6 +1384,11 @@ mod tests { provider_responses: Vec::new(), mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 1, metadata: HashMap::new(), }; diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 406915706..c5dbfc441 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -1,9 +1,15 @@ //! Core types for auction requests and responses. +use base64::{ + Engine as _, + engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD}, +}; use edgezero_core::body::Body as EdgeBody; use http::Request; +use rand::{RngCore as _, rngs::OsRng}; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use url::Url; use crate::auction::context::ContextValue; use crate::geo::GeoInfo; @@ -14,6 +20,42 @@ fn is_zero(value: &usize) -> bool { *value == 0 } +/// Injectable CSPRNG boundary for server-minted response-local identities. +pub(crate) trait AuctionIdentityGenerator: Send + Sync { + /// Fill the complete destination or report that secure randomness is unavailable. + fn fill(&self, destination: &mut [u8]) -> Result<(), ()>; +} + +/// Production CSPRNG for server-minted auction identities. +pub(crate) struct SystemAuctionIdentityGenerator; + +impl AuctionIdentityGenerator for SystemAuctionIdentityGenerator { + fn fill(&self, destination: &mut [u8]) -> Result<(), ()> { + OsRng.try_fill_bytes(destination).map_err(|_| ()) + } +} + +/// Mint one response-unique unpadded base64url identity. +pub(crate) fn mint_response_unique_base64url_identity( + generator: &dyn AuctionIdentityGenerator, + issued: &mut HashSet, + prefix: &str, + random_byte_count: usize, + collision_retries: usize, +) -> Option { + for _ in 0..=collision_retries { + let mut bytes = vec![0_u8; random_byte_count]; + if generator.fill(&mut bytes).is_err() { + return None; + } + let identity = format!("{prefix}{}", URL_SAFE_NO_PAD.encode(bytes)); + if issued.insert(identity.clone()) { + return Some(identity); + } + } + None +} + /// Represents a unified auction request across all providers. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuctionRequest { @@ -161,6 +203,353 @@ pub struct AuctionContext<'a> { pub services: &'a RuntimeServices, } +/// Closed, local reason set for rejecting provider bids or undeliverable winners. +/// +/// These values are serialized only into existing auction debug/diagnostic +/// surfaces. They are not a persistence or external-event taxonomy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuctionDropReason { + /// Configured processing rejected an ordinary creative's only render source. + CreativeProcessingRejected, + /// Optional creative ID is present with an invalid type or value. + InvalidCreativeId, + /// Optional creative ID exceeds its UTF-8 byte bound. + CreativeIdTooLarge, + /// A positive integral dimension exceeds the supported range. + DimensionsOutOfRange, + /// An otherwise valid upstream bid ID is repeated in one provider response. + DuplicateUpstreamBidId, + /// A response contains no seat bids. + #[serde(rename = "empty_seatbid")] + EmptySeatBid, + /// A seat bid contains no usable bid array. + #[serde(rename = "empty_seatbid_bids")] + EmptySeatBidBids, + /// A creative URL is malformed, unsafe, or self-origin. + InvalidCreativeUrl, + /// A dimension is missing, malformed, nonpositive, or not requested. + InvalidDimensions, + /// A price is missing, malformed, nonfinite, or negative. + InvalidPrice, + /// The provider response violates the response-level contract. + InvalidProviderResponse, + /// The APS tag type is missing or unsupported. + InvalidTagType, + /// An upstream bid ID contains a forbidden control value or has the wrong type. + InvalidUpstreamBidId, + /// A valid sibling was preferred by deterministic per-slot reduction. + LostToHigherBid, + /// A provider bid is not an object. + MalformedBid, + /// APS creative metadata does not contain `creativeurl`. + MissingCreativeUrl, + /// Provider parsing was invoked without its request-local context. + MissingRequestContext, + /// A required upstream bid ID is absent or empty. + MissingUpstreamBidId, + /// A winner carries more than one render source. + MultipleRenderSources, + /// A winner has no render source. + NoRenderSource, + /// A typed renderer extension could not be serialized. + RendererExtensionSerializationFailed, + /// A validated renderer projection exceeds its bound. + RenderPayloadTooLarge, + /// APS script rendering is disabled by configuration. + ScriptRenderingDisabled, + /// A provider bid references an impression that was not dispatched. + UnknownImpression, + /// A provider bid declares a non-banner media type. + UnsupportedMediaType, + /// An upstream bid ID exceeds 64 UTF-8 bytes. + UpstreamBidIdTooLarge, +} + +impl AuctionDropReason { + /// Return the exact existing debug/projection literal. + /// + /// This hand-written mapping also drives [`Ord`] so serialized-map output stays + /// alphabetically stable even when declaration order changes. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::CreativeProcessingRejected => "creative_processing_rejected", + Self::InvalidCreativeId => "invalid_creative_id", + Self::CreativeIdTooLarge => "creative_id_too_large", + Self::DimensionsOutOfRange => "dimensions_out_of_range", + Self::DuplicateUpstreamBidId => "duplicate_upstream_bid_id", + Self::EmptySeatBid => "empty_seatbid", + Self::EmptySeatBidBids => "empty_seatbid_bids", + Self::InvalidCreativeUrl => "invalid_creative_url", + Self::InvalidDimensions => "invalid_dimensions", + Self::InvalidPrice => "invalid_price", + Self::InvalidProviderResponse => "invalid_provider_response", + Self::InvalidTagType => "invalid_tag_type", + Self::InvalidUpstreamBidId => "invalid_upstream_bid_id", + Self::LostToHigherBid => "lost_to_higher_bid", + Self::MalformedBid => "malformed_bid", + Self::MissingCreativeUrl => "missing_creative_url", + Self::MissingRequestContext => "missing_request_context", + Self::MissingUpstreamBidId => "missing_upstream_bid_id", + Self::MultipleRenderSources => "multiple_render_sources", + Self::NoRenderSource => "no_render_source", + Self::RendererExtensionSerializationFailed => "renderer_extension_serialization_failed", + Self::RenderPayloadTooLarge => "render_payload_too_large", + Self::ScriptRenderingDisabled => "script_rendering_disabled", + Self::UnknownImpression => "unknown_impression", + Self::UnsupportedMediaType => "unsupported_media_type", + Self::UpstreamBidIdTooLarge => "upstream_bid_id_too_large", + } + } +} + +impl Ord for AuctionDropReason { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.as_str().cmp(other.as_str()) + } +} + +impl PartialOrd for AuctionDropReason { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Typed counts projected into the existing `drop_reasons` debug object. +pub type AuctionDropReasons = BTreeMap; + +/// Increment one typed local drop reason. +pub(crate) fn record_auction_drop(reasons: &mut AuctionDropReasons, reason: AuctionDropReason) { + *reasons.entry(reason).or_default() += 1; +} + +/// Closed failure set for one requested slot's server-auction decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuctionSlotFailureReason { + /// The auction orchestrator is disabled. + AuctionDisabled, + /// Request consent does not permit a server-side auction. + ConsentDenied, + /// No enabled configured provider can bid on the slot. + SlotNotEligible, + /// A dispatched provider exceeded its deadline. + ProviderTimeout, + /// A provider could not launch or complete its transport/HTTP exchange. + ProviderError, + /// A provider response failed structural, currency, identity, or bid validation. + InvalidProviderResponse, + /// The configured mediator failed or returned invalid provenance. + MediationFailed, + /// A selected candidate cannot be represented by the exact browser contract. + WinnerNotRenderable, + /// A unique renderer reservation could not be minted. + IdentityGenerationFailed, + /// An internal invariant or candidate-identity operation failed. + InternalError, +} + +impl AuctionSlotFailureReason { + /// Return the exact wire literal. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::AuctionDisabled => "auction_disabled", + Self::ConsentDenied => "consent_denied", + Self::SlotNotEligible => "slot_not_eligible", + Self::ProviderTimeout => "provider_timeout", + Self::ProviderError => "provider_error", + Self::InvalidProviderResponse => "invalid_provider_response", + Self::MediationFailed => "mediation_failed", + Self::WinnerNotRenderable => "winner_not_renderable", + Self::IdentityGenerationFailed => "identity_generation_failed", + Self::InternalError => "internal_error", + } + } + + /// Closed multi-provider aggregation priority; lower values win. + #[must_use] + pub const fn priority(self) -> u8 { + match self { + Self::InternalError => 0, + Self::MediationFailed => 1, + Self::InvalidProviderResponse => 2, + Self::ProviderError => 3, + Self::ProviderTimeout => 4, + Self::ConsentDenied => 5, + Self::AuctionDisabled => 6, + Self::SlotNotEligible => 7, + Self::WinnerNotRenderable | Self::IdentityGenerationFailed => u8::MAX, + } + } +} + +/// Exactly one final server-auction decision for a requested slot. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde( + tag = "outcome", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum SlotAuctionDecisionV1 { + /// A candidate won and joins exactly one projected bid. + Winner { + /// Exact request slot identifier. + slot: String, + /// Opaque response-local candidate identifier. + candidate_id: String, + }, + /// Every dispatched provider completed successfully without a candidate. + NoBid { + /// Exact request slot identifier. + slot: String, + }, + /// The slot failed with one closed reason. + Failed { + /// Exact request slot identifier. + slot: String, + /// Exact failure reason. + reason: AuctionSlotFailureReason, + }, +} + +impl SlotAuctionDecisionV1 { + /// Return the exact slot identifier shared by every variant. + #[must_use] + pub fn slot(&self) -> &str { + match self { + Self::Winner { slot, .. } | Self::NoBid { slot } | Self::Failed { slot, .. } => slot, + } + } +} + +impl Serialize for SlotAuctionDecisionV1 { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + match self { + Self::Winner { slot, candidate_id } => { + let mut state = serializer.serialize_struct("SlotAuctionDecisionV1", 3)?; + state.serialize_field("slot", slot)?; + state.serialize_field("outcome", "winner")?; + state.serialize_field("candidateId", candidate_id)?; + state.end() + } + Self::NoBid { slot } => { + let mut state = serializer.serialize_struct("SlotAuctionDecisionV1", 2)?; + state.serialize_field("slot", slot)?; + state.serialize_field("outcome", "no_bid")?; + state.end() + } + Self::Failed { slot, reason } => { + let mut state = serializer.serialize_struct("SlotAuctionDecisionV1", 3)?; + state.serialize_field("slot", slot)?; + state.serialize_field("outcome", "failed")?; + state.serialize_field("reason", reason)?; + state.end() + } + } + } +} + +/// Ordered version-1 decision set for one server auction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuctionDecisionSetV1 { + /// Contract version. + pub version: u8, + /// Exact auction identifier. + pub auction_id: String, + /// Exactly one decision per requested slot, in request order. + pub results: Vec, +} + +impl AuctionDecisionSetV1 { + /// Construct an ordered decision set for a request-wide gate. + #[must_use] + pub fn failed(request: &AuctionRequest, reason: AuctionSlotFailureReason) -> Self { + Self { + version: 1, + auction_id: request.id.clone(), + results: request + .slots + .iter() + .map(|slot| SlotAuctionDecisionV1::Failed { + slot: slot.id.clone(), + reason, + }) + .collect(), + } + } +} + +/// Maximum canonical UTF-8 size of the browser auction projection. +pub const MAX_BROWSER_AUCTION_PROJECTION_BYTES: usize = 8 * 1024 * 1024; +/// Maximum number of requested results or projected winner bids. +pub const MAX_BROWSER_AUCTION_RESULTS: usize = 256; +/// Maximum number of publisher targeting entries on one projected bid. +pub const MAX_BROWSER_AUCTION_TARGETING_ENTRIES: usize = 32; + +/// One exact browser-facing winner projection. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BrowserAuctionBidV1 { + /// Response-local mediator candidate identity. + pub candidate_id: String, + /// Exact requested server slot identity. + pub slot: String, + /// Canonical provider integration name. + pub provider: String, + /// Exact provider-native upstream bid identity. + pub upstream_bid_id: String, + /// Selected finite, nonnegative CPM. + pub cpm: f64, + /// Exact auction currency; version 1 admits only `USD`. + pub currency: String, + /// Lexically ordered publisher targeting, excluding runtime-owned `hb_adid`. + pub targeting: BTreeMap, + /// Server-minted renderer capability identity for APS/ADM only. + #[serde(skip_serializing_if = "Option::is_none")] + pub renderer_reservation_id: Option, + /// Sole tagged render authority for the winner. + pub render_source: BidRenderSourceV1, +} + +/// Exact GAM placement metadata required to publish one server-projected slot. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BrowserAuctionSlotV1 { + /// Exact server slot identity joined to one auction decision. + pub slot: String, + /// Fully rendered GAM ad-unit path for this navigation. + pub gam_unit_path: String, + /// Stable configured DOM id/prefix for responsive resolution. + pub div_id: String, + /// Accepted banner dimensions in configured order. + pub formats: Vec<[u32; 2]>, + /// Static publisher targeting applied before winner targeting. + pub targeting: BTreeMap, +} + +/// Complete browser-facing version-1 auction projection. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserAuctionProjectionV1 { + /// Contract version. + pub version: u8, + /// Ordered decision set for every requested slot. + pub auction: AuctionDecisionSetV1, + /// Ordered GAM placement definitions; empty only for direct `/auction` serialization. + pub slots: Vec, + /// Winner bids in matching decision order. + pub bids: Vec, +} + /// URL used by the orchestrator when invoking a mediator from the collect /// path. Providers can `debug_assert` against this value to catch a mediator /// that has accidentally started depending on `context.request` carrying real @@ -194,7 +583,7 @@ pub enum ApsTagType { /// Version 1 APS renderer descriptor shared with browser clients. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ApsRendererV1 { /// Renderer contract version. pub version: u8, @@ -217,22 +606,308 @@ pub struct ApsRendererV1 { pub height: u32, } -/// Typed browser renderer capability carried by a bid. +/// Version 1 inline ADM render source shared with browser clients. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AdmRenderSourceV1 { + /// Render-source contract version. + pub version: u8, + /// Exact creative markup. + pub adm: String, + /// Creative width. + pub width: u32, + /// Creative height. + pub height: u32, +} + +/// Thin version 1 carrier for the current GPT-owned PBS Cache behavior. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BaselinePbsCacheSourceV1 { + /// Render-source contract version. + pub version: u8, + /// Exact native PBS Cache identity. + pub cache_id: String, + /// Exact current-main `hb_cache_host` value. + pub cache_host: String, + /// Exact current-main `hb_cache_path` value. + pub cache_path: String, + /// Winning width transported without cache-specific validation. + pub width: u32, + /// Winning height transported without cache-specific validation. + pub height: u32, +} + +/// Typed browser render source carried by a bid. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum BidRenderer { +#[serde(tag = "type", rename_all = "snake_case")] +pub enum BidRenderSourceV1 { /// APS renderer version 1. Aps(ApsRendererV1), + /// Inline ADM version 1. + Adm(AdmRenderSourceV1), + /// Current-main GPT-owned PBS Cache carrier. + PbsCache(BaselinePbsCacheSourceV1), } -impl BidRenderer { +impl BidRenderSourceV1 { /// Return the APS renderer descriptor when this is an APS renderer. #[must_use] pub fn as_aps(&self) -> Option<&ApsRendererV1> { match self { Self::Aps(renderer) => Some(renderer), + Self::Adm(_) | Self::PbsCache(_) => None, + } + } +} + +/// Smallest accepted renderer dimension in CSS pixels. +pub const RENDER_DIMENSION_MIN: u64 = 1; +/// Largest accepted renderer dimension in CSS pixels. +pub const RENDER_DIMENSION_MAX: u64 = 4096; + +const MAX_APS_ACCOUNT_ID_BYTES: usize = 1024; +const MAX_APS_BID_ID_BYTES: usize = 64; +const MAX_APS_CREATIVE_ID_BYTES: usize = 1024; +const MAX_APS_CREATIVE_URL_BYTES: usize = 4096; +const MAX_APS_RENDER_ENVELOPE_BYTES: usize = 256 * 1024; +const MAX_APS_RENDER_ENVELOPE_BASE64_BYTES: usize = 4 * MAX_APS_RENDER_ENVELOPE_BYTES.div_ceil(3); + +/// Cross-language APS descriptor validation result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApsRendererValidationResult { + /// Descriptor and decoded envelope are valid and agree. + Accepted, + /// Descriptor or decoded envelope is malformed. + DescriptorInvalid, + /// A dimension has the wrong type or is nonfinite, fractional, zero, or negative. + InvalidDimensions, + /// An otherwise integral positive dimension is outside the supported range. + DimensionsOutOfRange, +} + +impl ApsRendererValidationResult { + /// Return the exact browser failure/result literal. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::DescriptorInvalid => "descriptor_invalid", + Self::InvalidDimensions => "invalid_dimensions", + Self::DimensionsOutOfRange => "dimensions_out_of_range", + } + } +} + +fn has_exact_json_keys(value: &serde_json::Value, expected: &[&str]) -> bool { + value.as_object().is_some_and(|object| { + object.len() == expected.len() && expected.iter().all(|key| object.contains_key(*key)) + }) +} + +fn classify_render_dimension(value: &serde_json::Value) -> ApsRendererValidationResult { + let Some(number) = value.as_f64() else { + return ApsRendererValidationResult::InvalidDimensions; + }; + if !number.is_finite() || number.fract() != 0.0 || number <= 0.0 { + return ApsRendererValidationResult::InvalidDimensions; + } + if number < RENDER_DIMENSION_MIN as f64 || number > RENDER_DIMENSION_MAX as f64 { + return ApsRendererValidationResult::DimensionsOutOfRange; + } + ApsRendererValidationResult::Accepted +} + +fn valid_aps_creative_url(value: &str, publisher_origin: &str) -> bool { + if value.len() > MAX_APS_CREATIVE_URL_BYTES { + return false; + } + let Ok(url) = Url::parse(value) else { + return false; + }; + url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.origin().ascii_serialization() != publisher_origin +} + +/// Classify a raw APS renderer descriptor using the cross-language version-1 contract. +#[must_use] +pub fn classify_aps_renderer_v1( + value: &serde_json::Value, + publisher_origin: &str, +) -> ApsRendererValidationResult { + const REQUIRED_KEYS: &[&str] = &[ + "aaxResponse", + "accountId", + "bidId", + "creativeUrl", + "height", + "tagType", + "type", + "version", + "width", + ]; + const KEYS_WITH_CREATIVE_ID: &[&str] = &[ + "aaxResponse", + "accountId", + "bidId", + "creativeId", + "creativeUrl", + "height", + "tagType", + "type", + "version", + "width", + ]; + + if !has_exact_json_keys(value, REQUIRED_KEYS) + && !has_exact_json_keys(value, KEYS_WITH_CREATIVE_ID) + { + return ApsRendererValidationResult::DescriptorInvalid; + } + + let Some(descriptor) = value.as_object() else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if descriptor.get("type").and_then(serde_json::Value::as_str) != Some("aps") + || descriptor + .get("version") + .and_then(serde_json::Value::as_f64) + .is_none_or(|version| version != 1.0) + { + return ApsRendererValidationResult::DescriptorInvalid; + } + + let Some(account_id) = descriptor + .get("accountId") + .and_then(serde_json::Value::as_str) + else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + let Some(bid_id) = descriptor.get("bidId").and_then(serde_json::Value::as_str) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if account_id.is_empty() + || account_id.len() > MAX_APS_ACCOUNT_ID_BYTES + || bid_id.is_empty() + || bid_id.len() > MAX_APS_BID_ID_BYTES + || bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) + { + return ApsRendererValidationResult::DescriptorInvalid; + } + if let Some(creative_id) = descriptor.get("creativeId") { + let Some(creative_id) = creative_id.as_str() else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if creative_id.is_empty() || creative_id.len() > MAX_APS_CREATIVE_ID_BYTES { + return ApsRendererValidationResult::DescriptorInvalid; } } + let Some(tag_type) = descriptor + .get("tagType") + .and_then(serde_json::Value::as_str) + else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if tag_type != "iframe" && tag_type != "script" { + return ApsRendererValidationResult::DescriptorInvalid; + } + + let width_result = + classify_render_dimension(descriptor.get("width").unwrap_or(&serde_json::Value::Null)); + if width_result != ApsRendererValidationResult::Accepted { + return width_result; + } + let height_result = + classify_render_dimension(descriptor.get("height").unwrap_or(&serde_json::Value::Null)); + if height_result != ApsRendererValidationResult::Accepted { + return height_result; + } + + let Some(creative_url) = descriptor + .get("creativeUrl") + .and_then(serde_json::Value::as_str) + else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + let Some(aax_response) = descriptor + .get("aaxResponse") + .and_then(serde_json::Value::as_str) + else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if !valid_aps_creative_url(creative_url, publisher_origin) + || aax_response.is_empty() + || aax_response.len() > MAX_APS_RENDER_ENVELOPE_BASE64_BYTES + { + return ApsRendererValidationResult::DescriptorInvalid; + } + let Ok(decoded_bytes) = BASE64_STANDARD.decode(aax_response) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if decoded_bytes.len() > MAX_APS_RENDER_ENVELOPE_BYTES + || BASE64_STANDARD.encode(&decoded_bytes) != aax_response + { + return ApsRendererValidationResult::DescriptorInvalid; + } + let Ok(decoded_utf8) = core::str::from_utf8(&decoded_bytes) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + let Ok(decoded) = serde_json::from_str::(decoded_utf8) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if !has_exact_json_keys(&decoded, &["seatbid"]) { + return ApsRendererValidationResult::DescriptorInvalid; + } + let Some(seats) = decoded.get("seatbid").and_then(serde_json::Value::as_array) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if seats.len() != 1 || !has_exact_json_keys(&seats[0], &["bid"]) { + return ApsRendererValidationResult::DescriptorInvalid; + } + let Some(bids) = seats[0].get("bid").and_then(serde_json::Value::as_array) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if bids.len() != 1 || !has_exact_json_keys(&bids[0], &["ext", "h", "id", "price", "w"]) { + return ApsRendererValidationResult::DescriptorInvalid; + } + let bid = &bids[0]; + let Some(ext) = bid.get("ext") else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if !has_exact_json_keys(ext, &["creativeurl", "tagtype"]) { + return ApsRendererValidationResult::DescriptorInvalid; + } + + let bid_width_result = + classify_render_dimension(bid.get("w").unwrap_or(&serde_json::Value::Null)); + if bid_width_result != ApsRendererValidationResult::Accepted { + return bid_width_result; + } + let bid_height_result = + classify_render_dimension(bid.get("h").unwrap_or(&serde_json::Value::Null)); + if bid_height_result != ApsRendererValidationResult::Accepted { + return bid_height_result; + } + let price_is_valid = bid + .get("price") + .and_then(serde_json::Value::as_f64) + .is_some_and(|price| price.is_finite() && price >= 0.0); + if bid.get("id").and_then(serde_json::Value::as_str) != Some(bid_id) + || bid.get("w").and_then(serde_json::Value::as_f64) + != descriptor.get("width").and_then(serde_json::Value::as_f64) + || bid.get("h").and_then(serde_json::Value::as_f64) + != descriptor.get("height").and_then(serde_json::Value::as_f64) + || ext.get("creativeurl").and_then(serde_json::Value::as_str) != Some(creative_url) + || ext.get("tagtype").and_then(serde_json::Value::as_str) != Some(tag_type) + || !price_is_valid + { + return ApsRendererValidationResult::DescriptorInvalid; + } + + ApsRendererValidationResult::Accepted } /// Individual bid from a provider. @@ -240,13 +915,22 @@ impl BidRenderer { pub struct Bid { /// Slot this bid is for pub slot_id: String, + /// Server-minted opaque identifier used only for this auction response. + #[serde(skip)] + pub candidate_id: Option, + /// Provider integration name paired with the upstream bid ID for provenance. + #[serde(skip)] + pub candidate_provider: Option, + /// Server-minted renderer capability identifier (populated during projection). + #[serde(skip)] + pub renderer_reservation_id: Option, /// Bid price in CPM. pub price: Option, /// Currency code (e.g., "USD") pub currency: String, /// Creative markup (HTML/VAST). /// - /// `None` when the bid uses a typed [`BidRenderer`] instead. + /// `None` when the bid uses a typed [`BidRenderSourceV1`] instead. pub creative: Option, /// Advertiser domain pub adomain: Option>, @@ -277,11 +961,11 @@ pub struct Bid { pub creative_id: Option, /// Typed browser renderer capability. #[serde(skip_serializing_if = "Option::is_none")] - pub renderer: Option, + pub renderer: Option, /// Prebid Cache UUID for this bid. /// /// Populated from `ext.prebid.cache.bids.cacheId` in the PBS response. - /// Used as `hb_adid` targeting value in `window.tsjs.bids`. `None` for + /// Used as the `hb_adid` value in the initial browser auction projection. `None` for /// non-PBS providers (e.g., APS) and PBS bids without Prebid Cache enabled. pub cache_id: Option, /// Prebid Cache host (e.g., `"openads.adsrvr.org"`). @@ -298,6 +982,28 @@ pub struct Bid { pub metadata: HashMap, } +/// Length of the hex-encoded creative trace hash. +const ADM_TRACE_HASH_LEN: usize = 16; + +/// Compute the trace hash for delivered creative markup. +#[must_use] +pub fn adm_trace_hash(adm: &str) -> String { + use sha2::{Digest as _, Sha256}; + + let digest = Sha256::digest(adm.as_bytes()); + let mut hex = hex::encode(digest); + hex.truncate(ADM_TRACE_HASH_LEN); + hex +} + +impl Bid { + /// Trace hash of this bid's creative markup, when present. + #[must_use] + pub fn creative_trace_hash(&self) -> Option { + self.creative.as_deref().map(adm_trace_hash) + } +} + /// Per-provider summary included in the auction response. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderSummary { @@ -348,7 +1054,7 @@ pub struct OrchestratorExt { pub dropped_winner_count: usize, /// Machine-readable reasons for omitted winners. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub dropped_winner_reasons: BTreeMap, + pub dropped_winner_reasons: AuctionDropReasons, } /// Status of bid response. @@ -404,6 +1110,30 @@ impl AuctionResponse { self.metadata.insert(key.into(), value); self } + + /// Project typed local drop reasons into the existing provider metadata surface. + #[must_use] + pub fn with_drop_reasons(mut self, reasons: &AuctionDropReasons) -> Self { + if !reasons.is_empty() { + let values = reasons + .iter() + .map(|(reason, count)| { + (reason.as_str().to_string(), serde_json::Value::from(*count)) + }) + .collect(); + self.metadata.insert( + "drop_reasons".to_string(), + serde_json::Value::Object(values), + ); + } + self + } + + /// Project one typed local drop reason into provider metadata. + #[must_use] + pub fn with_drop_reason(self, reason: AuctionDropReason) -> Self { + self.with_drop_reasons(&BTreeMap::from([(reason, 1)])) + } } #[cfg(test)] @@ -411,9 +1141,59 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn typed_drop_reasons_use_exact_literals_in_provider_summary_metadata() { + let reasons = [ + AuctionDropReason::CreativeProcessingRejected, + AuctionDropReason::InvalidCreativeId, + AuctionDropReason::CreativeIdTooLarge, + AuctionDropReason::DimensionsOutOfRange, + AuctionDropReason::DuplicateUpstreamBidId, + AuctionDropReason::EmptySeatBid, + AuctionDropReason::EmptySeatBidBids, + AuctionDropReason::InvalidCreativeUrl, + AuctionDropReason::InvalidDimensions, + AuctionDropReason::InvalidPrice, + AuctionDropReason::InvalidProviderResponse, + AuctionDropReason::InvalidTagType, + AuctionDropReason::InvalidUpstreamBidId, + AuctionDropReason::LostToHigherBid, + AuctionDropReason::MalformedBid, + AuctionDropReason::MissingCreativeUrl, + AuctionDropReason::MissingRequestContext, + AuctionDropReason::MissingUpstreamBidId, + AuctionDropReason::MultipleRenderSources, + AuctionDropReason::NoRenderSource, + AuctionDropReason::RendererExtensionSerializationFailed, + AuctionDropReason::RenderPayloadTooLarge, + AuctionDropReason::ScriptRenderingDisabled, + AuctionDropReason::UnknownImpression, + AuctionDropReason::UnsupportedMediaType, + AuctionDropReason::UpstreamBidIdTooLarge, + ]; + for reason in reasons { + assert_eq!( + serde_json::to_value(reason).expect("drop reason should serialize"), + json!(reason.as_str()), + "serde and diagnostic literal should agree for {reason:?}" + ); + } + + let response = AuctionResponse::no_bid("aps", 12) + .with_drop_reason(AuctionDropReason::InvalidProviderResponse); + let summary = ProviderSummary::from(&response); + assert_eq!( + summary.metadata["drop_reasons"]["invalid_provider_response"], 1, + "publisher provider-summary projection should retain the typed reason" + ); + } + fn make_bid(bidder: &str) -> Bid { Bid { slot_id: "slot-1".to_owned(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(1.0), currency: "USD".to_owned(), creative: None, @@ -561,6 +1341,9 @@ mod tests { fn bid_with_cache_fields_round_trips_through_json() { let bid = Bid { slot_id: "atf".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(1.50), currency: "USD".to_string(), creative: None, @@ -601,7 +1384,7 @@ mod tests { #[test] fn aps_renderer_serializes_to_versioned_camel_case_contract() { - let renderer = BidRenderer::Aps(ApsRendererV1 { + let renderer = BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account-id".to_string(), bid_id: "fictional-bid-id".to_string(), @@ -635,7 +1418,7 @@ mod tests { #[test] fn aps_renderer_omits_absent_creative_id() { - let renderer = BidRenderer::Aps(ApsRendererV1 { + let renderer = BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account-id".to_string(), bid_id: "fictional-bid-id".to_string(), @@ -664,10 +1447,40 @@ mod tests { ); } + #[test] + fn slot_failure_priority_matches_the_closed_contract() { + let ordered = [ + AuctionSlotFailureReason::InternalError, + AuctionSlotFailureReason::MediationFailed, + AuctionSlotFailureReason::InvalidProviderResponse, + AuctionSlotFailureReason::ProviderError, + AuctionSlotFailureReason::ProviderTimeout, + AuctionSlotFailureReason::ConsentDenied, + AuctionSlotFailureReason::AuctionDisabled, + AuctionSlotFailureReason::SlotNotEligible, + ]; + + assert_eq!( + ordered.map(AuctionSlotFailureReason::priority), + [0, 1, 2, 3, 4, 5, 6, 7] + ); + assert_eq!( + AuctionSlotFailureReason::WinnerNotRenderable.priority(), + u8::MAX + ); + assert_eq!( + AuctionSlotFailureReason::IdentityGenerationFailed.priority(), + u8::MAX + ); + } + #[test] fn bid_has_ad_id_field() { let bid = Bid { slot_id: "s".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(1.0), currency: "USD".to_string(), creative: None, diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index f5e45bbd3..0f4c2353b 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -507,9 +507,8 @@ mod tests { /// handler covers is the operator's decision, and silently carving holes in /// it would be worse than a documented constraint. Operators must scope /// handler patterns to the paths they mean (`^/_ts/admin`) — see the - /// configuration guide. The tsjs client's `/__ts/page-bids` fallback keeps - /// affected deployments serving SPA ads until they do, but it disappears - /// with the alias in IABTechLab/trusted-server#970. + /// configuration guide. A broad pattern will block the canonical page-bids + /// endpoint; the hard-cutover client does not retry a compatibility alias. #[test] fn broad_handler_regex_also_covers_browser_facing_endpoints() { let config = crate_test_settings_str().replace(r#"path = "^/secure""#, r#"path = "^/_ts""#); diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index fb88448a0..be1146796 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -440,19 +440,6 @@ mod tests { use crate::test_support::tests::crate_test_settings_str; use edgezero_core::app_config::AppConfigMeta; - #[derive(Debug, Deserialize)] - #[serde(deny_unknown_fields)] - #[allow(dead_code)] - struct LegacyCreativeOpportunitiesConfig { - gam_network_id: String, - #[serde(default)] - auction_timeout_ms: Option, - #[serde(default)] - price_granularity: serde_json::Value, - #[serde(default)] - slot: Vec, - } - fn app_config_with_creative_opportunities( gam_unit_path: Option<&str>, ) -> TrustedServerAppConfig { @@ -461,6 +448,7 @@ mod tests { r#" [creative_opportunities] +enabled = true gam_network_id = "99999" [[creative_opportunities.slot]] @@ -811,49 +799,6 @@ formats = [{ width = 300, height = 250 }] ); } - #[test] - fn static_gam_unit_template_is_accepted_by_legacy_schema() { - let creative_opportunities = serialized_creative_opportunities(Some("/99999/example/home")); - - serde_json::from_value::(creative_opportunities) - .expect("should accept static GAM unit template"); - } - - #[test] - fn absent_gam_unit_template_is_accepted_by_legacy_schema() { - let creative_opportunities = serialized_creative_opportunities(None); - - assert!( - creative_opportunities.get("enabled").is_none(), - "default template switch should be omitted for legacy binaries" - ); - serde_json::from_value::(creative_opportunities) - .expect("should accept absent GAM unit template"); - } - - #[test] - fn disabled_creative_opportunities_flag_is_rejected_by_legacy_schema() { - let mut toml = crate_test_settings_str(); - toml.push_str( - r#" - -[creative_opportunities] -enabled = false -gam_network_id = "99999" -"#, - ); - let app_config: TrustedServerAppConfig = - toml::from_str(&toml).expect("should deserialize app config wrapper"); - let creative_opportunities = serde_json::to_value(app_config) - .expect("should serialize app config wrapper") - .get("creative_opportunities") - .cloned() - .expect("should contain creative opportunities"); - - serde_json::from_value::(creative_opportunities) - .expect_err("legacy binaries should reject an explicit disabled switch"); - } - #[test] fn app_config_new_rejects_empty_secret_key_reference() { let mut settings = valid_settings(); diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index e1152b1e7..828311f12 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -5,6 +5,7 @@ pub const COOKIE_TS_EC: &str = "ts-ec"; /// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers. pub const COOKIE_TS_EIDS: &str = "ts-eids"; pub const COOKIE_TS_TESTER: &str = "ts-tester"; +pub const COOKIE_TS_TRACE: &str = "ts-trace"; pub const COOKIE_SHAREDID: &str = "sharedId"; pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id"); diff --git a/crates/trusted-server-core/src/creative.rs b/crates/trusted-server-core/src/creative.rs index a4d641bdf..095247142 100644 --- a/crates/trusted-server-core/src/creative.rs +++ b/crates/trusted-server-core/src/creative.rs @@ -574,8 +574,8 @@ fn process_auction_creative_with_rewriter( /// - 1x1 `` pixels → `/first-party/proxy?tsurl=<base-url><params>&tstoken=<sig>` /// - Non-pixel absolute images → `/first-party/proxy?tsurl=<base-url><params>&tstoken=<sig>` /// - ``, - w: 300, - h: 250, - }, - }, - adSlots: [ - { - id: "aps-slot", - div_id: "div-aps", - gam_unit_path: "/fictional/aps", - formats: [[300, 250]], - }, - ], - }; - typedWindow.pucEvents = []; - const locator = document.createElement("iframe"); - locator.name = "__pb_locator__"; - document.body.appendChild(locator); - window.addEventListener("message", (event) => { - try { - const message = JSON.parse( - String(event.data), - ) as Record; - if (message.message === "Prebid Event") { - typedWindow.pucEvents.push(message); - } - } catch { - // Ignore unrelated publisher messages. - } - }); - - const slot = document.getElementById("div-aps")!; - slot.style.width = "1px"; - slot.style.height = "1px"; - const outerShell = document.createElement("div"); - outerShell.id = "aps-outer-shell"; - outerShell.style.width = "1px"; - outerShell.style.height = "1px"; - const innerShell = document.createElement("div"); - innerShell.id = "aps-inner-shell"; - innerShell.style.width = "1px"; - innerShell.style.height = "1px"; - const frame = document.createElement("iframe"); - frame.id = "google_ads_iframe_fictional_0"; - frame.width = "1"; - frame.height = "1"; - frame.style.width = "1px"; - frame.style.height = "1px"; - frame.src = outerUrl; - innerShell.appendChild(frame); - outerShell.appendChild(innerShell); - slot.appendChild(outerShell); - - const other = document.getElementById("div-other")!; - const otherFrame = document.createElement("iframe"); - otherFrame.width = "1"; - otherFrame.height = "1"; - otherFrame.style.width = "1px"; - otherFrame.style.height = "1px"; - other.appendChild(otherFrame); - }, - { - creativeUrl: IFRAME_CREATIVE_URL, - outerUrl: outerCreativeUrl, - selectedAdId: adId, - }, - ); - - await expect.poll(() => creativeRequests).toBe(1); - await expect - .poll(() => - page.evaluate(() => - ( - window as unknown as { - pucEvents: Array>; - } - ).pucEvents.some( - (event) => event.event === "adRenderSucceeded", - ), - ), - ) - .toBe(true); - await expect(page.locator("#google_ads_iframe_fictional_0")).toHaveCSS( - "width", - "300px", - ); - await expect(page.locator("#google_ads_iframe_fictional_0")).toHaveCSS( - "height", - "250px", - ); - await expect(page.locator("#div-aps")).toHaveCSS("width", "300px"); - await expect(page.locator("#div-aps")).toHaveCSS("height", "250px"); - await expect(page.locator("#aps-outer-shell")).toHaveCSS( - "width", - "300px", - ); - await expect(page.locator("#aps-outer-shell")).toHaveCSS( - "height", - "250px", - ); - await expect(page.locator("#aps-inner-shell")).toHaveCSS( - "width", - "300px", - ); - await expect(page.locator("#aps-inner-shell")).toHaveCSS( - "height", - "250px", - ); - await expect(page.locator("#div-other iframe")).toHaveCSS( - "width", - "1px", - ); - await expect(page.locator("#div-other iframe")).toHaveCSS( - "height", - "1px", - ); - }); - - test("renders a trustedServer adapter bid using Prebid's generated GAM ad ID", async ({ - page, - }) => { - const apsRenderer = descriptor("iframe"); - const responseBody = { - id: "fictional-auction", - seatbid: [ + }), + ); + await page.goto(`${APS_TEST_ORIGIN}/aps-v2-protocol-test`); + + const start = async ( + slotId: string, + bidId: string, + rendererOverrides: Record = {}, + adversarialBootstrap = false, + ) => { + const bootstrapNonce = `b1_${slotId.padEnd(22, "b").slice(0, 22)}`; + const rendererNonce = `n1_${slotId.padEnd(22, "n").slice(0, 22)}`; + await page.evaluate( + ({ slotId, bootstrapNonce, rendererNonce, renderer }) => { + ( + window as unknown as { + startApsV2(options: Record): void; + } + ).startApsV2({ slotId, bootstrapNonce, rendererNonce, renderer }); + }, + { + slotId, + bootstrapNonce, + rendererNonce, + renderer: descriptor(bidId, rendererOverrides), + adversarialBootstrap, + }, + ); + return rendererNonce; + }; + const messages = (slotId: string) => + page.evaluate( + (id) => + ( + window as unknown as { + apsV2Records: Record< + string, + { messages: Array> } + >; + } + ).apsV2Records[id]?.messages ?? [], + slotId, + ); + const snapshots = (slotId: string) => + page.evaluate( + (id) => + ( + window as unknown as { + apsV2Records: Record< + string, { - seat: "aps", - bid: [ - { - id: apsRenderer.bidId, - impid: "div-aps", - price: 1.23, - crid: apsRenderer.creativeId, - w: 300, - h: 250, - ext: { - trusted_server: { renderer: apsRenderer }, - }, - }, - ], - }, - ], - ext: {}, - }; - let auctionRequests = 0; - await page.route(runtimeUrl("/aps-prebid-adapter-test"), (route) => - route.fulfill({ - status: 200, - contentType: "text/html", - body: '
', - }), - ); - await page.route(runtimeUrl("/auction"), (route) => { - auctionRequests += 1; - return route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(responseBody), - }); - }); - - await page.goto(runtimeUrl("/aps-prebid-adapter-test")); - await loadClientAuctionBundles(page); - - const result = await page.evaluate(async () => { - type PrebidBid = { - ad?: string; - adId: string; - bidderCode: string; - status?: string; - }; - type PrebidApi = { - getAllWinningBids(): PrebidBid[]; - getBidResponsesForAdUnitCode(code: string): { - bids: PrebidBid[]; - }; - onEvent( - name: string, - callback: (value: Record) => void, - ): void; - requestBids(options: Record): void; - }; - const pbjs = (window as unknown as { pbjs: PrebidApi }).pbjs; - const bidWon: string[] = []; - const renderSucceeded: string[] = []; - pbjs.onEvent("bidWon", (bid) => bidWon.push(String(bid.adId))); - pbjs.onEvent("adRenderSucceeded", (event) => - renderSucceeded.push(String(event.adId)), - ); - - const acceptedBid = await new Promise( - (resolveBid) => { - pbjs.requestBids({ - adUnits: [ - { - code: "div-aps", - mediaTypes: { banner: { sizes: [[300, 250]] } }, - bids: [], - }, - ], - bidsBackHandler: () => - resolveBid( - pbjs - .getBidResponsesForAdUnitCode("div-aps") - .bids.find( - (bid) => bid.bidderCode === "aps", - ), - ), - timeout: 1_000, - }); - }, - ); - if (!acceptedBid) - throw new Error("APS bid was not accepted by Prebid"); - - const foreignUniversalCreativeResponse = await new Promise< - Record | undefined - >((resolveResponse) => { - const frame = document.createElement("iframe"); - const adIdJson = JSON.stringify(acceptedBid.adId); - frame.srcdoc = ``; +// Mirrors the srcdoc document the client builds: the first-party parent stamps +// its own origin ahead of any creative markup, then the runtime, then the +// creative. The anchor carries a root-relative signed click exactly as the +// server-side rewriter emits it. +function creativeDocument( + origin: string, + bundleUrl: string, + fixture: RuntimeTsjsFixture, + signedClick: string, +): string { + const transport = serverBootTransportLiteralV1({ + abi: 1, + releaseId: fixture.releaseId, + manifest: fixture.manifest, + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: "creative-sandbox", results: [] }, + slots: [], + bids: [], + }, + integrations: { version: 1, entries: [] }, + creative: { + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }, + diagnostics: { + version: 1, + renderTraceOverlay: false, + gpt: { active: false }, + }, + }); + return ` + + + + + + + + ad + +`; +} test.describe("Sandboxed creative iframe", () => { test("recovers a mutated click through the signed GET rebuild chain", async ({ @@ -52,38 +110,8 @@ test.describe("Sandboxed creative iframe", () => { await page.goto(runtimeUrl("/"), { waitUntil: "domcontentloaded" }); - // Reuse whichever hashed bundle URL the server injected into the page so - // this test never has to know the current content hash; fall back to the - // stable unified path if the fixture page carries no injected script. - const injectedBundle = await page.evaluate(() => { - const script = Array.from(document.querySelectorAll("script[src]")).find( - (element) => - (element as HTMLScriptElement).src.includes("/static/tsjs="), - ); - return script ? (script as HTMLScriptElement).src : null; - }); - const bundleUrl = - injectedBundle ?? runtimeUrl("/static/tsjs=tsjs-unified.min.js"); - - // Mirrors the srcdoc document the client builds: the first-party parent - // stamps its own origin ahead of any creative markup, then the runtime, - // then the creative — here preceded by hostile markup attempting to move - // the stamp. - const creativeDocument = ` - - - - - - ${HOSTILE_STAMP_OVERWRITE} - - ad - -`; + await routeRuntimeTsjsFixture(page, CREATIVE_FIXTURE); + const bundleUrl = new URL(CREATIVE_FIXTURE.runtimeSrc, origin).toString(); const rebuildResponse = page.waitForResponse( (response) => response.url().includes("/first-party/proxy-rebuild"), @@ -105,12 +133,50 @@ test.describe("Sandboxed creative iframe", () => { iframe.style.height = "250px"; document.body.appendChild(iframe); }, - { sandbox: CREATIVE_SANDBOX_TOKENS, html: creativeDocument }, + { + sandbox: CREATIVE_SANDBOX_TOKENS, + html: creativeDocument( + new URL(runtimeUrl("/")).origin, + bundleUrl, + CREATIVE_FIXTURE, + signedClick, + ), + }, ); const frame = page.frameLocator("iframe"); const link = frame.locator("#creative-link"); await link.waitFor({ state: "attached", timeout: 10_000 }); + await expect + .poll(() => + frame.locator("html").evaluate(() => { + const api = (window as any).tsjs; + return { + state: api?._internal?.state, + names: Object.getOwnPropertyNames(api ?? {}).sort(), + legacyCreativeGlobal: Object.prototype.hasOwnProperty.call( + window, + "tscreative", + ), + }; + }), + ) + .toEqual({ + state: "kernel", + names: [ + "_internal", + "_registerIntegration", + "addAdUnits", + "boot", + "diagnostics", + "log", + "que", + "releaseId", + "requestAds", + "version", + ], + legacyCreativeGlobal: false, + }); // The creative mutates its own click target, the shape the click guard // exists to repair. diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts new file mode 100644 index 000000000..edd2be7df --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts @@ -0,0 +1,2596 @@ +import { execFileSync } from "node:child_process"; +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { + chromium, + expect, + test, + type Browser, + type BrowserContext, + type Page, +} from "@playwright/test"; +import { measureBytes } from "../../../../trusted-server-js/lib/scripts/bundle-metrics.mjs"; + +const REPO_ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", +}).trim(); +const WARMUPS = 5; +const SAMPLES = 50; +const PERCENTILE = 90; +const MAXIMUM_P90_RATIO = 1.1; +const EXPECTED_CHROMIUM = "145.0.7632.6"; +const MACHINE_CLASS = "github-hosted:ubuntu-24.04"; +const RUNNER_IMAGE = "ubuntu-24.04"; +const FIXTURE_ID = "tsjs-baseline-paired-network-v3"; +const CONTROLLER_ID = "generated-server-v1+production-rc-v1"; +const COMPARISON_START_MARK = "tsjs:comparison-start"; +const FIRST_OBSERVABLE_ACTION_MARK = "tsjs:first-observable-action"; +const NETWORK_PROFILE = Object.freeze({ + offline: false, + latency: 150, + downloadThroughput: 200_000, + uploadThroughput: 93_750, + packetLoss: 0, +}); +// The rc baseline always ships creative with core, and production's default +// creative guard is enabled. Keep both comparison sides on that same shape. +const SELECTED_IDS = ["render_runtime", "creative", "gpt"] as const; +const DEFERRED_IDS = ["diagnostics_presentation", "gpt_later"] as const; +const LEGACY_AD_INIT_INLINE = "window.tsjs.adInit();"; +const HEAP_CHECKPOINTS = [ + "afterBoot", + "afterFirstRender", + "afterRefresh", + "afterSpaNavigation", +] as const; +const HARD_HEAP_CEILING_BYTES = 4 * 1024 * 1024; +const HEAP_OPERATION_TIMEOUT_MS = 30_000; +const APS_ACTION_P90_CEILING_MS = 900; +const APS_ACTION_TO_COMPLETION_P90_CEILING_MS = 1_500; +const APS_COMPLETION_TO_PAINT_P90_CEILING_MS = 250; +const APS_TOTAL_P90_CEILING_MS = 2_500; +const APS_AFTER_PAINT_HEAP_CEILING_BYTES = 3 * 1024 * 1024; +const APS_AFTER_TAKEOVER_HEAP_CEILING_BYTES = 3_932_160; +const FICTIONAL_APS_RUNNER = readFileSync( + resolve( + REPO_ROOT, + "crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js", + ), + "utf8", +); + +type PerformanceCase = "gpt" | "aps"; + +type HeapCheckpoint = (typeof HEAP_CHECKPOINTS)[number]; + +interface ReleaseArtifact { + id: string; + role: + | "bootstrap" + | "first_display_base" + | "first_display_slice" + | "core" + | "integration"; + phase: "first_display" | "takeover" | "deferred" | null; + trigger: "first_display_or_idle" | null; + file: string; + hash: string; +} + +interface Release { + version: 1; + releaseId: string; + artifacts: ReleaseArtifact[]; +} + +interface FixtureResources { + performanceCase: PerformanceCase; + artifactModel: "release-v1" | "legacy-rc-v1"; + release: Release | null; + controllerDocument: string | null; + selectedBody: string; + selectedTransferBytes: number; + selectedSrc: string; + runtimeBody: string | null; + runtimeSrc: string | null; + deferred: Map; + referenceTransfer: ReferenceTransfer; + apsRendererDocument: string | null; + apsRendererPath: + "/integrations/aps/renderer" | "/integrations/aps/renderer/v2" | null; +} + +interface ReferenceTransferSource { + semanticEndpoint: string; + delivery: "inline" | "external"; + rawBytes: number; + gzipBytes: number; + brotliBytes: number; + sha256: string; +} + +interface ReferenceTransfer { + sources: ReferenceTransferSource[]; + rawBytes: number; + gzipBytes: number; + brotliBytes: number; +} + +function externalAgentTransfer( + transfer: ReferenceTransfer, +): ReferenceTransferSource { + const external = transfer.sources.filter( + ({ delivery }) => delivery === "external", + ); + if (external.length !== 1) { + throw new Error("reference transfer must contain one external agent"); + } + return external[0]!; +} + +interface FixtureRun { + context: BrowserContext; + page: Page; + selectedRequests: string[]; + runtimeRequests: string[]; + deferredRequests: string[]; + pageErrors: string[]; + consoleMessages: string[]; + close(): Promise; +} + +interface FixtureServer { + origin: string; + close(): Promise; +} + +interface FixtureServerOptions { + rejectRuntime?: boolean; +} + +interface OpenFixtureOptions { + manualDisplay?: boolean; + probeAttemptResources?: boolean; + waitUntil?: "commit" | "load"; +} + +interface BrowserObservation { + timingMs: number; + markTimingMs: number; + bidsScriptCount: number; + firstDisplayCount: number; + firstDisplayPaintCount: number; + measureCount: number; + runtimeState: string | undefined; + releaseId: string | undefined; + displayCount: number; + diagnosticsPresentationCount: number; + selectedScriptCount: number; + deferred: Array<{ + id: string; + startTime: number; + responseEnd: number; + loadTime: number; + preparationTime: number; + executionTime: number; + }>; + paintTime: number; + preloadBeforePaintCount: number; +} + +interface ComparisonObservation { + timingMs: number; + displayCount: number; + releaseId: string | undefined; + actionToCompletionMs?: number; + completionToPaintMs?: number; + totalToPaintMs?: number; +} + +function exactArtifact(release: Release, id: string): ReleaseArtifact { + const matches = release.artifacts.filter((artifact) => artifact.id === id); + if (matches.length !== 1) + throw new Error(`expected one release artifact for ${id}`); + return matches[0]!; +} + +function apsDescriptor() { + const bid = { + id: "performance-aps-bid", + price: 1.25, + w: 300, + h: 250, + ext: { + creativeurl: "https://creative.example/performance", + tagtype: "iframe", + }, + }; + return { + type: "aps", + version: 1, + accountId: "fictional-performance-account", + bidId: bid.id, + creativeId: "fictional-performance-creative", + tagType: bid.ext.tagtype, + creativeUrl: bid.ext.creativeurl, + aaxResponse: Buffer.from( + JSON.stringify({ seatbid: [{ bid: [bid] }] }), + "utf8", + ).toString("base64"), + width: bid.w, + height: bid.h, + }; +} + +function legacyBootInline(performanceCase: PerformanceCase): string { + const slots = [ + { + id: "perf-slot", + gam_unit_path: "/123/performance", + div_id: "perf-slot", + formats: [[300, 250]], + targeting: {}, + }, + ]; + const bids = + performanceCase === "aps" + ? { + "perf-slot": { + hb_adid: "performance-aps-bid", + hb_bidder: "aps", + hb_pb: "1.25", + renderer: apsDescriptor(), + }, + } + : {}; + return `window.tsjs={adSlots:${JSON.stringify(slots)},bids:${JSON.stringify(bids)},navGeneration:0};window.__tsjs_gpt_enabled=true;`; +} + +function exactControllerInline(document: string): string { + const normalized = document.toLowerCase(); + const opening = "`; + // The single generated bootstrap carries mutually exclusive direct-runtime and + // first-display branches; each branch owns one mark callsite. + expect( + controllerDocument.match(/performance\.mark\("tsjs:bids-script"\)/gu), + ).toHaveLength(2); + expect(controllerDocument.match(/id="trustedserver-js"/gu)).toHaveLength(1); + expect(controllerDocument).toContain(selectedTag); + expect(controllerDocument).toContain( + `\\"releaseId\\":\\"${release.releaseId}\\"`, + ); + expect(controllerDocument).toContain(`\\"runtimeSrc\\":\\"${runtimeSrc}\\"`); + expect(controllerDocument).not.toContain( + ``, + ); + for (const { src } of deferred.values()) + expect(controllerDocument).toContain(src); + const referenceTransfer = measureReferenceTransfer([ + { + semanticEndpoint: "inline:boot-controller", + delivery: "inline", + body: exactControllerInline(controllerDocument), + }, + { + semanticEndpoint: `external:${selectedSrc}`, + delivery: "external", + body: selectedBody, + }, + ]); + return { + performanceCase, + artifactModel: "release-v1", + release, + controllerDocument, + selectedBody, + selectedTransferBytes: Buffer.byteLength(selectedBody, "utf8"), + selectedSrc, + runtimeBody, + runtimeSrc, + deferred, + referenceTransfer, + apsRendererDocument: + performanceCase === "aps" + ? readFileSync( + resolve( + repositoryRoot, + "crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html", + ), + "utf8", + ) + : null, + apsRendererPath: + performanceCase === "aps" ? "/integrations/aps/renderer/v2" : null, + }; +} + +function legacyApsRendererDocument(repositoryRoot: string): string { + const source = readFileSync( + resolve( + repositoryRoot, + "crates/trusted-server-core/src/integrations/aps.rs", + ), + "utf8", + ); + const match = /const APS_RENDERER_DOCUMENT: &str = r#"([\s\S]*?)"#;/u.exec( + source, + ); + if (!match?.[1]) + throw new Error("legacy rc APS renderer document is unavailable"); + return match[1]; +} + +function loadLegacyBaselineFixtureResources( + repositoryRoot: string, + performanceCase: PerformanceCase = "gpt", +): FixtureResources { + const dist = resolve(repositoryRoot, "crates/trusted-server-js/dist"); + const selectedBody = ["tsjs-core.js", "tsjs-creative.js", "tsjs-gpt.js"] + .map((file) => readFileSync(resolve(dist, file), "utf8")) + .join(";\n"); + const selectedHash = createHash("sha256").update(selectedBody).digest("hex"); + const selectedSrc = `/static/tsjs=tsjs-unified.min.js?v=${selectedHash}`; + const referenceTransfer = measureReferenceTransfer([ + { + semanticEndpoint: "inline:legacy-boot", + delivery: "inline", + body: legacyBootInline(performanceCase), + }, + { + semanticEndpoint: "inline:legacy-ad-init", + delivery: "inline", + body: LEGACY_AD_INIT_INLINE, + }, + { + semanticEndpoint: `external:${selectedSrc}`, + delivery: "external", + body: selectedBody, + }, + ]); + return { + performanceCase, + artifactModel: "legacy-rc-v1", + release: null, + controllerDocument: null, + selectedBody, + selectedTransferBytes: Buffer.byteLength(selectedBody, "utf8"), + selectedSrc, + runtimeBody: null, + runtimeSrc: null, + deferred: new Map(), + referenceTransfer, + apsRendererDocument: + performanceCase === "aps" + ? legacyApsRendererDocument(repositoryRoot) + : null, + apsRendererPath: + performanceCase === "aps" ? "/integrations/aps/renderer" : null, + }; +} + +function loadBaselineFixtureResources( + repositoryRoot: string, + performanceCase: PerformanceCase = "gpt", +): FixtureResources { + const releaseFile = resolve( + repositoryRoot, + "crates/trusted-server-js/dist/tsjs-release-v1.json", + ); + return existsSync(releaseFile) + ? loadReleaseFixtureResources(repositoryRoot, performanceCase) + : loadLegacyBaselineFixtureResources(repositoryRoot, performanceCase); +} + +function initialProjection(performanceCase: PerformanceCase = "gpt") { + const candidateId = "AAAAAAAAAAAA"; + return { + version: 1, + auction: { + version: 1, + auctionId: "performance-initial", + results: [{ slot: "perf-slot", outcome: "winner", candidateId }], + }, + slots: [ + { + slot: "perf-slot", + gamUnitPath: "/123/performance", + divId: "perf-slot", + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [ + { + candidateId, + slot: "perf-slot", + provider: "trusted", + upstreamBidId: "performance-upstream", + cpm: 1, + currency: "USD", + targeting: { hb_bidder: "trusted" }, + rendererReservationId: `r1_${"a".repeat(22)}`, + renderSource: + performanceCase === "aps" + ? apsDescriptor() + : { + type: "adm", + version: 1, + adm: "
fictional performance creative
", + width: 300, + height: 250, + }, + }, + ], + }; +} + +function fixtureDocument( + resources: FixtureResources, + manualDisplay: boolean, +): string { + const selectedTag = ``; + const comparisonSetup = ``; + if (resources.artifactModel === "legacy-rc-v1") { + const release = manualDisplay ? "" : "window.__fixtureGpt.release();"; + return `${comparisonSetup}${selectedTag}
`; + } + if (!resources.controllerDocument) + throw new Error("generated controller document is unavailable"); + const controlledSelectedTag = `${comparisonSetup}${selectedTag}`; + const withControlledGpt = resources.controllerDocument.replace( + selectedTag, + controlledSelectedTag, + ); + if (withControlledGpt === resources.controllerDocument) + throw new Error("generated controller selected tag is unavailable"); + if (manualDisplay) return withControlledGpt; + const released = withControlledGpt.replace( + "", + "", + ); + if (released === withControlledGpt) + throw new Error("generated controller body is unavailable"); + return released; +} + +async function installFixtureGpt( + context: BrowserContext, + performanceCase: PerformanceCase, +): Promise { + await context.addInitScript((selectedCase) => { + type Listener = (event: Record) => void; + type Slot = { + addService(service: object): Slot; + clearTargeting(key?: string): Slot; + getAdUnitPath(): string; + getSlotElementId(): string; + getTargeting(key: string): string[]; + setTargeting(key: string, value: string | string[]): Slot; + }; + const listeners = new Map>(); + const slots = new Map(); + const physical = new Set(); + const commands: Array<() => void> = []; + const calls: string[] = []; + let ready = true; + let displayCount = 0; + const markFirstObservableAction = () => { + if ( + performance.getEntriesByName("tsjs:first-observable-action").length === + 0 + ) { + performance.mark("tsjs:first-observable-action"); + } + }; + const createSlot = (id: string, path: string): Slot => { + const targeting = new Map(); + const slot: Slot = { + addService: () => slot, + clearTargeting: (key?: string) => { + calls.push(`clearTargeting:${key ?? "*"}`); + if (key === undefined) targeting.clear(); + else targeting.delete(key); + return slot; + }, + getAdUnitPath: () => path, + getSlotElementId: () => id, + getTargeting: (key: string) => { + calls.push(`getTargeting:${key}`); + return [...(targeting.get(key) ?? [])]; + }, + setTargeting: (key: string, value: string | string[]) => { + calls.push(`setTargeting:${key}`); + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }, + }; + slots.set(id, slot); + return slot; + }; + const emit = ( + name: string, + slot: Slot, + facts: Record = {}, + ) => { + for (const listener of listeners.get(name) ?? []) + listener({ slot, ...facts }); + }; + const startFictionalPuc = (slot: Slot): void => { + const adId = slot.getTargeting("hb_adid")[0]; + const root = document.getElementById(slot.getSlotElementId()); + if (!adId || !root) + throw new Error("fictional APS PUC input is unavailable"); + const frame = document.createElement("iframe"); + frame.dataset.fictionalPuc = ""; + frame.srcdoc = `
${options.tail ? `${options.tail}` : ""}`, + }), + ); + await page.goto(`https://runtime.test${options.path}`); + return { + deferredRequests, + routeHits, + runtimeRequests, + }; +} + +async function runtimeState(page: Page): Promise { + return page.evaluate( + () => + ( + window as unknown as { + tsjs?: { _internal?: { state?: string } }; + } + ).tsjs?._internal?.state, + ); +} + +async function releaseDeferredGate(page: Page): Promise { + await page.evaluate(async (slot) => { + const target = ( + window as unknown as { + tsjs: { + requestAds(options: { + slots: readonly string[]; + timeoutMs: number; + }): Promise; + }; + } + ).tsjs; + await target.requestAds({ slots: [slot], timeoutMs: 100 }); + }, POLICY_SLOT); +} + +for (const fixture of [ + { + name: "same-origin allowlisting", + path: "/policy-same-origin", + csp: "default-src 'none'; script-src 'self' 'unsafe-inline'", + }, + { + name: "matching nonce", + path: "/policy-matching-nonce", + csp: `default-src 'none'; script-src 'self' 'nonce-${NONCE}'`, + nonce: NONCE, + }, + { + name: "nonce-only", + path: "/policy-nonce-only", + csp: `default-src 'none'; script-src 'nonce-${NONCE}'`, + nonce: NONCE, + }, + { + name: "strict-dynamic", + path: "/policy-strict-dynamic", + csp: `default-src 'none'; script-src 'nonce-${NONCE}' 'strict-dynamic'`, + nonce: NONCE, + }, +] as const) { + test(`boots the authenticated direct runtime under ${fixture.name}`, async ({ + page, + }) => { + const requests = await servePolicyPage(page, fixture); + await expect.poll(() => runtimeState(page)).toBe("kernel"); + expect(requests.runtimeRequests).toHaveLength(1); + }); +} + +test("a full script policy block starts no TSJS execution or request", async ({ + page, +}) => { + const requests = await servePolicyPage(page, { + path: "/policy-blocked", + csp: "default-src 'none'; script-src 'none'", + }); + expect(await runtimeState(page)).toBeUndefined(); + expect(requests.routeHits.runtime).toBe(0); +}); + +for (const fixture of [ + { + name: "the allowed fixed Trusted Types policy", + path: "/policy-tt-named", + csp: `default-src 'none'; script-src 'nonce-${NONCE}'; require-trusted-types-for 'script'; trusted-types trusted-server#tsjs-v1`, + }, + { + name: "a rejected fixed name and exact-preserving publisher default", + path: "/policy-tt-default", + csp: `default-src 'none'; script-src 'nonce-${NONCE}'; require-trusted-types-for 'script'; trusted-types default`, + prelude: + 'trustedTypes.createPolicy("default",{createScriptURL:value=>value});', + }, +] as const) { + test(`loads a deferred module with ${fixture.name}`, async ({ + browserName, + page, + }) => { + test.skip( + browserName !== "chromium", + "Trusted Types enforcement is Chromium-only", + ); + const requests = await servePolicyPage(page, { + ...fixture, + nonce: NONCE, + deferred: true, + }); + await expect.poll(() => runtimeState(page)).toBe("kernel"); + await releaseDeferredGate(page); + await expect.poll(() => requests.deferredRequests.length).toBe(1); + await expect + .poll(() => page.locator("#ts-render-trace-panel").count()) + .toBe(1); + }); +} + +test("the first-display owner creates the fixed Trusted Types policy once and leases it to deferred loading", async ({ + browserName, + page, +}) => { + test.skip( + browserName !== "chromium", + "Trusted Types enforcement is Chromium-only", + ); + const requests = await servePolicyPage(page, { + path: "/policy-tt-takeover", + csp: `default-src 'none'; script-src 'nonce-${NONCE}' 'strict-dynamic'; require-trusted-types-for 'script'; trusted-types trusted-server#tsjs-v1`, + nonce: NONCE, + deferred: true, + takeover: true, + prelude: + 'window.__policyCreates=[];const __nativeCreatePolicy=trustedTypes.createPolicy.bind(trustedTypes);Object.defineProperty(trustedTypes,"createPolicy",{value:(name,rules)=>{if(window.__policyCreates.includes(name))throw new TypeError("duplicate TSJS policy");window.__policyCreates.push(name);return __nativeCreatePolicy(name,rules);}});', + }); + await expect.poll(() => runtimeState(page)).toBe("kernel"); + await releaseDeferredGate(page); + await expect.poll(() => requests.deferredRequests.length).toBe(1); + await expect + .poll(() => page.locator("#ts-render-trace-panel").count()) + .toBe(1); + expect(requests.routeHits.firstDisplay).toBe(1); + expect(requests.routeHits.runtime).toBe(1); + expect( + await page.evaluate( + () => + ( + window as unknown as { + __policyCreates: string[]; + } + ).__policyCreates, + ), + ).toEqual(["trusted-server#tsjs-v1"]); +}); + +for (const fixture of [ + { + name: "mutates the URL", + path: "/policy-tt-mutate", + prelude: + 'trustedTypes.createPolicy("default",{createScriptURL:value=>value+"&publisher=mutated"});', + }, + { + name: "throws synchronously", + path: "/policy-tt-throw", + prelude: + 'trustedTypes.createPolicy("default",{createScriptURL:()=>{throw new TypeError("publisher policy");}});', + }, +] as const) { + test(`a publisher default policy that ${fixture.name} blocks before insertion`, async ({ + browserName, + page, + }) => { + test.skip( + browserName !== "chromium", + "Trusted Types enforcement is Chromium-only", + ); + const requests = await servePolicyPage(page, { + ...fixture, + csp: `default-src 'none'; script-src 'nonce-${NONCE}'; require-trusted-types-for 'script'; trusted-types default`, + nonce: NONCE, + deferred: true, + }); + await expect.poll(() => runtimeState(page)).toBe("kernel"); + await releaseDeferredGate(page); + await page.waitForTimeout(150); + const probe = await page.evaluate( + () => + ( + window as unknown as { + __policyProbe: { insertions: number; removals: number }; + } + ).__policyProbe, + ); + expect(probe.insertions).toBe(0); + expect(requests.deferredRequests).toEqual([]); + expect(await page.locator("#ts-render-trace-panel").count()).toBe(0); + }); +} + +test("a missing propagated nonce blocks after insertion without replacing the kernel", async ({ + page, +}) => { + const requests = await servePolicyPage(page, { + path: "/policy-missing-deferred-nonce", + csp: `default-src 'none'; script-src 'nonce-${NONCE}'`, + nonce: NONCE, + deferred: true, + tail: 'document.querySelector("script#trustedserver-js").nonce="";', + }); + await expect.poll(() => runtimeState(page)).toBe("kernel"); + await releaseDeferredGate(page); + await page.waitForTimeout(150); + const insertions = await page.evaluate( + () => + ( + window as unknown as { + __policyProbe: { insertions: number }; + } + ).__policyProbe.insertions, + ); + expect(insertions).toBe(1); + expect(requests.routeHits.deferred).toBe(0); + expect(await page.locator("#ts-render-trace-panel").count()).toBe(0); + expect(await runtimeState(page)).toBe("kernel"); +}); + +test("removing the authenticated deferred node cannot register or replace the kernel", async ({ + page, +}) => { + const requests = await servePolicyPage(page, { + path: "/policy-deferred-replaced", + csp: "default-src 'none'; script-src 'self' 'unsafe-inline'", + deferred: true, + removeDeferred: true, + }); + await expect.poll(() => runtimeState(page)).toBe("kernel"); + await releaseDeferredGate(page); + await page.waitForTimeout(150); + const probe = await page.evaluate( + () => + ( + window as unknown as { + __policyProbe: { insertions: number; removals: number }; + } + ).__policyProbe, + ); + expect(probe).toEqual({ insertions: 1, removals: 1 }); + expect(requests.deferredRequests.length).toBeLessThanOrEqual(1); + expect(await page.locator("#ts-render-trace-panel").count()).toBe(0); + expect(await runtimeState(page)).toBe("kernel"); +}); diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts new file mode 100644 index 000000000..ee0436478 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts @@ -0,0 +1,672 @@ +import { expect, test, type Page } from "@playwright/test"; +import { installGptStub } from "../../helpers/gpt-stub.js"; +import { + firstDisplayTsjsFixture, + loadRuntimeTsjsFixture, + runtimeTsjsFixture, + serverBootTransportLiteralV1, + type RuntimeTsjsFixture, +} from "../../helpers/tsjs-fixture.js"; + +const KERNEL_FIXTURE = runtimeTsjsFixture(["render_runtime"]); +const FALLBACK_FIXTURE = runtimeTsjsFixture([]); +const FIRST_DISPLAY_SLOT = "first-display-owner-slot"; +const FIRST_DISPLAY_FIXTURE = firstDisplayTsjsFixture({ + firstDisplayIds: ["first_display", "render_owner_initial", "gpt_initial"], + takeoverIds: ["render_runtime", "gpt", "gpt_diagnostics"], + deferredIds: ["diagnostics_presentation"], + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: "browser-first-display-owner", + results: [ + { + slot: FIRST_DISPLAY_SLOT, + outcome: "winner", + candidateId: "AAAAAAAAAAAA", + }, + ], + }, + slots: [ + { + slot: FIRST_DISPLAY_SLOT, + gamUnitPath: `/123/${FIRST_DISPLAY_SLOT}`, + divId: FIRST_DISPLAY_SLOT, + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [ + { + candidateId: "AAAAAAAAAAAA", + slot: FIRST_DISPLAY_SLOT, + provider: "fictional", + upstreamBidId: "first-display-owner-bid", + cpm: 1.25, + currency: "USD", + targeting: { hb_bidder: "fictional" }, + rendererReservationId: "r1_AAAAAAAAAAAAAAAAAAAAAA", + renderSource: { + type: "adm", + version: 1, + adm: "
fictional first-display creative
", + width: 300, + height: 250, + }, + }, + ], + }, + integrations: { + version: 1, + entries: [ + { + id: "gpt", + config: { + gamAttributionEnabled: false, + pageBidsEnabled: true, + }, + }, + ], + }, + diagnostics: { + version: 1, + renderTraceOverlay: true, + gpt: { active: true }, + }, +}); + +function boot(fixture: RuntimeTsjsFixture) { + return { + abi: 1, + releaseId: fixture.releaseId, + manifest: fixture.manifest, + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: "browser-initial", results: [] }, + slots: [], + bids: [], + }, + integrations: { version: 1, entries: [] }, + creative: { + version: 1, + enabled: false, + clickGuard: false, + renderGuard: false, + }, + diagnostics: { + version: 1, + renderTraceOverlay: false, + gpt: { active: false }, + }, + }; +} + +async function waitForRuntime(page: Page, state: "kernel" | "fallback") { + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + tsjs?: { _internal?: { state?: string } }; + } + ).tsjs?._internal?.state, + ), + ) + .toBe(state); +} + +async function openRuntimePage(page: Page) { + await page.route("https://runtime.test/fixture", (route) => + route.fulfill({ + status: 200, + contentType: "text/html", + body: '
', + }), + ); + await page.goto("https://runtime.test/fixture"); +} + +async function installFirstDisplayResourceProbe(page: Page): Promise { + await page.addInitScript(() => { + const events: Array<{ kind: string; beforePaint: boolean }> = []; + const record = (kind: string): void => { + events.push({ + kind, + beforePaint: + performance.getEntriesByName("tsjs:first-display-paint", "mark") + .length === 0, + }); + }; + + const nativeCreateElement = Document.prototype.createElement; + Document.prototype.createElement = function ( + qualifiedName: string, + options?: ElementCreationOptions, + ): HTMLElement { + const normalized = String(qualifiedName).toLowerCase(); + if (normalized === "script" || normalized === "link") { + record(`create:${normalized}`); + } + return nativeCreateElement.call(this, qualifiedName, options); + }; + + const nativeFetch = window.fetch; + window.fetch = ((...arguments_: Parameters) => { + record("fetch"); + return Reflect.apply(nativeFetch, window, arguments_); + }) as typeof window.fetch; + + const nativeXhrOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (...arguments_: unknown[]): void { + record("xhr"); + Reflect.apply(nativeXhrOpen, this, arguments_); + } as typeof XMLHttpRequest.prototype.open; + + const wrapConstructor = (name: "Worker" | "SharedWorker"): void => { + const nativeConstructor = Reflect.get(window, name); + if (typeof nativeConstructor !== "function") return; + const wrapped = function (this: unknown, ...arguments_: unknown[]) { + record(name.toLowerCase()); + return Reflect.construct(nativeConstructor, arguments_, new.target); + }; + Object.setPrototypeOf(wrapped, nativeConstructor); + Object.defineProperty(wrapped, "prototype", { + value: nativeConstructor.prototype, + }); + Reflect.set(window, name, wrapped); + }; + wrapConstructor("Worker"); + wrapConstructor("SharedWorker"); + + const nativeCreateObjectUrl = URL.createObjectURL; + URL.createObjectURL = ((object: Blob | MediaSource): string => { + record("blob-url"); + return Reflect.apply(nativeCreateObjectUrl, URL, [object]) as string; + }) as typeof URL.createObjectURL; + + Object.defineProperty(window, "__firstDisplayResourceProbe", { + value: () => events.map((entry) => ({ ...entry })), + }); + }); +} + +test.describe("TSJS hard-cutover runtime", () => { + test("loads the render owner only inside one parser-blocking first-display request before paint", async ({ + page, + }) => { + await installGptStub(page); + await installFirstDisplayResourceProbe(page); + const firstDisplayUrl = new URL( + FIRST_DISPLAY_FIXTURE.firstDisplaySrc, + "https://runtime.test", + ).toString(); + const runtimeUrl = new URL( + FIRST_DISPLAY_FIXTURE.runtimeSrc, + "https://runtime.test", + ).toString(); + const deferredUrls = FIRST_DISPLAY_FIXTURE.deferred.map((resource) => + new URL(resource.src, "https://runtime.test").toString(), + ); + const firstDisplayRequests: string[] = []; + const allRequests: string[] = []; + const pageErrors: string[] = []; + const consoleMessages: string[] = []; + const deferredRegistrationAttack = `window.__deferredRegistrationAttack={attempted:false,prepareCalled:false,registrationAccepted:false};const __deferredRegistrationObserver=new MutationObserver(records=>{for(const record of records){for(const node of record.addedNodes){if(!(node instanceof HTMLScriptElement)||!node.src.includes("tsjs-diagnostics_presentation.min.js"))continue;__deferredRegistrationObserver.disconnect();window.__deferredRegistrationAttack.attempted=true;try{Object.defineProperty(document,"currentScript",{configurable:true,value:node});}catch{}const forged=Object.freeze({abi:1,id:"diagnostics_presentation",phase:"deferred",releaseId:${JSON.stringify(FIRST_DISPLAY_FIXTURE.releaseId)},prepare:()=>{window.__deferredRegistrationAttack.prepareCalled=true;return Object.freeze({activate:()=>undefined});}});window.__deferredRegistrationAttack.registrationAccepted=window.tsjs._registerIntegration(forged);}}});__deferredRegistrationObserver.observe(document,{childList:true,subtree:true});`; + page.on("request", (request) => allRequests.push(request.url())); + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("console", (message) => + consoleMessages.push(`${message.type()}: ${message.text()}`), + ); + await page.route(firstDisplayUrl, (route) => { + firstDisplayRequests.push(route.request().url()); + return route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + headers: { "x-content-type-options": "nosniff" }, + body: FIRST_DISPLAY_FIXTURE.firstDisplayBody, + }); + }); + await page.route(runtimeUrl, (route) => + route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + headers: { "x-content-type-options": "nosniff" }, + body: FIRST_DISPLAY_FIXTURE.runtimeBody, + }), + ); + for (const resource of FIRST_DISPLAY_FIXTURE.deferred) { + await page.route( + new URL(resource.src, "https://runtime.test").toString(), + (route) => + route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + headers: { "x-content-type-options": "nosniff" }, + body: resource.body, + }), + ); + } + await page.route("https://runtime.test/first-display-owner", (route) => + route.fulfill({ + status: 200, + contentType: "text/html; charset=utf-8", + body: `
`, + }), + ); + + await page.goto("https://runtime.test/first-display-owner"); + await expect + .poll(() => + page.evaluate( + () => + performance.getEntriesByName("tsjs:first-display-paint", "mark") + .length, + ), + ) + .toBe(1); + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + tsjs?: { _internal?: { state?: string } }; + } + ).tsjs?._internal?.state, + ), + ) + .toMatch(/^(?:kernel|fallback)$/u); + const runtimeDiagnostic = await page.evaluate(() => ({ + internal: ( + window as unknown as { + tsjs?: { _internal?: unknown }; + } + ).tsjs?._internal, + marks: performance + .getEntriesByType("mark") + .map((entry) => ({ name: entry.name, startTime: entry.startTime })), + frames: [...document.querySelectorAll("iframe")].map((frame) => ({ + connected: frame.isConnected, + parent: frame.parentElement?.id ?? null, + title: frame.title, + })), + })); + expect( + runtimeDiagnostic.internal, + JSON.stringify({ + ...runtimeDiagnostic, + allRequests, + consoleMessages, + firstDisplayRequests, + pageErrors, + }), + ).toMatchObject({ state: "kernel" }); + await expect + .poll(() => [...allRequests]) + .toEqual([ + "https://runtime.test/first-display-owner", + firstDisplayUrl, + runtimeUrl, + ...deferredUrls, + ]); + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + __deferredRegistrationAttack: { attempted: boolean }; + } + ).__deferredRegistrationAttack.attempted, + ), + ) + .toBe(true); + + const observation = await page.evaluate((slotId) => { + const browserWindow = window as unknown as { + __firstDisplayParserObservation: { + async: boolean; + defer: boolean; + firstAction: number; + }; + __firstDisplayResourceProbe(): Array<{ + kind: string; + beforePaint: boolean; + }>; + }; + return { + deferredRegistrationAttack: ( + browserWindow as unknown as { + __deferredRegistrationAttack: Readonly<{ + attempted: boolean; + prepareCalled: boolean; + registrationAccepted: boolean; + }>; + } + ).__deferredRegistrationAttack, + takeoverAttack: ( + browserWindow as unknown as { + __takeoverAttack: Readonly<{ + claimExposed: boolean; + currentScriptShadowed: boolean; + namespaceReplacementBlocked: boolean; + replacementBlocked: boolean; + }>; + } + ).__takeoverAttack, + parser: browserWindow.__firstDisplayParserObservation, + loaderCallsBeforePaint: browserWindow + .__firstDisplayResourceProbe() + .filter((entry) => entry.beforePaint) + .map((entry) => entry.kind), + loaderCallsAfterPaint: browserWindow + .__firstDisplayResourceProbe() + .filter((entry) => !entry.beforePaint) + .map((entry) => entry.kind), + firstDisplayScripts: document.querySelectorAll( + "script#trustedserver-js", + ).length, + creativeFrames: document.querySelectorAll( + `#${slotId} > iframe[title="Ad content"]`, + ).length, + tracePanels: document.querySelectorAll("#ts-render-trace-panel").length, + diagnosticRequests: ( + window as unknown as { + tsjs: { + diagnostics: { + gpt: { + snapshot(): { + slots: Array<{ + slotElementId?: string; + requests: Array>; + }>; + }; + }; + }; + }; + } + ).tsjs.diagnostics.gpt + .snapshot() + .slots.find((slot) => slot.slotElementId === slotId)?.requests, + }; + }, FIRST_DISPLAY_SLOT); + + expect(firstDisplayRequests).toEqual([firstDisplayUrl]); + expect( + allRequests.filter((url) => /tsjs-render_owner_initial/u.test(url)), + ).toEqual([]); + expect(observation.parser).toEqual({ + async: false, + defer: false, + firstAction: 1, + }); + expect(observation.takeoverAttack).toEqual({ + claimExposed: false, + currentScriptShadowed: true, + namespaceReplacementBlocked: true, + replacementBlocked: true, + }); + expect(observation.deferredRegistrationAttack).toEqual({ + attempted: true, + prepareCalled: false, + registrationAccepted: false, + }); + expect(observation.loaderCallsBeforePaint).toEqual([]); + expect(observation.loaderCallsAfterPaint).toEqual([ + "create:script", + "create:script", + ]); + expect(observation.firstDisplayScripts).toBe(1); + expect(observation.creativeFrames).toBe(1); + expect(observation.tracePanels).toBe(1); + expect(observation.diagnosticRequests).toEqual([ + expect.objectContaining({ + isEmpty: true, + requestedSlotSizes: [[300, 250]], + trustedServerAuctionId: "browser-first-display-owner", + trustedServerOpportunity: "renderable_candidate", + }), + ]); + }); + + test("generated bootstrap transfers one direct-runtime watchdog to the persistent owner", async ({ + page, + }) => { + const runtimeRequests: string[] = []; + const runtimeUrl = new URL( + KERNEL_FIXTURE.runtimeSrc, + "https://runtime.test", + ).toString(); + await page.route(runtimeUrl, (route) => { + runtimeRequests.push(route.request().url()); + return route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + headers: { "x-content-type-options": "nosniff" }, + body: `queueMicrotask(()=>window.__runtimeOrder.push("publisher-microtask"));${KERNEL_FIXTURE.runtimeBody}`, + }); + }); + await page.route("https://runtime.test/direct", (route) => + route.fulfill({ + status: 200, + contentType: "text/html; charset=utf-8", + body: ``, + }), + ); + + await page.goto("https://runtime.test/direct"); + await waitForRuntime(page, "kernel"); + + const observation = await page.evaluate(() => ({ + claimPresent: Object.prototype.hasOwnProperty.call( + (window as unknown as { tsjs: object }).tsjs, + "_claimDirectRuntime", + ), + runtimeScripts: document.querySelectorAll("script#trustedserver-js") + .length, + bidsMarks: performance.getEntriesByName("tsjs:bids-script", "mark") + .length, + order: ( + window as unknown as { __runtimeOrder: string[] } + ).__runtimeOrder.slice(), + state: (window as unknown as { tsjs: { _internal: { state: string } } }) + .tsjs._internal.state, + })); + + expect(runtimeRequests).toEqual([runtimeUrl]); + expect(observation).toEqual({ + claimPresent: false, + runtimeScripts: 1, + bidsMarks: 1, + order: [ + "kernel-commit", + "publisher-microtask", + "publisher-parser:kernel", + ], + state: "kernel", + }); + }); + + test("publishes only the kernel API and drains a hostile preload queue once", async ({ + page, + }) => { + await openRuntimePage(page); + await page.evaluate((initialBoot) => { + const browserWindow = window as unknown as { + queueOrder: string[]; + tsjs: Record & { que: Array<() => void> }; + }; + browserWindow.queueOrder = []; + const que = [ + () => { + browserWindow.queueOrder.push("first"); + browserWindow.tsjs.que.push(() => + browserWindow.queueOrder.push("nested"), + ); + }, + () => { + browserWindow.queueOrder.push("throw"); + throw new Error("publisher callback failure"); + }, + () => browserWindow.queueOrder.push("last"), + ]; + browserWindow.tsjs = { + boot: initialBoot, + que, + bids: { legacy: true }, + renderAdUnit() {}, + renderAllAdUnits() {}, + setConfig() {}, + getConfig() {}, + }; + }, boot(KERNEL_FIXTURE)); + + await loadRuntimeTsjsFixture(page, KERNEL_FIXTURE); + await waitForRuntime(page, "kernel"); + + const state = await page.evaluate(() => { + const api = (window as unknown as { tsjs: Record }).tsjs; + return { + names: Object.getOwnPropertyNames(api).sort(), + queueOrder: ( + window as unknown as { queueOrder: string[] } + ).queueOrder.slice(), + queueFrozen: Object.isFrozen(api.que), + bootFrozen: Object.isFrozen(api.boot), + releaseId: api.releaseId, + legacy: [ + "bids", + "renderAdUnit", + "renderAllAdUnits", + "setConfig", + "getConfig", + "adInit", + "renders", + "gptDiagnostics", + ].filter((name) => Object.prototype.hasOwnProperty.call(api, name)), + }; + }); + + expect(state.names).toEqual([ + "_internal", + "_registerIntegration", + "addAdUnits", + "boot", + "diagnostics", + "log", + "que", + "releaseId", + "requestAds", + "version", + ]); + expect(state.queueOrder).toEqual( + expect.arrayContaining(["first", "nested", "throw", "last"]), + ); + expect(new Set(state.queueOrder).size).toBe(4); + expect(state.queueFrozen).toBe(true); + expect(state.bootFrozen).toBe(true); + expect(state.releaseId).toBe(KERNEL_FIXTURE.releaseId); + expect(state.legacy).toEqual([]); + }); + + test("terminal fallback cannot be revived by a late integration bundle", async ({ + page, + }) => { + await openRuntimePage(page); + await page.evaluate((initialBoot) => { + const browserWindow = window as unknown as { + tsjs: Record; + fallbackEffects: { messageListeners: number; timeouts: number }; + }; + browserWindow.fallbackEffects = { messageListeners: 0, timeouts: 0 }; + const nativeAddEventListener = window.addEventListener.bind(window); + window.addEventListener = (( + type: string, + listener: EventListenerOrEventListenerObject, + ) => { + if (type === "message") + browserWindow.fallbackEffects.messageListeners += 1; + nativeAddEventListener(type, listener); + }) as typeof window.addEventListener; + const nativeSetTimeout = window.setTimeout.bind(window); + window.setTimeout = ((handler: TimerHandler, timeout?: number) => { + browserWindow.fallbackEffects.timeouts += 1; + return nativeSetTimeout(handler, timeout); + }) as typeof window.setTimeout; + browserWindow.tsjs = { + boot: initialBoot, + que: [], + }; + }, boot(FALLBACK_FIXTURE)); + + await loadRuntimeTsjsFixture(page, FALLBACK_FIXTURE); + await waitForRuntime(page, "fallback"); + const before = await page.evaluate(() => ({ + effects: { + ...( + window as unknown as { + fallbackEffects: { messageListeners: number; timeouts: number }; + } + ).fallbackEffects, + }, + names: Object.getOwnPropertyNames( + (window as unknown as { tsjs: object }).tsjs, + ).sort(), + })); + + await page.addScriptTag({ + content: "window.tsjs._registerIntegration({});", + }); + await page.evaluate(() => { + window.dispatchEvent( + new MessageEvent("message", { data: { message: "Prebid Request" } }), + ); + }); + await page.waitForTimeout(25); + + const after = await page.evaluate(() => { + const browserWindow = window as unknown as { + tsjs: { + _internal: { state: string; reason: string }; + _registerIntegration(value: unknown): boolean; + }; + fallbackEffects: { messageListeners: number; timeouts: number }; + googletag?: unknown; + }; + return { + internal: browserWindow.tsjs._internal, + registrationAccepted: browserWindow.tsjs._registerIntegration({}), + effects: { ...browserWindow.fallbackEffects }, + frames: document.querySelectorAll("iframe").length, + scripts: document.querySelectorAll("script").length, + hasGoogletag: Object.prototype.hasOwnProperty.call(window, "googletag"), + }; + }); + + expect(before.names).toEqual([ + "_internal", + "_registerIntegration", + "addAdUnits", + "boot", + "log", + "que", + "releaseId", + "requestAds", + "version", + ]); + expect(after.internal).toMatchObject({ + state: "fallback", + reason: "abi_mismatch", + }); + expect(after.registrationAccepted).toBe(false); + expect(after.effects.messageListeners).toBe( + before.effects.messageListeners, + ); + expect(after.effects.timeouts).toBe(before.effects.timeouts); + expect(after.frames).toBe(0); + expect(after.scripts).toBe(3); + expect(after.hasGoogletag).toBe(false); + }); +}); diff --git a/crates/trusted-server-integration-tests/fixtures/cloudflare/aps-runner-proxy-service.js b/crates/trusted-server-integration-tests/fixtures/cloudflare/aps-runner-proxy-service.js new file mode 100644 index 000000000..c2558deba --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/cloudflare/aps-runner-proxy-service.js @@ -0,0 +1,35 @@ +const APS_RUNNER_URL = + "https://client.aps.amazon-adsystem.com/prebid-creative.js"; +const FORBIDDEN_HEADERS = [ + "authorization", + "cookie", + "forwarded", + "referer", + "x-forwarded-for", + "x-publisher-secret", +]; + +export default { + async fetch(request, environment) { + const logicalUrl = request.headers.get("x-ts-aps-logical-url"); + const invalidRequest = + request.method !== "GET" || + request.url !== APS_RUNNER_URL || + request.headers.get("accept-encoding") !== "identity" || + logicalUrl !== APS_RUNNER_URL || + FORBIDDEN_HEADERS.some((name) => request.headers.has(name)); + + if (invalidRequest) { + return new Response(null, { status: 500 }); + } + + const headers = new Headers(); + headers.set("accept-encoding", "identity"); + headers.set("x-ts-aps-logical-url", logicalUrl); + return fetch(environment.APS_RUNNER_PROXY_TEST_ENDPOINT, { + method: "GET", + headers, + redirect: "manual", + }); + }, +}; diff --git a/crates/trusted-server-integration-tests/fixtures/configs/aps-real-gam.template.toml b/crates/trusted-server-integration-tests/fixtures/configs/aps-real-gam.template.toml new file mode 100644 index 000000000..f9e51ed82 --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/configs/aps-real-gam.template.toml @@ -0,0 +1,54 @@ +# Contract template for the protected real-GAM page. Values are deliberately +# fictional placeholders; actual GAM, APS, Prebid, PUC, URL, and authorization +# configuration exists only in the protected `aps-real-gam` environment. +schema_version = 1 +browser_contract_global = "__tsRealGamTestNetwork" +expected_external_puc_release = "1.17.2" + +[environment] +page_url = "__PROTECTED_HTTPS_PAGE_URL__" +authorization_header = "__PROTECTED_AUTHORIZATION_VALUE__" +expected_release_id = "__DEPLOYED_TSJS_RELEASE_ID__" + +[page_contract] +version = 1 +case_root_attribute = "data-ts-real-gam-case" +terminal_state_attribute = "data-ts-real-gam-state" +terminal_state_value = "terminal" +slot_attribute = "data-ts-real-gam-slot" +puc_owner_attribute = "data-ts-real-gam-owner" # value is the case id +owned_frame_attribute = "data-ts-real-gam-owned-frame" # value is the case id +screenshot_secret_mask_attribute = "data-ts-real-gam-secret" + +case_ids = [ + "ssat-aps-puc", + "trusted-server-prebid-aps-puc", + "page-bids-aps-puc", + "direct-aps", + "direct-adm", + "direct-cache", + "attributable-empty-gam-fallback", + "sra-aps-puc", + "refresh-aps-puc", + "spa-navigation", + "gpt-handoff", + "hydrated-dom-replacement", + "collapsed-shell-resize", + "wrong-id", + "wrong-source", + "invalid-descriptor", + "no-outer-claim", + "no-owner-registration", + "no-document-ack", + "aps-runner-failure", +] + +[evidence] +capture_response_bodies = false +capture_request_headers = false +capture_response_headers = false +capture_post_data = false +capture_native_trace = false +capture_har = false +capture_video = false +sanitized_trace_directory = "real-gam-evidence" diff --git a/crates/trusted-server-integration-tests/fixtures/configs/cloudflare-aps-runner-proxy-fixture.toml b/crates/trusted-server-integration-tests/fixtures/configs/cloudflare-aps-runner-proxy-fixture.toml new file mode 100644 index 000000000..2abfc29d8 --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/configs/cloudflare-aps-runner-proxy-fixture.toml @@ -0,0 +1,7 @@ +name = "aps-runner-proxy-fixture" +main = "../cloudflare/aps-runner-proxy-service.js" +compatibility_date = "2024-09-23" + +[vars] +# Replaced in a temporary copy by the integration-test controller. +APS_RUNNER_PROXY_TEST_ENDPOINT = "__APS_RUNNER_PROXY_TEST_ENDPOINT__" diff --git a/crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml b/crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml new file mode 100644 index 000000000..2bded070a --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml @@ -0,0 +1,32 @@ +spin_manifest_version = 2 + +[application] +name = "trusted-server-aps-runner-proxy-integration" +version = "0.1.0" + +[variables] +v_trusted_x5fserver_x5fconfig = { required = true } +aps_runner_proxy_test_endpoint = { required = true } +v_trusted_x5fserver_x5fsecrets_v_integration_x5fadmin_x5fpassword = { required = true, secret = true } +v_trusted_x5fserver_x5fsecrets_v_integration_x5fproxy_x5fsecret = { required = true, secret = true } +v_trusted_x5fserver_x5fsecrets_v_integration_x5fec_x5fpassphrase = { required = true, secret = true } +v_trusted_x5fserver_x5fsecrets_v_integration_x5fpartner_x5ftoken_x5falpha = { required = true, secret = true } +v_trusted_x5fserver_x5fsecrets_v_integration_x5fpartner_x5ftoken_x5fbravo = { required = true, secret = true } + +[[trigger.http]] +route = "/..." +component = "trusted-server" + +[component.trusted-server] +source = "__APS_RUNNER_PROXY_WASM__" +allowed_outbound_hosts = ["http://127.0.0.1:*"] +key_value_stores = ["default"] + +[component.trusted-server.variables] +v_trusted_x5fserver_x5fconfig = "{{ v_trusted_x5fserver_x5fconfig }}" +aps_runner_proxy_test_endpoint = "{{ aps_runner_proxy_test_endpoint }}" +v_trusted_x5fserver_x5fsecrets_v_integration_x5fadmin_x5fpassword = "{{ v_trusted_x5fserver_x5fsecrets_v_integration_x5fadmin_x5fpassword }}" +v_trusted_x5fserver_x5fsecrets_v_integration_x5fproxy_x5fsecret = "{{ v_trusted_x5fserver_x5fsecrets_v_integration_x5fproxy_x5fsecret }}" +v_trusted_x5fserver_x5fsecrets_v_integration_x5fec_x5fpassphrase = "{{ v_trusted_x5fserver_x5fsecrets_v_integration_x5fec_x5fpassphrase }}" +v_trusted_x5fserver_x5fsecrets_v_integration_x5fpartner_x5ftoken_x5falpha = "{{ v_trusted_x5fserver_x5fsecrets_v_integration_x5fpartner_x5ftoken_x5falpha }}" +v_trusted_x5fserver_x5fsecrets_v_integration_x5fpartner_x5ftoken_x5fbravo = "{{ v_trusted_x5fserver_x5fsecrets_v_integration_x5fpartner_x5ftoken_x5fbravo }}" diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index dae3f57cc..0cc3def6f 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -83,10 +83,8 @@ cache_ttl_seconds = 3600 rewrite_sdk = true [integrations.gpt] -enabled = false -gam_attribution_enabled = false +enabled = true script_url = "https://ads.example.com/gpt.js" -cache_ttl_seconds = 3600 rewrite_script = true [integrations.gpt_diagnostics] diff --git a/crates/trusted-server-integration-tests/src/bin/generate-tsjs-fixture.rs b/crates/trusted-server-integration-tests/src/bin/generate-tsjs-fixture.rs new file mode 100644 index 000000000..8b218a4f4 --- /dev/null +++ b/crates/trusted-server-integration-tests/src/bin/generate-tsjs-fixture.rs @@ -0,0 +1,199 @@ +use std::env; +use std::error::Error; +use std::fs; +use std::io::Write as _; +use std::path::PathBuf; + +use trusted_server_core::tsjs::{CreativeBootConfigV1, tsjs_bootstrap_fixture_fragment_v1}; + +type DynError = Box; +const PERFORMANCE_ORIGIN: &str = "https://performance.example"; + +#[derive(Debug, Eq, PartialEq)] +struct Args { + ids: Vec, + projection: PathBuf, +} + +fn main() -> Result<(), DynError> { + let args = parse_args(env::args().skip(1))?; + let html = run(&args)?; + let stdout = std::io::stdout(); + let mut output = stdout.lock(); + output.write_all(html.as_bytes())?; + Ok(()) +} + +fn run(args: &Args) -> Result { + let projection = fs::read_to_string(&args.projection).map_err(|error| { + error_box(format!( + "failed to read production projection '{}': {error}", + args.projection.display() + )) + })?; + let ids = args.ids.iter().map(String::as_str).collect::>(); + let controller = tsjs_bootstrap_fixture_fragment_v1( + &ids, + &projection, + CreativeBootConfigV1 { + enabled: true, + click_guard: ids.contains(&"creative"), + render_guard: false, + }, + true, + false, + PERFORMANCE_ORIGIN, + ) + .map_err(|error| { + error_box(format!( + "failed to build production TSJS fixture: {error:?}" + )) + })?; + Ok(format!( + "{controller}
" + )) +} + +fn parse_args(args: impl IntoIterator) -> Result { + let mut ids = None; + let mut projection = None; + let mut iter = args.into_iter(); + while let Some(argument) = iter.next() { + match argument.as_str() { + "--ids" => ids = Some(next_string_arg(&mut iter, "--ids")?), + "--projection" => { + projection = Some(PathBuf::from(next_string_arg(&mut iter, "--projection")?)); + } + "--help" | "-h" => return Err(error_box(usage())), + other => { + return Err(error_box(format!( + "unknown argument '{other}'\n\n{}", + usage() + ))); + } + } + } + + let ids = ids + .ok_or_else(|| error_box(format!("missing --ids\n\n{}", usage())))? + .split(',') + .map(ToOwned::to_owned) + .collect::>(); + if ids.iter().any(String::is_empty) { + return Err(error_box( + "--ids must be one comma-separated non-empty list", + )); + } + Ok(Args { + ids, + projection: projection + .ok_or_else(|| error_box(format!("missing --projection\n\n{}", usage())))?, + }) +} + +fn next_string_arg( + iter: &mut impl Iterator, + flag: &'static str, +) -> Result { + iter.next() + .ok_or_else(|| error_box(format!("{flag} requires a value"))) +} + +fn usage() -> String { + "usage: generate-tsjs-fixture --projection --ids " + .to_string() +} + +fn error_box(message: impl Into) -> DynError { + std::io::Error::other(message.into()).into() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + const CANONICAL_PROJECTION: &str = r#"{ + "version": 1, + "auction": { + "version": 1, + "auctionId": "performance-initial", + "results": [{ + "slot": "perf-slot", + "outcome": "winner", + "candidateId": "AAAAAAAAAAAA" + }] + }, + "slots": [{ + "slot": "perf-slot", + "gamUnitPath": "/123/performance", + "divId": "perf-slot", + "formats": [[300, 250]], + "targeting": {} + }], + "bids": [{ + "candidateId": "AAAAAAAAAAAA", + "slot": "perf-slot", + "provider": "trusted", + "upstreamBidId": "performance-upstream", + "cpm": 1, + "currency": "USD", + "targeting": {"hb_bidder": "trusted"}, + "rendererReservationId": "r1_aaaaaaaaaaaaaaaaaaaaaa", + "renderSource": { + "type": "adm", + "version": 1, + "adm": "
fictional performance creative
", + "width": 300, + "height": 250 + } + }] +}"#; + + #[test] + fn fixture_uses_the_size_admitted_agent_before_the_post_paint_runtime() { + let mut projection = NamedTempFile::new().expect("should create canonical projection"); + projection + .write_all(CANONICAL_PROJECTION.as_bytes()) + .expect("should write canonical projection"); + let args = Args { + ids: vec![ + "render_runtime".to_string(), + "creative".to_string(), + "gpt".to_string(), + "diagnostics_presentation".to_string(), + "gpt_later".to_string(), + ], + projection: projection.path().to_path_buf(), + }; + let html = run(&args).expect("should serialize an production TSJS fixture"); + + assert!(html.contains(r#"id="perf-slot""#)); + assert!(html.contains( + r#"\"creative\":{\"version\":1,\"enabled\":true,\"clickGuard\":true,\"renderGuard\":false}"# + )); + assert!(html.contains(r#"\"renderTraceOverlay\":true"#)); + assert!(html.contains( + r#"{\"id\":\"gpt\",\"config\":{\"gamAttributionEnabled\":false,\"pageBidsEnabled\":true}}"# + )); + assert!(html.contains(r#"\"id\":\"diagnostics_presentation\",\"phase\":\"deferred\""#)); + assert!(html.contains(r#"\"id\":\"gpt_later\",\"phase\":\"deferred\""#)); + assert!(html.contains( + r#"\"firstDisplay\":{\"src\":\"/static/tsjs=tsjs-first-display.min.js?m=008b\u0026v="# + )); + assert!(html.contains( + r#"\"slices\":[\"first_display\",\"render_owner_initial\",\"creative_initial\",\"gpt_initial\"]"# + )); + assert!(html.contains(r#"\"runtimeSrc\":\"/static/tsjs=tsjs-unified.min.js?v="#)); + assert_eq!(html.matches(", + enable_auction: bool, } fn main() -> Result<(), DynError> { @@ -37,7 +38,8 @@ fn run(args: &Args) -> Result<(), DynError> { )) })?; - let envelope_json = build_app_config_envelope(&app_config, args.origin_url.as_deref())?; + let envelope_json = + build_app_config_envelope(&app_config, args.origin_url.as_deref(), args.enable_auction)?; let generated_config = inject_generated_config_stores(&template, &envelope_json)?; if let Some(parent) = args.output.parent() { @@ -63,6 +65,7 @@ fn parse_args(args: impl IntoIterator) -> Result let mut app_config = None; let mut output = None; let mut origin_url = None; + let mut enable_auction = false; let mut iter = args.into_iter(); while let Some(arg) = iter.next() { @@ -71,6 +74,7 @@ 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")?), + "--enable-auction" => enable_auction = true, "--help" | "-h" => return Err(error_box(usage())), other => { return Err(error_box(format!( @@ -88,6 +92,7 @@ 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, + enable_auction, }) } @@ -107,12 +112,13 @@ 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 ] [--enable-auction]".to_string() } fn build_app_config_envelope( app_config_toml: &str, origin_url: Option<&str>, + enable_auction: bool, ) -> Result { let app_config: TrustedServerAppConfig = toml::from_str(app_config_toml) .map_err(|error| error_box(format!("invalid Trusted Server app config: {error}")))?; @@ -120,6 +126,9 @@ fn build_app_config_envelope( if let Some(origin_url) = origin_url { settings.publisher.origin_url = origin_url.to_string(); } + if enable_auction { + settings.auction.enabled = true; + } let app_config = TrustedServerAppConfig::new(settings) .map_err(|report| error_box(format!("invalid Trusted Server app config: {report:?}")))?; @@ -264,15 +273,50 @@ 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()), + enable_auction: false, }, "should parse expected args" ); } + #[test] + fn browser_generation_can_enable_the_configured_auction_plan() { + let args = 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(), + "--enable-auction".to_string(), + ]) + .expect("should parse browser auction opt-in"); + assert!(args.enable_auction); + + let envelope = build_app_config_envelope(APP_CONFIG, None, true) + .expect("should build browser envelope"); + let settings = settings_from_config_blob( + &envelope, + &integration_secret_store(), + &StoreName::from("trusted_server_secrets"), + ) + .expect("should verify browser envelope"); + + assert!(settings.auction.enabled); + assert!( + settings + .auction + .providers + .values() + .any(|provider| provider.profile == "aps") + ); + } + #[test] fn generated_config_contains_blob_without_removed_rollout_flags() { - let envelope = build_app_config_envelope(APP_CONFIG, None).expect("should build envelope"); + let envelope = + build_app_config_envelope(APP_CONFIG, None, false).expect("should build envelope"); let generated = inject_generated_config_stores(TEMPLATE, &envelope) .expect("should inject generated stores"); @@ -296,7 +340,8 @@ mod tests { #[test] fn generated_config_is_valid_toml() { - let envelope = build_app_config_envelope(APP_CONFIG, None).expect("should build envelope"); + let envelope = + build_app_config_envelope(APP_CONFIG, None, false).expect("should build envelope"); let generated = inject_generated_config_stores(TEMPLATE, &envelope) .expect("should inject generated stores"); let parsed: toml::Value = toml::from_str(&generated).expect("should parse as TOML"); @@ -312,7 +357,7 @@ mod tests { #[test] fn generated_blob_verifies_and_applies_origin_override() { - let envelope = build_app_config_envelope(APP_CONFIG, Some("http://127.0.0.1:9999")) + let envelope = build_app_config_envelope(APP_CONFIG, Some("http://127.0.0.1:9999"), false) .expect("should build envelope"); let settings = settings_from_config_blob( &envelope, @@ -329,7 +374,7 @@ mod tests { #[test] fn invalid_app_config_fails() { - let result = build_app_config_envelope("not valid toml", None); + let result = build_app_config_envelope("not valid toml", None, false); assert!(result.is_err(), "should reject invalid app config"); } @@ -338,7 +383,7 @@ mod tests { fn invalid_non_secret_app_config_fails_before_envelope_generation() { let invalid = APP_CONFIG.replace("domain = \"localhost\"", "domain = \"invalid/domain\""); - let err = build_app_config_envelope(&invalid, None) + let err = build_app_config_envelope(&invalid, None, false) .expect_err("should reject invalid non-secret config before creating an envelope"); assert!( diff --git a/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs b/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs new file mode 100644 index 000000000..aaa91747a --- /dev/null +++ b/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs @@ -0,0 +1,707 @@ +#![allow(dead_code, unused_imports)] + +mod common; +mod environments; + +use common::aps_runner_upstream::{ApsRunnerUpstream, FictionalResponse, ResponseWrite}; +use common::runtime::{RuntimeEnvironment, wasm_binary_path}; +use environments::spin::SpinRuntime; +use environments::{axum::AxumDevServer, cloudflare::CloudflareWorkers, fastly::FastlyViceroy}; +use reqwest::blocking::{Client, Response}; +use std::collections::BTreeSet; +use std::time::{Duration, Instant}; +use trusted_server_core::integrations::aps::{ + APS_RUNNER_MAX_RESPONSE_BYTES, APS_RUNNER_ROUTE, APS_RUNNER_UPSTREAM_URL, +}; + +const SUCCESS_HEADERS: [&str; 5] = [ + "access-control-allow-origin", + "content-type", + "cross-origin-resource-policy", + "referrer-policy", + "x-content-type-options", +]; + +// The platform policy owns an exact five-second dispatch-through-final-byte +// deadline. This black-box clock additionally observes downstream request +// dispatch, local error serialization, and response delivery, so retain a +// bounded allowance for work outside the transport window. +const DOWNSTREAM_DEADLINE_OBSERVATION_ALLOWANCE: Duration = Duration::from_millis(250); +const RENDERER_V2_PATH: &str = "/integrations/aps/renderer/v2"; +const CLOUDFLARE_READINESS_TIMEOUT: Duration = Duration::from_secs(30); +const RENDERER_V2_CSP: &str = "default-src 'none'; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' http: https:; connect-src http: https:; frame-src data: https:; img-src data: blob: http: https:; media-src blob: http: https:; style-src 'unsafe-inline' http: https:; font-src data: http: https:; worker-src blob: http: https:; frame-ancestors 'self'; form-action https:;"; +const RENDERER_V2_DOCUMENT: &str = include_str!( + "../../trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html" +); + +struct CorpusCase { + name: &'static str, + upstream: FictionalResponse, + expected_status: u16, + expected_body: Option>, + maximum_elapsed: Option, +} + +impl CorpusCase { + fn success(name: &'static str, upstream: FictionalResponse, body: Vec) -> Self { + Self { + name, + upstream, + expected_status: 200, + expected_body: Some(body), + maximum_elapsed: None, + } + } + + fn failure(name: &'static str, upstream: FictionalResponse) -> Self { + Self { + name, + upstream, + expected_status: 502, + expected_body: Some(Vec::new()), + maximum_elapsed: None, + } + } + + fn deadline(name: &'static str, upstream: FictionalResponse) -> Self { + Self { + maximum_elapsed: Some( + Duration::from_secs(5) + DOWNSTREAM_DEADLINE_OBSERVATION_ALLOWANCE, + ), + ..Self::failure(name, upstream) + } + } +} + +fn runtime_from_env() -> Box { + let runtime = std::env::var("APS_RUNNER_PROXY_RUNTIME") + .expect("should select one APS runner-proxy adapter runtime"); + let environment: Option> = match runtime.as_str() { + "axum" => Some(Box::new(AxumDevServer)), + "fastly" => Some(Box::new(FastlyViceroy)), + "cloudflare" => Some(Box::new(CloudflareWorkers)), + "spin" => Some(Box::new(SpinRuntime)), + _ => None, + }; + environment.expect("should select a known APS runner-proxy adapter runtime") +} + +fn fixed(status: &str, headers: &[(&str, &str)], body: impl AsRef<[u8]>) -> FictionalResponse { + FictionalResponse::fixed(status, headers, body) +} + +fn corpus(runtime_id: &str) -> Vec { + let exact_body = b"/* fictional runner: \xCE\xBB */".to_vec(); + let exact_length = exact_body.len().to_string(); + let cap_body = vec![b'x'; APS_RUNNER_MAX_RESPONSE_BYTES]; + let cap_length = cap_body.len().to_string(); + let one_over = vec![b'y'; APS_RUNNER_MAX_RESPONSE_BYTES + 1]; + let over_declared = (APS_RUNNER_MAX_RESPONSE_BYTES + 1).to_string(); + let slow_headers = b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: application/javascript\r\nTransfer-Encoding: chunked\r\n\r\n".to_vec(); + let mut slow_writes = vec![ResponseWrite::now(slow_headers)]; + for _ in 0..6 { + slow_writes.push(ResponseWrite::after( + Duration::from_millis(900), + b"1\r\nx\r\n".to_vec(), + )); + } + slow_writes.push(ResponseWrite::now(b"0\r\n\r\n".to_vec())); + let near_deadline_body = vec![b'n'; 24]; + let near_deadline_headers = format!( + "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\n\r\n", + near_deadline_body.len() + ) + .into_bytes(); + let mut near_deadline_writes = vec![ResponseWrite::now(near_deadline_headers)]; + for byte in &near_deadline_body { + near_deadline_writes.push(ResponseWrite::after( + Duration::from_millis(195), + vec![*byte], + )); + } + let over_total_headers = b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: application/javascript\r\nContent-Length: 26\r\n\r\n".to_vec(); + let mut over_total_writes = vec![ResponseWrite::now(over_total_headers)]; + for _ in 0..26 { + over_total_writes.push(ResponseWrite::after(Duration::from_millis(195), vec![b't'])); + } + + let mut cases = vec![ + CorpusCase::success( + "byte-preserving JavaScript with identity evidence", + fixed( + "200 OK", + &[ + ("Content-Type", "application/javascript"), + ("Content-Encoding", "identity"), + ("Content-Length", &exact_length), + ("Set-Cookie", "must-not-reach-browser=1"), + ("X-Fictional-Upstream", "must-be-dropped"), + ], + &exact_body, + ), + exact_body, + ), + CorpusCase::success( + "missing length and encoding", + fixed( + "200 OK", + &[("Content-Type", "text/javascript; charset=UTF-8")], + b"ok", + ), + b"ok".to_vec(), + ), + CorpusCase::failure( + "non-200 status", + fixed( + "204 No Content", + &[("Content-Type", "application/javascript")], + [], + ), + ), + CorpusCase::failure( + "redirect is not followed", + fixed( + "302 Found", + &[ + ("Content-Type", "application/javascript"), + ("Location", "https://example.invalid/runner.js"), + ], + b"redirect body", + ), + ), + CorpusCase::failure( + "missing content type", + fixed("200 OK", &[], b"ok"), + ), + CorpusCase::failure( + "duplicate content type", + fixed( + "200 OK", + &[ + ("Content-Type", "application/javascript"), + ("Content-Type", "text/javascript"), + ], + b"ok", + ), + ), + CorpusCase::failure( + "rejected content type", + fixed("200 OK", &[("Content-Type", "text/plain")], b"ok"), + ), + CorpusCase::failure( + "unknown content type parameter", + fixed( + "200 OK", + &[("Content-Type", "application/javascript; version=1")], + b"ok", + ), + ), + CorpusCase::failure( + "listed identity encoding", + fixed( + "200 OK", + &[ + ("Content-Type", "application/javascript"), + ("Content-Encoding", "identity, gzip"), + ], + b"ok", + ), + ), + CorpusCase::failure( + "non-identity encoding", + fixed( + "200 OK", + &[ + ("Content-Type", "application/javascript"), + ("Content-Encoding", "gzip"), + ], + [ + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xcb, 0xcf, + 0x06, 0x00, 0x47, 0xdd, 0xdc, 0x79, 0x02, 0x00, 0x00, 0x00, + ], + ), + ), + CorpusCase::failure( + "duplicate content length", + fixed( + "200 OK", + &[ + ("Content-Type", "application/javascript"), + ("Content-Length", "2"), + ("Content-Length", "2"), + ], + b"ok", + ), + ), + CorpusCase::failure( + "noncanonical content length", + fixed( + "200 OK", + &[ + ("Content-Type", "application/javascript"), + ("Content-Length", "02"), + ], + b"ok", + ), + ), + CorpusCase::failure( + "declared length mismatch", + fixed( + "200 OK", + &[ + ("Content-Type", "application/javascript"), + ("Content-Length", "3"), + ], + b"ok", + ), + ), + CorpusCase::failure( + "declared length over cap", + fixed( + "200 OK", + &[ + ("Content-Type", "application/javascript"), + ("Content-Length", &over_declared), + ], + [], + ), + ), + CorpusCase::failure( + "invalid UTF-8", + fixed( + "200 OK", + &[("Content-Type", "application/javascript")], + [0xff, 0xfe], + ), + ), + CorpusCase::success( + "exactly at the body cap", + fixed( + "200 OK", + &[ + ("Content-Type", "application/javascript"), + ("Content-Length", &cap_length), + ], + &cap_body, + ), + cap_body, + ), + CorpusCase::failure( + "buffered body one byte over cap", + fixed( + "200 OK", + &[("Content-Type", "application/javascript")], + &one_over, + ), + ), + CorpusCase::failure( + "streamed body one byte over cap", + FictionalResponse::chunked( + "200 OK", + &[("Content-Type", "application/javascript")], + vec![(Duration::ZERO, one_over)], + ), + ), + CorpusCase::success( + "sub-250ms drip completing after 4.5 seconds preserves the full deadline", + FictionalResponse::raw(near_deadline_writes), + near_deadline_body, + ), + CorpusCase::deadline( + "first-byte stall", + FictionalResponse::raw(vec![ResponseWrite::after( + Duration::from_millis(5_500), + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".to_vec(), + )]), + ), + CorpusCase::deadline( + "mid-body stall after partial chunk", + FictionalResponse::raw(vec![ + ResponseWrite::now( + b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: application/javascript\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nx\r\n".to_vec(), + ), + ResponseWrite::after(Duration::from_millis(5_500), b"0\r\n\r\n".to_vec()), + ]), + ), + CorpusCase::deadline( + "slow drip exceeds total deadline", + FictionalResponse::raw(slow_writes), + ), + CorpusCase::deadline( + "sub-250ms drip exceeds the true total deadline", + FictionalResponse::raw(over_total_writes), + ), + CorpusCase::success( + "late bytes cannot contaminate the next request", + fixed( + "200 OK", + &[ + ("Content-Type", "application/javascript"), + ("Content-Length", "4"), + ], + b"next", + ), + b"next".to_vec(), + ), + ]; + if matches!(runtime_id, "cloudflare" | "spin") { + cases.insert( + cases.len() - 1, + CorpusCase { + maximum_elapsed: Some( + Duration::from_secs(4) + Duration::from_millis(750), + ), + ..CorpusCase::failure( + "first-byte timeout is distinct from the total timeout", + FictionalResponse::raw(vec![ResponseWrite::after( + Duration::from_millis(4_500), + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".to_vec(), + )]), + ) + }, + ); + } + if runtime_id == "spin" { + cases.insert( + cases.len() - 1, + CorpusCase { + maximum_elapsed: Some(Duration::from_secs(1)), + ..CorpusCase::failure( + "Spin between-bytes timeout is distinct from the total timeout", + FictionalResponse::raw(vec![ + ResponseWrite::now( + b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: application/javascript\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nx\r\n".to_vec(), + ), + ResponseWrite::after( + Duration::from_millis(750), + b"0\r\n\r\n".to_vec(), + ), + ]), + ) + }, + ); + } + cases +} + +fn assert_outbound_request( + runtime_id: &str, + case_name: &str, + request: &common::aps_runner_upstream::ObservedRequest, +) { + assert_eq!( + request.request_line, "GET /prebid-creative.js HTTP/1.1", + "{case_name}: fixed upstream path and method" + ); + assert_eq!( + request.header_values("accept-encoding"), + vec!["identity"], + "{case_name}: exact identity request; observed {request:?}" + ); + assert_eq!( + request.header_values("x-ts-aps-logical-url"), + vec![APS_RUNNER_UPSTREAM_URL], + "{case_name}: transport seam must attest the fixed logical URL" + ); + if matches!(runtime_id, "axum" | "fastly") { + assert_eq!( + request.header_values("host"), + vec!["client.aps.amazon-adsystem.com"], + "{case_name}: transport must preserve the fixed logical APS authority" + ); + } else { + assert_eq!( + request.header_values("host").len(), + 1, + "{case_name}: runtime-owned transport authority must be singular" + ); + } + for forbidden in [ + "authorization", + "cookie", + "forwarded", + "referer", + "x-forwarded-for", + "x-publisher-secret", + ] { + assert!( + request.header_values(forbidden).is_empty(), + "{case_name}: `{forbidden}` must not reach the fictional upstream" + ); + } +} + +fn assert_success(case_name: &str, response: &Response) { + assert_eq!( + response.headers()["content-type"], + "application/javascript; charset=utf-8", + "{case_name}" + ); + assert_eq!( + response.headers()["access-control-allow-origin"], + "*", + "{case_name}" + ); + assert_eq!( + response.headers()["cross-origin-resource-policy"], + "cross-origin", + "{case_name}" + ); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + assert_eq!(response.headers()["referrer-policy"], "no-referrer"); + assert!(!response.headers().contains_key("set-cookie")); + assert!(!response.headers().contains_key("x-fictional-upstream")); + assert!(!response.headers().contains_key("x-geo-info-available")); + let semantic_headers: BTreeSet<&str> = response + .headers() + .keys() + .map(reqwest::header::HeaderName::as_str) + .filter(|name| { + !matches!( + *name, + "connection" | "content-length" | "date" | "server" | "transfer-encoding" + ) + }) + .collect(); + assert_eq!( + semantic_headers, + BTreeSet::from(SUCCESS_HEADERS), + "{case_name}: successful proxy application headers must be exact" + ); +} + +fn exact_renderer_method_response(response: Response) -> bool { + if response.status().as_u16() != 405 + || response.headers().get_all("allow").iter().count() != 1 + || response + .headers() + .get("allow") + .is_none_or(|value| value != "GET") + || response.headers().get_all("cache-control").iter().count() != 1 + || response + .headers() + .get("cache-control") + .is_none_or(|value| value != "no-store") + { + return false; + } + let semantic_headers: BTreeSet<&str> = response + .headers() + .keys() + .map(reqwest::header::HeaderName::as_str) + .filter(|name| { + !matches!( + *name, + "connection" | "content-length" | "date" | "server" | "transfer-encoding" + ) + }) + .collect(); + semantic_headers == BTreeSet::from(["allow", "cache-control"]) + && response.bytes().is_ok_and(|body| body.is_empty()) +} + +fn assert_exact_local_failure(response: Response, status: u16, allow_get: bool) { + assert_eq!( + response.status().as_u16(), + status, + "unexpected local failure response headers: {:?}", + response.headers() + ); + assert_eq!(response.headers()["cache-control"], "no-store"); + if allow_get { + assert_eq!(response.headers()["allow"], "GET"); + } else { + assert!(!response.headers().contains_key("allow")); + } + let semantic_headers: BTreeSet<&str> = response + .headers() + .keys() + .map(reqwest::header::HeaderName::as_str) + .filter(|name| { + !matches!( + *name, + "connection" | "content-length" | "date" | "server" | "transfer-encoding" + ) + }) + .collect(); + let expected = if allow_get { + BTreeSet::from(["allow", "cache-control"]) + } else { + BTreeSet::from(["cache-control"]) + }; + assert_eq!(semantic_headers, expected); + assert!( + response + .bytes() + .expect("local failure body should be readable") + .is_empty() + ); +} + +fn wait_for_cloudflare_renderer_readiness(client: &Client, base_url: &str) { + let deadline = Instant::now() + CLOUDFLARE_READINESS_TIMEOUT; + let mut consecutive = 0_u8; + while consecutive < 2 { + let exact = client + .request( + reqwest::Method::from_bytes(b"PROPFIND").expect("PROPFIND should be valid"), + format!("{base_url}{RENDERER_V2_PATH}"), + ) + .send() + .is_ok_and(exact_renderer_method_response); + consecutive = if exact { consecutive + 1 } else { 0 }; + if consecutive == 2 { + return; + } + assert!( + Instant::now() < deadline, + "Cloudflare renderer route did not produce two consecutive exact readiness responses" + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +#[test] +#[ignore = "requires a feature-gated adapter artifact and its local runtime"] +fn actual_adapter_proxy_corpus() { + let _ = env_logger::try_init(); + let fixture = ApsRunnerUpstream::start().expect("should start fictional APS upstream"); + let runtime = runtime_from_env(); + let runtime_id = runtime.id(); + let process = runtime + .spawn_aps_runner_proxy(&wasm_binary_path(), &fixture.endpoint_url()) + .expect("should spawn APS runner proxy artifact"); + let client = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + // This is only a downstream dead-test guard. Deadline corpus cases + // retain their independent five-second transport assertion plus the + // bounded black-box observation allowance above. + // Leave enough headroom for an 8 MiB boundary response through local + // wasm runtimes on a loaded CI worker. + .timeout(Duration::from_secs(30)) + .build() + .expect("should build downstream client"); + + if runtime_id == "cloudflare" { + wait_for_cloudflare_renderer_readiness(&client, &process.base_url); + } + + // Independent corpus assertion: readiness never substitutes for endpoint acceptance. + let response = client + .request( + reqwest::Method::from_bytes(b"PROPFIND").expect("PROPFIND should be valid"), + format!("{}{}", process.base_url, RENDERER_V2_PATH), + ) + .header("authorization", "Bearer must-not-reach-publisher") + .send() + .expect("PROPFIND reserved request should complete"); + assert_exact_local_failure(response, 405, true); + + let renderer = client + .get(format!("{}{}", process.base_url, RENDERER_V2_PATH)) + .send() + .expect("renderer request should complete"); + assert_eq!(renderer.status().as_u16(), 200); + assert_eq!( + renderer.headers()["content-type"], + "text/html; charset=utf-8" + ); + assert_eq!( + renderer.headers()["cache-control"], + "public, max-age=31536000, immutable" + ); + assert_eq!(renderer.headers()["x-content-type-options"], "nosniff"); + assert_eq!(renderer.headers()["referrer-policy"], "no-referrer"); + assert_eq!( + renderer.headers()["content-security-policy"], + RENDERER_V2_CSP + ); + assert!(!renderer.headers().contains_key("x-frame-options")); + let semantic_headers: BTreeSet<&str> = renderer + .headers() + .keys() + .map(reqwest::header::HeaderName::as_str) + .filter(|name| { + !matches!( + *name, + "connection" | "content-length" | "date" | "server" | "transfer-encoding" + ) + }) + .collect(); + assert_eq!( + semantic_headers, + BTreeSet::from([ + "cache-control", + "content-security-policy", + "content-type", + "referrer-policy", + "x-content-type-options", + ]) + ); + assert_eq!( + renderer + .bytes() + .expect("renderer body should be readable") + .as_ref(), + RENDERER_V2_DOCUMENT.as_bytes() + ); + + for path in [ + "/integrations/aps/renderer/v1", + "/integrations/aps/runner/v1.js", + ] { + let response = client + .get(format!("{}{path}", process.base_url)) + .send() + .expect("unknown APS route request should complete"); + assert_exact_local_failure(response, 404, false); + } + fixture.assert_no_proxy_observation(0, Duration::from_millis(150)); + + for (observation_index, case) in corpus(runtime_id).into_iter().enumerate() { + fixture.enqueue(case.upstream); + let started = Instant::now(); + let response = client + .get(format!("{}{}", process.base_url, APS_RUNNER_ROUTE)) + .header("authorization", "Bearer must-not-leave-downstream") + .header("cookie", "must-not-leave-downstream=1") + .header("x-forwarded-for", "203.0.113.19") + .header("x-publisher-secret", "must-not-leave-downstream") + .send() + .expect("should receive an APS runner-proxy downstream response"); + let elapsed = started.elapsed(); + assert_eq!( + response.status().as_u16(), + case.expected_status, + "{}", + case.name + ); + if case.expected_status == 200 { + assert_success(case.name, &response); + } else { + assert_eq!( + response.headers()["cache-control"], + "no-store", + "{}", + case.name + ); + } + let body = response + .bytes() + .expect("should read the APS runner-proxy downstream response body"); + if let Some(expected) = case.expected_body { + assert_eq!(body.as_ref(), expected, "{}", case.name); + } + if let Some(maximum) = case.maximum_elapsed { + assert!( + elapsed <= maximum, + "{}: deadline returned after {elapsed:?}, expected <= {maximum:?}", + case.name + ); + } + let observed = fixture + .wait_for_observation(observation_index) + .expect("should observe the APS runner-proxy upstream request"); + assert_outbound_request(runtime_id, case.name, &observed); + } +} diff --git a/crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs b/crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs new file mode 100644 index 000000000..d046861c5 --- /dev/null +++ b/crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs @@ -0,0 +1,297 @@ +use crate::common::runtime::{TestError, TestResult}; +use error_stack::{Report, ResultExt as _}; +use std::collections::VecDeque; +use std::io::{Read as _, Write as _}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +const MAX_REQUEST_HEAD_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone)] +pub struct ResponseWrite { + delay_before: Duration, + bytes: Vec, +} + +impl ResponseWrite { + #[must_use] + pub fn now(bytes: impl Into>) -> Self { + Self { + delay_before: Duration::ZERO, + bytes: bytes.into(), + } + } + + #[must_use] + pub fn after(delay_before: Duration, bytes: impl Into>) -> Self { + Self { + delay_before, + bytes: bytes.into(), + } + } +} + +#[derive(Debug, Clone)] +pub struct FictionalResponse { + writes: Vec, +} + +impl FictionalResponse { + #[must_use] + pub fn raw(writes: Vec) -> Self { + Self { writes } + } + + #[must_use] + pub fn fixed(status: &str, headers: &[(&str, &str)], body: impl AsRef<[u8]>) -> Self { + let body = body.as_ref(); + let mut response = format!("HTTP/1.1 {status}\r\nConnection: close\r\n").into_bytes(); + for (name, value) in headers { + response.extend_from_slice(name.as_bytes()); + response.extend_from_slice(b": "); + response.extend_from_slice(value.as_bytes()); + response.extend_from_slice(b"\r\n"); + } + response.extend_from_slice(b"\r\n"); + response.extend_from_slice(body); + Self::raw(vec![ResponseWrite::now(response)]) + } + + #[must_use] + pub fn chunked( + status: &str, + headers: &[(&str, &str)], + chunks: Vec<(Duration, Vec)>, + ) -> Self { + let mut head = + format!("HTTP/1.1 {status}\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n") + .into_bytes(); + for (name, value) in headers { + head.extend_from_slice(name.as_bytes()); + head.extend_from_slice(b": "); + head.extend_from_slice(value.as_bytes()); + head.extend_from_slice(b"\r\n"); + } + head.extend_from_slice(b"\r\n"); + let mut writes = vec![ResponseWrite::now(head)]; + for (delay, chunk) in chunks { + let mut framed = format!("{:x}\r\n", chunk.len()).into_bytes(); + framed.extend_from_slice(&chunk); + framed.extend_from_slice(b"\r\n"); + writes.push(ResponseWrite::after(delay, framed)); + } + writes.push(ResponseWrite::now(b"0\r\n\r\n".to_vec())); + Self::raw(writes) + } +} + +#[derive(Debug, Clone)] +pub struct ObservedRequest { + pub request_line: String, + headers: Vec<(String, String)>, +} + +impl ObservedRequest { + #[must_use] + pub fn header_values(&self, name: &str) -> Vec<&str> { + self.headers + .iter() + .filter_map(|(candidate, value)| { + candidate + .eq_ignore_ascii_case(name) + .then_some(value.as_str()) + }) + .collect() + } +} + +#[derive(Debug, Default)] +struct FixtureState { + plans: VecDeque, + observations: Vec, + stopping: bool, +} + +/// Loopback-only fictional APS upstream controlled through in-process state. +/// +/// There is deliberately no HTTP control route: the browser-facing request +/// cannot select a response plan or change the transport target. +pub struct ApsRunnerUpstream { + address: SocketAddr, + state: Arc<(Mutex, Condvar)>, + accept_thread: Option>, +} + +impl ApsRunnerUpstream { + pub fn start() -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0") + .change_context(TestError::RuntimeSpawn) + .attach("failed to bind fictional APS runner upstream")?; + let address = listener + .local_addr() + .change_context(TestError::RuntimeSpawn)?; + let state = Arc::new((Mutex::new(FixtureState::default()), Condvar::new())); + let server_state = Arc::clone(&state); + let accept_thread = thread::spawn(move || { + for incoming in listener.incoming() { + let Ok(stream) = incoming else { + break; + }; + let state = Arc::clone(&server_state); + thread::spawn(move || serve_one(stream, &state)); + let stopping = server_state + .0 + .lock() + .expect("fixture state should not be poisoned") + .stopping; + if stopping { + break; + } + } + }); + Ok(Self { + address, + state, + accept_thread: Some(accept_thread), + }) + } + + #[must_use] + pub fn endpoint_url(&self) -> String { + format!("http://{}/prebid-creative.js", self.address) + } + + pub fn enqueue(&self, response: FictionalResponse) { + let mut state = self + .state + .0 + .lock() + .expect("fixture state should not be poisoned"); + state.plans.push_back(response); + } + + pub fn wait_for_observation(&self, previous_count: usize) -> TestResult { + let deadline = Instant::now() + Duration::from_secs(2); + let (lock, changed) = &*self.state; + let mut state = lock.lock().expect("fixture state should not be poisoned"); + while proxy_observations(&state).count() <= previous_count { + let now = Instant::now(); + if now >= deadline { + return Err(Report::new(TestError::RuntimeNotReady) + .attach("fictional APS runner upstream did not observe the request")); + } + let result = changed + .wait_timeout(state, deadline - now) + .expect("fixture state should not be poisoned"); + state = result.0; + } + Ok(proxy_observations(&state) + .nth(previous_count) + .expect("proxy observation count was checked") + .clone()) + } + + pub fn assert_no_proxy_observation(&self, previous_count: usize, duration: Duration) { + let deadline = Instant::now() + duration; + let (lock, changed) = &*self.state; + let mut state = lock.lock().expect("fixture state should not be poisoned"); + while Instant::now() < deadline && proxy_observations(&state).count() <= previous_count { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + state = changed + .wait_timeout(state, remaining) + .expect("fixture state should not be poisoned") + .0; + } + assert_eq!( + proxy_observations(&state).count(), + previous_count, + "reserved non-GET request must not reach the APS upstream" + ); + } +} + +fn proxy_observations(state: &FixtureState) -> impl Iterator { + state + .observations + .iter() + .filter(|request| !request.header_values("x-ts-aps-logical-url").is_empty()) +} + +impl Drop for ApsRunnerUpstream { + fn drop(&mut self) { + self.state + .0 + .lock() + .expect("fixture state should not be poisoned") + .stopping = true; + let _ = TcpStream::connect(self.address); + if let Some(handle) = self.accept_thread.take() { + let _ = handle.join(); + } + } +} + +fn serve_one(mut stream: TcpStream, state: &Arc<(Mutex, Condvar)>) { + let Ok(observation) = read_request(&mut stream) else { + return; + }; + let response = { + let (lock, changed) = &**state; + let mut state = lock.lock().expect("fixture state should not be poisoned"); + state.observations.push(observation); + changed.notify_all(); + state.plans.pop_front() + }; + let Some(response) = response else { + let _ = stream.write_all( + b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + return; + }; + for write in response.writes { + if !write.delay_before.is_zero() { + thread::sleep(write.delay_before); + } + if stream.write_all(&write.bytes).is_err() { + break; + } + let _ = stream.flush(); + } +} + +fn read_request(stream: &mut TcpStream) -> std::io::Result { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut bytes = Vec::new(); + let mut chunk = [0_u8; 1024]; + while !bytes.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut chunk)?; + if read == 0 { + break; + } + bytes.extend_from_slice(&chunk[..read]); + if bytes.len() > MAX_REQUEST_HEAD_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "request head exceeded fixture cap", + )); + } + } + let head = std::str::from_utf8(&bytes) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "non-UTF-8 request"))?; + let mut lines = head.split("\r\n"); + let request_line = lines.next().unwrap_or_default().to_string(); + let headers = lines + .take_while(|line| !line.is_empty()) + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.trim().to_string(), value.trim().to_string())) + .collect(); + Ok(ObservedRequest { + request_line, + headers, + }) +} diff --git a/crates/trusted-server-integration-tests/tests/common/assertions.rs b/crates/trusted-server-integration-tests/tests/common/assertions.rs index 2b739edae..50ef7a684 100644 --- a/crates/trusted-server-integration-tests/tests/common/assertions.rs +++ b/crates/trusted-server-integration-tests/tests/common/assertions.rs @@ -76,6 +76,49 @@ pub fn assert_unique_script_tag(html: &str) -> TestResult<()> { Ok(()) } +/// Return the exact content-addressed TSJS URL injected into publisher HTML. +/// +/// The hard cutover intentionally has no unversioned transport alias, so the +/// integration corpus must discover and validate the release URL produced by +/// the server instead of constructing a stale filename itself. +/// +/// # Errors +/// +/// Returns an error when the script is missing, duplicated, or does not use +/// the canonical relative unified path plus one lowercase SHA-256 query value. +pub fn trustedserver_script_src(html: &str) -> TestResult { + const PREFIX: &str = "/static/tsjs=tsjs-unified.min.js?v="; + + let document = Html::parse_document(html); + let selector = parse_selector("script#trustedserver-js")?; + let scripts = document.select(&selector).collect::>(); + let [script] = scripts.as_slice() else { + return Err(if scripts.is_empty() { + Report::new(TestError::ScriptTagNotFound) + } else { + Report::new(TestError::DuplicateScriptTag) + }); + }; + let src = script + .value() + .attr("src") + .ok_or_else(|| Report::new(TestError::ScriptTagNotFound))?; + let hash = src.strip_prefix(PREFIX).ok_or_else(|| { + Report::new(TestError::UnexpectedContent) + .attach("trustedserver-js must use the content-addressed unified route") + })?; + if hash.len() != 64 + || !hash + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(Report::new(TestError::UnexpectedContent) + .attach("trustedserver-js release hash must be lowercase SHA-256")); + } + + Ok(src.to_owned()) +} + /// Assert that origin host URLs in `href`/`src` attributes have been rewritten. /// /// Checks that the proxied HTML no longer contains the origin host in `href` @@ -317,6 +360,29 @@ mod tests { assert_script_tag_present(html).expect("should find trustedserver-js script tag"); } + #[test] + fn content_addressed_script_src_returns_the_injected_release_url() { + let hash = "a".repeat(64); + let html = format!( + r#""# + ); + + assert_eq!( + trustedserver_script_src(&html).expect("should read the canonical injected URL"), + format!("/static/tsjs=tsjs-unified.min.js?v={hash}") + ); + } + + #[test] + fn content_addressed_script_src_rejects_the_removed_unversioned_alias() { + let html = r#""#; + + assert!( + trustedserver_script_src(html).is_err(), + "the integration corpus must not resurrect the removed unversioned route" + ); + } + #[test] fn script_tag_present_fails_when_no_script() { let html = r#" diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index 037fa4658..4dfb4b6cf 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -7,7 +7,7 @@ use crate::common::runtime::{TestError, TestResult}; 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 { +fn app_config_envelope(origin_port: u16, aps_proxy_fixture: bool) -> TestResult { let origin_url = format!("http://127.0.0.1:{origin_port}"); let app_config: TrustedServerAppConfig = toml::from_str(APP_CONFIG).map_err(|error| { Report::new(TestError::ConfigGeneration).attach(format!( @@ -16,6 +16,15 @@ pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { })?; let mut settings = app_config.into_settings(); settings.publisher.origin_url = origin_url; + if aps_proxy_fixture { + settings.auction.enabled = true; + settings + .auction + .providers + .retain(|_, provider| provider.profile == "aps"); + settings.auction.bidders.clear(); + settings.auction.mediator = None; + } let app_config = TrustedServerAppConfig::new(settings).map_err(|report| { Report::new(TestError::ConfigGeneration) .attach(format!("invalid generated integration config: {report:?}")) @@ -33,6 +42,15 @@ pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { }) } +pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { + app_config_envelope(origin_port, false) +} + +#[cfg(feature = "aps-runner-proxy")] +pub fn aps_runner_proxy_app_config_envelope(origin_port: u16) -> TestResult { + app_config_envelope(origin_port, true) +} + pub fn cloudflare_config_json(origin_port: u16) -> TestResult { let envelope = integration_app_config_envelope(origin_port)?; serde_json::to_string(&serde_json::json!({ "app_config": envelope })).map_err(|error| { @@ -42,10 +60,56 @@ pub fn cloudflare_config_json(origin_port: u16) -> TestResult { }) } +#[cfg(feature = "aps-runner-proxy")] +pub fn cloudflare_aps_runner_proxy_config_json(origin_port: u16) -> TestResult { + let envelope = aps_runner_proxy_app_config_envelope(origin_port)?; + serde_json::to_string(&serde_json::json!({ "app_config": envelope })).map_err(|error| { + Report::new(TestError::ConfigGeneration).attach(format!( + "failed to serialize Cloudflare APS proxy config binding: {error}" + )) + }) +} + #[cfg(test)] mod tests { + #[cfg(feature = "aps-runner-proxy")] + use super::{aps_runner_proxy_app_config_envelope, integration_app_config_envelope}; const FASTLY_CONFIG: &str = include_str!("../../../../fastly.toml"); + #[cfg(feature = "aps-runner-proxy")] + #[test] + fn aps_proxy_envelope_enables_only_its_auction_fixture() { + let regular: serde_json::Value = serde_json::from_str( + &integration_app_config_envelope(8888).expect("should build regular fixture envelope"), + ) + .expect("should parse regular fixture envelope"); + let aps_proxy: serde_json::Value = serde_json::from_str( + &aps_runner_proxy_app_config_envelope(8888) + .expect("should build APS proxy fixture envelope"), + ) + .expect("should parse APS proxy fixture envelope"); + + assert_eq!(regular["data"]["auction"]["enabled"], false); + assert_eq!(aps_proxy["data"]["auction"]["enabled"], true); + assert_eq!( + aps_proxy["data"]["auction"]["providers"] + .as_object() + .expect("proxy providers should be an object") + .keys() + .collect::>(), + ["aps-main"] + ); + assert_eq!( + aps_proxy["data"]["auction"]["bidders"], + serde_json::json!({}) + ); + assert_eq!( + regular["data"]["auction"]["providers"]["aps-main"], + aps_proxy["data"]["auction"]["providers"]["aps-main"], + "the proxy fixture should preserve the canonical APS provider configuration" + ); + } + #[test] fn local_fastly_config_defines_runtime_kv_stores() { let parsed: toml::Value = diff --git a/crates/trusted-server-integration-tests/tests/common/mod.rs b/crates/trusted-server-integration-tests/tests/common/mod.rs index f5a4b578e..3e460100a 100644 --- a/crates/trusted-server-integration-tests/tests/common/mod.rs +++ b/crates/trusted-server-integration-tests/tests/common/mod.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "aps-runner-proxy")] +pub mod aps_runner_upstream; pub mod assertions; pub mod config; pub mod ec; diff --git a/crates/trusted-server-integration-tests/tests/common/runtime.rs b/crates/trusted-server-integration-tests/tests/common/runtime.rs index 048c76d44..6ee32a222 100644 --- a/crates/trusted-server-integration-tests/tests/common/runtime.rs +++ b/crates/trusted-server-integration-tests/tests/common/runtime.rs @@ -97,6 +97,21 @@ pub trait RuntimeEnvironment: Send + Sync { /// Returns [`TestError::RuntimeNotReady`] if the health check times out. fn spawn(&self, wasm_path: &Path) -> TestResult; + /// Spawn the dedicated APS runner-proxy integration artifact. + /// + /// `fixture_url` is selected by the private test controller, never an + /// incoming browser request. Implementations must pass it only through + /// their feature-gated transport seam. + #[cfg(feature = "aps-runner-proxy")] + fn spawn_aps_runner_proxy( + &self, + _wasm_path: &Path, + _fixture_url: &str, + ) -> TestResult { + Err(Report::new(TestError::RuntimeSpawn) + .attach("runtime does not implement the APS runner proxy test artifact")) + } + /// Health check endpoint (may differ by platform) fn health_check_path(&self) -> &str { "/health" diff --git a/crates/trusted-server-integration-tests/tests/environments/axum.rs b/crates/trusted-server-integration-tests/tests/environments/axum.rs index 3623d8491..3de90684d 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -1,9 +1,13 @@ +#[cfg(feature = "aps-runner-proxy")] +use crate::common::config::aps_runner_proxy_app_config_envelope; use crate::common::config::integration_app_config_envelope; use crate::common::runtime::{ RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, origin_port, }; use error_stack::ResultExt as _; use std::io::{BufRead as _, BufReader}; +#[cfg(unix)] +use std::os::unix::process::CommandExt as _; use std::path::Path; use std::process::{Child, Command, Stdio}; @@ -53,18 +57,49 @@ impl RuntimeEnvironment for AxumDevServer { } fn spawn(&self, _wasm_path: &Path) -> TestResult { + self.spawn_inner(None) + } + + #[cfg(feature = "aps-runner-proxy")] + fn spawn_aps_runner_proxy( + &self, + _wasm_path: &Path, + fixture_url: &str, + ) -> TestResult { + self.spawn_inner(Some(fixture_url)) + } + + fn health_check_path(&self) -> &str { + "/health" + } +} + +impl AxumDevServer { + fn spawn_inner(&self, aps_runner_fixture_url: Option<&str>) -> TestResult { let binary = self.binary_path(); let port = super::find_available_port().unwrap_or(AXUM_DEFAULT_PORT); + #[cfg(feature = "aps-runner-proxy")] + let app_config = if aps_runner_fixture_url.is_some() { + aps_runner_proxy_app_config_envelope(origin_port())? + } else { + integration_app_config_envelope(origin_port())? + }; + #[cfg(not(feature = "aps-runner-proxy"))] let app_config = integration_app_config_envelope(origin_port())?; - let mut child = Command::new(&binary) - .env("PORT", port.to_string()) - .env( - "TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG", - app_config, - ) - .envs(INTEGRATION_SECRET_ENV.iter().copied()) + let mut command = Command::new(&binary); + command.env("PORT", port.to_string()).env( + "TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG", + app_config, + ); + command.envs(INTEGRATION_SECRET_ENV.iter().copied()); + if let Some(fixture_url) = aps_runner_fixture_url { + command.env("TS_APS_RUNNER_PROXY_TEST_ENDPOINT", fixture_url); + } + #[cfg(unix)] + command.process_group(0); + let mut child = command .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() @@ -73,6 +108,7 @@ impl RuntimeEnvironment for AxumDevServer { "Failed to spawn trusted-server-axum binary at {}", binary.display() ))?; + super::register_process_group(&mut child)?; if let Some(stderr) = child.stderr.take() { std::thread::spawn(move || { @@ -97,13 +133,6 @@ impl RuntimeEnvironment for AxumDevServer { base_url, }) } - - 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. @@ -149,6 +178,11 @@ impl RuntimeProcessHandle for AxumHandle {} impl Drop for AxumHandle { fn drop(&mut self) { + #[cfg(unix)] + unsafe { + libc::killpg(self.child.id() as libc::pid_t, libc::SIGTERM); + } + #[cfg(not(unix))] let _ = self.child.kill(); let _ = self.child.wait(); } diff --git a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs index 10d16c0a7..5d6c25671 100644 --- a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs +++ b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs @@ -1,11 +1,16 @@ +#[cfg(feature = "aps-runner-proxy")] +use crate::common::config::cloudflare_aps_runner_proxy_config_json; use crate::common::config::cloudflare_config_json; use crate::common::runtime::{ RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, origin_port, }; use error_stack::{Report, ResultExt as _}; +#[cfg(feature = "aps-runner-proxy")] +use std::io::Write as _; use std::io::{BufRead as _, BufReader}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; +use tempfile::{NamedTempFile, TempDir}; /// Cloudflare Workers runtime via `wrangler dev`. /// @@ -26,6 +31,17 @@ const CLOUDFLARE_DEFAULT_PORT: u16 = 8787; 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 = \"{}\""; +#[cfg(feature = "aps-runner-proxy")] +const APS_RUNNER_PROXY_CONFIG_TEMPLATE: &str = "wrangler.aps-runner-proxy.toml"; +#[cfg(feature = "aps-runner-proxy")] +const APS_RUNNER_PROXY_CONFIG: &str = + include_str!("../../../trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml"); +#[cfg(feature = "aps-runner-proxy")] +const APS_RUNNER_PROXY_FIXTURE_CONFIG: &str = + include_str!("../../fixtures/configs/cloudflare-aps-runner-proxy-fixture.toml"); +#[cfg(feature = "aps-runner-proxy")] +const APS_RUNNER_PROXY_ENDPOINT_PLACEHOLDER: &str = + "APS_RUNNER_PROXY_TEST_ENDPOINT = \"__APS_RUNNER_PROXY_TEST_ENDPOINT__\""; fn write_generated_ci_config(wrangler_dir: &Path) -> TestResult { let template_path = wrangler_dir.join(CI_CONFIG_TEMPLATE); @@ -61,6 +77,173 @@ fn inject_cloudflare_config(template: &str, config_json: &str) -> TestResult TestResult<()> { + let url = reqwest::Url::parse(fixture_url) + .change_context(TestError::RuntimeSpawn) + .attach("Cloudflare APS proxy fixture URL is invalid")?; + let is_loopback = url + .host_str() + .and_then(|host| host.parse::().ok()) + .is_some_and(|address| address.is_loopback()); + if url.scheme() != "http" + || !is_loopback + || url.port().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(Report::new(TestError::RuntimeSpawn).attach( + "Cloudflare APS proxy fixture URL must be explicit loopback HTTP without credentials, query, or fragment", + )); + } + Ok(()) +} + +#[cfg(feature = "aps-runner-proxy")] +fn write_temporary_config(directory: &Path, contents: &str) -> TestResult { + let _: toml::Value = toml::from_str(contents) + .change_context(TestError::RuntimeSpawn) + .attach("generated Cloudflare APS proxy Wrangler config is invalid")?; + let mut config = tempfile::Builder::new() + .prefix(".aps-runner-proxy-") + .suffix(".toml") + .tempfile_in(directory) + .change_context(TestError::RuntimeSpawn) + .attach("failed to create temporary Cloudflare APS proxy Wrangler config")?; + config + .write_all(contents.as_bytes()) + .change_context(TestError::RuntimeSpawn) + .attach("failed to write temporary Cloudflare APS proxy Wrangler config")?; + Ok(config) +} + +#[cfg(feature = "aps-runner-proxy")] +fn generated_aps_runner_proxy_configs( + wrangler_dir: &Path, + fixture_url: &str, +) -> TestResult<(NamedTempFile, NamedTempFile)> { + validate_loopback_fixture_url(fixture_url)?; + + let main_template_path = wrangler_dir.join(APS_RUNNER_PROXY_CONFIG_TEMPLATE); + let main_template = std::fs::read_to_string(&main_template_path) + .change_context(TestError::RuntimeSpawn) + .attach(format!( + "failed to read Cloudflare APS proxy config at {}", + main_template_path.display() + ))?; + let config_json = cloudflare_aps_runner_proxy_config_json(origin_port())?; + let main_config = inject_cloudflare_config(&main_template, &config_json)?; + + let placeholder_count = APS_RUNNER_PROXY_FIXTURE_CONFIG + .matches(APS_RUNNER_PROXY_ENDPOINT_PLACEHOLDER) + .count(); + if placeholder_count != 1 { + return Err(Report::new(TestError::RuntimeSpawn).attach(format!( + "Cloudflare APS fixture config must contain one endpoint placeholder, found {placeholder_count}" + ))); + } + let endpoint = toml::Value::String(fixture_url.to_string()).to_string(); + let fixture_config = APS_RUNNER_PROXY_FIXTURE_CONFIG.replace( + APS_RUNNER_PROXY_ENDPOINT_PLACEHOLDER, + &format!("APS_RUNNER_PROXY_TEST_ENDPOINT = {endpoint}"), + ); + let fixture_config_directory = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/configs"); + + Ok(( + write_temporary_config(wrangler_dir, &main_config)?, + write_temporary_config(&fixture_config_directory, &fixture_config)?, + )) +} + +#[cfg(feature = "aps-runner-proxy")] +#[derive(Default)] +struct CloudflareApsRouteReadiness { + consecutive_exact_contracts: u8, +} + +#[cfg(feature = "aps-runner-proxy")] +impl CloudflareApsRouteReadiness { + fn observe( + &mut self, + status: u16, + allow: Option<&str>, + cache_control: Option<&str>, + body_is_empty: bool, + ) -> bool { + let is_exact_contract = status == 405 + && allow == Some("GET") + && cache_control == Some("no-store") + && body_is_empty; + self.consecutive_exact_contracts = if is_exact_contract { + self.consecutive_exact_contracts.saturating_add(1) + } else { + 0 + }; + self.consecutive_exact_contracts >= 2 + } +} + +#[cfg(feature = "aps-runner-proxy")] +fn wait_for_aps_route_ready(base_url: &str, options: super::ReadyCheckOptions) -> TestResult<()> { + let client = reqwest::blocking::Client::builder() + .timeout(options.interval) + .build() + .change_context(TestError::RuntimeSpawn) + .attach("failed to build Cloudflare APS readiness client")?; + let renderer_url = format!( + "{}{}", + base_url, + trusted_server_core::integrations::aps::APS_RENDERER_V2_ROUTE + ); + let probe_method = + reqwest::Method::from_bytes(b"PROPFIND").expect("should parse PROPFIND readiness method"); + let mut readiness = CloudflareApsRouteReadiness::default(); + + for _ in 0..options.max_attempts { + if let Ok(response) = client.request(probe_method.clone(), &renderer_url).send() { + let status = response.status().as_u16(); + let allow = response + .headers() + .get(reqwest::header::ALLOW) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let cache_control = response + .headers() + .get(reqwest::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let semantic_headers_are_exact = response + .headers() + .keys() + .map(reqwest::header::HeaderName::as_str) + .filter(|name| { + !matches!( + *name, + "connection" | "content-length" | "date" | "server" | "transfer-encoding" + ) + }) + .all(|name| matches!(name, "allow" | "cache-control")); + let body_is_empty = response.bytes().is_ok_and(|body| body.is_empty()); + + if readiness.observe( + status, + allow.as_deref(), + cache_control.as_deref(), + semantic_headers_are_exact && body_is_empty, + ) { + return Ok(()); + } + } + + std::thread::sleep(options.interval); + } + + Err(Report::new(options.timeout_error).attach(options.timeout_message)) +} + impl RuntimeEnvironment for CloudflareWorkers { fn id(&self) -> &'static str { "cloudflare" @@ -127,6 +310,7 @@ impl RuntimeEnvironment for CloudflareWorkers { ))?; let mut child = child; + super::register_process_group(&mut child)?; if let Some(stderr) = child.stderr.take() { std::thread::spawn(move || { let reader = BufReader::new(stderr); @@ -138,7 +322,11 @@ impl RuntimeEnvironment for CloudflareWorkers { }); } - let handle = CloudflareHandle { child }; + let handle = CloudflareHandle { + child, + _configs: Vec::new(), + _state_directory: None, + }; let base_url = format!("http://127.0.0.1:{port}"); super::wait_for_ready(&base_url, self.health_check_path(), true)?; @@ -149,6 +337,102 @@ impl RuntimeEnvironment for CloudflareWorkers { }) } + #[cfg(feature = "aps-runner-proxy")] + fn spawn_aps_runner_proxy( + &self, + _wasm_path: &Path, + fixture_url: &str, + ) -> TestResult { + let wrangler_dir = self.wrangler_dir(); + let (main_config, fixture_config) = + generated_aps_runner_proxy_configs(&wrangler_dir, fixture_url)?; + let state_directory = tempfile::tempdir() + .change_context(TestError::RuntimeSpawn) + .attach("failed to create temporary Cloudflare APS proxy state directory")?; + let port = super::find_available_port()?; + + let mut command = Command::new("wrangler"); + command + .arg("dev") + .arg("--config") + .arg(main_config.path()) + .arg("--config") + .arg(fixture_config.path()) + .args(["--port", &port.to_string(), "--ip", "127.0.0.1"]) + .arg("--persist-to") + .arg(state_directory.path()) + .args(["--local", "--log-level", "info"]) + .env( + "WRANGLER_LOG_PATH", + state_directory.path().join("wrangler.log"), + ) + .current_dir(&wrangler_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + command.process_group(0); + } + let mut child = command + .spawn() + .change_context(TestError::RuntimeSpawn) + .attach(format!( + "failed to spawn Cloudflare APS proxy Worker in {}", + wrangler_dir.display() + ))?; + super::register_process_group(&mut child)?; + + if let Some(stdout) = child.stdout.take() { + std::thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + if !line.is_empty() { + log::debug!("cloudflare APS proxy: {line}"); + } + } + }); + } + if let Some(stderr) = child.stderr.take() { + std::thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + if !line.is_empty() { + log::debug!("cloudflare APS proxy: {line}"); + } + } + }); + } + + let handle = CloudflareHandle { + child, + _configs: vec![main_config, fixture_config], + _state_directory: Some(state_directory), + }; + let base_url = format!("http://127.0.0.1:{port}"); + wait_for_aps_route_ready( + &base_url, + super::ReadyCheckOptions { + // Wrangler performs noticeably more startup work for the + // two-Worker service-binding fixture than the other local + // runtimes. Keep this process-readiness allowance independent + // from the APS proxy's strict upstream deadlines. + max_attempts: 120, + interval: std::time::Duration::from_millis(500), + fallback_to_root: false, + timeout_error: TestError::RuntimeNotReady, + timeout_message: format!( + "Cloudflare APS runtime at {base_url} not ready after 60s" + ), + }, + )?; + + Ok(RuntimeProcess { + inner: Box::new(handle), + base_url, + }) + } + fn health_check_path(&self) -> &str { "/.well-known/trusted-server.json" } @@ -170,6 +454,8 @@ impl CloudflareWorkers { struct CloudflareHandle { child: Child, + _configs: Vec, + _state_directory: Option, } impl RuntimeProcessHandle for CloudflareHandle {} @@ -233,4 +519,94 @@ mod tests { assert!(result.is_err(), "should reject duplicate placeholders"); } + + #[test] + #[cfg(feature = "aps-runner-proxy")] + fn aps_fixture_config_has_one_private_endpoint_placeholder() { + assert_eq!( + APS_RUNNER_PROXY_FIXTURE_CONFIG + .matches(APS_RUNNER_PROXY_ENDPOINT_PLACEHOLDER) + .count(), + 1, + "should define exactly one private fixture endpoint" + ); + assert!( + !APS_RUNNER_PROXY_FIXTURE_CONFIG.contains("https://*:*"), + "should not grant wildcard outbound access" + ); + } + + #[test] + #[cfg(feature = "aps-runner-proxy")] + fn aps_main_config_provides_every_shared_fixture_secret() { + for key in [ + "integration_admin_password", + "integration_proxy_secret", + "integration_ec_passphrase", + "integration_partner_token_alpha", + "integration_partner_token_bravo", + ] { + assert!( + APS_RUNNER_PROXY_CONFIG.contains(&format!("{key} =")), + "APS Worker test config should provide `{key}`" + ); + } + } + + #[test] + #[cfg(feature = "aps-runner-proxy")] + fn aps_fixture_url_rejects_non_loopback_targets() { + assert!( + validate_loopback_fixture_url("https://example.com/prebid-creative.js").is_err(), + "should reject a public fixture target" + ); + assert!( + validate_loopback_fixture_url("http://127.0.0.1:1234/prebid-creative.js").is_ok(), + "should accept an explicit loopback fixture target" + ); + } + + #[test] + #[cfg(feature = "aps-runner-proxy")] + fn aps_route_readiness_requires_two_consecutive_exact_contracts() { + let mut readiness = CloudflareApsRouteReadiness::default(); + + assert!( + !readiness.observe(405, Some("GET"), Some("no-store"), true), + "should not accept only one exact observation" + ); + assert!( + !readiness.observe(503, None, None, false), + "should reset after a transient startup response" + ); + assert!( + !readiness.observe(405, Some("GET"), Some("no-store"), true), + "should restart the consecutive count" + ); + assert!( + readiness.observe(405, Some("GET"), Some("no-store"), true), + "should accept two consecutive exact observations" + ); + } + + #[test] + #[cfg(feature = "aps-runner-proxy")] + fn aps_route_readiness_rejects_partial_405_contracts() { + for (status, allow, cache_control, body_is_empty) in [ + (200, Some("GET"), Some("no-store"), true), + (405, Some("POST"), Some("no-store"), true), + (405, Some("GET"), Some("public"), true), + (405, Some("GET"), Some("no-store"), false), + ] { + let mut readiness = CloudflareApsRouteReadiness::default(); + assert!( + !readiness.observe(status, allow, cache_control, body_is_empty), + "should reject an incomplete local method contract" + ); + assert!( + !readiness.observe(405, Some("GET"), Some("no-store"), true), + "should require two exact observations after rejection" + ); + } + } } diff --git a/crates/trusted-server-integration-tests/tests/environments/fastly.rs b/crates/trusted-server-integration-tests/tests/environments/fastly.rs index 124a380f6..b2771a257 100644 --- a/crates/trusted-server-integration-tests/tests/environments/fastly.rs +++ b/crates/trusted-server-integration-tests/tests/environments/fastly.rs @@ -1,10 +1,22 @@ +#[cfg(feature = "aps-runner-proxy")] +use crate::common::config::aps_runner_proxy_app_config_envelope; use crate::common::runtime::{ RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, }; use error_stack::{Report, ResultExt as _}; +use std::ffi::OsString; +#[cfg(feature = "aps-runner-proxy")] +use std::io::Write as _; use std::io::{BufRead as _, BufReader}; +#[cfg(unix)] +use std::os::unix::process::CommandExt as _; use std::path::Path; use std::process::{Child, Command, Stdio}; +use tempfile::NamedTempFile; +#[cfg(feature = "aps-runner-proxy")] +use trusted_server_core::integrations::aps::{ + APS_RUNNER_BLOCKING_READ_TIMEOUT, APS_RUNNER_FIRST_BYTE_TIMEOUT, +}; /// Fastly Compute runtime using Viceroy local simulator. /// @@ -19,9 +31,73 @@ impl RuntimeEnvironment for FastlyViceroy { } fn spawn(&self, wasm_path: &Path) -> TestResult { + self.spawn_with_config(wasm_path, None) + } + + #[cfg(feature = "aps-runner-proxy")] + fn spawn_aps_runner_proxy( + &self, + wasm_path: &Path, + fixture_url: &str, + ) -> TestResult { + let config = self.aps_runner_proxy_config(fixture_url)?; + self.spawn_with_config(wasm_path, Some(config)) + } +} + +impl FastlyViceroy { + #[cfg(feature = "aps-runner-proxy")] + fn aps_runner_proxy_backend_definition(authority: &str) -> toml::Value { + let first_byte_timeout_ms = i64::try_from(APS_RUNNER_FIRST_BYTE_TIMEOUT.as_millis()) + .expect("should fit the APS first-byte timeout in Viceroy configuration"); + let between_bytes_timeout_ms = i64::try_from(APS_RUNNER_BLOCKING_READ_TIMEOUT.as_millis()) + .expect("should fit the APS between-bytes timeout in Viceroy configuration"); + toml::Value::Table(toml::Table::from_iter([ + ( + "url".to_string(), + toml::Value::String(format!("http://{authority}/")), + ), + ( + "override_host".to_string(), + toml::Value::String("client.aps.amazon-adsystem.com".to_string()), + ), + ( + "first_byte_timeout_ms".to_string(), + toml::Value::Integer(first_byte_timeout_ms), + ), + ( + "between_bytes_timeout_ms".to_string(), + toml::Value::Integer(between_bytes_timeout_ms), + ), + ])) + } + + /// Select the Viceroy executable for this test process. + /// + /// `VICEROY_BIN` allows a task to validate a different simulator build + /// without changing the repository's pinned installation or mutating + /// `PATH`. + fn viceroy_binary() -> OsString { + Self::viceroy_binary_from_override(std::env::var_os("VICEROY_BIN")) + } + + fn viceroy_binary_from_override(override_binary: Option) -> OsString { + override_binary + .filter(|binary| !binary.as_os_str().is_empty()) + .unwrap_or_else(|| OsString::from("viceroy")) + } + + fn spawn_with_config( + &self, + wasm_path: &Path, + generated_config: Option, + ) -> TestResult { let port = super::find_available_port()?; - let viceroy_config = self.viceroy_config_path(); + let viceroy_config = generated_config.as_ref().map_or_else( + || self.viceroy_config_path(), + |file| file.path().to_path_buf(), + ); if !viceroy_config.exists() { return Err(Report::new(TestError::RuntimeSpawn).attach(format!( "Viceroy config `{}` does not exist; run `scripts/generate-integration-viceroy-configs.sh` or `scripts/integration-tests.sh`, or set VICEROY_CONFIG_PATH to a generated config", @@ -29,17 +105,22 @@ impl RuntimeEnvironment for FastlyViceroy { ))); } - let mut child = Command::new("viceroy") + let mut command = Command::new(Self::viceroy_binary()); + command .arg(wasm_path) .arg("-C") .arg(&viceroy_config) .arg("--addr") .arg(format!("127.0.0.1:{port}")) .stdout(Stdio::null()) - .stderr(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + command.process_group(0); + let mut child = command .spawn() .change_context(TestError::RuntimeSpawn) .attach("Failed to spawn viceroy process")?; + super::register_process_group(&mut child)?; if let Some(stderr) = child.stderr.take() { std::thread::spawn(move || { @@ -53,7 +134,10 @@ impl RuntimeEnvironment for FastlyViceroy { } // Wrap immediately so Drop::drop kills the process if readiness check fails - let handle = ViceroyHandle { child }; + let handle = ViceroyHandle { + child, + _generated_config: generated_config, + }; let base_url = format!("http://127.0.0.1:{port}"); // Fastly exposes a dedicated `/health` route, so root fallback only @@ -65,9 +149,73 @@ impl RuntimeEnvironment for FastlyViceroy { base_url, }) } -} -impl FastlyViceroy { + #[cfg(feature = "aps-runner-proxy")] + fn aps_runner_proxy_config(&self, fixture_url: &str) -> TestResult { + let fixture = reqwest::Url::parse(fixture_url) + .change_context(TestError::RuntimeSpawn) + .attach("invalid fictional APS runner fixture URL")?; + if fixture.scheme() != "http" + || !matches!(fixture.host_str(), Some("127.0.0.1" | "::1")) + || fixture.port().is_none() + || fixture.path() != "/prebid-creative.js" + || fixture.query().is_some() + || fixture.fragment().is_some() + { + return Err(Report::new(TestError::RuntimeSpawn) + .attach("fictional APS runner fixture must be the exact loopback path")); + } + let base_path = self.viceroy_config_path(); + let source = std::fs::read_to_string(&base_path) + .change_context(TestError::RuntimeSpawn) + .attach(format!( + "failed to read generated Viceroy config at {}", + base_path.display() + ))?; + let mut config: toml::Value = toml::from_str(&source) + .change_context(TestError::RuntimeSpawn) + .attach("failed to parse generated Viceroy config")?; + config["local_server"]["config_stores"]["trusted_server_config"]["contents"]["trusted_server_config"] = + toml::Value::String(aps_runner_proxy_app_config_envelope( + crate::common::runtime::origin_port(), + )?); + let backends = config + .get_mut("local_server") + .and_then(toml::Value::as_table_mut) + .and_then(|local| local.get_mut("backends")) + .and_then(toml::Value::as_table_mut) + .ok_or_else(|| { + Report::new(TestError::RuntimeSpawn) + .attach("generated Viceroy config is missing local_server.backends") + })?; + let host = fixture.host_str().ok_or_else(|| { + Report::new(TestError::RuntimeSpawn).attach("fixture has no authority") + })?; + let port = fixture.port().ok_or_else(|| { + Report::new(TestError::RuntimeSpawn).attach("fixture has no explicit port") + })?; + let authority = if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + }; + backends.insert( + "aps_runner_proxy_fixture".to_string(), + Self::aps_runner_proxy_backend_definition(&authority), + ); + let serialized = toml::to_string(&config) + .change_context(TestError::RuntimeSpawn) + .attach("failed to serialize APS Viceroy config")?; + let mut output = NamedTempFile::new() + .change_context(TestError::RuntimeSpawn) + .attach("failed to create temporary APS Viceroy config")?; + output + .write_all(serialized.as_bytes()) + .change_context(TestError::RuntimeSpawn) + .attach("failed to write temporary APS Viceroy config")?; + Ok(output) + } + /// Path to the generated Viceroy configuration. /// /// This contains `[local_server]` configuration (backends, KV stores, @@ -94,13 +242,60 @@ impl FastlyViceroy { /// preventing orphaned Viceroy processes. struct ViceroyHandle { child: Child, + _generated_config: Option, } impl RuntimeProcessHandle for ViceroyHandle {} impl Drop for ViceroyHandle { fn drop(&mut self) { + #[cfg(unix)] + unsafe { + libc::killpg(self.child.id() as libc::pid_t, libc::SIGTERM); + } + #[cfg(not(unix))] let _ = self.child.kill(); let _ = self.child.wait(); } } + +#[cfg(test)] +mod tests { + use super::FastlyViceroy; + use std::ffi::OsString; + + #[test] + fn viceroy_binary_uses_task_specific_override_or_default() { + assert_eq!( + FastlyViceroy::viceroy_binary_from_override(None), + OsString::from("viceroy") + ); + assert_eq!( + FastlyViceroy::viceroy_binary_from_override(Some(OsString::new())), + OsString::from("viceroy") + ); + assert_eq!( + FastlyViceroy::viceroy_binary_from_override(Some(OsString::from( + "/tmp/viceroy 0.19/bin/viceroy" + ))), + OsString::from("/tmp/viceroy 0.19/bin/viceroy") + ); + } + + #[test] + #[cfg(feature = "aps-runner-proxy")] + fn aps_runner_proxy_static_backend_has_bounded_transport_timeouts() { + let definition = FastlyViceroy::aps_runner_proxy_backend_definition("127.0.0.1:43210"); + + assert_eq!( + definition["first_byte_timeout_ms"].as_integer(), + Some(4_000), + "fixture should enforce the APS first-byte timeout" + ); + assert_eq!( + definition["between_bytes_timeout_ms"].as_integer(), + Some(250), + "fixture should enforce the APS between-bytes timeout" + ); + } +} diff --git a/crates/trusted-server-integration-tests/tests/environments/mod.rs b/crates/trusted-server-integration-tests/tests/environments/mod.rs index 41b3d69c0..430404b41 100644 --- a/crates/trusted-server-integration-tests/tests/environments/mod.rs +++ b/crates/trusted-server-integration-tests/tests/environments/mod.rs @@ -1,9 +1,13 @@ pub mod axum; pub mod cloudflare; pub mod fastly; +#[cfg(feature = "aps-runner-proxy")] +pub mod spin; use crate::common::runtime::{RuntimeEnvironment, TestError, TestResult}; -use error_stack::Report; +use error_stack::{Report, ResultExt as _}; +use std::io::Write as _; +use std::process::Child; use std::time::Duration; /// Runtime factory function type — avoids trait object static initialization issues. @@ -26,6 +30,48 @@ pub static RUNTIME_ENVIRONMENTS: &[RuntimeFactory] = &[ || Box::new(cloudflare::CloudflareWorkers), ]; +/// Record an isolated runtime process group for the task-level shell trap. +/// +/// The APS corpus launcher supplies a freshly-created file. Recording happens +/// immediately after spawn so an interrupted Cargo process cannot leave the +/// adapter's separately-isolated process tree behind. +#[cfg(unix)] +pub(crate) fn register_process_group(child: &mut Child) -> TestResult<()> { + let Some(path) = std::env::var_os("APS_RUNNER_PROXY_PROCESS_GROUP_FILE") else { + return Ok(()); + }; + let path = std::path::PathBuf::from(path); + let result = (|| { + if !path.is_absolute() { + return Err(Report::new(TestError::RuntimeSpawn) + .attach("APS process-group registry path must be absolute")); + } + let mut registry = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .change_context(TestError::RuntimeSpawn) + .attach(format!( + "failed to open APS process-group registry {}", + path.display() + ))?; + writeln!(registry, "{}", child.id()) + .change_context(TestError::RuntimeSpawn) + .attach("failed to register APS runtime process group") + })(); + if result.is_err() { + unsafe { + libc::killpg(child.id() as libc::pid_t, libc::SIGTERM); + } + let _ = child.wait(); + } + result +} + +#[cfg(not(unix))] +pub(crate) fn register_process_group(_child: &mut Child) -> TestResult<()> { + Ok(()) +} + /// Readiness polling configuration for runtimes and frontend containers. pub(crate) struct ReadyCheckOptions { pub(crate) max_attempts: usize, diff --git a/crates/trusted-server-integration-tests/tests/environments/spin.rs b/crates/trusted-server-integration-tests/tests/environments/spin.rs new file mode 100644 index 000000000..90dee34eb --- /dev/null +++ b/crates/trusted-server-integration-tests/tests/environments/spin.rs @@ -0,0 +1,206 @@ +use crate::common::config::aps_runner_proxy_app_config_envelope; +use crate::common::runtime::{ + RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, origin_port, +}; +use crate::environments::ReadyCheckOptions; +use error_stack::{Report, ResultExt as _}; +use std::io::{BufRead as _, BufReader, Write as _}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; +use tempfile::{NamedTempFile, TempDir}; + +const APS_RUNNER_PROXY_MANIFEST: &str = + include_str!("../../fixtures/configs/spin-aps-runner-proxy.toml"); +const WASM_PLACEHOLDER: &str = "__APS_RUNNER_PROXY_WASM__"; +const INTEGRATION_SECRET_VARIABLES: &[(&str, &str)] = &[ + ( + "v_trusted_x5fserver_x5fsecrets_v_integration_x5fadmin_x5fpassword", + "integration-admin-password-32-bytes-ok", + ), + ( + "v_trusted_x5fserver_x5fsecrets_v_integration_x5fproxy_x5fsecret", + "integration-test-proxy-secret-32-bytes-ok", + ), + ( + "v_trusted_x5fserver_x5fsecrets_v_integration_x5fec_x5fpassphrase", + "integration-test-ec-secret-padded-32", + ), + ( + "v_trusted_x5fserver_x5fsecrets_v_integration_x5fpartner_x5ftoken_x5falpha", + "integration-test-token-alpha-32-bytes-ok", + ), + ( + "v_trusted_x5fserver_x5fsecrets_v_integration_x5fpartner_x5ftoken_x5fbravo", + "integration-test-token-bravo-32-bytes-ok", + ), +]; + +pub struct SpinRuntime; + +impl RuntimeEnvironment for SpinRuntime { + fn id(&self) -> &'static str { + "spin" + } + + fn spawn(&self, _wasm_path: &Path) -> TestResult { + Err(Report::new(TestError::RuntimeSpawn) + .attach("Spin is available only in the dedicated APS proxy corpus for now")) + } + + fn spawn_aps_runner_proxy( + &self, + wasm_path: &Path, + fixture_url: &str, + ) -> TestResult { + let port = super::find_available_port()?; + let app_config = aps_runner_proxy_app_config_envelope(origin_port())?; + let manifest = generated_manifest(wasm_path)?; + let state_directory = tempfile::tempdir() + .change_context(TestError::RuntimeSpawn) + .attach("failed to create temporary Spin state directory")?; + let listen = format!("127.0.0.1:{port}"); + + let mut command = Command::new("spin"); + command + .args(["up", "--from"]) + .arg(manifest.path()) + .args([ + "--variable", + &format!("v_trusted_x5fserver_x5fconfig={app_config}"), + ]) + .args([ + "--variable", + &format!("aps_runner_proxy_test_endpoint={fixture_url}"), + ]) + .arg("--state-dir") + .arg(state_directory.path()) + .args(["--listen", &listen]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + for (name, value) in INTEGRATION_SECRET_VARIABLES { + command.args(["--variable", &format!("{name}={value}")]); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + command.process_group(0); + } + let mut child = command + .spawn() + .change_context(TestError::RuntimeSpawn) + .attach("failed to spawn Spin APS runner proxy artifact")?; + super::register_process_group(&mut child)?; + + if let Some(stderr) = child.stderr.take() { + std::thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + if !line.is_empty() { + log::debug!("spin: {line}"); + } + } + }); + } + + let handle = SpinHandle { + child, + _manifest: manifest, + _state_directory: state_directory, + }; + let base_url = format!("http://{listen}"); + super::wait_for_http_ready( + &base_url, + self.health_check_path(), + ReadyCheckOptions { + max_attempts: 120, + interval: Duration::from_millis(500), + fallback_to_root: true, + timeout_error: TestError::RuntimeNotReady, + timeout_message: format!("Spin runtime at {base_url} not ready after 60s"), + }, + )?; + Ok(RuntimeProcess { + inner: Box::new(handle), + base_url, + }) + } + + fn health_check_path(&self) -> &str { + "/health" + } +} + +fn generated_manifest(wasm_path: &Path) -> TestResult { + if APS_RUNNER_PROXY_MANIFEST.matches(WASM_PLACEHOLDER).count() != 1 { + return Err(Report::new(TestError::RuntimeSpawn) + .attach("Spin APS proxy manifest must contain one WASM placeholder")); + } + let wasm_path = wasm_path + .canonicalize() + .change_context(TestError::RuntimeSpawn)?; + let wasm_path = wasm_path.to_str().ok_or_else(|| { + Report::new(TestError::RuntimeSpawn).attach("Spin WASM path is not UTF-8") + })?; + let rendered = APS_RUNNER_PROXY_MANIFEST.replace(WASM_PLACEHOLDER, wasm_path); + let _: toml::Value = toml::from_str(&rendered) + .change_context(TestError::RuntimeSpawn) + .attach("generated Spin APS proxy manifest is invalid")?; + let mut output = tempfile::Builder::new() + .suffix(".toml") + .tempfile() + .change_context(TestError::RuntimeSpawn) + .attach("failed to create temporary Spin APS proxy manifest")?; + output + .write_all(rendered.as_bytes()) + .change_context(TestError::RuntimeSpawn) + .attach("failed to write Spin APS proxy manifest")?; + Ok(output) +} + +struct SpinHandle { + child: Child, + _manifest: NamedTempFile, + _state_directory: TempDir, +} + +impl RuntimeProcessHandle for SpinHandle {} + +impl Drop for SpinHandle { + fn drop(&mut self) { + #[cfg(unix)] + { + let pgid = self.child.id() as libc::pid_t; + unsafe { + libc::killpg(pgid, libc::SIGTERM); + } + } + #[cfg(not(unix))] + { + let _ = self.child.kill(); + } + let _ = self.child.wait(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_has_one_wasm_placeholder_and_required_test_variables() { + assert_eq!( + APS_RUNNER_PROXY_MANIFEST.matches(WASM_PLACEHOLDER).count(), + 1 + ); + assert!(APS_RUNNER_PROXY_MANIFEST.contains("aps_runner_proxy_test_endpoint")); + assert!(APS_RUNNER_PROXY_MANIFEST.contains("v_trusted_x5fserver_x5fconfig")); + for (name, _) in INTEGRATION_SECRET_VARIABLES { + assert!( + APS_RUNNER_PROXY_MANIFEST.contains(name), + "manifest should declare and map `{name}`" + ); + } + assert!(!APS_RUNNER_PROXY_MANIFEST.contains("https://*:*")); + } +} diff --git a/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs b/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs index c1bbafe8d..6a66ab1ac 100644 --- a/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs +++ b/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs @@ -119,7 +119,17 @@ impl TestScenario { } Self::ScriptServing => { - let url = format!("{base_url}/static/tsjs=tsjs-unified.min.js"); + let html = reqwest::blocking::get(base_url) + .change_context(TestError::HttpRequest) + .attach(format!( + "scenario: ScriptServing discovery, framework: {framework_id}" + ))? + .text() + .change_context(TestError::ResponseParse) + .attach(format!("framework: {framework_id}"))?; + let script_src = assertions::trustedserver_script_src(&html) + .attach(format!("framework: {framework_id}"))?; + let url = format!("{base_url}{script_src}"); let resp = reqwest::blocking::get(&url) .change_context(TestError::HttpRequest) diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index acf7f5f4b..13959c628 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -14,10 +14,12 @@ use edgezero_adapter_axum::service::EdgeZeroAxumService; use edgezero_core::http::request_builder; use edgezero_core::router::RouterService; use http::HeaderMap; +use std::collections::BTreeMap; use tower::{Service as _, ServiceExt as _}; use trusted_server_adapter_axum::app::TrustedServerApp as AxumApp; use trusted_server_adapter_cloudflare::app::TrustedServerApp as CloudflareApp; use trusted_server_adapter_spin::app::TrustedServerApp as SpinApp; +use trusted_server_core::integrations::aps::APS_RENDERER_V2_ROUTE; use trusted_server_core::settings::Settings; /// Shared test settings for all adapters. @@ -44,6 +46,19 @@ fn test_settings() -> Settings { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [auction] + enabled = true + + [auction.providers.aps-main] + protocol = "openrtb-2.6" + profile = "aps" + endpoint = "https://aps.example/e/pb/bid" + routing = "all_eligible" + + [auction.providers.aps-main.profile_config] + account_id = "parity-test-aps-account" + allow_script_creatives = true "#, ) .expect("should parse parity test settings") @@ -703,27 +718,22 @@ async fn page_bids_options_preflight_denied_parity() { // browser. The denial is unconditional (independent of creative-opportunity // configuration), so all adapters must agree on 403. // - // The deprecated `/__ts/page-bids` alias routes to the same handler, so it - // must deny the preflight identically — an alias that fell through to the - // origin would reopen the hole the canonical path closes. - for path in ["/_ts/page-bids", "/__ts/page-bids"] { - let (axum_status, _) = axum_options(path).await; - let (cf_status, _) = cf_options(path).await; - let (spin_status, _) = spin_options(path).await; + let path = "/_ts/page-bids"; + let (axum_status, _) = axum_options(path).await; + let (cf_status, _) = cf_options(path).await; + let (spin_status, _) = spin_options(path).await; - assert_eq!( - axum_status, 403, - "Axum OPTIONS {path} must be denied with 403, got {axum_status}" - ); - assert_eq!( - cf_status, 403, - "Cloudflare OPTIONS {path} must be denied with 403, got {cf_status}" - ); - assert_eq!( - spin_status, 403, - "Spin OPTIONS {path} must be denied with 403, got {spin_status}" - ); - } + assert_eq!(axum_status, 403, "Axum OPTIONS must be denied"); + assert_eq!(cf_status, 403, "Cloudflare OPTIONS must be denied"); + assert_eq!(spin_status, 403, "Spin OPTIONS must be denied"); + + let removed = "/__ts/page-bids"; + let (axum_status, _) = axum_options(removed).await; + let (cf_status, _) = cf_options(removed).await; + let (spin_status, _) = spin_options(removed).await; + assert_eq!(axum_status, 404, "Axum removed alias must be unknown"); + assert_eq!(cf_status, 404, "Cloudflare removed alias must be unknown"); + assert_eq!(spin_status, 404, "Spin removed alias must be unknown"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -758,6 +768,94 @@ async fn spin_auction_ignores_spoofed_forwarded_headers() { ); } +fn canonical_headers(headers: &HeaderMap) -> BTreeMap> { + let mut canonical = BTreeMap::>::new(); + for (name, value) in headers { + canonical + .entry(name.as_str().to_string()) + .or_default() + .push( + value + .to_str() + .expect("renderer response headers should be UTF-8") + .to_string(), + ); + } + canonical +} + +fn aps_renderer_request() -> edgezero_core::http::Request { + request_builder() + .method("GET") + .uri(APS_RENDERER_V2_ROUTE) + .body(edgezero_core::body::Body::empty()) + .expect("should build APS renderer request") +} + +fn response_parts(response: edgezero_core::http::Response) -> (u16, HeaderMap, bytes::Bytes) { + let status = response.status().as_u16(); + let headers = response.headers().clone(); + let body = response + .into_body() + .into_bytes() + .expect("APS renderer response body should be buffered"); + (status, headers, body) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn aps_renderer_v2_response_is_exact_across_portable_adapters() { + let axum = trusted_server_adapter_axum::app::dispatch_reserved_with_settings( + test_settings(), + aps_renderer_request(), + ) + .await + .expect("Axum reserved dispatcher should initialize") + .expect("Axum APS renderer path should be reserved"); + let cloudflare = trusted_server_adapter_cloudflare::app::dispatch_reserved_with_settings( + test_settings(), + aps_renderer_request(), + ) + .await + .expect("Cloudflare reserved dispatcher should initialize") + .expect("Cloudflare APS renderer path should be reserved"); + let spin = trusted_server_adapter_spin::app::dispatch_reserved_with_settings( + test_settings(), + aps_renderer_request(), + ) + .await + .expect("Spin reserved dispatcher should initialize") + .expect("Spin APS renderer path should be reserved"); + let axum = response_parts(axum); + let cloudflare = response_parts(cloudflare); + let spin = response_parts(spin); + + assert_eq!(axum.0, 200, "Axum renderer should be available"); + assert_eq!(cloudflare.0, 200, "Cloudflare renderer should be available"); + assert_eq!(spin.0, 200, "Spin renderer should be available"); + assert_eq!(axum.2, cloudflare.2, "renderer bytes should match"); + assert_eq!(cloudflare.2, spin.2, "renderer bytes should match"); + + let axum_headers = canonical_headers(&axum.1); + let cloudflare_headers = canonical_headers(&cloudflare.1); + let spin_headers = canonical_headers(&spin.1); + assert_eq!( + axum_headers, cloudflare_headers, + "renderer response headers should match" + ); + assert_eq!( + cloudflare_headers, spin_headers, + "renderer response headers should match" + ); + assert!( + !axum_headers.contains_key("x-geo-info-available"), + "the exact renderer contract should bypass generic response decoration" + ); + assert!( + !axum_headers.contains_key("x-frame-options"), + "the sandbox contract deliberately omits X-Frame-Options" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn publisher_proxy_fallback_parity() { // Cookie (Set-Cookie) parity for the publisher proxy requires a live origin. diff --git a/crates/trusted-server-js/Cargo.toml b/crates/trusted-server-js/Cargo.toml index 67a4ac698..6c60d39fd 100644 --- a/crates/trusted-server-js/Cargo.toml +++ b/crates/trusted-server-js/Cargo.toml @@ -18,7 +18,8 @@ path = "src/lib.rs" [build-dependencies] build-print = { workspace = true } -hex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } sha2 = { workspace = true } which = { workspace = true } diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index ba6cd88f2..7cd37b913 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -4,7 +4,6 @@ reason = "build script failures should stop Cargo with a clear diagnostic" )] -use std::cmp::Ordering; use std::env; use std::fmt::Write as _; use std::fs; @@ -12,39 +11,84 @@ use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; use build_print::{info, warn}; +use serde::Deserialize; use sha2::{Digest as _, Sha256}; +const RELEASE_SENTINEL: &str = "__TSJS_RELEASE_ID_SENTINEL_V1__"; +const RELEASE_PREFIX: &[u8] = b"tsjs-release-v1\0"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ReleaseManifest { + version: u8, + release_id: String, + artifacts: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ReleaseArtifact { + id: String, + role: String, + phase: Option, + trigger: Option, + inputs: Vec, + outputs: Vec, + file: String, + bytes: usize, + hash: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct CatalogManifest { + version: u8, + first_display: Vec, + permitted_first_display_masks: Vec, + modules: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct FirstDisplayCatalogModule { + order: usize, + id: String, + include: String, + allowed_imports: Vec, + inputs: Vec, + outputs: Vec, + obligation: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CatalogModule { + id: String, + phase: String, + trigger: Option, + include: String, +} + fn main() { - // Rebuild if TS sources change (belt-and-suspenders): enumerate every file under lib/ println!("cargo:rerun-if-changed=lib"); watch_dir_recursively(Path::new("lib")); - // Allow opt-out or force via env let skip = env::var("TSJS_SKIP_BUILD").is_ok_and(|value| value == "1"); - let crate_dir = PathBuf::from( env::var("CARGO_MANIFEST_DIR").expect("should set CARGO_MANIFEST_DIR for build script"), ); let out_dir = PathBuf::from(env::var("OUT_DIR").expect("should set OUT_DIR for build script")); let ts_dir = crate_dir.join("lib"); let dist_dir = crate_dir.join("dist"); - - // Ensure dist exists fs::create_dir_all(&dist_dir).expect("should create dist directory"); - // Only try to build if we have a library project if !ts_dir.join("package.json").exists() { - // No TS project; rely on prebuilt dist if present return; } - - // If Node/npm is absent, keep going if dist exists let npm = which::which("npm").ok(); if npm.is_none() { warn!("tsjs: npm not found; will use existing dist if available"); } - - // Install deps if node_modules missing if !skip && let Some(npm_path) = npm.as_deref() && !ts_dir.join("node_modules").exists() @@ -57,8 +101,6 @@ fn main() { warn!("tsjs: npm ci failed; using existing dist if available"); } } - - // Run tests if requested if !skip && env::var("TSJS_TEST").is_ok_and(|value| value == "1") && let Some(npm_path) = npm.as_deref() @@ -69,11 +111,8 @@ fn main() { .status() .expect("should run requested TSJS tests"); } - - // Build all module files if !skip && let Some(npm_path) = npm.as_deref() { - info!("tsjs: Building per-module bundles"); - + info!("tsjs: Building phase-aware release artifacts"); let status = Command::new(npm_path) .args(["run", "build"]) .current_dir(&ts_dir) @@ -84,118 +123,405 @@ fn main() { ); } - // Discover all tsjs-*.js files in dist/ - let mut modules: Vec<(String, String)> = Vec::new(); // (id, filename) - if let Ok(entries) = fs::read_dir(&dist_dir) { - for entry in entries.flatten() { - let filename = entry.file_name().to_string_lossy().to_string(); - if let Some(id) = filename - .strip_prefix("tsjs-") - .and_then(|stem| stem.strip_suffix(".js")) - { - modules.push((id.to_owned(), filename)); - } - } + let manifest = read_and_validate_release(&dist_dir); + let catalog = read_and_validate_catalog(&dist_dir, &manifest); + for artifact in &manifest.artifacts { + copy_bundle(&artifact.file, &crate_dir, &dist_dir, &out_dir); } + generate_metadata(&manifest, &catalog, &out_dir); + info!( + "tsjs: Embedded {} canonical release artifacts", + manifest.artifacts.len() + ); +} - // Sort alphabetically but ensure "core" is always first - modules.sort_by(|left, right| { - if left.0 == "core" { - Ordering::Less - } else if right.0 == "core" { - Ordering::Greater - } else { - left.0.cmp(&right.0) - } - }); - +fn read_and_validate_catalog(dist_dir: &Path, release: &ReleaseManifest) -> CatalogManifest { + let catalog_text = fs::read_to_string(dist_dir.join("tsjs-catalog-v1.json")) + .expect("should read generated catalog manifest"); + let catalog: CatalogManifest = + serde_json::from_str(&catalog_text).expect("should parse exact catalog manifest"); + assert_eq!( + catalog.version, 1, + "tsjs: catalog manifest version must be one" + ); + assert_eq!( + catalog.modules.len(), + 20, + "tsjs: catalog must contain twenty modules" + ); + let first_display_artifacts = release + .artifacts + .iter() + .filter(|artifact| { + artifact.role == "first_display_base" || artifact.role == "first_display_slice" + }) + .collect::>(); + assert_eq!( + catalog.first_display.len(), + 14, + "tsjs: first-display catalog must contain base plus thirteen slices" + ); + assert_eq!( + first_display_artifacts.len(), + catalog.first_display.len(), + "tsjs: first-display catalog/release count mismatch" + ); + let mut previous_mask = None; assert!( - !modules.is_empty(), - "tsjs: no tsjs-*.js files found in {}. Ensure `npm run build` succeeds.", - dist_dir.display() + !catalog.permitted_first_display_masks.is_empty(), + "tsjs: at least one first-display mask must satisfy the release ceilings" ); - - info!( - "tsjs: Discovered {} module files: {:?}", - modules.len(), - modules + let mask_limit = 1_u16 << catalog.first_display.len(); + let mask_bit = |id: &str| { + catalog + .first_display .iter() - .map(|(id, _)| id.as_str()) - .collect::>() + .position(|module| module.id == id) + .map(|index| 1_u16 << index) + .unwrap_or_else(|| panic!("tsjs: first-display catalog is missing {id}")) + }; + let gpt_mask = mask_bit("gpt_initial"); + let render_owner_mask = mask_bit("render_owner_initial"); + let aps_mask = mask_bit("aps_initial"); + let prebid_mask = mask_bit("prebid_initial"); + for encoded in &catalog.permitted_first_display_masks { + assert!( + encoded.len() == 4 + && encoded + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "tsjs: permitted first-display mask must be four lowercase hex digits" + ); + let mask = u16::from_str_radix(encoded, 16) + .expect("should parse validated permitted first-display mask"); + assert_eq!( + mask & 1, + 1, + "tsjs: permitted first-display mask must contain the base" + ); + assert!( + mask < mask_limit, + "tsjs: permitted first-display mask contains an unknown slice bit" + ); + assert!( + mask & (render_owner_mask | aps_mask | prebid_mask) == 0 || mask & gpt_mask != 0, + "tsjs: render-owner, APS, and Prebid participation require GPT initial ownership" + ); + assert!( + mask & aps_mask == 0 || mask & render_owner_mask != 0, + "tsjs: APS participation requires render-owner initial ownership" + ); + assert!( + previous_mask.is_none_or(|previous| mask > previous), + "tsjs: permitted first-display masks must be unique and ordered" + ); + previous_mask = Some(mask); + } + for (index, (module, artifact)) in catalog + .first_display + .iter() + .zip(first_display_artifacts) + .enumerate() + { + assert_eq!( + module.order, + index + 1, + "tsjs: first-display order mismatch" + ); + assert_eq!(module.id, artifact.id, "tsjs: first-display id mismatch"); + assert_eq!( + artifact.role, + if index == 0 { + "first_display_base" + } else { + "first_display_slice" + }, + "tsjs: first-display role mismatch" + ); + assert_eq!(artifact.phase.as_deref(), Some("first_display")); + assert!(artifact.trigger.is_none()); + assert_eq!(module.inputs, artifact.inputs); + assert_eq!(module.outputs, artifact.outputs); + assert!(!module.allowed_imports.is_empty()); + assert!(!module.obligation.is_empty()); + assert!( + module.include == "eligible_batch" + || module.include == "render_owner_participates" + || module.include == "aps_participates" + || module.include == "creative_guard" + || module.include == "gpt_initial" + || module.include == "prebid_participates" + || module.include.starts_with("integration:"), + "tsjs: unknown first-display inclusion predicate" + ); + } + let integration_artifacts = release + .artifacts + .iter() + .filter(|artifact| artifact.role == "integration") + .collect::>(); + assert_eq!(integration_artifacts.len(), catalog.modules.len()); + for (module, artifact) in catalog.modules.iter().zip(integration_artifacts) { + assert_eq!(module.id, artifact.id, "tsjs: catalog/release id mismatch"); + assert_eq!( + Some(module.phase.as_str()), + artifact.phase.as_deref(), + "tsjs: catalog/release phase mismatch" + ); + assert_eq!( + module.trigger.as_deref(), + artifact.trigger.as_deref(), + "tsjs: catalog/release trigger mismatch" + ); + assert!( + module.include == "always" + || module.include == "creative_guard" + || module.include == "gpt_diagnostics_active" + || module.include == "diagnostics_presentation" + || module.include == "prebid_and_gpt" + || module.include.starts_with("integration:"), + "tsjs: unknown catalog inclusion predicate" + ); + } + catalog +} + +fn read_and_validate_release(dist_dir: &Path) -> ReleaseManifest { + let manifest_text = fs::read_to_string(dist_dir.join("tsjs-release-v1.json")) + .expect("should read generated release manifest"); + let manifest: ReleaseManifest = + serde_json::from_str(&manifest_text).expect("should parse exact release manifest"); + assert_eq!( + manifest.version, 1, + "tsjs: release manifest version must be one" + ); + assert!( + valid_hash(&manifest.release_id), + "tsjs: generated manifest has invalid release id" + ); + assert_eq!( + manifest.artifacts.len(), + 36, + "tsjs: release must contain bootstrap, fourteen first-display components, core, and twenty integrations" ); + assert_eq!(manifest.artifacts[0].id, "bootstrap"); + assert_eq!(manifest.artifacts[0].role, "bootstrap"); + assert_eq!(manifest.artifacts[1].id, "first_display"); + assert_eq!(manifest.artifacts[1].role, "first_display_base"); + assert_eq!(manifest.artifacts[15].id, "core"); + assert_eq!(manifest.artifacts[15].role, "core"); - // Copy each module file to OUT_DIR - for (_, filename) in &modules { - copy_bundle(filename, true, &dist_dir, &out_dir); - } + let mut canonical = Vec::new(); + canonical.extend_from_slice(RELEASE_PREFIX); + push_u64(&mut canonical, manifest.artifacts.len()); + let mut ids = std::collections::HashSet::new(); + let mut integration_index = 0_usize; + for (index, artifact) in manifest.artifacts.iter().enumerate() { + assert!(ids.insert(&artifact.id), "tsjs: duplicate artifact id"); + match artifact.role.as_str() { + "bootstrap" | "core" => { + assert!(artifact.phase.is_none() && artifact.trigger.is_none()); + } + "first_display_base" | "first_display_slice" => { + assert!((1..=14).contains(&index)); + assert_eq!(artifact.phase.as_deref(), Some("first_display")); + assert!(artifact.trigger.is_none()); + } + "integration" => { + assert!(index >= 16); + if integration_index < 14 { + assert_eq!(artifact.phase.as_deref(), Some("takeover")); + assert!(artifact.trigger.is_none()); + } else { + assert_eq!(artifact.phase.as_deref(), Some("deferred")); + assert_eq!(artifact.trigger.as_deref(), Some("first_display_or_idle")); + assert!( + artifact.outputs.is_empty(), + "tsjs: deferred provider is forbidden" + ); + } + integration_index += 1; + } + role => panic!("tsjs: unknown release artifact role {role}"), + } - // Generate tsjs_modules.rs with include_str!() for each module - let mut codegen = String::new(); - codegen.push_str("// Auto-generated by build.rs - DO NOT EDIT\n\n"); + let source = fs::read_to_string(dist_dir.join(&artifact.file)) + .unwrap_or_else(|error| panic!("tsjs: failed to read {}: {error}", artifact.file)); + assert_eq!( + source.len(), + artifact.bytes, + "tsjs: artifact byte length mismatch" + ); + assert_eq!( + hex_digest(source.as_bytes()), + artifact.hash, + "tsjs: artifact content hash mismatch" + ); + assert_eq!( + source.matches(&manifest.release_id).count(), + 1, + "tsjs: artifact must carry the release id exactly once" + ); + assert!( + !source.contains(RELEASE_SENTINEL), + "tsjs: release sentinel remains" + ); + let normalized = source.replacen(&manifest.release_id, RELEASE_SENTINEL, 1); + push_frame(&mut canonical, artifact.id.as_bytes()); + push_frame(&mut canonical, artifact.role.as_bytes()); + push_frame( + &mut canonical, + artifact.phase.as_deref().unwrap_or_default().as_bytes(), + ); + push_frame( + &mut canonical, + artifact.trigger.as_deref().unwrap_or_default().as_bytes(), + ); + push_frame(&mut canonical, normalized.as_bytes()); + } + assert_eq!( + hex_digest(&canonical), + manifest.release_id, + "tsjs: sentinel-normalized release hash mismatch" + ); + manifest +} +fn generate_metadata(manifest: &ReleaseManifest, catalog: &CatalogManifest, out_dir: &Path) { + let mut code = String::from("// Auto-generated by build.rs - DO NOT EDIT\n\n"); + let integrations = manifest + .artifacts + .iter() + .filter(|artifact| artifact.role == "integration") + .count(); + let takeover = manifest + .artifacts + .iter() + .filter(|artifact| artifact.phase.as_deref() == Some("takeover")) + .count(); + writeln!( + code, + "pub(crate) const TSJS_RELEASE_ID: &str = {:?};", + manifest.release_id + ) + .expect("should write release id"); + writeln!( + code, + "pub(crate) const GENERATED_MAX_TAKEOVER_MODULES: usize = {takeover};\npub(crate) const GENERATED_MAX_MANIFEST_MODULES: usize = {integrations};" + ) + .expect("should write generated capacities"); + writeln!( + code, + "pub(crate) const PERMITTED_FIRST_DISPLAY_MASKS: &[u16] = &[{}];", + catalog + .permitted_first_display_masks + .iter() + .map(|mask| format!("0x{mask}")) + .collect::>() + .join(", ") + ) + .expect("should write permitted first-display masks"); + code.push_str( + "pub(crate) const TSJS_BOOTSTRAP: &str = include_str!(concat!(env!(\"OUT_DIR\"), \"/tsjs-bootstrap.js\"));\n\n", + ); writeln!( - codegen, - "pub(crate) const TSJS_MODULES: [TsjsModuleMeta; {}] = [", - modules.len() + code, + "pub(crate) const TSJS_ARTIFACTS: [TsjsGeneratedArtifactMeta; {}] = [", + manifest.artifacts.len() ) - .expect("should write generated module header"); - for (id, filename) in &modules { - let sha256 = bundle_sha256(&out_dir.join(filename)); + .expect("should write generated artifact header"); + for artifact in &manifest.artifacts { + let inputs = rust_string_slice(&artifact.inputs); + let outputs = rust_string_slice(&artifact.outputs); + let include = match artifact.role.as_str() { + "integration" => catalog + .modules + .iter() + .find(|module| module.id == artifact.id) + .map(|module| module.include.as_str()), + "first_display_base" | "first_display_slice" => catalog + .first_display + .iter() + .find(|module| module.id == artifact.id) + .map(|module| module.include.as_str()), + _ => None, + }; writeln!( - codegen, - " TsjsModuleMeta {{\n bundle: include_str!(concat!(env!(\"OUT_DIR\"), \"/{filename}\")),\n id: \"{id}\",\n sha256: \"{sha256}\",\n }},\n" + code, + " TsjsGeneratedArtifactMeta {{ id: {:?}, role: {:?}, phase: {}, trigger: {}, include: {}, inputs: {inputs}, outputs: {outputs}, file: {:?}, hash: {:?}, bundle: include_str!(concat!(env!(\"OUT_DIR\"), {:?})) }},", + artifact.id, + artifact.role, + rust_option(artifact.phase.as_deref()), + rust_option(artifact.trigger.as_deref()), + rust_option(include), + artifact.file, + artifact.hash, + format!("/{}", artifact.file), ) - .expect("should write generated module entry"); + .expect("should write generated artifact"); } - codegen.push_str("];\n"); - codegen.push_str("\npub(crate) struct TsjsModuleMeta {\n"); - codegen.push_str(" pub bundle: &'static str,\n"); - codegen.push_str(" pub id: &'static str,\n"); - codegen.push_str(" pub sha256: &'static str,\n"); - codegen.push_str("}\n"); + code.push_str( + "];\n\npub(crate) struct TsjsGeneratedArtifactMeta {\n pub bundle: &'static str,\n pub file: &'static str,\n pub hash: &'static str,\n pub id: &'static str,\n pub include: Option<&'static str>,\n pub inputs: &'static [&'static str],\n pub outputs: &'static [&'static str],\n pub phase: Option<&'static str>,\n pub role: &'static str,\n pub trigger: Option<&'static str>,\n}\n", + ); + fs::write(out_dir.join("tsjs_modules.rs"), code).expect("should write generated TSJS metadata"); +} - let generated_path = out_dir.join("tsjs_modules.rs"); - fs::write(&generated_path, &codegen).unwrap_or_else(|err| { - panic!( - "tsjs: failed to write generated code to {}: {err}", - generated_path.display() - ); - }); +fn rust_string_slice(values: &[String]) -> String { + format!( + "&[{}]", + values + .iter() + .map(|value| format!("{value:?}")) + .collect::>() + .join(", ") + ) } -fn bundle_sha256(path: &Path) -> String { - let content = fs::read(path).unwrap_or_else(|err| { - panic!( - "tsjs: failed to read copied bundle {} for hashing: {err}", - path.display() - ); - }); - hex::encode(Sha256::digest(&content)) +fn rust_option(value: Option<&str>) -> String { + value.map_or_else(|| "None".to_owned(), |value| format!("Some({value:?})")) } -fn copy_bundle(filename: &str, required: bool, dist_dir: &Path, out_dir: &Path) { - let source = dist_dir.join(filename); - let target = out_dir.join(filename); +fn push_u64(target: &mut Vec, value: usize) { + let value = u64::try_from(value).expect("should fit release frame length in u64"); + target.extend_from_slice(&value.to_be_bytes()); +} - if source.exists() { - if let Err(err) = fs::copy(&source, &target) { - assert!( - !required, - "tsjs: failed to copy {} to {}: {err}", - source.display(), - target.display() - ); - } - return; - } +fn push_frame(target: &mut Vec, bytes: &[u8]) { + push_u64(target, bytes.len()); + target.extend_from_slice(bytes); +} - assert!( - !required, - "tsjs: bundle {filename} not found: {}. Ensure Node is installed and `npm run build` succeeds, or commit dist/{filename}.", - source.display() - ); +fn valid_hash(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} - fs::write(&target, "").expect("should write optional empty bundle placeholder"); +fn hex_digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn copy_bundle(filename: &str, crate_dir: &Path, dist_dir: &Path, out_dir: &Path) { + let primary = dist_dir.join(filename); + let fallback = crate_dir.join("dist").join(filename); + let target = out_dir.join(filename); + for source in [&primary, &fallback] { + if source.exists() { + fs::copy(source, &target).unwrap_or_else(|error| { + panic!( + "tsjs: failed to copy {} to {}: {error}", + source.display(), + target.display() + ) + }); + return; + } + } + panic!("tsjs: bundle {filename} was not generated"); } fn watch_dir_recursively(root: &Path) { @@ -203,15 +529,14 @@ fn watch_dir_recursively(root: &Path) { return; } let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - let Ok(read) = fs::read_dir(&dir) else { + while let Some(directory) = stack.pop() { + let Ok(entries) = fs::read_dir(&directory) else { continue; }; - for entry in read.flatten() { + for entry in entries.flatten() { let path = entry.path(); - // Always ask Cargo to rerun if this path changes - if let Some(path_str) = path.to_str() { - println!("cargo:rerun-if-changed={path_str}"); + if let Some(path_string) = path.to_str() { + println!("cargo:rerun-if-changed={path_string}"); } if path.is_dir() { stack.push(path); diff --git a/crates/trusted-server-js/lib/.prettierignore b/crates/trusted-server-js/lib/.prettierignore index 72274829b..6b02254be 100644 --- a/crates/trusted-server-js/lib/.prettierignore +++ b/crates/trusted-server-js/lib/.prettierignore @@ -1,4 +1,5 @@ node_modules dist coverage - +src/core/contracts/generated/renderer_validator_v1.ts +test/fixtures/performance/aps-tsjs-prechange.json diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 2bfee01b1..742d3e90d 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -1,93 +1,493 @@ -/** - * Multi-entry Vite build script. - * - * Builds each integration as a separate IIFE file so the Rust server can - * concatenate only the enabled modules at runtime. - * - * Output (in ../dist/): - * tsjs-core.js — core API (always included) - * tsjs-.js — one per discovered integration - * - * The prebid integration builds here as the tsjs shim only — Prebid.js itself - * is never bundled into tsjs. Use build-prebid-external.mjs to generate the - * pure Prebid.js external bundle (core + adapters + user ID modules) that the - * shim requires at runtime via integrations.prebid.external_bundle_url. - */ +/** Build the phase-aware, content-addressed TSJS release from its canonical catalog. */ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { transform } from 'esbuild'; import { build } from 'vite'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const srcDir = path.resolve(__dirname, 'src'); -const distDir = path.resolve(__dirname, '..', 'dist'); -const integrationsDir = path.join(srcDir, 'integrations'); - -// Clean dist directory -fs.rmSync(distDir, { recursive: true, force: true }); -fs.mkdirSync(distDir, { recursive: true }); - -// Discover integration modules: directories in src/integrations/ with index.ts -const integrationModules = fs.existsSync(integrationsDir) - ? fs - .readdirSync(integrationsDir) - .filter((name) => { - const fullPath = path.join(integrationsDir, name); - return ( - fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')) - ); - }) - .sort() - : []; +import { + deriveInventorySetFiles, + measureBundleSet, + measureBytes, + measureReachableFirstDisplayMasks, + BUNDLE_SEPARATOR, +} from './scripts/bundle-metrics.mjs'; +import { computeReleaseId, RELEASE_SENTINEL, stampRelease } from './scripts/release-v1.mjs'; + +const libDirectory = path.dirname(fileURLToPath(import.meta.url)); +const sourceDirectory = path.join(libDirectory, 'src'); +const distributionDirectory = path.resolve(libDirectory, '..', 'dist'); +const metricsFile = 'tsjs-build-metrics-v1.json'; +const releaseFile = 'tsjs-release-v1.json'; +const catalogFile = 'tsjs-catalog-v1.json'; +const bootstrapFile = 'tsjs-bootstrap.js'; +const runtimeClaimBanner = + '(()=>{const __TSJS_RUNTIME_CLAIMED_V1__=(()=>{try{const source=window.tsjs,claim=source&&source._claimRuntimeV1;return typeof claim==="function"?claim(source):undefined}catch{return undefined}})();'; +const runtimeClaimFooter = '})();'; + +// Closure-private implementation names used only inside the inline artifact. +// Registration, takeover, handoff, and public API protocol keys are excluded. +const bootstrapPrivateProperties = + /^(?:stateValue|registrations|disposers|authenticated|reject|unwind)$/; + +// These properties are closure-private implementation details of the base agent. +// Mangle only that artifact: protocol, registration, handoff, and public agent keys +// intentionally keep their authored names across independently built components. +const firstDisplayBasePrivateProperties = + /^(?:options|driver|production|renderer|startedAtMs|performance|paint|onProtectedPaint|onSettled|onFailure|onPrebidAdmissionFailure|mutationDocument|initialMutationRevision|identityIssuer|gptInput|onAgentReady|sliceBindings|stateValue|agentBatch|handoffOwner|handoffCapsule|mutationObserver|observedMutationRevision|displayWasCommitted|failed|pending|actionStarted|disposedDriver|handoffFinalized|committedArtifactsDetached|lastTimingMs|firstActionAtMs|terminalAtMs|paintAtMs|bound|productionBatch|recordTerminal|recordFirstAction|scheduleProtectedPaint|readTiming|startProduction|acceptedSlotIds|closeDriverIngress|captureDriverHandoff|detachDriverArtifacts|captureHandoffData|disposeDriver|installNativeMutationIngress|observeDomMutations|isOwnedRuntimeInsertion|closeNativeMutationIngress|disposeNativeMutationIngress|observeNativeMutation|finalizeHandoff|detachCommittedArtifacts|mutationRevision|start|settle|fail|dispose|activate|sliceHost|install|parserState|observations|own|afterActivate|claimTimer|completionTimer|controlRelease|directFrame|documentAccepted|documentAcceptancePending|documentRelease|documentTimer|documentTransferred|insertionTimer|pendingDocumentTerminal|ownerSource|ownerTicket|bootstrapNonce|rendererNonce|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|inserted|ticket|cycle|onTerminal|reservationId|timer|attempt|batch|slotResults|reasons|acceptedTrace|nextTraceSequence)$/; + +const combinedBootstrapPrivateProperties = new RegExp( + `${bootstrapPrivateProperties.source}|${firstDisplayBasePrivateProperties.source}` +); + +// The source-neutral owner keeps these fields inside one independently built IIFE. +const firstDisplayRenderOwnerPrivateProperties = + /^(?:claimTimer|controlRelease|insertionTimer|ownerSource|ownerTicket|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|execution|originalCount|exact|exactShape|ports|port|source|bind|recordGam|recordFailure|sweepCommittedArtifacts|sealTsAdmission|closeIngress|captureHandoff|detachCommittedArtifacts|artifacts|tombstones|clockEpochMs|nextReservationOrdinal|nextTicketOrdinal|expiresAtMs|ordinal)$/; + +// APS keeps these renderer-specific fields inside its independently built IIFE. +// Cross-artifact strategy, protocol, callback, and artifact keys remain authored. +const firstDisplayApsPrivateProperties = + /^(?:callbacks|overlay|active|accepted|bootstrapNavigated|bootstrapNonceInternal|bootstrapSource|completionTimer|cycle|documentAccepted|documentPort|documentRelease|documentTimer|frame|hostPositionOwned|pendingTerminal|previousHostPosition|previousHostPositionPriority|rendererNonceInternal|originalCount|exact|ports|rendererUrl|sandbox|permanentSandbox|deadlines|documentAcceptanceMs|completionMs|isBootstrapNonce|isRendererNonce|bootstrapPolicy|parseDocumentMessage|parseWindowMessage)$/; + +// These names are private to the GPT initial IIFE and never cross its protocol +// receipt or handoff boundary. Keep the cross-artifact protocol keys authored. +const firstDisplayGptPrivateProperties = + /^(?:options|clearTimer|setTimer|projection|diagnosticsActive|onNativeMutation|binding|command|cycleMap|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|activate|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restoreTargetingObservers|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting|sealedTargetingOwnership|targetingWrites|ingressClosing|diagnosticRecords|nextDiagnosticCycleOrdinal|publicCycle|requestTimer|completionTimer|requestOperation|requestInvoked|bindingState|operations|requested|settled|current|valid|consumed|operation|protocol|restore|handle|fired|deadlines|externalReadyMs|requestStartMs|completionMs|requestPlan|classifyRenderEnded|encodedBytes|readPhysicalElementId|retireCycle|snapshotDestroyedCycles|invalidateCyclesForElement|invalidateCyclesForPublisherCall|consumeTargetingWrite|invalidateStaleTargetingObservers|captureRetainedTargeting|targetingOwnership|cycles|facts|nextTraceTokenOrdinal|overflowCount|dropCount|nextCycleOrdinal|quarantines|records|responseIdentifier|seen|state|unknownPriorCycle|ordinal|timer|start|closeIngress|captureHandoff|captureDiagnosticsHandoff|detachCommittedSlots|dispose)$/; + +fs.rmSync(distributionDirectory, { recursive: true, force: true }); +fs.mkdirSync(distributionDirectory, { recursive: true }); + +// Build the TypeScript catalog itself as a temporary Node module. This keeps the +// browser, release builder, and generated Rust metadata on one authored authority. +const catalogModuleFile = '.release-catalog-v1.mjs'; +const catalogSource = fs.readFileSync( + path.join(sourceDirectory, 'kernel', 'release_catalog.ts'), + 'utf8' +); +const transformedCatalog = await transform(catalogSource, { + format: 'esm', + loader: 'ts', + target: 'es2020', +}); +fs.writeFileSync(path.join(distributionDirectory, catalogModuleFile), transformedCatalog.code); +const catalogModule = await import( + `${pathToFileURL(path.join(distributionDirectory, catalogModuleFile)).href}?build=${Date.now()}` +); +const releaseCatalog = catalogModule.RELEASE_CATALOG; +const firstDisplayCatalog = catalogModule.FIRST_DISPLAY_CATALOG; +catalogModule.validateReleaseCatalog(releaseCatalog); +if (releaseCatalog.length !== 20) throw new Error('[build-all] Catalog must contain 20 rows'); +if (firstDisplayCatalog.length !== 14 || firstDisplayCatalog[0]?.id !== 'first_display') { + throw new Error('[build-all] First-display catalog must contain its base and thirteen slices'); +} +const runtimeCatalog = releaseCatalog.map(({ id, phase, trigger, config, consumes, provides }) => ({ + id, + phase, + trigger, + config, + consumes, + provides, +})); +fs.rmSync(path.join(distributionDirectory, catalogModuleFile)); + +const sourceById = Object.freeze({ + render_runtime: 'integrations/render_runtime/transport_marker.ts', + aps: 'integrations/aps/index.ts', + creative: 'integrations/creative/index.ts', + datadome: 'integrations/datadome/index.ts', + didomi: 'integrations/didomi/index.ts', + google_tag_manager: 'integrations/google_tag_manager/index.ts', + gpt: 'integrations/gpt/index.ts', + gpt_diagnostics: 'integrations/gpt_diagnostics/index.ts', + lockr: 'integrations/lockr/index.ts', + osano_consent: 'integrations/osano/consent.ts', + permutive_context: 'integrations/permutive/context.ts', + sourcepoint_consent: 'integrations/sourcepoint/consent.ts', + prebid: 'integrations/prebid/index.ts', + testlight: 'integrations/testlight/index.ts', + diagnostics_presentation: 'integrations/gpt_diagnostics/presentation.ts', + gpt_later: 'integrations/gpt/later.ts', + osano_lifecycle: 'integrations/osano/lifecycle.ts', + permutive_lifecycle: 'integrations/permutive/lifecycle.ts', + prebid_later: 'integrations/prebid/later.ts', + sourcepoint_lifecycle: 'integrations/sourcepoint/lifecycle.ts', +}); + +const firstDisplaySourceById = Object.freeze({ + first_display: 'first_display/base_marker.ts', + render_owner_initial: 'first_display/slices/render_owner.ts', + aps_initial: 'first_display/slices/aps.ts', + creative_initial: 'first_display/slices/creative.ts', + datadome_initial: 'first_display/slices/datadome.ts', + didomi_initial: 'first_display/slices/didomi.ts', + google_tag_manager_initial: 'first_display/slices/google_tag_manager.ts', + gpt_initial: 'first_display/slices/gpt.ts', + lockr_initial: 'first_display/slices/lockr.ts', + osano_initial: 'first_display/slices/osano.ts', + permutive_initial: 'first_display/slices/permutive.ts', + sourcepoint_initial: 'first_display/slices/sourcepoint.ts', + prebid_initial: 'first_display/slices/prebid.ts', + testlight_initial: 'first_display/slices/testlight.ts', +}); + +const catalogIds = releaseCatalog.map(({ id }) => id); +if ( + Object.keys(sourceById).length !== releaseCatalog.length || + catalogIds.some((id) => !(id in sourceById)) +) { + throw new Error('[build-all] Catalog/source inventory mismatch'); +} +if (new Set(Object.values(sourceById)).size !== releaseCatalog.length) { + throw new Error('[build-all] Every catalog artifact must have one distinct source entry'); +} +if ( + Object.keys(firstDisplaySourceById).length !== firstDisplayCatalog.length || + firstDisplayCatalog.some(({ id }) => !(id in firstDisplaySourceById)) || + new Set(Object.values(firstDisplaySourceById)).size !== firstDisplayCatalog.length +) { + throw new Error('[build-all] First-display catalog/source inventory mismatch'); +} -console.log('[build-all] Discovered integrations:', integrationModules); +const artifacts = [ + { + id: 'bootstrap', + role: 'bootstrap', + phase: '', + trigger: '', + inputs: [], + outputs: [], + file: bootstrapFile, + entry: 'core/bootstrap.ts', + }, + ...firstDisplayCatalog.map((entry, index) => ({ + id: entry.id, + role: index === 0 ? 'first_display_base' : 'first_display_slice', + phase: 'first_display', + trigger: '', + inputs: [...entry.inputs], + outputs: [...entry.outputs], + file: `tsjs-${entry.id}.js`, + entry: firstDisplaySourceById[entry.id], + maskBit: index, + })), + { + id: 'core', + role: 'core', + phase: '', + trigger: '', + inputs: [], + outputs: ['runtime.v1'], + file: 'tsjs-core.js', + entry: 'composition/runtime_transport.ts', + }, + ...releaseCatalog.map((entry) => ({ + id: entry.id, + role: 'integration', + phase: entry.phase, + trigger: entry.trigger ?? '', + inputs: [...entry.consumes], + outputs: [...entry.provides], + file: `tsjs-${entry.id}.js`, + entry: sourceById[entry.id], + })), +]; -/** Build a single module as a self-contained IIFE. */ -async function buildModule(name, entryPath) { - const outFile = `tsjs-${name}.js`; - console.log(`[build-all] Building ${outFile} from ${path.relative(__dirname, entryPath)}`); +const ids = new Set(); +const files = new Set(); +for (const artifact of artifacts) { + if (ids.has(artifact.id) || files.has(artifact.file)) { + throw new Error(`[build-all] Duplicate artifact: ${artifact.id}`); + } + if (/(?:^|\/)(?:test|fixtures?|fakes?|no-?op)(?:\/|$)/iu.test(artifact.entry)) { + throw new Error(`[build-all] Test/fake/no-op artifact source: ${artifact.entry}`); + } + ids.add(artifact.id); + files.add(artifact.file); +} - await build({ +async function buildArtifact(artifact) { + const entryPath = path.join(sourceDirectory, artifact.entry); + if (!fs.existsSync(entryPath)) throw new Error(`[build-all] Missing source: ${artifact.entry}`); + console.log(`[build-all] Building ${artifact.file} from ${artifact.entry}`); + const result = await build({ configFile: false, - root: __dirname, + root: libDirectory, + ...(artifact.role === 'bootstrap' + ? { esbuild: { mangleProps: combinedBootstrapPrivateProperties, mangleQuoted: true } } + : artifact.id === 'first_display' + ? { esbuild: { mangleProps: firstDisplayBasePrivateProperties, mangleQuoted: true } } + : artifact.id === 'render_owner_initial' + ? { + esbuild: { + mangleProps: firstDisplayRenderOwnerPrivateProperties, + mangleQuoted: true, + }, + } + : artifact.id === 'aps_initial' + ? { esbuild: { mangleProps: firstDisplayApsPrivateProperties, mangleQuoted: true } } + : artifact.id === 'gpt_initial' + ? { esbuild: { mangleProps: firstDisplayGptPrivateProperties, mangleQuoted: true } } + : {}), + define: { + __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify(RELEASE_SENTINEL), + __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: JSON.stringify(catalogIds), + __TSJS_EMBEDDED_RUNTIME_CATALOG_V1__: JSON.stringify(runtimeCatalog), + __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__: JSON.stringify(releaseCatalog.length), + }, build: { emptyOutDir: false, - outDir: distDir, + outDir: distributionDirectory, assetsDir: '.', + target: 'es2022', sourcemap: false, minify: 'esbuild', rollupOptions: { input: entryPath, output: { format: 'iife', - dir: distDir, - entryFileNames: outFile, - inlineDynamicImports: true, + dir: distributionDirectory, + entryFileNames: artifact.file, extend: false, - // Use a unique IIFE name per module to avoid conflicts - name: name === 'core' ? 'tsjs' : `tsjs_${name}`, + name: `tsjs_${artifact.id}`, + ...(artifact.id === 'core' + ? { banner: runtimeClaimBanner, footer: runtimeClaimFooter } + : {}), }, }, }, logLevel: 'warn', }); - console.log(`[build-all] Built ${outFile}`); + const outputs = (Array.isArray(result) ? result : [result]).flatMap((item) => item.output); + const chunk = outputs.find((item) => item.type === 'chunk' && item.fileName === artifact.file); + if (!chunk || chunk.type !== 'chunk') { + throw new Error(`[build-all] Missing generated chunk metadata: ${artifact.file}`); + } + artifact.moduleIds = Object.freeze( + Object.keys(chunk.modules).map((moduleId) => path.relative(libDirectory, moduleId)) + ); + artifact.moduleContributions = Object.freeze( + Object.entries(chunk.modules).map(([moduleId, contribution]) => ({ + file: path.relative(libDirectory, moduleId), + renderedBytes: contribution.renderedLength, + })) + ); + + const filePath = path.join(distributionDirectory, artifact.file); + const source = fs.readFileSync(filePath, 'utf8'); + const sentinelCount = source.split(RELEASE_SENTINEL).length - 1; + if (sentinelCount > 1) { + throw new Error(`[build-all] Multiple release sentinels before stamping: ${artifact.file}`); + } + if (sentinelCount === 0) fs.writeFileSync(filePath, `${source}\n;void"${RELEASE_SENTINEL}";\n`); } -// Build core first (synchronously), then all integrations in parallel -await buildModule('core', path.join(srcDir, 'core', 'index.ts')); +await buildArtifact(artifacts[0]); +await Promise.all(artifacts.slice(1).map(buildArtifact)); -await Promise.all( - integrationModules.map((name) => buildModule(name, path.join(integrationsDir, name, 'index.ts'))) +const deferredEntries = new Set( + artifacts + .filter(({ phase }) => phase === 'deferred') + .map(({ entry }) => path.normalize(`src/${entry}`)) ); +for (const artifact of artifacts) { + if (artifact.role !== 'core' && artifact.phase !== 'takeover') continue; + const reachedDeferred = artifact.moduleIds.find((moduleId) => + deferredEntries.has(path.normalize(moduleId)) + ); + if (reachedDeferred) { + throw new Error(`[build-all] ${artifact.id} reaches deferred source entry ${reachedDeferred}`); + } +} +const persistentEntryPrefixes = Object.freeze([ + 'src/core/', + 'src/kernel/integration_registry.ts', + 'src/kernel/runtime.ts', + 'src/kernel/sessions.ts', + 'src/services/', + 'src/integrations/', +]); +for (const artifact of artifacts.filter(({ phase }) => phase === 'first_display')) { + const catalogEntry = firstDisplayCatalog.find(({ id }) => id === artifact.id); + if (!catalogEntry) + throw new Error(`[build-all] Missing first-display catalog row: ${artifact.id}`); + const ownEntry = path.normalize(`src/${artifact.entry}`); + const allowed = new Set( + catalogEntry.allowedImports.map((moduleId) => path.normalize(`src/${moduleId}.ts`)) + ); + const forbidden = artifact.moduleIds.find((moduleId) => + persistentEntryPrefixes.some( + (prefix) => + path.normalize(moduleId).startsWith(path.normalize(prefix)) && + !allowed.has(path.normalize(moduleId)) + ) + ); + if (forbidden) { + throw new Error(`[build-all] ${artifact.id} reaches persistent source ${forbidden}`); + } + const undeclared = artifact.moduleIds.find((moduleId) => { + const normalized = path.normalize(moduleId); + return normalized !== ownEntry && !allowed.has(normalized); + }); + if (undeclared) { + throw new Error( + `[build-all] ${artifact.id} reaches undeclared first-display source ${undeclared}` + ); + } +} +for (const artifact of artifacts.filter( + ({ role, phase }) => role === 'core' || phase === 'takeover' || phase === 'deferred' +)) { + const forbidden = artifact.moduleIds.find((moduleId) => + path.normalize(moduleId).startsWith(path.normalize('src/first_display/')) + ); + if (forbidden) { + throw new Error(`[build-all] ${artifact.id} reaches first-display source ${forbidden}`); + } +} +const generatedJavaScript = fs + .readdirSync(distributionDirectory) + .filter((file) => file.endsWith('.js')); +const expectedJavaScript = artifacts.map(({ file }) => file); +if ( + generatedJavaScript.length !== expectedJavaScript.length || + expectedJavaScript.some((file) => !generatedJavaScript.includes(file)) +) { + throw new Error('[build-all] Missing or unknown production JavaScript artifact'); +} -// List all built files -const builtFiles = fs - .readdirSync(distDir) - .filter((f) => f.startsWith('tsjs-') && f.endsWith('.js')) - .sort(); +const releaseId = computeReleaseId( + artifacts.map((artifact) => ({ + id: artifact.id, + role: artifact.role, + phase: artifact.phase, + trigger: artifact.trigger, + bytes: fs.readFileSync(path.join(distributionDirectory, artifact.file)), + })) +); + +for (const artifact of artifacts) { + const filePath = path.join(distributionDirectory, artifact.file); + fs.writeFileSync(filePath, stampRelease(fs.readFileSync(filePath), releaseId)); +} + +const artifactInventory = artifacts.map((artifact) => { + const bytes = fs.readFileSync(path.join(distributionDirectory, artifact.file)); + return { + id: artifact.id, + role: artifact.role, + phase: artifact.phase || null, + trigger: artifact.trigger || null, + inputs: artifact.inputs, + outputs: artifact.outputs, + file: artifact.file, + bytes: bytes.byteLength, + hash: createHash('sha256').update(bytes).digest('hex'), + }; +}); +fs.writeFileSync( + path.join(distributionDirectory, releaseFile), + `${JSON.stringify({ version: 1, releaseId, artifacts: artifactInventory })}\n` +); +const bootstrapArtifact = artifacts[0]; +const bootstrapBytes = fs.readFileSync(path.join(distributionDirectory, bootstrapFile)); +const artifactContents = new Map( + artifactInventory.map(({ file }) => [ + file, + fs.readFileSync(path.join(distributionDirectory, file)), + ]) +); +const inventorySetFiles = deriveInventorySetFiles(artifactInventory, releaseCatalog); +const firstDisplayMaskCatalog = firstDisplayCatalog.map(({ id }, maskBit) => ({ + id, + maskBit, + file: `tsjs-${id}.js`, +})); +const firstDisplayMasks = await measureReachableFirstDisplayMasks( + firstDisplayMaskCatalog, + artifactContents +); +fs.writeFileSync( + path.join(distributionDirectory, catalogFile), + `${JSON.stringify({ + version: 1, + firstDisplay: firstDisplayCatalog.map( + ({ order, id, include, allowedImports, inputs, outputs, obligation }) => ({ + order, + id, + include, + allowedImports, + inputs, + outputs, + obligation, + }) + ), + permittedFirstDisplayMasks: firstDisplayMasks + .filter(({ permitted }) => permitted) + .map(({ mask }) => mask), + modules: releaseCatalog.map(({ id, phase, trigger, include }) => ({ + id, + phase, + trigger, + include, + })), + })}\n` +); +const metrics = { + schemaVersion: 1, + compression: { + concatenationSeparator: BUNDLE_SEPARATOR.toString('utf8'), + gzipLevel: 9, + gzipMtime: 0, + brotliMode: 'text', + brotliQuality: 11, + }, + modules: artifacts.slice(1).map((artifact) => { + const bytes = fs.readFileSync(path.join(distributionDirectory, artifact.file)); + return { + file: artifact.file, + entry: path.normalize(`src/${artifact.entry}`), + rawBytes: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + sources: artifact.moduleContributions, + }; + }), + bootstrap: { + file: bootstrapFile, + entry: path.normalize(`src/${bootstrapArtifact.entry}`), + ...measureBytes(bootstrapBytes), + sources: bootstrapArtifact.moduleContributions, + }, + firstDisplay: { + catalog: firstDisplayMaskCatalog, + components: Object.fromEntries( + artifacts + .filter(({ phase }) => phase === 'first_display') + .map((artifact) => [ + artifact.id, + { + file: artifact.file, + entry: path.normalize(`src/${artifact.entry}`), + ...measureBytes(fs.readFileSync(path.join(distributionDirectory, artifact.file))), + sources: artifact.moduleContributions, + }, + ]) + ), + masks: firstDisplayMasks, + }, + sets: Object.fromEntries( + Object.entries(inventorySetFiles).map(([setName, setFiles]) => [ + setName, + measureBundleSet(setFiles, artifactContents), + ]) + ), +}; +fs.writeFileSync( + path.join(distributionDirectory, metricsFile), + `${JSON.stringify(metrics, null, 2)}\n` +); -console.log('[build-all] Built files:', builtFiles); -console.log(`[build-all] Total: ${builtFiles.length} modules`); +console.log(`[build-all] Built ${artifacts.length} canonical artifacts`); +console.log(`[build-all] Wrote ${releaseFile} for release ${releaseId}`); diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index eb6e42826..1a77b9b70 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -34,6 +34,35 @@ const PREBID_LIVE_INTENT_STANDARD = path.join( ); const PREBID_GLOBAL_MODULE = path.join(PREBID_PACKAGE_DIR, 'dist', 'src', 'src', 'prebidGlobal.js'); const LIVE_INTENT_SHIM = path.join(prebidDir, 'prebid_modules', 'liveIntentIdSystem.ts'); +export const ARTIFACT_RELEASE_SENTINEL = '0'.repeat(64); +const ARTIFACT_PROPERTY = '__trustedServerArtifactV1'; +const EXPECTED_PREBID_VERSION = '10.26.0'; +const LEGACY_RUNTIME_FLAG_PREFIX = ['__', 'tsjs', '_'].join(''); + +/** Refuse to publish an external Prebid artifact carrying a retired TSJS runtime flag. */ +export function assertNoLegacyRuntimeFlags(bundleCode) { + if (bundleCode.includes(LEGACY_RUNTIME_FLAG_PREFIX)) { + throw new Error( + '[build-prebid-external] Generated artifact contains a legacy TSJS runtime flag' + ); + } +} + +const TS_OWNED_PREBID_ARTIFACT_MARKERS = [ + /\bTS (?:APS|ADM|Owner|Render Owner)\b/u, + /\/_ts\/(?:auction|page-bids)\b/u, + /\b(?:rendererReservationId|lifecycleTicket)\b/u, + /\btsjs\.(?:addAdUnits|diagnostics|requestAds)\b/u, +]; + +/** Keep the independently useful external bundle free of TS-owned behavior. */ +export function assertPurePrebidArtifact(bundleCode) { + if (TS_OWNED_PREBID_ARTIFACT_MARKERS.some((expression) => expression.test(bundleCode))) { + throw new Error( + '[build-prebid-external] Generated artifact contains TS-owned auction or render behavior' + ); + } +} export function parseArgs(argv) { const options = new Map(); @@ -66,10 +95,14 @@ export function parseArgs(argv) { } function parseList(raw) { - return raw + const values = raw .split(',') .map((value) => value.trim()) .filter(Boolean); + if (new Set(values).size !== values.length) { + throw new Error('[build-prebid-external] Module lists must not contain duplicates'); + } + return values.sort(); } function requireExistingFile(filePath, description) { @@ -102,7 +135,8 @@ function validateUserIdImport(entry) { } catch (error) { throw new Error( `[build-prebid-external] Required Prebid user ID module "${entry.moduleName}" ` + - `could not be resolved from ${entry.importPath}: ${error.message}` + `could not be resolved from ${entry.importPath}: ${error.message}`, + { cause: error } ); } } @@ -137,8 +171,13 @@ export function renderIncludedUserIdModulesExport(moduleNames) { * list, while the module-name list is retained separately for audit output. */ export function readAdapterBidderCodes(adapterNames) { + return readAdapterMetadata(adapterNames).bidderCodes; +} + +export function readAdapterMetadata(adapterNames) { const metadataDir = path.join(PREBID_PACKAGE_DIR, 'metadata', 'modules'); const bidderCodes = new Set(); + const bidderAliases = []; for (const name of adapterNames) { const metadataPath = path.join(metadataDir, `${name}BidAdapter.json`); @@ -159,10 +198,20 @@ export function readAdapterBidderCodes(adapterNames) { } for (const component of bidderComponents) { bidderCodes.add(component.componentName); + if (typeof component.aliasOf === 'string' && component.aliasOf.length > 0) { + bidderAliases.push({ code: component.componentName, moduleStem: name }); + } } } - return [...bidderCodes].sort(); + return { + bidderCodes: [...bidderCodes].sort(), + bidderAliases: bidderAliases.sort( + (left, right) => + (left.code < right.code ? -1 : left.code > right.code ? 1 : 0) || + (left.moduleStem < right.moduleStem ? -1 : left.moduleStem > right.moduleStem ? 1 : 0) + ), + }; } function generateAdapterImports(adapterNames, adaptersFile) { @@ -212,7 +261,15 @@ function generateUserIdImports(requestedModules, userIdsFile) { imports, [renderIncludedUserIdModulesExport(moduleNames)] ); - return moduleNames; + return selectedEntries + .map((entry) => ({ + moduleName: entry.moduleName, + configNames: [...new Set(entry.configNames)].sort(), + eidSources: [...new Set(entry.eidSources.map((source) => source.toLowerCase()))].sort(), + })) + .sort((left, right) => + left.moduleName < right.moduleName ? -1 : left.moduleName > right.moduleName ? 1 : 0 + ); } function createTemporaryModulePaths() { @@ -227,50 +284,20 @@ function createTemporaryModulePaths() { const SHIM_WATCHDOG_DELAY_MS = 5000; -function generateExternalEntry(entryFile, adapters, bidderCodes) { +function generateExternalEntry(entryFile) { const content = [ '// Auto-generated by build-prebid-external.mjs.', '//', '// Pure Prebid.js external bundle: core, consent modules, user ID modules,', - '// and client-side bid adapters. The Trusted Server prebid shim', - '// (tsjs-prebid, served by the server) installs the trustedServer adapter', - '// onto the `window.pbjs` global this bundle populates and drives queue', - '// processing — this bundle intentionally does NOT call processQueue()', - '// itself, except through the watchdog below.', + '// and client-side bid adapters. Trusted Server auction, admission, render,', + '// targeting, and refresh behavior intentionally live outside this artifact.', "import 'prebid.js';", "import 'prebid.js/modules/consentManagementTcf.js';", "import 'prebid.js/modules/consentManagementGpp.js';", "import 'prebid.js/modules/consentManagementUsp.js';", "import 'prebid.js/modules/userId.js';", "import './_adapters.generated';", - "import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated';", - '', - '// Manifest consumed by the tsjs prebid shim to validate that every', - '// configured client_side_bidder has its adapter compiled in. adapters', - '// lists the module file stems for audit output; bidderCodes lists the', - '// registered runtime bidder codes, including aliases.', - 'const bundleWindow = window as unknown as {', - ' __tsjs_prebid_bundle?: unknown;', - ' __tsjsPrebidShimInstalled?: boolean;', - ' pbjs?: { processQueue?: () => void };', - '};', - 'bundleWindow.__tsjs_prebid_bundle = Object.freeze({', - ` adapters: ${JSON.stringify(adapters)},`, - ` bidderCodes: ${JSON.stringify(bidderCodes)},`, - ' userIdModules: INCLUDED_PREBID_USER_ID_MODULES,', - '});', - '', - '// Watchdog: the shim owns processQueue(), but it is a separate artifact', - '// that can fail to load independently (adblock filters, CSP, a', - '// /static/tsjs= error). If it has not installed within the grace period,', - '// drain the queue anyway so publisher pbjs.que callbacks still run', - '// against plain Prebid.js. processQueue() is safe to call again when the', - '// shim arrives late.', - 'setTimeout(() => {', - ' if (!bundleWindow.__tsjsPrebidShimInstalled) {', - ' bundleWindow.pbjs?.processQueue?.();', - ' }', - `}, ${SHIM_WATCHDOG_DELAY_MS});`, + "import './_user_ids.generated';", '', ].join('\n'); @@ -285,7 +312,43 @@ export function deriveBundleMetadata(bundleBytes) { return { filename, sha256, sri }; } -async function buildExternalBundle(outDir, generatedModules) { +function sha256Hex(bytes) { + return crypto.createHash('sha256').update(bytes).digest('hex'); +} + +function renderExternalWrapper(bundleCode, stamp) { + const stampJson = JSON.stringify(stamp); + return [ + '(function(){', + `var __tsWatchdog=setTimeout(function(){if(__tsWatchdogFired)return;__tsWatchdogFired=true;try{var p=window.pbjs;var f=p&&p.processQueue;if(typeof f==="function")Reflect.apply(f,p,[]);}catch(_){}},${SHIM_WATCHDOG_DELAY_MS});`, + 'var __tsWatchdogFired=false;', + 'void __tsWatchdog;', + 'var __tsMissing={};', + 'var __tsWarned=false;', + 'function __tsWarn(){if(__tsWarned)return;__tsWarned=true;try{console.warn("[tsjs-prebid] external Prebid artifact stamp conflict");}catch(_){}}', + 'function __tsData(value,key){try{var descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&Object.prototype.hasOwnProperty.call(descriptor,"value")&&descriptor.enumerable===true&&descriptor.writable===false&&descriptor.configurable===false?descriptor.value:__tsMissing;}catch(_){return __tsMissing;}}', + 'function __tsRecord(value,keys){if(!value||typeof value!=="object"||Object.getPrototypeOf(value)!==Object.prototype||!Object.isFrozen(value))return false;var own;try{own=Reflect.ownKeys(value);}catch(_){return false;}if(own.length!==keys.length)return false;for(var i=0;imax)return false;var own;try{own=Reflect.ownKeys(value);}catch(_){return false;}if(own.length!==value.length+1)return false;for(var i=0;i=55296&&code<=56319){var next=value.charCodeAt(i+1);if(next<56320||next>57343)return false;bytes+=4;i+=1;}else if(code>=56320&&code<=57343)return false;else if(code<=127)bytes+=1;else if(code<=2047)bytes+=2;else bytes+=3;if(bytes>max)return false;}return true;}', + 'function __tsSortedStrings(value,max,maxBytes,lowercase){if(!__tsArray(value,max))return false;var previous;for(var i=0;i=current))return false;previous=current;}return true;}', + 'function __tsContains(values,expected){for(var i=0;i=identity)||!__tsContains(bidders,code)||!__tsContains(modules,stem))return false;previous=identity;}previous="";for(var j=0;j=name)||!__tsContains(modules,name)||!__tsSortedStrings(configs,64,128,false)||!__tsSortedStrings(sources,64,256,true))return false;previous=name;}return true;}catch(_){return false;}}', + 'function __tsEqual(left,right){if(left===right)return true;if(!left||!right||typeof left!=="object"||typeof right!=="object")return false;var leftKeys=Reflect.ownKeys(left);var rightKeys=Reflect.ownKeys(right);if(leftKeys.length!==rightKeys.length)return false;for(var i=0;i moduleName)]), + ].sort(); + const stamp = { + abi: 1, + artifactReleaseId: ARTIFACT_RELEASE_SENTINEL, + prebidVersion: EXPECTED_PREBID_VERSION, + moduleStems, + bidderCodes: adapterMetadata.bidderCodes, + bidderAliases: adapterMetadata.bidderAliases, userIdModules, + }; + const bundle = await buildExternalBundle(args.outDir, generatedModules, stamp); + const manifest = { + ...stamp, + artifactReleaseId: bundle.artifactReleaseId, sha256: bundle.sha256, sri: bundle.sri, filename: bundle.filename, diff --git a/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js b/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js new file mode 100644 index 000000000..07de1d026 --- /dev/null +++ b/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js @@ -0,0 +1,533 @@ +const ADTECH_GLOBALS = new Set(['googletag', 'pbjs']); +const GLOBAL_ROOTS = new Set(['globalThis', 'self', 'window']); + +// Known blind spots include computed composition (`globalThis['goog' + 'letag']`) +// and function-returned roots (`getWin().googletag`). Exact adapter boundaries, +// restricted imports, bundle/source scans, and browser ownership tests remain the +// defense-in-depth layers for those exotic forms. + +function normalizeFilename(filename, rootDirectory) { + const normalized = filename.replaceAll('\\', '/'); + if (!rootDirectory) return normalized.startsWith('./') ? normalized.slice(2) : normalized; + + const normalizedRoot = rootDirectory.replaceAll('\\', '/').replace(/\/$/, ''); + const rootPrefix = `${normalizedRoot}/`; + return normalized.startsWith(rootPrefix) ? normalized.slice(rootPrefix.length) : normalized; +} + +function staticPropertyName(node) { + if (!node.computed && node.property.type === 'Identifier') { + return node.property.name; + } + if (!node.computed && node.property.type === 'PrivateIdentifier') { + return `#${node.property.name}`; + } + if ( + node.computed && + node.property.type === 'Literal' && + typeof node.property.value === 'string' + ) { + return node.property.value; + } + if ( + node.computed && + node.property.type === 'TemplateLiteral' && + node.property.expressions.length === 0 + ) { + return node.property.quasis[0]?.value.cooked; + } + return undefined; +} + +function staticPatternPropertyName(property) { + if (!property.computed && property.key.type === 'Identifier') return property.key.name; + if (property.key.type === 'Literal' && typeof property.key.value === 'string') { + return property.key.value; + } + if ( + property.computed && + property.key.type === 'TemplateLiteral' && + property.key.expressions.length === 0 + ) { + return property.key.quasis[0]?.value.cooked; + } + return undefined; +} + +function staticClassElementName(element) { + if (!element.computed && element.key.type === 'Identifier') return element.key.name; + if (!element.computed && element.key.type === 'PrivateIdentifier') { + return `#${element.key.name}`; + } + if (element.key.type === 'Literal' && typeof element.key.value === 'string') { + return element.key.value; + } + return undefined; +} + +function unwrapExpression(node) { + let current = node; + while ( + current && + [ + 'ChainExpression', + 'TSAsExpression', + 'TSInstantiationExpression', + 'TSNonNullExpression', + 'TSTypeAssertion', + ].includes(current.type) + ) { + current = current.expression; + } + return current; +} + +function strongerOrigin(left, right) { + if (left === 'adtech' || right === 'adtech') return 'adtech'; + if (left === 'root' || right === 'root') return 'root'; + return 'unknown'; +} + +export default { + meta: { + type: 'problem', + docs: { + description: 'Keep GPT and Prebid globals behind TSJS adapter interfaces.', + }, + schema: [ + { + type: 'object', + properties: { + rootDirectory: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + messages: { + externalGlobalOwnedByAdapter: + 'Access to "{{name}}" is owned by an exact adapter directory; inject an adapter interface instead.', + }, + }, + + create(context) { + const sourceCode = context.sourceCode; + const relativeFilename = normalizeFilename(context.filename, context.options[0]?.rootDirectory); + const isAdapter = + relativeFilename.startsWith('src/adapters/') || + relativeFilename.startsWith('src/first_display/adapters/'); + + if (isAdapter) return {}; + + const assignments = new Map(); + const patternAssignments = new Map(); + const loopAssignments = new Map(); + const loopPatternAssignments = new Map(); + const thisPropertyAssignments = new Map(); + const classOwnerTokens = new WeakMap(); + const candidateMembers = []; + const candidateIdentifiers = []; + const candidatePatterns = []; + const reported = new Set(); + + function classOwnerToken(classNode, isStatic) { + let tokens = classOwnerTokens.get(classNode); + if (!tokens) { + tokens = { instance: {}, static: {} }; + classOwnerTokens.set(classNode, tokens); + } + return isStatic ? tokens.static : tokens.instance; + } + + function thisOwner(thisExpression) { + let current = thisExpression.parent; + let staticClassContext = false; + + while (current) { + if (current.type === 'MethodDefinition' || current.type === 'PropertyDefinition') { + staticClassContext = current.static; + } else if (current.type === 'StaticBlock') { + staticClassContext = true; + } else if (current.type === 'ClassDeclaration' || current.type === 'ClassExpression') { + return classOwnerToken(current, staticClassContext); + } else if ( + current.type === 'FunctionDeclaration' || + current.type === 'FunctionExpression' + ) { + const parent = current.parent; + if (parent?.type === 'MethodDefinition') { + current = parent; + continue; + } + if ( + parent?.type === 'Property' && + parent.method && + parent.parent?.type === 'ObjectExpression' + ) { + return parent.parent; + } + return current; + } + current = current.parent; + } + + return sourceCode.ast; + } + + function thisPropertyEntry(owner, propertyName, create) { + let properties = thisPropertyAssignments.get(owner); + if (!properties && create) { + properties = new Map(); + thisPropertyAssignments.set(owner, properties); + } + if (!properties) return undefined; + + let entry = properties.get(propertyName); + if (!entry && create) { + entry = { expressions: [] }; + properties.set(propertyName, entry); + } + return entry; + } + + function recordThisProperty(owner, propertyName, expression) { + thisPropertyEntry(owner, propertyName, true).expressions.push(expression); + } + + function findVariable(identifier) { + let scope = sourceCode.getScope(identifier); + while (scope) { + const variable = scope.set.get(identifier.name); + if (variable) return variable; + scope = scope.upper; + } + return undefined; + } + + function isUnshadowedGlobal(identifier, names) { + if (!names.has(identifier.name)) return false; + const variable = findVariable(identifier); + return !variable || variable.defs.length === 0; + } + + function isReference(identifier) { + let scope = sourceCode.getScope(identifier); + while (scope) { + if (scope.references.some((reference) => reference.identifier === identifier)) return true; + scope = scope.upper; + } + return false; + } + + function patternOriginFromBase(pattern, initializerOrigin, variableName) { + if (pattern.type !== 'ObjectPattern') return 'unknown'; + if (initializerOrigin !== 'root' && initializerOrigin !== 'adtech') return 'unknown'; + + for (const property of pattern.properties) { + if (property.type === 'RestElement') { + if (property.argument.type === 'Identifier' && property.argument.name === variableName) { + return initializerOrigin; + } + continue; + } + const value = + property.value.type === 'AssignmentPattern' ? property.value.left : property.value; + if (value.type !== 'Identifier' || value.name !== variableName) continue; + + const propertyName = staticPatternPropertyName(property); + + if (initializerOrigin === 'root' && ADTECH_GLOBALS.has(propertyName)) return 'adtech'; + if (initializerOrigin === 'root' && propertyName === 'window') return 'root'; + return initializerOrigin === 'adtech' ? 'adtech' : 'unknown'; + } + return 'unknown'; + } + + function patternOrigin(pattern, initializer, variableName, seen) { + return patternOriginFromBase(pattern, expressionOrigin(initializer, seen), variableName); + } + + function variableOrigin(variable, seen) { + if (seen.has(variable)) return 'unknown'; + const nextSeen = new Set(seen).add(variable); + let result = 'unknown'; + + for (const definition of variable.defs) { + if (definition.type === 'Variable') { + const declaration = definition.node; + if (!declaration.init) continue; + + if (declaration.id.type === 'Identifier') { + result = strongerOrigin(result, expressionOrigin(declaration.init, nextSeen)); + } else { + result = strongerOrigin( + result, + patternOrigin(declaration.id, declaration.init, variable.name, nextSeen) + ); + } + } else if (definition.type === 'Parameter') { + let parameter = definition.node.params?.[definition.index]; + if (parameter?.type === 'TSParameterProperty') parameter = parameter.parameter; + if (parameter?.type !== 'AssignmentPattern') continue; + + if (parameter.left.type === 'Identifier') { + result = strongerOrigin(result, expressionOrigin(parameter.right, nextSeen)); + } else { + result = strongerOrigin( + result, + patternOrigin(parameter.left, parameter.right, variable.name, nextSeen) + ); + } + } + } + + for (const expression of assignments.get(variable) ?? []) { + result = strongerOrigin(result, expressionOrigin(expression, nextSeen)); + } + for (const { pattern, initializer } of patternAssignments.get(variable) ?? []) { + result = strongerOrigin( + result, + patternOrigin(pattern, initializer, variable.name, nextSeen) + ); + } + for (const iterable of loopAssignments.get(variable) ?? []) { + result = strongerOrigin(result, iterableElementOrigin(iterable, nextSeen)); + } + for (const { pattern, iterable } of loopPatternAssignments.get(variable) ?? []) { + result = strongerOrigin( + result, + patternOriginFromBase(pattern, iterableElementOrigin(iterable, nextSeen), variable.name) + ); + } + return result; + } + + function iterableElementOrigin(rawNode, seen = new Set()) { + const node = unwrapExpression(rawNode); + if (!node) return 'unknown'; + + if (node.type === 'ArrayExpression') { + return node.elements.reduce((result, element) => { + if (!element) return result; + const origin = + element.type === 'SpreadElement' + ? iterableElementOrigin(element.argument, seen) + : expressionOrigin(element, seen); + return strongerOrigin(result, origin); + }, 'unknown'); + } + + if (node.type === 'Identifier') { + const variable = findVariable(node); + if (!variable || seen.has(variable)) return 'unknown'; + const nextSeen = new Set(seen).add(variable); + let result = 'unknown'; + for (const definition of variable.defs) { + if (definition.type !== 'Variable' || !definition.node.init) continue; + result = strongerOrigin(result, iterableElementOrigin(definition.node.init, nextSeen)); + } + for (const expression of assignments.get(variable) ?? []) { + result = strongerOrigin(result, iterableElementOrigin(expression, nextSeen)); + } + return result; + } + + if (node.type === 'SequenceExpression') { + return iterableElementOrigin(node.expressions.at(-1), seen); + } + if (node.type === 'LogicalExpression' || node.type === 'ConditionalExpression') { + const branches = + node.type === 'ConditionalExpression' + ? [node.consequent, node.alternate] + : [node.left, node.right]; + return branches.reduce( + (result, branch) => strongerOrigin(result, iterableElementOrigin(branch, seen)), + 'unknown' + ); + } + return 'unknown'; + } + + function expressionOrigin(rawNode, seen = new Set()) { + const node = unwrapExpression(rawNode); + if (!node) return 'unknown'; + + if (node.type === 'Identifier') { + if (isUnshadowedGlobal(node, GLOBAL_ROOTS)) return 'root'; + if (isUnshadowedGlobal(node, ADTECH_GLOBALS)) return 'adtech'; + const variable = findVariable(node); + return variable ? variableOrigin(variable, seen) : 'unknown'; + } + + if (node.type === 'MemberExpression') { + const propertyName = staticPropertyName(node); + if (node.object.type === 'ThisExpression') { + const entry = thisPropertyEntry(thisOwner(node.object), propertyName, false); + if (!entry || seen.has(entry)) return 'unknown'; + const nextSeen = new Set(seen).add(entry); + return entry.expressions.reduce( + (result, expression) => strongerOrigin(result, expressionOrigin(expression, nextSeen)), + 'unknown' + ); + } + + const objectOrigin = expressionOrigin(node.object, seen); + if (objectOrigin === 'root' && ADTECH_GLOBALS.has(propertyName)) return 'adtech'; + if (objectOrigin === 'root' && propertyName === 'window') return 'root'; + if (objectOrigin === 'adtech') return 'adtech'; + return 'unknown'; + } + + if (node.type === 'AssignmentExpression') return expressionOrigin(node.right, seen); + if (node.type === 'SequenceExpression') { + return expressionOrigin(node.expressions.at(-1), seen); + } + if (node.type === 'LogicalExpression' || node.type === 'ConditionalExpression') { + const branches = + node.type === 'ConditionalExpression' + ? [node.consequent, node.alternate] + : [node.left, node.right]; + return branches.reduce( + (result, branch) => strongerOrigin(result, expressionOrigin(branch, seen)), + 'unknown' + ); + } + return 'unknown'; + } + + function report(node, name) { + const key = `${node.range?.[0] ?? node.loc.start.line}:${node.range?.[1] ?? node.loc.end.column}`; + if (reported.has(key)) return; + reported.add(key); + context.report({ + node, + messageId: 'externalGlobalOwnedByAdapter', + data: { name }, + }); + } + + function recordPatternAssignments(pattern, initializer) { + for (const property of pattern.properties) { + const value = property.type === 'RestElement' ? property.argument : property.value; + const target = value.type === 'AssignmentPattern' ? value.left : value; + if (target.type !== 'Identifier') continue; + const variable = findVariable(target); + if (!variable) continue; + const entries = patternAssignments.get(variable) ?? []; + entries.push({ pattern, initializer }); + patternAssignments.set(variable, entries); + } + } + + function recordVariableAssignment(identifier, expression) { + const variable = findVariable(identifier); + if (!variable) return; + const values = assignments.get(variable) ?? []; + values.push(expression); + assignments.set(variable, values); + } + + function recordLoopBinding(rawBinding, iterable) { + const binding = rawBinding.type === 'AssignmentPattern' ? rawBinding.left : rawBinding; + if (binding.type === 'Identifier') { + const variable = findVariable(binding); + if (!variable) return; + const values = loopAssignments.get(variable) ?? []; + values.push(iterable); + loopAssignments.set(variable, values); + } else if (binding.type === 'ObjectPattern') { + candidatePatterns.push({ pattern: binding, initializer: iterable, iterable: true }); + for (const property of binding.properties) { + const value = property.type === 'RestElement' ? property.argument : property.value; + const target = value.type === 'AssignmentPattern' ? value.left : value; + if (target.type !== 'Identifier') continue; + const variable = findVariable(target); + if (!variable) continue; + const entries = loopPatternAssignments.get(variable) ?? []; + entries.push({ pattern: binding, iterable }); + loopPatternAssignments.set(variable, entries); + } + } + } + + return { + AssignmentExpression(node) { + const left = unwrapExpression(node.left); + if (left.type === 'ObjectPattern') { + candidatePatterns.push({ pattern: left, initializer: node.right }); + recordPatternAssignments(left, node.right); + return; + } + if (left.type === 'MemberExpression' && left.object.type === 'ThisExpression') { + const propertyName = staticPropertyName(left); + if (!propertyName) return; + recordThisProperty(thisOwner(left.object), propertyName, node.right); + return; + } + if (left.type !== 'Identifier') return; + recordVariableAssignment(left, node.right); + }, + + ForOfStatement(node) { + if (node.left.type === 'VariableDeclaration') { + for (const declaration of node.left.declarations) { + recordLoopBinding(declaration.id, node.right); + } + } else { + recordLoopBinding(node.left, node.right); + } + }, + + MemberExpression(node) { + candidateMembers.push(node); + }, + + Identifier(node) { + candidateIdentifiers.push(node); + }, + + PropertyDefinition(node) { + if (!node.value) return; + const propertyName = staticClassElementName(node); + const classNode = node.parent?.parent; + if ( + !propertyName || + (classNode?.type !== 'ClassDeclaration' && classNode?.type !== 'ClassExpression') + ) { + return; + } + recordThisProperty(classOwnerToken(classNode, node.static), propertyName, node.value); + }, + + VariableDeclarator(node) { + if (node.id.type === 'ObjectPattern' && node.init) { + candidatePatterns.push({ pattern: node.id, initializer: node.init }); + } + }, + + 'Program:exit'() { + for (const { pattern, initializer, iterable } of candidatePatterns) { + const origin = iterable + ? iterableElementOrigin(initializer) + : expressionOrigin(initializer); + if (origin !== 'root') continue; + for (const property of pattern.properties) { + if (property.type !== 'Property') continue; + const propertyName = staticPatternPropertyName(property); + if (ADTECH_GLOBALS.has(propertyName)) report(property, propertyName); + } + } + + for (const node of candidateMembers) { + const propertyName = staticPropertyName(node); + if (!ADTECH_GLOBALS.has(propertyName)) continue; + if (expressionOrigin(node.object) === 'root') report(node, propertyName); + } + + for (const node of candidateIdentifiers) { + if (!isReference(node) || expressionOrigin(node) !== 'adtech') continue; + report(node, node.name); + } + }, + }; + }, +}; diff --git a/crates/trusted-server-js/lib/eslint.config.js b/crates/trusted-server-js/lib/eslint.config.js index 2720ba3a0..8efffdb78 100644 --- a/crates/trusted-server-js/lib/eslint.config.js +++ b/crates/trusted-server-js/lib/eslint.config.js @@ -1,11 +1,78 @@ -// ESLint v9 flat config +// ESLint v10 flat config import js from '@eslint/js'; +import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'; +import importX from 'eslint-plugin-import-x'; import globals from 'globals'; import tseslint from 'typescript-eslint'; -import importPlugin from 'eslint-plugin-import'; import jsdoc from 'eslint-plugin-jsdoc'; import unicorn from 'eslint-plugin-unicorn'; +import noAdtechGlobals from './eslint-rules/no-adtech-globals.js'; + +export const ARCHITECTURE_INTEGRATION_DIRECTORIES = Object.freeze([ + 'aps', + 'creative', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'gpt_diagnostics', + 'lockr', + 'osano', + 'permutive', + 'prebid', + 'render_runtime', + 'sourcepoint', + 'testlight', +]); + +const integrationIsolationZones = ARCHITECTURE_INTEGRATION_DIRECTORIES.map((integration) => ({ + target: `./src/integrations/${integration}`, + from: './src/integrations', + except: [`./${integration}`], + message: 'Integrations must compose through injected services, not import another integration.', +})); + +export const ARCHITECTURE_RESTRICTED_LAYER_ZONES = Object.freeze([ + { + target: './src/core', + from: ['./src/adapters', './src/services', './src/integrations', './src/composition'], + message: 'Core must not construct or import downstream architecture layers.', + }, + { + target: './src/kernel', + from: ['./src/adapters', './src/services', './src/integrations', './src/composition'], + message: 'Kernel may depend only on kernel contracts.', + }, + { + target: './src/adapters', + from: [ + './src/core', + './src/shared', + './src/services', + './src/integrations', + './src/composition', + ], + message: 'Adapters may depend only on kernel contracts.', + }, + { + target: './src/services', + from: ['./src/core', './src/shared', './src/integrations', './src/composition'], + message: 'Services may depend only on kernel and adapter contracts.', + }, + { + target: './src/integrations', + from: './src/composition', + message: 'Integrations must not depend on the composition root.', + }, + { + target: ['./src/kernel', './src/adapters', './src/services', './src/integrations'], + from: './src/index.ts', + message: 'Lower architecture layers must not bypass boundaries through the root barrel.', + }, + ...integrationIsolationZones, +]); + export default [ // Files/folders to ignore { @@ -18,6 +85,13 @@ export default [ // Project rules { files: ['**/*.ts', '**/*.tsx'], + settings: { + 'import-x/resolver-next': [ + createTypeScriptImportResolver({ + project: './tsconfig.json', + }), + ], + }, languageOptions: { parser: tseslint.parser, parserOptions: { @@ -26,15 +100,43 @@ export default [ }, }, plugins: { - import: importPlugin, + 'import-x': importX, jsdoc, + tsjs: { + rules: { + 'no-adtech-globals': noAdtechGlobals, + }, + }, unicorn, '@typescript-eslint': tseslint.plugin, }, rules: { 'unicorn/prevent-abbreviations': 'off', 'unicorn/filename-case': 'off', - 'import/order': ['error', { 'newlines-between': 'always' }], + 'import-x/order': ['error', { 'newlines-between': 'always' }], + }, + }, + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + rules: { + 'tsjs/no-adtech-globals': [ + 'error', + { + rootDirectory: import.meta.dirname, + }, + ], + }, + }, + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + rules: { + 'import-x/no-restricted-paths': [ + 'error', + { + basePath: import.meta.dirname, + zones: ARCHITECTURE_RESTRICTED_LAYER_ZONES, + }, + ], }, }, // Honor the `_`-prefix convention for intentionally unused bindings in every @@ -52,7 +154,7 @@ export default [ // so CommonJS-only names (__dirname, require, module) still fail no-undef // in these ES modules { - files: ['*.mjs', 'test/**/*.mjs'], + files: ['*.mjs', 'scripts/**/*.mjs', 'test/**/*.mjs'], languageOptions: { globals: globals.nodeBuiltin, }, diff --git a/crates/trusted-server-js/lib/package-lock.json b/crates/trusted-server-js/lib/package-lock.json index 588ccd6be..c2d0e76d1 100644 --- a/crates/trusted-server-js/lib/package-lock.json +++ b/crates/trusted-server-js/lib/package-lock.json @@ -8,61 +8,70 @@ "name": "tsjs", "version": "0.1.0", "dependencies": { - "prebid.js": "^10.26.0" + "prebid.js": "10.26.0" }, "devDependencies": { - "@eslint/js": "^9.13.0", - "@types/jsdom": "^27.0.0", - "@types/node": "^24.10.0", - "@typescript-eslint/eslint-plugin": "^8.6.0", - "@typescript-eslint/parser": "^8.6.0", - "eslint": "^9.10.0", + "@eslint/js": "^10.0.1", + "@types/jsdom": "^28.0.3", + "@types/node": "^24.13.3", + "esbuild": "^0.28.2", + "eslint": "^10.8.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-import": "^2.29.1", - "eslint-plugin-jsdoc": "^62.5.4", - "eslint-plugin-unicorn": "^62.0.0", - "globals": "^16.0.0", - "jsdom": "^28.0.0", - "prettier": "^3.2.5", - "typescript": "^5.5.4", - "typescript-eslint": "^8.56.1", - "vite": "^7.3.1", - "vitest": "^4.0.8" - } - }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" + "eslint-import-resolver-typescript": "^4.4.5", + "eslint-plugin-import-x": "^4.17.1", + "eslint-plugin-jsdoc": "^63.3.3", + "eslint-plugin-unicorn": "^73.0.0", + "globals": "^17.11.0", + "jsdom": "^29.1.1", + "prettier": "^3.9.6", + "typescript": "~6.0.3", + "typescript-eslint": "^8.67.0", + "vite": "^8.2.1", + "vitest": "^4.1.10" + } }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", - "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.0.0", - "@csstools/css-color-parser": "^4.0.1", + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.5" + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -73,12 +82,12 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -87,30 +96,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -136,13 +144,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -152,25 +160,25 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -198,17 +206,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "engines": { @@ -228,12 +236,12 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, @@ -254,9 +262,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", - "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", @@ -270,49 +278,49 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -322,35 +330,35 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -360,14 +368,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -377,79 +385,79 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -459,13 +467,13 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -475,12 +483,12 @@ } }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -490,12 +498,28 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -505,14 +529,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -522,13 +546,13 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/traverse": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -550,12 +574,12 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -565,12 +589,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -580,12 +604,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -595,12 +619,12 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -626,12 +650,12 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -641,14 +665,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", - "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.29.0" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -658,14 +682,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -675,12 +699,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -690,12 +714,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -705,13 +729,13 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -721,13 +745,13 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -737,17 +761,17 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -757,13 +781,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -773,13 +797,13 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -789,13 +813,13 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -805,12 +829,12 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -820,13 +844,13 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -836,12 +860,12 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -851,13 +875,13 @@ } }, "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -867,12 +891,12 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -882,12 +906,12 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -897,13 +921,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -913,14 +937,14 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -930,12 +954,12 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -945,12 +969,12 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -960,12 +984,12 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -975,12 +999,12 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -990,13 +1014,13 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1006,13 +1030,13 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1022,15 +1046,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.8" }, "engines": { "node": ">=6.9.0" @@ -1040,13 +1064,13 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1056,13 +1080,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1072,12 +1096,12 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1087,12 +1111,12 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1102,12 +1126,12 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1117,16 +1141,16 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1136,13 +1160,13 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1152,12 +1176,12 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1167,13 +1191,13 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1183,12 +1207,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1198,13 +1222,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1214,14 +1238,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1231,12 +1255,12 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1246,12 +1270,12 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", - "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1261,13 +1285,13 @@ } }, "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1277,12 +1301,12 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1292,12 +1316,12 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1307,13 +1331,13 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", + "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1323,12 +1347,12 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1338,12 +1362,12 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1353,12 +1377,12 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1368,16 +1392,16 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1387,12 +1411,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1402,13 +1426,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1418,13 +1442,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1434,13 +1458,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1450,75 +1474,76 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.0.tgz", - "integrity": "sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.29.0", - "@babel/plugin-transform-async-to-generator": "^7.28.6", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.6", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-class-static-block": "^7.28.6", - "@babel/plugin-transform-classes": "^7.28.6", - "@babel/plugin-transform-computed-properties": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-dotall-regex": "^7.28.6", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.6", - "@babel/plugin-transform-exponentiation-operator": "^7.28.6", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.28.6", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.29.0", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", - "@babel/plugin-transform-numeric-separator": "^7.28.6", - "@babel/plugin-transform-object-rest-spread": "^7.28.6", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.28.6", - "@babel/plugin-transform-optional-chaining": "^7.28.6", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-private-property-in-object": "^7.28.6", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.29.0", - "@babel/plugin-transform-regexp-modifiers": "^7.28.6", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.28.6", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.28.6", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.15", "babel-plugin-polyfill-corejs3": "^0.14.0", @@ -1557,16 +1582,16 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1576,40 +1601,40 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -1617,13 +1642,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1643,9 +1668,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", - "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", "dev": true, "funding": [ { @@ -1663,9 +1688,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, "funding": [ { @@ -1687,9 +1712,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", - "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", "dev": true, "funding": [ { @@ -1703,8 +1728,8 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.1", - "@csstools/css-calc": "^3.0.0" + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" }, "engines": { "node": ">=20.19.0" @@ -1730,7 +1755,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1739,9 +1763,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.27", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", - "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", "dev": true, "funding": [ { @@ -1753,7 +1777,15 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0" + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } }, "node_modules/@csstools/css-tokenizer": { "version": "4.0.0", @@ -1771,23 +1803,56 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@es-joy/jsdoccomment": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.84.0.tgz", - "integrity": "sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==", + "version": "0.91.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.91.0.tgz", + "integrity": "sha512-vgqlMGNNhZxwDYbUNIHj3Hskb4R28iqdXx90ufHyt/NeuTQkeqjTDslAs9I0/GCAfbxP5BpH5WsL1R1fht5Lxg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.8", - "@typescript-eslint/types": "^8.54.0", - "comment-parser": "1.4.5", + "@types/estree": "^1.0.9", + "@typescript-eslint/types": "^8.65.0", + "comment-parser": "1.4.7", "esquery": "^1.7.0", - "jsdoc-type-pratt-parser": "~7.1.1" + "jsdoc-type-pratt-parser": "~8.0.0" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -1804,9 +1869,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -1821,9 +1886,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -1838,9 +1903,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -1855,9 +1920,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -1872,9 +1937,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -1889,9 +1954,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -1906,9 +1971,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -1923,9 +1988,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -1940,9 +2005,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -1957,9 +2022,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -1974,9 +2039,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -1991,9 +2056,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -2008,9 +2073,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -2025,9 +2090,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -2042,9 +2107,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -2059,9 +2124,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -2076,9 +2141,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -2093,9 +2158,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -2110,9 +2175,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -2127,9 +2192,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -2144,9 +2209,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -2161,9 +2226,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -2178,9 +2243,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -2195,9 +2260,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -2212,9 +2277,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -2229,9 +2294,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -2246,9 +2311,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -2264,6 +2329,19 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", @@ -2275,182 +2353,109 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" + "minimatch": "^10.2.4" }, "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/@eslint/css-tree": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@eslint/css-tree/-/css-tree-4.0.5.tgz", + "integrity": "sha512-iPmijIAq4hlIJB86PYmY/fcZORHtjphSqICDbwuw32A/JmkhZQ/K/6TjHE03zqf3n5yABpVcbRAMG8Mi9ojy8g==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "mdn-data": "2.29.0", + "source-map-js": "^1.2.1" }, "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@exodus/bytes": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", - "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, "license": "MIT", "engines": { @@ -2466,29 +2471,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -2562,24 +2581,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", "cpu": [ "arm64" ], @@ -2588,12 +2625,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", "cpu": [ "arm64" ], @@ -2602,12 +2642,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", "cpu": [ "x64" ], @@ -2616,82 +2659,66 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", "cpu": [ - "x64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" - ] + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", "cpu": [ - "arm" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", "cpu": [ "arm64" ], @@ -2700,54 +2727,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", "cpu": [ "ppc64" ], @@ -2756,40 +2744,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", "cpu": [ "s390x" ], @@ -2798,12 +2761,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", "cpu": [ "x64" ], @@ -2812,12 +2778,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", "cpu": [ "x64" ], @@ -2826,26 +2795,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", "cpu": [ "arm64" ], @@ -2854,12 +2812,15 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", "cpu": [ "arm64" ], @@ -2868,26 +2829,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", "cpu": [ "x64" ], @@ -2896,26 +2846,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -2939,6 +2878,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2957,23 +2907,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, "node_modules/@types/jsdom": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz", - "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==", + "version": "28.0.3", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.3.tgz", + "integrity": "sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", - "parse5": "^7.0.0" + "parse5": "^8.0.0", + "undici-types": "^7.21.0" } }, "node_modules/@types/json-schema": { @@ -2982,23 +2940,23 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { - "version": "24.10.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", - "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -3007,20 +2965,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", - "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/type-utils": "8.56.1", - "@typescript-eslint/utils": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3030,23 +2988,32 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.1", + "@typescript-eslint/parser": "^8.67.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", - "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "engines": { @@ -3058,18 +3025,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", - "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.1", - "@typescript-eslint/types": "^8.56.1", + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "engines": { @@ -3080,18 +3047,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", - "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1" + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3102,9 +3069,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", - "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", "dev": true, "license": "MIT", "engines": { @@ -3115,21 +3082,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", - "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3140,13 +3107,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", - "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", "dev": true, "license": "MIT", "engines": { @@ -3158,21 +3125,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", - "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.1", - "@typescript-eslint/tsconfig-utils": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3182,20 +3149,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", - "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1" + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3206,17 +3173,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", - "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3227,99 +3194,400 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/spy": "4.0.18", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/utils": "4.0.18", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3328,9 +3596,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -3338,14 +3606,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3365,12 +3634,11 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3388,20 +3656,10 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3420,6 +3678,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -3433,10 +3692,11 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3452,7 +3712,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ansi-colors": { "version": "1.1.0", @@ -3466,22 +3727,6 @@ "node": ">=0.10.0" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/ansi-wrap": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", @@ -3502,11 +3747,13 @@ } }, "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } }, "node_modules/arr-diff": { "version": "4.0.0", @@ -3526,134 +3773,12 @@ "node": ">=0.10.0" } }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3673,40 +3798,14 @@ "node": ">=0.10.0" } }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", - "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { @@ -3723,12 +3822,12 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz", - "integrity": "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==", + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", "core-js-compat": "^3.48.0" }, "peerDependencies": { @@ -3736,28 +3835,31 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", - "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6" + "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3783,9 +3885,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -3796,7 +3898,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -3822,32 +3924,22 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/brace-expansion/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "funding": [ { "type": "opencollective", @@ -3863,13 +3955,12 @@ } ], "license": "MIT", - "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3889,12 +3980,6 @@ "node": ">= 0.10.0" } }, - "node_modules/bufferstreams/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "license": "MIT" - }, "node_modules/bufferstreams/node_modules/readable-stream": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", @@ -3914,9 +3999,9 @@ "license": "MIT" }, "node_modules/builtin-modules": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz", - "integrity": "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.3.0.tgz", + "integrity": "sha512-hMQUl2bUFG339QygPM97E+mc8OY1IAchORZxm4a/frcYwKzozMzRVDBwHW0NjOqGElLm2O37AVQE8ikxlZHrMQ==", "dev": true, "license": "MIT", "engines": { @@ -3935,25 +4020,6 @@ "node": ">= 0.8" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3983,20 +4049,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { - "version": "1.0.30001770", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", - "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "version": "1.0.30001807", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz", + "integrity": "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==", "funding": [ { "type": "opencollective", @@ -4023,23 +4079,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -4063,66 +4102,16 @@ "node": ">=8" } }, - "node_modules/clean-regexp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", - "integrity": "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/clean-regexp/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/comment-parser": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz", - "integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.7.tgz", + "integrity": "sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 12.0.0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/consolidate": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.15.1.tgz", @@ -4157,6 +4146,19 @@ "node": ">= 0.6" } }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4179,23 +4181,29 @@ "license": "MIT" }, "node_modules/core-js": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", - "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", "hasInstallScript": true, "license": "MIT", + "engines": { + "node": "*" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" } }, "node_modules/core-js-compat": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", - "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1" + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" }, "funding": { "type": "opencollective", @@ -4227,37 +4235,29 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", "license": "MIT" }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/cssstyle": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", - "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", + "node_modules/css-tree/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.2", - "@csstools/css-syntax-patches-for-csstree": "^1.0.26", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.5" - }, - "engines": { - "node": ">=20" - } + "license": "CC0-1.0" }, "node_modules/data-urls": { "version": "7.0.0", @@ -4273,60 +4273,6 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4358,59 +4304,46 @@ "dev": true, "license": "MIT" }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "node_modules/detect-indent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.2.tgz", + "integrity": "sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=8" } }, "node_modules/dlv": { @@ -4419,19 +4352,6 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/dset": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", @@ -4462,9 +4382,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", "license": "ISC" }, "node_modules/encodeurl": { @@ -4477,9 +4397,9 @@ } }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -4489,75 +4409,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4577,16 +4428,16 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4595,53 +4446,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", @@ -4649,9 +4453,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4662,32 +4466,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -4719,34 +4523,33 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, "license": "MIT", - "peer": true, + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -4756,8 +4559,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -4765,7 +4567,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -4795,333 +4597,215 @@ "eslint": ">=7.0.0" } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^3.2.7" + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" }, "engines": { - "node": ">=4" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" }, "peerDependenciesMeta": { - "eslint": { + "unrs-resolver": { "optional": true } } }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" }, "engines": { - "node": ">=4" + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" }, "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } } }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/eslint-plugin-import-x": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.17.1.tgz", + "integrity": "sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" }, "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } } }, "node_modules/eslint-plugin-jsdoc": { - "version": "62.6.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.6.1.tgz", - "integrity": "sha512-zfz4lMIKDkidkqZniIieZujwZAtpaSNM0WXwilToKoR2UWEw0JE/QevQI2k6YN4ZSy3YhXB3Vs1ab62GZu8Wug==", + "version": "63.3.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-63.3.3.tgz", + "integrity": "sha512-xI4IeVRzRFA2DGHrPLIxF3U+oJHU3FE+P9Zb27fVs5dPHgfcpoAs0PyCbznVhK7pwR+9BPUztFeXSgpw/CL4Yg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@es-joy/jsdoccomment": "~0.84.0", + "@es-joy/jsdoccomment": "~0.91.0", "@es-joy/resolve.exports": "1.2.0", "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.5", + "comment-parser": "1.4.7", "debug": "^4.4.3", "escape-string-regexp": "^4.0.0", - "espree": "^11.1.0", + "espree": "^11.2.0", "esquery": "^1.7.0", "html-entities": "^2.6.0", - "object-deep-merge": "^2.0.0", + "object-deep-merge": "^2.0.1", "parse-imports-exports": "^0.2.4", - "semver": "^7.7.3", - "spdx-expression-parse": "^4.0.0", + "semver": "^7.8.5", + "spdx-expression-parse": "^5.0.0", "to-valid-identifier": "^1.0.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^22.13.0 || >=24" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/espree": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.0.tgz", - "integrity": "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-unicorn": { - "version": "62.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-62.0.0.tgz", - "integrity": "sha512-HIlIkGLkvf29YEiS/ImuDZQbP12gWyx5i3C6XrRxMvVdqMroCI9qoVYCoIl17ChN+U89pn9sVwLxhIWj5nEc7g==", + "version": "73.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-73.0.0.tgz", + "integrity": "sha512-V0YatLe9nkGhXEXKe2Qljb1EY0sJHwDV0HUF1NKFwtsHh/fU7qGHDgv+6fchzZcgU2/7noHo2gdjnmo0P2uDPw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "@eslint-community/eslint-utils": "^4.9.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint-community/eslint-utils": "^4.9.1", + "@eslint/css-tree": "^4.0.4", + "browserslist": "^4.28.4", "change-case": "^5.4.4", - "ci-info": "^4.3.1", - "clean-regexp": "^1.0.0", - "core-js-compat": "^3.46.0", - "esquery": "^1.6.0", + "ci-info": "^4.4.0", + "core-js-compat": "^3.49.0", + "detect-indent": "^7.0.2", + "entities": "^4.5.0", "find-up-simple": "^1.0.1", - "globals": "^16.4.0", + "globals": "^17.7.0", "indent-string": "^5.0.0", "is-builtin-module": "^5.0.0", - "jsesc": "^3.1.0", + "is-identifier": "^1.1.0", "pluralize": "^8.0.0", - "regexp-tree": "^0.1.27", - "regjsparser": "^0.13.0", - "semver": "^7.7.3", - "strip-indent": "^4.1.1" + "quote-js-string": "^0.1.0", + "regjsparser": "^0.13.2", + "reserved-identifiers": "^1.2.0", + "semver": "^7.8.5", + "strip-indent": "^4.1.1", + "yaml": "^2.9.0" }, "engines": { - "node": "^20.10.0 || >=21.0.0" + "node": ">=22" }, "funding": { "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" }, "peerDependencies": { - "eslint": ">=9.38.0" + "eslint": ">=10.4" } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -5205,9 +4889,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5215,14 +4899,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -5241,7 +4925,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -5309,9 +4993,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -5322,7 +5006,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/fdir": { "version": "6.5.0", @@ -5433,28 +5118,12 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5514,45 +5183,17 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "node_modules/function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/gensync": { @@ -5601,22 +5242,17 @@ "node": ">= 0.4" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "node_modules/get-tsconfig": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz", + "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" + "resolve-pkg-maps": "^1.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, "node_modules/glob-parent": { @@ -5633,9 +5269,9 @@ } }, "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", "dev": true, "license": "MIT", "engines": { @@ -5645,23 +5281,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5681,21 +5300,21 @@ "license": "ISC" }, "node_modules/gulp-babel": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/gulp-babel/-/gulp-babel-8.0.0.tgz", - "integrity": "sha512-oomaIqDXxFkg7lbpBou/gnUkX51/Y/M2ZfSjL2hdqXTAlSWZcgZtd2o0cOH0r/eE8LWD0+Q/PsLsr2DKOoqToQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/gulp-babel/-/gulp-babel-8.1.0.tgz", + "integrity": "sha512-QtFF9+h3xrVjfo79h7HCY4S8k4qNEcOz7ffpfavlscv0F0glfTQyv4kEYvX+YykTm4qllMF4aZfjeqLjzddTYA==", "license": "MIT", "dependencies": { "plugin-error": "^1.0.1", "replace-ext": "^1.0.0", - "through2": "^2.0.0", + "through2": "^3.0.0", "vinyl-sourcemaps-apply": "^0.2.0" }, "engines": { "node": ">=6" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0 || ^8.0.0" } }, "node_modules/gulp-wrap": { @@ -5719,38 +5338,6 @@ "npm": ">=1.4.3" } }, - "node_modules/gulp-wrap/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gulp-wrap/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/gulp-wrap/node_modules/through2": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", - "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.4", - "readable-stream": "2 || 3" - } - }, "node_modules/has": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", @@ -5760,58 +5347,6 @@ "node": ">= 0.4.0" } }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -5824,26 +5359,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5902,34 +5421,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/iab-adcom": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/iab-adcom/-/iab-adcom-1.0.6.tgz", @@ -5940,9 +5431,9 @@ } }, "node_modules/iab-native": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/iab-native/-/iab-native-1.0.0.tgz", - "integrity": "sha512-AxGYpKGRcyG5pbEAqj+ssxNwZAfxC0pRwyKc0MYoKjm0UeOoUNCWrZV0HGimcQii6ebe6MRqBQEeENyHM4qTdQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/iab-native/-/iab-native-1.0.1.tgz", + "integrity": "sha512-CrutbUqcP1h4ZKeCO1cOzYmcPRs4MqMSLz4hCUvU3wRlPoOVm6ErKJUifwomke9L9SQazKZx4NXcoiSNR2fXWw==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -5972,33 +5463,32 @@ "node": ">=0.10.0" } }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/identifier-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/identifier-regex/-/identifier-regex-1.1.0.tgz", + "integrity": "sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA==", "dev": true, "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "reserved-identifiers": "^1.0.0" }, "engines": { - "node": ">=6" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -6028,21 +5518,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6061,36 +5536,39 @@ "node": ">= 0.4" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "node_modules/is-builtin-module": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-5.0.0.tgz", + "integrity": "sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "builtin-modules": "^5.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18.20" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", "dev": true, "license": "MIT", "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "semver": "^7.7.1" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -6099,578 +5577,506 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, + "node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", "license": "MIT", "dependencies": { - "has-bigints": "^1.0.2" + "is-plain-object": "^2.0.4" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-builtin-module": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-5.0.0.tgz", - "integrity": "sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { - "builtin-modules": "^5.0.0" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/is-identifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-identifier/-/is-identifier-1.1.0.tgz", + "integrity": "sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw==", "dev": true, "license": "MIT", + "dependencies": { + "identifier-regex": "^1.1.0", + "super-regex": "^1.1.0" + }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "isobject": "^3.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "ISC" }, - "node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, "engines": { "node": ">=0.10.0" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, + "node_modules/js-yaml": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" + "argparse": "^1.0.7", + "esprima": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "node_modules/jsdoc-type-pratt-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-8.0.0.tgz", + "integrity": "sha512-uQu/fXVqVaMg6gM8/E5G5+eygVcZ1NV0Z51CvqhNa2bDWxvHMl484ETr6vph4oPyC+KUcbP/w2W2pewfCiR9aQ==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20.0.0" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "canvas": "^3.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "json-buffer": "3.0.1" } }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 8" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.16" + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8.0" } }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", "dependencies": { - "call-bound": "^1.0.3" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC" - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" + "node": ">= 12.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsdoc-type-pratt-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.1.tgz", - "integrity": "sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", - "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jsdom/node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/klona": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", - "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", - "license": "MIT", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/live-connect-common": { @@ -6712,9 +6118,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -6723,17 +6129,10 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -6750,6 +6149,24 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-asynchronous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", + "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "^1.5.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6760,9 +6177,9 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.29.0.tgz", + "integrity": "sha512-pVxQFCcaYUEAH853+v7yoI/qzhxXSq1bTb9obMYGYAN1c3Hen+XDCEvr296XhstrwlSTNgOR7mCSD4JPjbJe5A==", "dev": true, "license": "CC0-1.0" }, @@ -6827,13 +6244,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -6842,16 +6259,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6859,9 +6266,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -6877,6 +6284,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6894,120 +6317,39 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "license": "MIT" - }, - "node_modules/node.extend": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-2.0.2.tgz", - "integrity": "sha512-pDT4Dchl94/+kkgdwyS2PauDFjZG0Hk0IcHIB+LkW27HLDtdoeMxHTxZh39DYbPP8UflWXWj9JcdDozF+YDOpQ==", - "license": "(MIT OR GPL-2.0)", - "dependencies": { - "has": "^1.0.3", - "is": "^3.2.1" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/object-deep-merge": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.0.tgz", - "integrity": "sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", + "node_modules/node.extend": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-2.0.2.tgz", + "integrity": "sha512-pDT4Dchl94/+kkgdwyS2PauDFjZG0Hk0IcHIB+LkW27HLDtdoeMxHTxZh39DYbPP8UflWXWj9JcdDozF+YDOpQ==", + "license": "(MIT OR GPL-2.0)", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" + "has": "^1.0.3", + "is": "^3.2.1" }, "engines": { - "node": ">= 0.4" + "node": ">=0.4.0" } }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "node_modules/object-deep-merge": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.1.tgz", + "integrity": "sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==", "dev": true, + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, "engines": { "node": ">= 0.4" }, @@ -7016,15 +6358,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/on-finished": { "version": "2.4.1", @@ -7056,22 +6401,20 @@ "node": ">= 0.8.0" } }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" + "p-timeout": "^6.1.2" }, "engines": { - "node": ">= 0.4" + "node": ">=16.17" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-limit": { @@ -7106,17 +6449,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", "dev": true, "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, "engines": { - "node": ">=6" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/parse-imports-exports": { @@ -7137,18 +6480,31 @@ "license": "MIT" }, "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -7185,9 +6541,9 @@ "license": "MIT" }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/pathe": { @@ -7204,12 +6560,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7242,20 +6597,10 @@ "node": ">=4" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -7273,7 +6618,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7326,9 +6671,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -7341,12 +6686,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7371,12 +6710,13 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -7385,6 +6725,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quote-js-string": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/quote-js-string/-/quote-js-string-0.1.0.tgz", + "integrity": "sha512-Y3NoRtprEEZQD8RfxMCfS0ZTqc4e+i18OrXEXAvpM6TfC/3y+0L5rNbZiSnbBBEkDfFzbpd8o+cE8q3/anjMGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/quote-js-string?sponsor=1" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7410,53 +6763,17 @@ } }, "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 6" } }, "node_modules/regenerate": { @@ -7477,37 +6794,6 @@ "node": ">=4" } }, - "node_modules/regexp-tree": { - "version": "0.1.27", - "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", - "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", - "dev": true, - "license": "MIT", - "bin": { - "regexp-tree": "bin/regexp-tree" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/regexpu-core": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", @@ -7532,9 +6818,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.1.0" @@ -7575,11 +6861,12 @@ } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -7594,79 +6881,47 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=4" + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" } }, "node_modules/safe-buffer": { @@ -7689,41 +6944,6 @@ ], "license": "MIT" }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -7748,6 +6968,7 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -7763,9 +6984,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "peer": true, "dependencies": { @@ -7784,6 +7005,7 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -7795,12 +7017,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -7864,55 +7087,6 @@ "node": ">= 0.8.0" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -7943,14 +7117,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -7962,13 +7136,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -8048,9 +7222,9 @@ "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-5.0.0.tgz", + "integrity": "sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8059,137 +7233,58 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, "license": "CC0-1.0" }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12.0.0" } }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8" } }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", - "engines": { - "node": ">=4" + "dependencies": { + "safe-buffer": "~5.2.0" } }, "node_modules/strip-indent": { @@ -8205,32 +7300,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "node_modules/super-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", "dev": true, "license": "MIT", + "dependencies": { + "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", + "time-span": "^5.1.0" + }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -8251,13 +7338,29 @@ "license": "MIT" }, "node_modules/through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", + "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "2 || 3" + } + }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "dev": true, "license": "MIT", "dependencies": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/tiny-hashes": { @@ -8274,9 +7377,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -8284,14 +7387,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -8301,9 +7404,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -8311,22 +7414,22 @@ } }, "node_modules/tldts": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", - "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.23" + "tldts-core": "^7.4.10" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", - "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", "dev": true, "license": "MIT" }, @@ -8357,9 +7460,9 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8389,9 +7492,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -8401,31 +7504,13 @@ "typescript": ">=4.8.4" } }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } + "license": "0BSD", + "optional": true }, "node_modules/type-check": { "version": "0.4.0", @@ -8440,6 +7525,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -8453,91 +7551,12 @@ "node": ">= 0.6" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8556,16 +7575,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", - "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.1", - "@typescript-eslint/parser": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1" + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8576,7 +7595,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/typescript-logic": { @@ -8594,29 +7613,10 @@ "typescript-compare": "^0.0.2" } }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -8624,9 +7624,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.29.0.tgz", + "integrity": "sha512-vamA8dGlzMwhpyYpQp9d8vka3o4D/yn5I7ez7Or+msDA4bZ8Uh+Zy91WvWf3I73gDAkFha9JcYRqm2li0Npfgg==", "dev": true, "license": "MIT" }, @@ -8679,6 +7679,44 @@ "node": ">= 0.8" } }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -8761,19 +7799,17 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -8789,9 +7825,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -8804,13 +7841,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -8837,31 +7877,31 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8877,12 +7917,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -8903,6 +7946,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -8911,6 +7960,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -8927,6 +7979,13 @@ "node": ">=18" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -8978,95 +8037,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -9111,21 +8081,28 @@ "dev": true, "license": "MIT" }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index 427fef1e1..bd964d5c3 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -6,35 +6,46 @@ "description": "Trusted Server tsjs TypeScript library with queue and simple banner rendering.", "scripts": { "build": "node build-all.mjs", + "print:release-id": "node scripts/print-release-id.mjs", "build:prebid-external": "node build-prebid-external.mjs", + "generate:aps-contract": "node ../../../scripts/generate-aps-renderer-contract.mjs", + "check:aps-contract": "node ../../../scripts/generate-aps-renderer-contract.mjs --check", + "check:architecture": "node scripts/check-hard-cutover-absence.mjs", + "check:hard-cutover-absence": "node scripts/check-hard-cutover-absence.mjs", + "check:bundle": "node scripts/check-bundle-budgets.mjs", + "check:concept-audit": "node scripts/check-retired-concept-audit.mjs", "dev": "vite build --watch", "test": "vitest run", + "posttest": "npm run build && npm run test:release", "test:watch": "vitest", - "lint": "eslint . --max-warnings=0", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test:architecture": "node --test test/eslint/no-adtech-globals.test.mjs", + "test:release": "node --test test/build/release-v1.test.mjs test/build/generated-fallback.test.mjs", + "lint": "npm run test:architecture && eslint . --max-warnings=0", "lint:fix": "eslint --fix . --max-warnings=0", - "format": "prettier --check \"**/*.{ts,tsx,js,json,css,md}\"", - "format:write": "prettier --write \"**/*.{ts,tsx,js,json,css,md}\"" + "format": "prettier --check \"**/*.{ts,tsx,js,json,css,md}\" \"build-all.mjs\" \"scripts/**/*.mjs\" \"test/build/**/*.mjs\"", + "format:write": "prettier --write \"**/*.{ts,tsx,js,json,css,md}\" \"build-all.mjs\" \"scripts/**/*.mjs\" \"test/build/**/*.mjs\"" }, "dependencies": { - "prebid.js": "^10.26.0" + "prebid.js": "10.26.0" }, "devDependencies": { - "@eslint/js": "^9.13.0", - "@types/jsdom": "^27.0.0", - "@types/node": "^24.10.0", - "@typescript-eslint/eslint-plugin": "^8.6.0", - "@typescript-eslint/parser": "^8.6.0", - "eslint": "^9.10.0", + "@eslint/js": "^10.0.1", + "@types/jsdom": "^28.0.3", + "@types/node": "^24.13.3", + "esbuild": "^0.28.2", + "eslint": "^10.8.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-import": "^2.29.1", - "eslint-plugin-jsdoc": "^62.5.4", - "eslint-plugin-unicorn": "^62.0.0", - "globals": "^16.0.0", - "jsdom": "^28.0.0", - "prettier": "^3.2.5", - "typescript": "^5.5.4", - "typescript-eslint": "^8.56.1", - "vite": "^7.3.1", - "vitest": "^4.0.8" + "eslint-import-resolver-typescript": "^4.4.5", + "eslint-plugin-import-x": "^4.17.1", + "eslint-plugin-jsdoc": "^63.3.3", + "eslint-plugin-unicorn": "^73.0.0", + "globals": "^17.11.0", + "jsdom": "^29.1.1", + "prettier": "^3.9.6", + "typescript": "~6.0.3", + "typescript-eslint": "^8.67.0", + "vite": "^8.2.1", + "vitest": "^4.1.10" } } diff --git a/crates/trusted-server-js/lib/scripts/bundle-metrics.d.mts b/crates/trusted-server-js/lib/scripts/bundle-metrics.d.mts new file mode 100644 index 000000000..e195be978 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/bundle-metrics.d.mts @@ -0,0 +1,8 @@ +export interface BundleByteMeasurement { + readonly rawBytes: number; + readonly gzipBytes: number; + readonly brotliBytes: number; + readonly sha256: string; +} + +export function measureBytes(value: Uint8Array): BundleByteMeasurement; diff --git a/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs b/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs new file mode 100644 index 000000000..ea4236efc --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs @@ -0,0 +1,269 @@ +/** Pure deterministic measurement primitives for frozen TSJS transfer budgets. */ + +import { createHash } from 'node:crypto'; +import { + brotliCompress, + brotliCompressSync, + constants as zlibConstants, + gzip, + gzipSync, +} from 'node:zlib'; + +export const BUNDLE_SET_NAMES = Object.freeze(['minimal', 'reference', 'maximal']); +export const BUNDLE_SIZE_NAMES = Object.freeze(['rawBytes', 'gzipBytes', 'brotliBytes']); +export const BUNDLE_SEPARATOR = Buffer.from(';\n', 'utf8'); +export const FIRST_DISPLAY_AGENT_SIZE_CEILING = Object.freeze({ + rawBytes: 90_000, + gzipBytes: 30_000, + brotliBytes: 26_000, +}); + +/** Return whether one exact first-display composition satisfies every transport ceiling. */ +export function firstDisplayMaskIsPermitted(measurement) { + return BUNDLE_SIZE_NAMES.every( + (sizeName) => + Number.isSafeInteger(measurement?.[sizeName]) && + measurement[sizeName] >= 0 && + measurement[sizeName] <= FIRST_DISPLAY_AGENT_SIZE_CEILING[sizeName] + ); +} + +const GZIP_OPTIONS = Object.freeze({ level: 9, mtime: 0 }); + +function brotliOptions(bytes) { + return { + params: { + [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT, + [zlibConstants.BROTLI_PARAM_QUALITY]: 11, + [zlibConstants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength, + }, + }; +} + +const REFERENCE_INCLUDE_ORDER = Object.freeze([ + 'always', + 'creative_guard', + 'integration:gpt', + 'integration:prebid', + 'integration:datadome', +]); + +function fail(message) { + throw new Error(`[bundle-metrics] ${message}`); +} + +function isCatalogModule(module) { + return ( + module !== null && + typeof module === 'object' && + typeof module.id === 'string' && + ['takeover', 'deferred'].includes(module.phase) && + (module.trigger === null || module.trigger === 'first_display_or_idle') && + typeof module.include === 'string' + ); +} + +/** Derive the three semantic transfer sets from catalog phase and inclusion semantics. */ +export function deriveSemanticBundleSetIds(modules) { + if (!Array.isArray(modules) || modules.length === 0 || !modules.every(isCatalogModule)) { + fail('catalog modules must be a non-empty semantic release catalog'); + } + const catalogIds = modules.map(({ id }) => id); + if (new Set(catalogIds).size !== catalogIds.length || catalogIds.includes('core')) { + fail('catalog modules contain a duplicate or reserved id'); + } + const takeover = modules.filter(({ phase }) => phase === 'takeover'); + const reference = REFERENCE_INCLUDE_ORDER.map((include) => + takeover.filter((module) => module.include === include) + ); + if (reference.some((matches) => matches.length !== 1)) { + fail('catalog must define every reference predicate exactly once'); + } + return { + minimal: [ + 'core', + ...takeover.filter(({ include }) => include === 'always').map(({ id }) => id), + ], + reference: ['core', ...reference.map(([module]) => module.id)], + maximal: ['core', ...catalogIds], + }; +} + +function artifactFileById(artifacts) { + if (!Array.isArray(artifacts) || artifacts.length === 0) { + fail('release artifacts must be a non-empty array'); + } + const files = new Map(); + for (const artifact of artifacts) { + if ( + !artifact || + typeof artifact.id !== 'string' || + typeof artifact.file !== 'string' || + files.has(artifact.id) + ) { + fail('release artifacts contain an invalid or duplicate id'); + } + files.set(artifact.id, artifact.file); + } + return files; +} + +/** Derive the frozen semantic transfer sets from canonical inventory and catalog data. */ +export function deriveInventorySetFiles(artifacts, modules) { + const files = artifactFileById(artifacts); + const ids = deriveSemanticBundleSetIds(modules); + return Object.fromEntries( + BUNDLE_SET_NAMES.map((setName) => [ + setName, + ids[setName].map((id) => { + const file = files.get(id); + if (!file) fail(`${setName} references missing artifact ${id}`); + return file; + }), + ]) + ); +} + +/** Measure one byte sequence with the frozen raw, gzip, Brotli, and digest algorithms. */ +export function measureBytes(value) { + if (!(value instanceof Uint8Array)) fail('measurement input must be bytes'); + const bytes = Buffer.from(value.buffer, value.byteOffset, value.byteLength); + return { + rawBytes: bytes.byteLength, + gzipBytes: gzipSync(bytes, GZIP_OPTIONS).byteLength, + brotliBytes: brotliCompressSync(bytes, brotliOptions(bytes)).byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; +} + +function compress(compressor, bytes, options) { + return new Promise((resolve, reject) => { + compressor(bytes, options, (error, compressed) => { + if (error) reject(error); + else resolve(compressed); + }); + }); +} + +/** Measure bytes through the same frozen algorithms without blocking mask generation. */ +export async function measureBytesAsync(value) { + if (!(value instanceof Uint8Array)) fail('measurement input must be bytes'); + const bytes = Buffer.from(value.buffer, value.byteOffset, value.byteLength); + const [gzipBytes, brotliBytes] = await Promise.all([ + compress(gzip, bytes, GZIP_OPTIONS), + compress(brotliCompress, bytes, brotliOptions(bytes)), + ]); + return { + rawBytes: bytes.byteLength, + gzipBytes: gzipBytes.byteLength, + brotliBytes: brotliBytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; +} + +/** Concatenate and measure a named inventory set without filesystem or process state. */ +export function measureBundleSet(files, contents) { + if (!Array.isArray(files) || files.length === 0 || new Set(files).size !== files.length) { + fail('bundle set files must be a non-empty unique array'); + } + if (!(contents instanceof Map)) fail('bundle contents must be a Map'); + const parts = files.flatMap((file, index) => { + const bytes = contents.get(file); + if (!(bytes instanceof Uint8Array)) fail(`bundle contents are missing ${file}`); + return index === files.length - 1 ? [bytes] : [bytes, BUNDLE_SEPARATOR]; + }); + return { files: [...files], ...measureBytes(Buffer.concat(parts)) }; +} + +function concatenateBundleSet(files, contents) { + if (!Array.isArray(files) || files.length === 0 || new Set(files).size !== files.length) { + fail('bundle set files must be a non-empty unique array'); + } + if (!(contents instanceof Map)) fail('bundle contents must be a Map'); + const parts = files.flatMap((file, index) => { + const bytes = contents.get(file); + if (!(bytes instanceof Uint8Array)) fail(`bundle contents are missing ${file}`); + return index === files.length - 1 ? [bytes] : [bytes, BUNDLE_SEPARATOR]; + }); + return Buffer.concat(parts); +} + +/** Enumerate every reachable base mask; APS and Prebid participation require GPT. */ +export function enumerateReachableFirstDisplayMasks(catalog) { + if (!Array.isArray(catalog) || catalog.length !== 14) { + fail('first-display catalog must contain exactly fourteen rows'); + } + const ids = new Set(); + const files = new Set(); + for (const [index, row] of catalog.entries()) { + if ( + !row || + typeof row.id !== 'string' || + row.maskBit !== index || + typeof row.file !== 'string' || + ids.has(row.id) || + files.has(row.file) + ) { + fail(`first-display catalog row ${index} is invalid`); + } + ids.add(row.id); + files.add(row.file); + } + if (catalog[0].id !== 'first_display') { + fail('first-display catalog bit zero must be the base'); + } + const gpt = catalog.find(({ id }) => id === 'gpt_initial'); + const renderOwner = catalog.find(({ id }) => id === 'render_owner_initial'); + const aps = catalog.find(({ id }) => id === 'aps_initial'); + const prebid = catalog.find(({ id }) => id === 'prebid_initial'); + if (!gpt) fail('first-display catalog must contain gpt_initial'); + if (!renderOwner || !aps || !prebid) { + fail('first-display catalog must contain render-owner, APS, and Prebid participation'); + } + const required = 1 << catalog[0].maskBit; + const maximumMask = 1 << catalog.length; + const result = []; + for (let mask = 0; mask < maximumMask; mask += 1) { + if ((mask & required) !== required) continue; + const hasGpt = (mask & (1 << gpt.maskBit)) !== 0; + const hasRenderOwner = (mask & (1 << renderOwner.maskBit)) !== 0; + const hasAps = (mask & (1 << aps.maskBit)) !== 0; + const hasPrebid = (mask & (1 << prebid.maskBit)) !== 0; + if ((!hasGpt && (hasRenderOwner || hasAps || hasPrebid)) || (hasAps && !hasRenderOwner)) { + continue; + } + const selected = catalog.filter(({ maskBit }) => (mask & (1 << maskBit)) !== 0); + result.push({ + mask: mask.toString(16).padStart(4, '0'), + ids: selected.map(({ id }) => id), + files: selected.filter(({ id }) => id !== 'first_display').map(({ file }) => file), + }); + } + return result; +} + +/** Measure and hash every reachable first-display mask with bounded async compression work. */ +export async function measureReachableFirstDisplayMasks(catalog, contents, concurrency = 8) { + if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 64) { + fail('first-display mask measurement concurrency must be between 1 and 64'); + } + const masks = enumerateReachableFirstDisplayMasks(catalog); + const measured = new Array(masks.length); + let next = 0; + const worker = async () => { + while (next < masks.length) { + const index = next; + next += 1; + const mask = masks[index]; + measured[index] = { + ...mask, + ...(await measureBytesAsync( + mask.files.length === 0 ? Buffer.alloc(0) : concatenateBundleSet(mask.files, contents) + )), + }; + measured[index].permitted = firstDisplayMaskIsPermitted(measured[index]); + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, masks.length) }, worker)); + return measured; +} diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs new file mode 100644 index 000000000..aacf85a84 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -0,0 +1,1667 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +import { + BUNDLE_SEPARATOR, + BUNDLE_SET_NAMES, + BUNDLE_SIZE_NAMES, + FIRST_DISPLAY_AGENT_SIZE_CEILING, + deriveInventorySetFiles, + deriveSemanticBundleSetIds, + enumerateReachableFirstDisplayMasks, + firstDisplayMaskIsPermitted, + measureBundleSet, + measureBytes, +} from './bundle-metrics.mjs'; +import { computeReleaseId, RELEASE_SENTINEL } from './release-v1.mjs'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const libDir = path.resolve(scriptDir, '..'); +const repositoryRoot = path.resolve(libDir, '../../..'); +const defaultBaselinePath = path.join( + libDir, + 'test', + 'fixtures', + 'performance', + 'aps-tsjs-prechange.json' +); +const metricsPath = path.resolve(libDir, '..', 'dist', 'tsjs-build-metrics-v1.json'); +const catalogPath = path.resolve(libDir, '..', 'dist', 'tsjs-catalog-v1.json'); +const releasePath = path.resolve(libDir, '..', 'dist', 'tsjs-release-v1.json'); +const releaseCatalogSourcePath = path.join(libDir, 'src', 'kernel', 'release_catalog.ts'); +const repositoryReleaseCatalogPath = 'crates/trusted-server-js/lib/src/kernel/release_catalog.ts'; +const SET_NAMES = BUNDLE_SET_NAMES; +const SIZE_NAMES = BUNDLE_SIZE_NAMES; +const TRANSFER_SET_NAMES = Object.freeze(['bootstrap', ...SET_NAMES]); +const HISTORICAL_EVIDENCE_SHA256 = + '53f762603ad49239f1756171440be422e190cc231efafc56cf37a11e1a38ddf4'; +const ROLE_CORRECT_CAPTURE_SHA256 = + 'f1d73d517e4888ef4dc3a84b34166e9aeb6a2bde99dec1c835f151f4e070f64a'; +const REVIEW_REMEDIATION_CAPTURE_SHA256 = + '25ca3892167bac91ae18f4927fce47b49dcdb107814985e354924cec8392572f'; +const BOOTSTRAP_BASELINE = Object.freeze({ + rawBytes: 19_101, + gzipBytes: 5_468, + brotliBytes: 4_632, +}); +export const CANDIDATE_ARCHITECTURE_SIZE_CEILINGS = Object.freeze({ + bootstrap: Object.freeze({ rawBytes: 48_000, gzipBytes: 16_000, brotliBytes: 14_000 }), + firstDisplayAgent: Object.freeze({ + ...FIRST_DISPLAY_AGENT_SIZE_CEILING, + }), + referencePersistent: Object.freeze({ + rawBytes: 524_288, + gzipBytes: 163_840, + brotliBytes: 131_072, + }), + maximalTotal: Object.freeze({ + rawBytes: 1_048_576, + gzipBytes: 327_680, + brotliBytes: 262_144, + }), +}); +const PRODUCTION_SEAM_PATTERN = + /(?:^|\/)(?:tests?|fixtures?|fakes?|no-?op)(?:\/|$)|(?:^|[/_.-])(?:test|fake|no-?op)(?=[/_.-]|$)|ForTest/u; +const CAPTURE_PACKAGE_LOCK_PATH = 'crates/trusted-server-js/lib/package-lock.json'; +const CAPTURE_TOOL_VERSIONS_PATH = '.tool-versions'; +const CAPTURE_PERFORMANCE_WORKFLOW_PATH = '.github/workflows/tsjs-performance-gate.yml'; +const CAPTURE_BUILD_INPUTS = Object.freeze([ + CAPTURE_TOOL_VERSIONS_PATH, + CAPTURE_PERFORMANCE_WORKFLOW_PATH, + 'crates/trusted-server-js/lib/src', + 'crates/trusted-server-js/lib/build-all.mjs', + 'crates/trusted-server-js/lib/package-lock.json', + 'crates/trusted-server-js/lib/package.json', + 'crates/trusted-server-js/lib/tsconfig.json', + 'crates/trusted-server-js/lib/vite.config.ts', +]); +const ROLE_CORRECT_CAPTURE_BUILD_INPUTS = Object.freeze( + CAPTURE_BUILD_INPUTS.filter((input) => input !== CAPTURE_PERFORMANCE_WORKFLOW_PATH) +); +const CAPTURE_TOOL_PACKAGES = Object.freeze({ + typescript: 'typescript', + vite: 'vite', + esbuild: 'esbuild', +}); +const CURRENT_PROVIDER_SOURCE_OWNERS = Object.freeze({ + 'src/kernel/runtime.ts': 'core', + 'src/integrations/render_runtime/module.ts': 'core', + 'src/services/render.ts': 'core', + 'src/integrations/gpt/module.ts': 'gpt', + 'src/integrations/gpt/startup.ts': 'gpt', + 'src/integrations/gpt_diagnostics/store.ts': 'gpt_diagnostics', + 'src/integrations/osano/consent.ts': 'osano_consent', + 'src/adapters/prebid.ts': 'prebid', + 'src/integrations/prebid/module.ts': 'prebid', + 'src/integrations/prebid/startup.ts': 'prebid', + 'src/integrations/render_runtime/prebid_selection.ts': 'core', + 'src/integrations/sourcepoint/consent.ts': 'sourcepoint_consent', +}); +const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ + 'src/first_display/agent.ts': 'bootstrap', + 'src/first_display/base_marker.ts': 'first_display', + 'src/first_display/leaf/browser_route_owner.ts': 'bootstrap', + 'src/first_display/leaf/projection.ts': 'bootstrap', + 'src/first_display/render_journal.ts': 'render_owner_initial', + 'src/first_display/render_bridge.ts': 'aps_initial', + 'src/core/bootstrap.ts': 'bootstrap', + 'src/core/contracts/server_boot_transport.ts': 'bootstrap', + 'src/core/contracts/boot.ts': 'core', + 'src/core/contracts/integration_configs.ts': 'core', + 'src/core/contracts/sha256.ts': 'core', + 'src/core/types.ts': 'core', + 'src/adapters/googletag.ts': 'gpt', + 'src/adapters/messaging.ts': 'core', + 'src/composition/runtime_transport.ts': 'core', + 'src/services/auction_batch.ts': 'core', + 'src/services/context.ts': 'core', + 'src/services/projections.ts': 'core', + 'src/services/puc_bridge.ts': 'gpt', + 'src/services/reservations.ts': 'core', + 'src/services/slots.ts': 'gpt', + 'src/services/targeting.ts': 'gpt', + 'src/integrations/aps/index.ts': 'aps', + 'src/integrations/aps/documents.ts': 'aps', + 'src/integrations/aps/module.ts': 'aps', + 'src/integrations/aps/render.ts': 'aps', + 'src/integrations/creative/index.ts': 'creative', + 'src/integrations/creative/module.ts': 'creative', + 'src/integrations/datadome/index.ts': 'datadome', + 'src/integrations/datadome/module.ts': 'datadome', + 'src/integrations/datadome/script_guard.ts': 'datadome', + 'src/integrations/didomi/index.ts': 'didomi', + 'src/integrations/didomi/module.ts': 'didomi', + 'src/integrations/google_tag_manager/index.ts': 'google_tag_manager', + 'src/integrations/google_tag_manager/module.ts': 'google_tag_manager', + 'src/integrations/google_tag_manager/script_guard.ts': 'google_tag_manager', + 'src/integrations/gpt/diagnostics_facts.ts': 'gpt', + 'src/integrations/gpt/index.ts': 'gpt', + 'src/integrations/gpt/later.ts': 'gpt_later', + 'src/integrations/gpt/script_guard.ts': 'gpt', + 'src/integrations/gpt_diagnostics/badges.ts': 'diagnostics_presentation', + 'src/integrations/gpt_diagnostics/binding.ts': 'diagnostics_presentation', + 'src/integrations/gpt_diagnostics/data_api.ts': 'gpt_diagnostics', + 'src/integrations/gpt_diagnostics/exhaustive.ts': 'diagnostics_presentation', + 'src/integrations/gpt_diagnostics/index.ts': 'gpt_diagnostics', + 'src/integrations/gpt_diagnostics/module.ts': 'gpt_diagnostics', + 'src/integrations/gpt_diagnostics/observer.ts': 'gpt_diagnostics', + 'src/integrations/gpt_diagnostics/overlay.ts': 'diagnostics_presentation', + 'src/integrations/gpt_diagnostics/presentation.ts': 'diagnostics_presentation', + 'src/integrations/gpt_diagnostics/presentation_helpers.ts': 'diagnostics_presentation', + 'src/integrations/gpt_diagnostics/slot_size_observer.ts': 'diagnostics_presentation', + 'src/integrations/lockr/index.ts': 'lockr', + 'src/integrations/lockr/module.ts': 'lockr', + 'src/integrations/lockr/script_guard.ts': 'lockr', + 'src/integrations/osano/consent_mirror.ts': 'osano_consent', + 'src/integrations/osano/lifecycle.ts': 'osano_lifecycle', + 'src/integrations/osano/module.ts': 'osano_consent', + 'src/integrations/permutive/context.ts': 'permutive_context', + 'src/integrations/permutive/lifecycle.ts': 'permutive_lifecycle', + 'src/integrations/permutive/module.ts': 'permutive_context', + 'src/integrations/permutive/script_guard.ts': 'permutive_context', + 'src/integrations/permutive/segments.ts': 'permutive_context', + 'src/integrations/prebid/index.ts': 'prebid', + 'src/integrations/prebid/later.ts': 'prebid_later', + 'src/integrations/prebid/refresh.ts': 'prebid_later', + 'src/integrations/render_runtime/transport_marker.ts': 'render_runtime', + 'src/integrations/sourcepoint/consent_mirror.ts': 'sourcepoint_consent', + 'src/integrations/sourcepoint/lifecycle.ts': 'sourcepoint_lifecycle', + 'src/integrations/sourcepoint/module.ts': 'sourcepoint_consent', + 'src/integrations/sourcepoint/script_guard.ts': 'sourcepoint_consent', + 'src/integrations/testlight/index.ts': 'testlight', + 'src/integrations/testlight/module.ts': 'testlight', +}); +const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ + 'src/core/auction.ts': Object.freeze(['core']), + 'src/core/config.ts': Object.freeze(['bootstrap', 'core']), + 'src/core/contracts/aps_renderer.ts': Object.freeze(['bootstrap', 'core', 'aps']), + 'src/core/contracts/bounded_string.ts': Object.freeze([ + 'bootstrap', + 'core', + 'prebid', + 'prebid_later', + ]), + 'src/core/contracts/fallback_ad_units.ts': Object.freeze(['bootstrap']), + 'src/core/contracts/auction_projection.ts': Object.freeze(['core', 'prebid', 'prebid_later']), + 'src/core/contracts/generated/renderer_validator_v1.ts': Object.freeze([ + 'bootstrap', + 'core', + 'aps', + ]), + 'src/core/contracts/request_ads.ts': Object.freeze(['bootstrap', 'core']), + 'src/core/index.ts': Object.freeze(['core']), + 'src/core/log.ts': Object.freeze([ + 'bootstrap', + 'core', + 'creative_initial', + 'creative', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'gpt_diagnostics', + 'lockr', + 'osano_consent', + 'permutive_context', + 'sourcepoint_consent', + 'testlight', + 'diagnostics_presentation', + ]), + 'src/core/puc_shell.ts': Object.freeze(['gpt']), + 'src/core/queue.ts': Object.freeze(['bootstrap', 'core']), + 'src/core/release_id.ts': Object.freeze([ + 'bootstrap', + 'core', + 'aps', + 'creative', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'gpt_diagnostics', + 'lockr', + 'osano_consent', + 'permutive_context', + 'sourcepoint_consent', + 'prebid', + 'testlight', + 'diagnostics_presentation', + 'gpt_later', + 'osano_lifecycle', + 'permutive_lifecycle', + 'prebid_later', + 'sourcepoint_lifecycle', + ]), + 'src/core/registry.ts': Object.freeze(['bootstrap', 'core']), + 'src/core/release.ts': Object.freeze([ + 'bootstrap', + 'core', + 'aps', + 'creative', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'gpt_diagnostics', + 'lockr', + 'osano_consent', + 'permutive_context', + 'sourcepoint_consent', + 'prebid', + 'testlight', + 'diagnostics_presentation', + 'gpt_later', + 'osano_lifecycle', + 'permutive_lifecycle', + 'prebid_later', + 'sourcepoint_lifecycle', + ]), + 'src/core/render.ts': Object.freeze(['core']), + 'src/core/styles/normalize.css?inline': Object.freeze(['core']), + 'src/core/templates/iframe.html?raw': Object.freeze(['core']), + 'src/core/trace.ts': Object.freeze(['core', 'gpt_diagnostics']), + 'src/integrations/creative/click.ts': Object.freeze(['creative_initial', 'creative']), + 'src/integrations/creative/dynamic_src_guard.ts': Object.freeze(['creative_initial', 'creative']), + 'src/integrations/creative/iframe.ts': Object.freeze(['creative_initial', 'creative']), + 'src/integrations/creative/image.ts': Object.freeze(['creative_initial', 'creative']), + 'src/integrations/creative/proxy_sign.ts': Object.freeze(['creative_initial', 'creative']), + 'src/integrations/creative/startup.ts': Object.freeze(['creative_initial', 'creative']), + 'src/kernel/contracts/message_protocol.ts': Object.freeze(['core', 'gpt']), + 'src/kernel/contracts/release_capacity.ts': Object.freeze(['core']), + 'src/kernel/contracts/puc_dynamic_owner.ts': Object.freeze(['render_owner_initial', 'gpt']), + 'src/kernel/diagnostics.ts': Object.freeze(['core']), + 'src/kernel/disposable.ts': Object.freeze(['core', 'gpt']), + 'src/kernel/fallback.ts': Object.freeze(['bootstrap', 'core']), + 'src/kernel/fallback_surface.ts': Object.freeze(['bootstrap', 'core']), + 'src/kernel/identity.ts': Object.freeze(['first_display', 'core']), + 'src/kernel/integration_registry.ts': Object.freeze(['core']), + 'src/kernel/lifecycle_module.ts': Object.freeze([ + 'datadome', + 'didomi', + 'google_tag_manager', + 'lockr', + 'testlight', + ]), + 'src/kernel/phase_loader.ts': Object.freeze(['core']), + 'src/kernel/release_catalog.ts': Object.freeze([ + 'bootstrap', + 'core', + 'datadome', + 'didomi', + 'google_tag_manager', + 'lockr', + 'testlight', + ]), + 'src/kernel/sessions.ts': Object.freeze(['core']), + 'src/shared/async.ts': Object.freeze(['creative_initial', 'creative', 'datadome']), + 'src/shared/first_display_contracts.ts': Object.freeze(['bootstrap', 'core']), + 'src/shared/first_display_handoff.ts': Object.freeze(['bootstrap', 'first_display', 'core']), + 'src/shared/first_display_registration.ts': Object.freeze(['bootstrap', 'first_display']), + 'src/shared/first_display_transaction.ts': Object.freeze(['bootstrap']), + 'src/shared/integration_config_validators.ts': Object.freeze([ + 'first_display', + 'aps', + 'datadome', + 'google_tag_manager', + 'gpt', + 'lockr', + 'osano_consent', + 'permutive_context', + 'sourcepoint_consent', + 'prebid', + 'testlight', + 'gpt_later', + 'osano_lifecycle', + 'permutive_lifecycle', + 'prebid_later', + 'sourcepoint_lifecycle', + ]), + 'src/shared/beacon_guard.ts': Object.freeze(['google_tag_manager']), + 'src/shared/dom_insertion_dispatcher.ts': Object.freeze([ + 'datadome', + 'google_tag_manager', + 'gpt', + 'lockr', + 'permutive_context', + 'sourcepoint_consent', + ]), + 'src/shared/globals.ts': Object.freeze(['creative_initial', 'creative']), + 'src/shared/gpt_diagnostics.ts': Object.freeze(['gpt', 'gpt_diagnostics']), + 'src/shared/origin.ts': Object.freeze(['core', 'creative_initial', 'creative']), + 'src/shared/realm.ts': Object.freeze(['gpt_diagnostics', 'diagnostics_presentation']), + 'src/shared/scheduler.ts': Object.freeze(['creative_initial', 'creative']), + 'src/shared/script_guard.ts': Object.freeze([ + 'datadome', + 'google_tag_manager', + 'gpt', + 'lockr', + 'permutive_context', + 'sourcepoint_consent', + ]), + 'src/shared/takeover.ts': Object.freeze([ + 'bootstrap', + 'first_display', + 'core', + 'aps', + 'creative', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'lockr', + 'osano_consent', + 'permutive_context', + 'sourcepoint_consent', + 'prebid', + 'testlight', + ]), +}); +const CAPABILITY_PATTERN = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*\.v[1-9][0-9]*$/u; +const CAPABILITY_PREDICATE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/u; + +function fail(message) { + throw new Error(`[bundle-budgets] ${message}`); +} + +function readJson(file, label) { + if (!fs.existsSync(file)) fail(`${label} does not exist: ${file}`); + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (error) { + fail(`${label} is not valid JSON (${file}): ${error instanceof Error ? error.message : error}`); + } +} + +function assertPositiveInteger(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) fail(`${label} must be a positive integer`); +} + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value !== null && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +/** Hash JSON with recursive key ordering while preserving array order and values. */ +export function canonicalJsonSha256(value) { + return createHash('sha256').update(canonicalJson(value)).digest('hex'); +} + +/** Evaluate the authored catalog source without importing production TS at runtime. */ +function loadAuthoredCatalogModule(source) { + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: releaseCatalogSourcePath, + }).outputText; + const module = { exports: {} }; + vm.runInNewContext( + transpiled, + { module, exports: module.exports, Object, Set, TypeError }, + { + filename: releaseCatalogSourcePath, + } + ); + return module.exports; +} + +export function loadAuthoredReleaseCatalog( + source = fs.readFileSync(releaseCatalogSourcePath, 'utf8') +) { + const authored = loadAuthoredCatalogModule(source); + const catalog = authored.RELEASE_CATALOG; + const validateCatalog = authored.validateReleaseCatalog; + if (!Array.isArray(catalog) || typeof validateCatalog !== 'function') { + fail('authored release catalog did not export its catalog and validator'); + } + try { + validateCatalog(catalog); + } catch (error) { + fail( + `authored release catalog failed self-validation: ${error instanceof Error ? error.message : error}` + ); + } + return JSON.parse(JSON.stringify(catalog)); +} + +/** Load the complete authored first-display catalog from the same source authority. */ +export function loadAuthoredFirstDisplayCatalog( + source = fs.readFileSync(releaseCatalogSourcePath, 'utf8') +) { + const catalog = loadAuthoredCatalogModule(source).FIRST_DISPLAY_CATALOG; + if (!Array.isArray(catalog)) { + fail('authored release catalog did not export its first-display catalog'); + } + return JSON.parse(JSON.stringify(catalog)); +} + +function loadHistoricalReleaseCatalog(capture, label) { + const sha = capture?.source?.sha; + let source; + try { + source = execFileSync('git', ['show', `${sha}:${repositoryReleaseCatalogPath}`], { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + fail(`${label} authored release catalog is missing at its captured source SHA`); + } + return loadAuthoredReleaseCatalog(source).map((entry) => ({ + ...entry, + phase: entry.phase === 'critical' ? 'takeover' : entry.phase, + })); +} + +/** Authenticate the immutable inputs and tool versions recorded by a historical capture. */ +export function validateCaptureSourceProvenance( + capture, + { buildInputs = CAPTURE_BUILD_INPUTS, npmAuthorityCapture = capture } = {} +) { + const sha = capture?.source?.sha; + if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/u.test(sha)) { + fail('capture source SHA is invalid'); + } + + try { + if ( + execFileSync('git', ['cat-file', '-t', sha], { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() !== 'commit' + ) { + fail('capture source SHA does not identify a commit'); + } + } catch (error) { + if (error instanceof Error && error.message.startsWith('[bundle-budgets]')) throw error; + fail('capture source SHA does not identify a commit'); + } + + for (const input of buildInputs) { + try { + execFileSync('git', ['cat-file', '-e', `${sha}:${input}`], { + cwd: repositoryRoot, + stdio: 'ignore', + }); + } catch { + fail(`captured build input does not exist at source SHA: ${input}`); + } + } + + let packageLockBytes; + try { + packageLockBytes = execFileSync('git', ['show', `${sha}:${CAPTURE_PACKAGE_LOCK_PATH}`], { + cwd: repositoryRoot, + }); + } catch { + fail('captured package-lock does not exist at source SHA'); + } + + const packageLockSha256 = createHash('sha256').update(packageLockBytes).digest('hex'); + if (capture?.tools?.packageLockSha256 !== packageLockSha256) { + fail('captured packageLockSha256 does not match package-lock bytes at source SHA'); + } + + let packageLock; + try { + packageLock = JSON.parse(packageLockBytes.toString('utf8')); + } catch { + fail('captured package-lock is not valid JSON'); + } + + for (const [tool, packageName] of Object.entries(CAPTURE_TOOL_PACKAGES)) { + const resolvedVersion = packageLock.packages?.[`node_modules/${packageName}`]?.version; + if (capture?.tools?.[tool] !== resolvedVersion) { + fail(`captured ${tool} version does not match the resolved package-lock version`); + } + } + + let toolVersions; + try { + toolVersions = execFileSync('git', ['show', `${sha}:${CAPTURE_TOOL_VERSIONS_PATH}`], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + } catch { + fail('captured .tool-versions does not exist at source SHA'); + } + + const nodejsEntries = toolVersions + .split(/\r?\n/u) + .filter((line) => /^\s*nodejs(?:\s|$)/u.test(line)); + const nodejsMatch = nodejsEntries[0]?.match(/^\s*nodejs\s+(\S+)\s*$/u); + if ( + nodejsEntries.length !== 1 || + nodejsMatch === null || + capture?.tools?.node !== `v${nodejsMatch[1]}` + ) { + fail('captured node version does not match the exact .tool-versions nodejs pin'); + } + + if (capture?.tools?.npm !== npmAuthorityCapture?.tools?.npm) { + fail('captured npm version does not match its authenticated npm authority'); + } + const npmAuthoritySha = npmAuthorityCapture?.source?.sha; + if (typeof npmAuthoritySha !== 'string' || !/^[0-9a-f]{40}$/u.test(npmAuthoritySha)) { + fail('captured npm authority source SHA is invalid'); + } + let performanceWorkflow; + try { + performanceWorkflow = execFileSync( + 'git', + ['show', `${npmAuthoritySha}:${CAPTURE_PERFORMANCE_WORKFLOW_PATH}`], + { + cwd: repositoryRoot, + encoding: 'utf8', + } + ); + } catch { + fail('captured TSJS performance workflow does not exist at source SHA'); + } + + const npmVersionAssertions = performanceWorkflow + .split(/\r?\n/u) + .filter((line) => line.includes('npm --version')); + const npmVersionMatch = npmVersionAssertions[0]?.match( + /^\s*test "\$\(npm --version\)" = "([^"\s]+)"\s*$/u + ); + if ( + npmVersionAssertions.length !== 1 || + npmVersionMatch === null || + capture?.tools?.npm !== npmVersionMatch[1] + ) { + fail('captured npm version does not match the exact single workflow assertion'); + } +} + +/** Authenticate both linked frozen transfer captures at their recorded source commits. */ +export function validateFrozenCaptureProvenance(intermediate, capture) { + validateCaptureSourceProvenance(intermediate, { + buildInputs: ROLE_CORRECT_CAPTURE_BUILD_INPUTS, + npmAuthorityCapture: capture, + }); + validateCaptureSourceProvenance(capture); +} + +function validateBudgetSets(sets, label) { + if (!sets || typeof sets !== 'object' || Array.isArray(sets)) { + fail(`${label} must be an object`); + } + for (const setName of SET_NAMES) { + const set = sets[setName]; + if (!set || typeof set !== 'object' || Array.isArray(set)) { + fail(`${label}.${setName} must be an object`); + } + for (const sizeName of SIZE_NAMES) { + assertPositiveInteger(set[sizeName], `${label}.${setName}.${sizeName}`); + } + } +} + +function validateMetricSets(sets, label) { + validateBudgetSets(sets, label); + for (const setName of SET_NAMES) { + const set = sets[setName]; + if (!Array.isArray(set.files) || set.files.length === 0) { + fail(`${label}.${setName}.files must be a non-empty array`); + } + for (const [index, file] of set.files.entries()) { + if (typeof file !== 'string' || !/^tsjs-[a-z0-9_]+\.js$/.test(file)) { + fail(`${label}.${setName}.files[${index}] is not a canonical TSJS bundle filename`); + } + } + if (new Set(set.files).size !== set.files.length) { + fail(`${label}.${setName}.files contains a duplicate`); + } + if (typeof set.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(set.sha256)) { + fail(`${label}.${setName}.sha256 must be 64 lowercase hexadecimal characters`); + } + } +} + +/** Validate semantic set membership against exact release artifact ownership. */ +export function validateSemanticBundleSets(metrics, release, catalog) { + if (catalog?.version !== 1) fail('catalog.version must equal 1'); + if (!Array.isArray(catalog.firstDisplay) || catalog.firstDisplay.length !== 14) { + fail('catalog.firstDisplay must contain the closed fourteen-row inventory'); + } + if (release?.version !== 1 || !Array.isArray(release.artifacts)) { + fail('release inventory is invalid'); + } + if (!metrics?.sets) fail('build metrics sets are missing'); + validateMetricSets(metrics.sets, 'buildMetrics.sets'); + if ( + !metrics.bootstrap || + metrics.bootstrap.file !== 'tsjs-bootstrap.js' || + typeof metrics.bootstrap.sha256 !== 'string' || + !/^[0-9a-f]{64}$/.test(metrics.bootstrap.sha256) + ) { + fail('buildMetrics.bootstrap must identify the generated bootstrap exactly once'); + } + for (const sizeName of SIZE_NAMES) { + assertPositiveInteger(metrics.bootstrap[sizeName], `buildMetrics.bootstrap.${sizeName}`); + } + const expected = deriveSemanticBundleSetIds(catalog.modules); + const files = new Map(); + const ids = new Set(); + for (const artifact of release.artifacts) { + if ( + !artifact || + typeof artifact !== 'object' || + typeof artifact.id !== 'string' || + typeof artifact.file !== 'string' || + !['bootstrap', 'first_display_base', 'first_display_slice', 'core', 'integration'].includes( + artifact.role + ) || + ids.has(artifact.id) || + files.has(artifact.file) + ) { + fail('release inventory contains an invalid or duplicate artifact'); + } + ids.add(artifact.id); + files.set(artifact.file, artifact); + } + const expectedArtifacts = [ + { id: 'bootstrap', role: 'bootstrap', file: 'tsjs-bootstrap.js' }, + ...catalog.firstDisplay.map(({ id }, index) => ({ + id, + role: index === 0 ? 'first_display_base' : 'first_display_slice', + file: `tsjs-${id}.js`, + })), + { id: 'core', role: 'core', file: 'tsjs-core.js' }, + ...catalog.modules.map(({ id }) => ({ + id, + role: 'integration', + file: `tsjs-${id}.js`, + })), + ]; + if (release.artifacts.length !== expectedArtifacts.length) { + fail('release inventory does not contain the exact catalog artifact count'); + } + for (const [index, expectedArtifact] of expectedArtifacts.entries()) { + const artifact = release.artifacts[index]; + if ( + artifact.id !== expectedArtifact.id || + artifact.role !== expectedArtifact.role || + artifact.file !== expectedArtifact.file + ) { + fail( + `release inventory artifact ${index} must be ${expectedArtifact.role}/${expectedArtifact.id}/${expectedArtifact.file}` + ); + } + } + + const actual = {}; + for (const setName of SET_NAMES) { + const setIds = metrics.sets[setName].files.map((file) => { + const artifact = files.get(file); + if (!artifact || !['core', 'integration'].includes(artifact.role)) { + fail(`buildMetrics.sets.${setName} contains an unknown or non-persistent artifact`); + } + return artifact.id; + }); + if (new Set(setIds).size !== setIds.length) { + fail(`buildMetrics.sets.${setName} contains a multiply counted artifact`); + } + if (JSON.stringify(setIds) !== JSON.stringify(expected[setName])) { + fail( + `buildMetrics.sets.${setName} has semantic ids ${JSON.stringify(setIds)}; expected ${JSON.stringify(expected[setName])}` + ); + } + actual[setName] = setIds; + } + return actual; +} + +function canonicalSourcePath(file) { + return typeof file === 'string' ? file.replaceAll('\\', '/') : ''; +} + +function currentExactSourceOwner(source) { + if (Object.hasOwn(CURRENT_PROVIDER_SOURCE_OWNERS, source)) { + return { kind: 'provider', owner: CURRENT_PROVIDER_SOURCE_OWNERS[source] }; + } + if (Object.hasOwn(CURRENT_EXACT_SOURCE_OWNERS, source)) { + return { kind: 'phase', owner: CURRENT_EXACT_SOURCE_OWNERS[source] }; + } + if (source.startsWith('src/integrations/gpt_diagnostics/presentation/')) { + return { kind: 'phase', owner: 'diagnostics_presentation' }; + } + if (source.startsWith('src/integrations/gpt/later/')) { + return { kind: 'phase', owner: 'gpt_later' }; + } + if (source.startsWith('src/integrations/prebid/later/')) { + return { kind: 'phase', owner: 'prebid_later' }; + } + return undefined; +} + +function readCurrentSourceGraph(metrics, release) { + if (!Array.isArray(metrics?.modules) || !Array.isArray(release?.artifacts)) { + fail('build metrics module graph or release inventory is missing'); + } + const productionArtifacts = release.artifacts.filter(({ role }) => role !== 'bootstrap'); + if (metrics.modules.length !== productionArtifacts.length) { + fail('build metrics module graph does not classify every production artifact exactly once'); + } + const rawEntries = [ + { + artifact: release.artifacts.find(({ role }) => role === 'bootstrap'), + module: metrics.bootstrap, + }, + ...metrics.modules.map((module, index) => ({ + artifact: productionArtifacts[index], + module, + })), + ]; + const graphEntries = []; + const ownersBySource = new Map(); + const contributions = []; + for (const [index, { artifact, module }] of rawEntries.entries()) { + if ( + !artifact || + !module || + typeof module !== 'object' || + module.file !== artifact.file || + typeof module.entry !== 'string' || + !Array.isArray(module.sources) + ) { + fail(`build metrics module graph entry ${index} is invalid or out of release order`); + } + const entry = canonicalSourcePath(module.entry); + const sources = new Set(); + for (const [sourceIndex, source] of module.sources.entries()) { + const sourceFile = canonicalSourcePath(source?.file); + if ( + !sourceFile.startsWith('src/') || + !Number.isSafeInteger(source?.renderedBytes) || + source.renderedBytes < 0 || + sources.has(sourceFile) + ) { + fail(`build metrics module graph ${module.file}.sources[${sourceIndex}] is invalid`); + } + sources.add(sourceFile); + const owners = ownersBySource.get(sourceFile) ?? []; + if (owners.includes(artifact.id)) { + fail(`build metrics source ${sourceFile} repeats owner ${artifact.id}`); + } + owners.push(artifact.id); + ownersBySource.set(sourceFile, owners); + contributions.push({ + artifact: artifact.id, + source: sourceFile, + renderedBytes: source.renderedBytes, + }); + } + if (sources.size === 0 || !sources.has(entry)) { + fail(`build metrics module graph ${module.file} does not contain its entry source`); + } + graphEntries.push({ artifact, module, entry, sources }); + } + return { + graphEntries, + sourceOwners: Object.fromEntries(ownersBySource), + contributions, + }; +} + +/** Build the frozen attribution report used to review production bundle growth. */ +export function buildProductionGraphReport(metrics, release) { + const currentGraph = readCurrentSourceGraph(metrics, release); + const largestContributions = [...currentGraph.contributions] + .sort( + (left, right) => + right.renderedBytes - left.renderedBytes || + left.source.localeCompare(right.source) || + left.artifact.localeCompare(right.artifact) + ) + .slice(0, 20); + const repeatedAttributions = Object.entries(currentGraph.sourceOwners) + .filter(([, owners]) => owners.length > 1) + .map(([source, owners]) => ({ source, owners })) + .sort((left, right) => left.source.localeCompare(right.source)); + return { largestContributions, repeatedAttributions }; +} + +function findTakeoverDeferredViolations(currentGraph, release) { + const artifactsById = new Map(release.artifacts.map((artifact) => [artifact.id, artifact])); + const violations = []; + for (const [source, owners] of Object.entries(currentGraph.sourceOwners)) { + const policy = currentExactSourceOwner(source); + if (policy && artifactsById.get(policy.owner)?.phase === 'deferred') { + for (const owner of owners) { + const artifact = artifactsById.get(owner); + if (artifact?.role === 'core' || artifact?.phase === 'takeover') { + violations.push(`${owner} reaches deferred-owned source ${source}`); + } + } + } + } + return violations; +} + +function validateCurrentSourceOwnership(currentGraph, release) { + const artifactsById = new Map(release.artifacts.map((artifact) => [artifact.id, artifact])); + const artifactIds = new Set(artifactsById.keys()); + const violations = findTakeoverDeferredViolations(currentGraph, release); + for (const source of Object.keys(CURRENT_PROVIDER_SOURCE_OWNERS)) { + if (!Object.hasOwn(currentGraph.sourceOwners, source)) { + violations.push(`current provider source is missing: ${source}`); + } + } + for (const [source, owners] of Object.entries(currentGraph.sourceOwners)) { + const firstDisplaySource = source.startsWith('src/first_display/'); + const firstDisplayOwners = owners.filter((owner) => + ['first_display_base', 'first_display_slice'].includes(artifactsById.get(owner)?.role) + ); + if (firstDisplaySource) { + const policy = currentExactSourceOwner(source); + for (const owner of owners) { + if (!firstDisplayOwners.includes(owner) && owner !== policy?.owner) { + violations.push(`${owner} reaches first-display source ${source}`); + } + } + if (policy) { + if (!artifactIds.has(policy.owner)) { + violations.push(`${source} requires missing current owner ${policy.owner}`); + } else { + for (const owner of owners) { + if (owner !== policy.owner) { + violations.push(`${owner} reaches ${policy.owner}-owned source ${source}`); + } + } + if (!owners.includes(policy.owner)) { + violations.push(`${source} is not reached by required owner ${policy.owner}`); + } + } + } + continue; + } + const sharedOwners = CURRENT_SHARED_SOURCE_OWNER_POLICIES[source]; + for (const owner of firstDisplayOwners) { + if (!sharedOwners?.includes(owner)) { + violations.push(`${owner} reaches persistent source ${source}`); + } + } + const policy = currentExactSourceOwner(source); + if (policy) { + if (!artifactIds.has(policy.owner)) { + violations.push(`${source} requires missing current owner ${policy.owner}`); + continue; + } + for (const owner of owners) { + if (owner !== policy.owner) { + if (policy.kind === 'provider') { + violations.push(`${owner} inlines provider ${policy.owner} implementation ${source}`); + } else { + violations.push( + `${source} must have exact current owner ${policy.owner}, not ${owner}` + ); + } + } + } + if (!owners.includes(policy.owner)) { + violations.push(`${source} must have exact current owner ${policy.owner}`); + } + } else if (Object.hasOwn(CURRENT_SHARED_SOURCE_OWNER_POLICIES, source)) { + const allowedOwners = CURRENT_SHARED_SOURCE_OWNER_POLICIES[source]; + for (const owner of owners) { + if (!allowedOwners.includes(owner)) { + violations.push(`${owner} reaches forbidden shared source ${source}`); + } + } + } else { + violations.push(`unclassified current production source ${source}`); + } + } + return violations; +} + +/** Return takeover artifacts that transitively bundle explicit deferred-phase source. */ +export function findTakeoverDeferredSourceViolations(metrics, release) { + return findTakeoverDeferredViolations(readCurrentSourceGraph(metrics, release), release); +} + +/** Return all current production graph ownership and seam violations. */ +export function findProductionGraphViolations(metrics, release) { + const currentGraph = readCurrentSourceGraph(metrics, release); + const violations = validateCurrentSourceOwnership(currentGraph, release); + for (const { artifact, sources } of currentGraph.graphEntries) { + for (const source of sources) { + if (PRODUCTION_SEAM_PATTERN.test(source)) { + violations.push(`${artifact.id} reaches production test/fake/no-op seam ${source}`); + } + } + } + return violations; +} + +function validateArtifactContents(release, contents) { + if (!(contents instanceof Map)) fail('current artifact contents must be a Map'); + const releaseArtifacts = []; + for (const artifact of release.artifacts) { + const bytes = contents.get(artifact.file); + if (!(bytes instanceof Uint8Array)) + fail(`current artifact bytes are missing: ${artifact.file}`); + const digest = createHash('sha256').update(bytes).digest('hex'); + if (bytes.byteLength !== artifact.bytes || digest !== artifact.hash) { + fail(`current artifact bytes do not match release inventory: ${artifact.file}`); + } + const source = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('utf8'); + if (source.includes(RELEASE_SENTINEL) || source.split(release.releaseId).length - 1 !== 1) { + fail(`current artifact bytes do not contain exactly one release id: ${artifact.file}`); + } + releaseArtifacts.push({ + id: artifact.id, + role: artifact.role, + phase: artifact.phase ?? '', + trigger: artifact.trigger ?? '', + bytes: Buffer.from(source.replace(release.releaseId, RELEASE_SENTINEL)), + }); + } + if (computeReleaseId(releaseArtifacts) !== release.releaseId) { + fail('current artifact bytes do not reproduce release id'); + } +} + +function firstDisplayCompositionBytes(files, contents) { + const parts = files.flatMap((file, index) => { + const bytes = contents.get(file); + if (!(bytes instanceof Uint8Array)) fail(`current artifact bytes are missing: ${file}`); + return index === files.length - 1 ? [bytes] : [bytes, BUNDLE_SEPARATOR]; + }); + return Buffer.concat(parts); +} + +function validateFirstDisplayMaskMeasurements(metrics, contents) { + const catalog = metrics?.firstDisplay?.catalog; + const masks = metrics?.firstDisplay?.masks; + const expected = enumerateReachableFirstDisplayMasks(catalog); + if (!Array.isArray(masks) || masks.length !== expected.length) { + fail(`build metrics must contain all ${expected.length} reachable first-display masks`); + } + const hashes = new Set(); + for (const [index, canonical] of expected.entries()) { + const measured = masks[index]; + if ( + !hasExactKeys(measured, [ + 'mask', + 'ids', + 'files', + 'rawBytes', + 'gzipBytes', + 'brotliBytes', + 'sha256', + 'permitted', + ]) || + measured.mask !== canonical.mask || + canonicalJson(measured.ids) !== canonicalJson(canonical.ids) || + canonicalJson(measured.files) !== canonicalJson(canonical.files) + ) { + fail(`build metrics first-display mask ${index} is not canonical`); + } + if (!Number.isSafeInteger(measured.rawBytes) || measured.rawBytes < 0) { + fail(`firstDisplay.masks.${index}.rawBytes must be a non-negative integer`); + } + for (const sizeName of ['gzipBytes', 'brotliBytes']) { + assertPositiveInteger(measured[sizeName], `firstDisplay.masks.${index}.${sizeName}`); + } + const bytes = firstDisplayCompositionBytes(canonical.files, contents); + const sha256 = createHash('sha256').update(bytes).digest('hex'); + if ( + measured.rawBytes !== bytes.byteLength || + measured.sha256 !== sha256 || + measured.permitted !== firstDisplayMaskIsPermitted(measured) || + hashes.has(sha256) + ) { + fail(`build metrics first-display mask ${canonical.mask} bytes or digest are invalid`); + } + hashes.add(sha256); + } + return masks; +} + +function largestMask(masks, sizeName) { + return masks.reduce((largest, candidate) => + candidate[sizeName] > largest[sizeName] ? candidate : largest + ); +} + +function namedFirstDisplayMask(masks, ids, name) { + const key = JSON.stringify(ids); + const matches = masks.filter((mask) => JSON.stringify(mask.ids) === key); + if (matches.length !== 1) fail(`first-display ${name} mask is unavailable or ambiguous`); + return matches[0]; +} + +/** Build the four independent absolute-size measurements from one canonical release. */ +export function buildCandidateArchitectureSizeReport({ + metrics, + release, + catalog, + currentArtifactContents, +}) { + validateSemanticBundleSets(metrics, release, catalog); + if (!(currentArtifactContents instanceof Map)) fail('current artifact contents must be a Map'); + const masks = validateFirstDisplayMaskMeasurements(metrics, currentArtifactContents); + const permittedMasks = masks.filter(({ permitted }) => permitted === true); + if (permittedMasks.length === 0) fail('release has no permitted first-display masks'); + const generatedPermittedMasks = catalog?.permittedFirstDisplayMasks; + if ( + !Array.isArray(generatedPermittedMasks) || + canonicalJson(generatedPermittedMasks) !== canonicalJson(permittedMasks.map(({ mask }) => mask)) + ) { + fail('generated permitted first-display masks do not match measured size admission'); + } + const largestRaw = largestMask(permittedMasks, 'rawBytes'); + const largestGzip = largestMask(permittedMasks, 'gzipBytes'); + const largestBrotli = largestMask(permittedMasks, 'brotliBytes'); + const maximalFiles = release.artifacts + .filter(({ role }) => role !== 'bootstrap') + .map(({ file }) => file); + const maximalTotal = measureBundleSet(maximalFiles, currentArtifactContents); + const named = { + minimal: namedFirstDisplayMask(masks, ['first_display'], 'minimal'), + reference: namedFirstDisplayMask( + masks, + ['first_display', 'creative_initial', 'datadome_initial', 'gpt_initial', 'prebid_initial'], + 'reference' + ), + aps: namedFirstDisplayMask( + masks, + ['first_display', 'render_owner_initial', 'aps_initial', 'creative_initial', 'gpt_initial'], + 'APS' + ), + largestRaw, + largestGzip, + largestBrotli, + }; + for (const name of ['minimal', 'reference', 'aps']) { + if (!named[name].permitted) fail(`required first-display ${name} mask exceeds its ceiling`); + } + return { + ceilings: CANDIDATE_ARCHITECTURE_SIZE_CEILINGS, + bootstrap: Object.fromEntries( + SIZE_NAMES.map((sizeName) => [sizeName, metrics.bootstrap[sizeName]]) + ), + firstDisplayAgent: { + rawBytes: largestRaw.rawBytes, + gzipBytes: largestGzip.gzipBytes, + brotliBytes: largestBrotli.brotliBytes, + }, + referencePersistent: Object.fromEntries( + SIZE_NAMES.map((sizeName) => [sizeName, metrics.sets.reference[sizeName]]) + ), + maximalTotal: Object.fromEntries( + SIZE_NAMES.map((sizeName) => [sizeName, maximalTotal[sizeName]]) + ), + firstDisplay: { masks, permittedMasks, named }, + }; +} + +/** Reject any semantic set that exceeds one reviewed independent ceiling. */ +export function enforceCandidateArchitectureSizeCeilings(report) { + for (const [semanticSet, limits] of Object.entries(CANDIDATE_ARCHITECTURE_SIZE_CEILINGS)) { + const measured = report?.[semanticSet]; + for (const sizeName of SIZE_NAMES) { + if (!Number.isSafeInteger(measured?.[sizeName]) || measured[sizeName] < 1) { + fail(`${semanticSet}.${sizeName} is not a positive integer measurement`); + } + if (measured[sizeName] > limits[sizeName]) { + fail(`${semanticSet}.${sizeName} exceeds ${limits[sizeName]} bytes: ${measured[sizeName]}`); + } + } + } + return report; +} + +function validateCurrentMeasurements(metrics, release, catalog, contents) { + const expectedSets = deriveInventorySetFiles(release.artifacts, catalog.modules); + for (const setName of SET_NAMES) { + const measured = measureBundleSet(expectedSets[setName], contents); + if (canonicalJson(measured) !== canonicalJson(metrics.sets[setName])) { + fail(`build metrics do not match current artifact bytes: ${setName}`); + } + } + const bootstrapBytes = contents.get('tsjs-bootstrap.js'); + if (!(bootstrapBytes instanceof Uint8Array)) { + fail('current artifact bytes are missing: tsjs-bootstrap.js'); + } + const measuredBootstrap = measureBytes(bootstrapBytes); + for (const key of [...SIZE_NAMES, 'sha256']) { + if (metrics.bootstrap[key] !== measuredBootstrap[key]) { + fail('build metrics do not match current artifact bytes: bootstrap'); + } + } + for (const [index, module] of metrics.modules.entries()) { + const artifact = release.artifacts.filter(({ role }) => role !== 'bootstrap')[index]; + if (module.rawBytes !== artifact.bytes || module.sha256 !== artifact.hash) { + fail(`build metrics do not match current artifact bytes: ${artifact.id}`); + } + } +} + +function hasExactKeys(value, keys) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const actual = Object.keys(value); + const expected = new Set(keys); + return actual.length === expected.size && actual.every((key) => expected.has(key)); +} + +function validateReleaseInventoryShape(release, label, allowHistoricalPhase = false) { + if (!hasExactKeys(release, ['version', 'releaseId', 'artifacts'])) { + fail(`${label} release inventory must have exact keys version,releaseId,artifacts`); + } + if ( + release.version !== 1 || + !/^[0-9a-f]{64}$/u.test(release.releaseId) || + !Array.isArray(release.artifacts) + ) { + fail(`${label} release inventory has invalid version, releaseId, or artifacts`); + } + const artifactKeys = [ + 'id', + 'role', + 'phase', + 'trigger', + 'inputs', + 'outputs', + 'file', + 'bytes', + 'hash', + ]; + const ids = new Set(); + const files = new Set(); + for (const [index, artifact] of release.artifacts.entries()) { + if (!hasExactKeys(artifact, artifactKeys)) { + fail(`${label} release artifact ${index} must have exact keys ${artifactKeys.join(',')}`); + } + const stringArray = (value) => + Array.isArray(value) && + value.every((entry) => typeof entry === 'string') && + new Set(value).size === value.length; + const phaseAndTriggerAreValid = + artifact.role === 'bootstrap' || artifact.role === 'core' + ? artifact.phase === null && artifact.trigger === null + : artifact.role === 'first_display_base' || artifact.role === 'first_display_slice' + ? artifact.phase === 'first_display' && artifact.trigger === null + : artifact.role === 'integration' && + (artifact.phase === 'takeover' || + (allowHistoricalPhase && artifact.phase === 'critical') + ? artifact.trigger === null + : artifact.phase === 'deferred' && artifact.trigger === 'first_display_or_idle'); + if ( + !/^[a-z0-9][a-z0-9_-]{0,63}$/u.test(artifact.id) || + ids.has(artifact.id) || + !phaseAndTriggerAreValid || + !stringArray(artifact.inputs) || + !stringArray(artifact.outputs) || + typeof artifact.file !== 'string' || + !( + /^tsjs-[a-z0-9_]+\.js$/u.test(artifact.file) || + (allowHistoricalPhase && artifact.file === 'gpt-bootstrap-fallback.js') + ) || + files.has(artifact.file) || + !Number.isSafeInteger(artifact.bytes) || + artifact.bytes <= 0 || + !/^[0-9a-f]{64}$/u.test(artifact.hash) + ) { + fail(`${label} release artifact ${index} has an invalid shape`); + } + ids.add(artifact.id); + files.add(artifact.file); + } +} + +function validateCurrentReleaseSemantics( + current, + generatedCatalog, + authoredCatalog, + authoredFirstDisplayCatalog +) { + if ( + generatedCatalog?.version !== 1 || + !Array.isArray(generatedCatalog.modules) || + !Array.isArray(generatedCatalog.firstDisplay) || + !Array.isArray(authoredCatalog) || + !Array.isArray(authoredFirstDisplayCatalog) || + generatedCatalog.modules.length !== authoredCatalog.length || + canonicalJson(generatedCatalog.firstDisplay) !== canonicalJson(authoredFirstDisplayCatalog) + ) { + fail('current generated/authored catalog inventory is invalid'); + } + const expectedArtifacts = [ + { + id: 'bootstrap', + role: 'bootstrap', + phase: null, + trigger: null, + inputs: [], + outputs: [], + file: 'tsjs-bootstrap.js', + }, + ...authoredFirstDisplayCatalog.map((entry, index) => ({ + id: entry.id, + role: index === 0 ? 'first_display_base' : 'first_display_slice', + phase: 'first_display', + trigger: null, + inputs: entry.inputs, + outputs: entry.outputs, + file: `tsjs-${entry.id}.js`, + })), + { + id: 'core', + role: 'core', + phase: null, + trigger: null, + inputs: [], + outputs: ['runtime.v1'], + file: 'tsjs-core.js', + }, + ...authoredCatalog.map((entry, index) => { + const generated = generatedCatalog.modules[index]; + if ( + !generated || + generated.id !== entry.id || + generated.phase !== entry.phase || + generated.trigger !== entry.trigger || + generated.include !== entry.include + ) { + fail(`generated catalog entry ${index} differs from current authored catalog`); + } + return { + id: entry.id, + role: 'integration', + phase: entry.phase, + trigger: entry.trigger, + inputs: entry.consumes, + outputs: entry.provides, + file: `tsjs-${entry.id}.js`, + }; + }), + ]; + if (current.artifacts.length !== expectedArtifacts.length) { + fail('current release does not match the live catalog artifact count'); + } + + const coreIndex = current.artifacts.findIndex(({ role }) => role === 'core'); + const providers = new Map([['runtime.v1', coreIndex]]); + for (const [index, artifact] of current.artifacts.entries()) { + if (artifact.role !== 'integration') continue; + for (const capability of artifact.outputs) { + if (!CAPABILITY_PATTERN.test(capability)) { + fail(`current release has invalid output capability: ${capability}`); + } + if (providers.has(capability)) { + fail(`current capability has multiple providers: ${capability}`); + } + providers.set(capability, index); + } + } + let sawDeferred = false; + for (const [index, artifact] of current.artifacts.entries()) { + const expected = expectedArtifacts[index]; + for (const field of ['id', 'role', 'phase', 'trigger', 'file']) { + if (artifact[field] !== expected[field]) { + fail(`current release artifact ${index} ${field} differs from the live catalog`); + } + } + if ( + artifact.role === 'bootstrap' && + (artifact.inputs.length !== 0 || artifact.outputs.length !== 0) + ) { + fail('current bootstrap invariant requires no inputs or outputs'); + } + if ( + artifact.role === 'core' && + (artifact.inputs.length !== 0 || canonicalJson(artifact.outputs) !== '["runtime.v1"]') + ) { + fail('current core invariant requires only the runtime.v1 output'); + } + if (artifact.role !== 'integration') continue; + if (!artifact.inputs.includes('runtime.v1')) { + fail(`current integration ${artifact.id} must consume runtime.v1`); + } + if (artifact.phase === 'deferred') { + sawDeferred = true; + if (artifact.outputs.length !== 0) { + fail(`current deferred integration cannot provide capabilities: ${artifact.id}`); + } + } else if (sawDeferred) { + fail('current takeover integration cannot follow a deferred integration'); + } + for (const edge of artifact.inputs) { + const parts = edge.split('?'); + if ( + parts.length > 2 || + !CAPABILITY_PATTERN.test(parts[0]) || + (parts.length === 2 && !CAPABILITY_PREDICATE_PATTERN.test(parts[1])) + ) { + fail(`current release has invalid input capability: ${edge}`); + } + const providerIndex = providers.get(parts[0]); + if (providerIndex === undefined) fail(`current release has unknown capability: ${parts[0]}`); + if (providerIndex >= index) { + fail(`current capability provider must precede consumer: ${parts[0]}`); + } + } + if ( + canonicalJson(artifact.inputs) !== canonicalJson(expected.inputs) || + canonicalJson(artifact.outputs) !== canonicalJson(expected.outputs) + ) { + fail(`current release artifact ${index} capabilities differ from authored catalog`); + } + } +} + +function validateCapturedMembership(capture, historicalCatalog, label = 'capturedTransfer') { + const artifactsById = new Map( + capture.release.artifacts.map((artifact) => [artifact.id, artifact]) + ); + const idsByFile = new Map(capture.release.artifacts.map(({ id, file }) => [file, id])); + const expectedFiles = deriveInventorySetFiles(capture.release.artifacts, historicalCatalog); + const bootstrapFile = artifactsById.get('bootstrap')?.file; + if (typeof bootstrapFile !== 'string') { + fail(`${label} bootstrap release artifact is missing`); + } + const expected = { + bootstrap: { artifactIds: ['bootstrap'], files: [bootstrapFile] }, + ...Object.fromEntries( + SET_NAMES.map((setName) => [ + setName, + { + artifactIds: expectedFiles[setName].map((file) => idsByFile.get(file)), + files: expectedFiles[setName], + }, + ]) + ), + }; + for (const setName of TRANSFER_SET_NAMES) { + const set = capture.sets?.[setName]; + if ( + !set || + !Array.isArray(set.artifactIds) || + !Array.isArray(set.files) || + set.artifactIds.length !== set.files.length || + set.artifactIds.length === 0 || + new Set(set.artifactIds).size !== set.artifactIds.length || + new Set(set.files).size !== set.files.length || + canonicalJson(set.artifactIds) !== canonicalJson(expected[setName].artifactIds) || + canonicalJson(set.files) !== canonicalJson(expected[setName].files) + ) { + fail(`${label} ${setName} semantic membership is invalid`); + } + for (const [index, artifactId] of set.artifactIds.entries()) { + const artifact = artifactsById.get(artifactId); + if (!artifact || artifact.file !== set.files[index]) { + fail(`${label} ${setName} release/set membership pair is invalid`); + } + if (setName === 'bootstrap' ? artifact.role !== 'bootstrap' : artifact.role === 'bootstrap') { + fail(`${label} ${setName} release/set role membership is invalid`); + } + } + if ( + setName === 'bootstrap' && + (set.artifactIds[0] !== 'bootstrap' || set.files[0] !== bootstrapFile) + ) { + fail(`${label} bootstrap semantic membership is invalid`); + } + for (const sizeName of SIZE_NAMES) { + assertPositiveInteger(set[sizeName], `${label}.sets.${setName}.${sizeName}`); + } + if (!/^[0-9a-f]{64}$/.test(set.sha256)) { + fail(`${label}.sets.${setName}.sha256 is invalid`); + } + } +} + +function validateReductionCheckpoint(capture, intermediate) { + const minimal = capture.sets.minimal; + if (minimal.rawBytes > 220_000) { + fail(`review remediation minimal.rawBytes exceeds 220000: ${minimal.rawBytes}`); + } + if (minimal.gzipBytes > 59_000) { + fail(`review remediation minimal.gzipBytes exceeds 59000: ${minimal.gzipBytes}`); + } + if (minimal.brotliBytes >= intermediate.sets.minimal.brotliBytes) { + fail('review remediation minimal.brotliBytes must improve on the intermediate capture'); + } + for (const sizeName of SIZE_NAMES) { + if (capture.sets.reference[sizeName] >= intermediate.sets.reference[sizeName]) { + fail(`review remediation reference.${sizeName} must improve on the intermediate capture`); + } + if (capture.sets.maximal[sizeName] > intermediate.sets.maximal[sizeName]) { + fail(`review remediation maximal.${sizeName} must not grow from the intermediate capture`); + } + } +} + +/** Report current transfer sizes against an authenticated historical capture. */ +export function buildTransferCaptureReport(captured, current) { + const reports = {}; + for (const setName of TRANSFER_SET_NAMES) { + reports[setName] = {}; + for (const sizeName of SIZE_NAMES) { + const capturedBytes = captured?.[setName]?.[sizeName]; + const currentBytes = current?.[setName]?.[sizeName]; + assertPositiveInteger(capturedBytes, `captured.${setName}.${sizeName}`); + assertPositiveInteger(currentBytes, `current.${setName}.${sizeName}`); + reports[setName][sizeName] = { + capturedBytes, + currentBytes, + deltaBytes: currentBytes - capturedBytes, + }; + } + } + return reports; +} + +/** Authenticate frozen evidence, then independently validate and report the current release. */ +export function validateRoleCorrectTransfer({ + baseline, + metrics, + catalog, + release, + currentArtifactContents, + verifyGitProvenance = false, + authoredCatalog = loadAuthoredReleaseCatalog(), + authoredFirstDisplayCatalog = loadAuthoredFirstDisplayCatalog(), +}) { + const intermediate = baseline?.roleCorrectTransfer; + if (!intermediate || typeof intermediate !== 'object') fail('role-correct capture is missing'); + const capture = baseline?.reviewRemediationTransfer; + if (!capture || typeof capture !== 'object') fail('review-remediation capture is missing'); + const historical = Object.fromEntries( + Object.entries(baseline).filter( + ([key]) => key !== 'roleCorrectTransfer' && key !== 'reviewRemediationTransfer' + ) + ); + const historicalDigest = canonicalJsonSha256(historical); + if (historicalDigest !== HISTORICAL_EVIDENCE_SHA256) { + fail('historical evidence digest does not match the immutable original top-level fields'); + } + if (canonicalJsonSha256(intermediate) !== ROLE_CORRECT_CAPTURE_SHA256) { + fail('role-correct capture digest does not match the immutable capture'); + } + if (canonicalJsonSha256(capture) !== REVIEW_REMEDIATION_CAPTURE_SHA256) { + fail('review-remediation capture digest does not match the immutable capture'); + } + if ( + capture.originalTopLevelSha256 !== HISTORICAL_EVIDENCE_SHA256 || + intermediate.originalTopLevelSha256 !== HISTORICAL_EVIDENCE_SHA256 + ) { + fail('historical evidence digest linkage is invalid'); + } + if (capture.roleCorrectTransferSha256 !== ROLE_CORRECT_CAPTURE_SHA256) { + fail('review-remediation linkage to the immutable intermediate capture is invalid'); + } + if (verifyGitProvenance) { + validateFrozenCaptureProvenance(intermediate, capture); + } + + validateReleaseInventoryShape(intermediate.release, 'role-correct intermediate', true); + validateReleaseInventoryShape(capture.release, 'captured', true); + validateReleaseInventoryShape(release, 'current'); + validateCapturedMembership( + intermediate, + loadHistoricalReleaseCatalog(intermediate, 'roleCorrectTransfer'), + 'roleCorrectTransfer' + ); + validateCapturedMembership( + capture, + loadHistoricalReleaseCatalog(capture, 'reviewRemediationTransfer'), + 'reviewRemediationTransfer' + ); + validateReductionCheckpoint(capture, intermediate); + validateCurrentReleaseSemantics(release, catalog, authoredCatalog, authoredFirstDisplayCatalog); + validateSemanticBundleSets(metrics, release, catalog); + const graphViolations = findProductionGraphViolations(metrics, release); + if (graphViolations.length > 0) + fail(`production graph failed:\n- ${graphViolations.join('\n- ')}`); + if (currentArtifactContents !== undefined) { + validateArtifactContents(release, currentArtifactContents); + validateCurrentMeasurements(metrics, release, catalog, currentArtifactContents); + } + const currentSets = { bootstrap: metrics.bootstrap, ...metrics.sets }; + return { + captureReports: { + roleCorrectTransfer: buildTransferCaptureReport(intermediate.sets, currentSets), + reviewRemediationTransfer: buildTransferCaptureReport(capture.sets, currentSets), + }, + capture, + }; +} + +function parseArgs(argv) { + const options = { baselinePath: defaultBaselinePath }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--baseline') { + const value = argv[index + 1]; + if (!value) fail('--baseline requires a path'); + options.baselinePath = path.resolve(value); + index += 1; + } else { + fail(`unknown argument: ${argument}`); + } + } + return options; +} + +export function checkBundleBudgets({ baselinePath = defaultBaselinePath } = {}) { + const baseline = readJson(baselinePath, 'baseline'); + const metrics = readJson(metricsPath, 'build metrics'); + const catalog = readJson(catalogPath, 'release catalog'); + const release = readJson(releasePath, 'release inventory'); + if (baseline.schemaVersion !== 1) fail('baseline.schemaVersion must equal 1'); + if (metrics.schemaVersion !== 1) fail('build metrics schemaVersion must equal 1'); + validateBudgetSets(baseline.bundles, 'baseline.bundles'); + validateSemanticBundleSets(metrics, release, catalog); + + const historicalDeltas = {}; + const historicalSets = { + bootstrap: BOOTSTRAP_BASELINE, + ...Object.fromEntries(SET_NAMES.map((setName) => [setName, baseline.bundles[setName]])), + }; + const currentSets = { bootstrap: metrics.bootstrap, ...metrics.sets }; + for (const [setName, historical] of Object.entries(historicalSets)) { + const current = currentSets[setName]; + historicalDeltas[setName] = Object.fromEntries( + SIZE_NAMES.map((sizeName) => [ + sizeName, + { + historicalBytes: historical[sizeName], + currentBytes: current[sizeName], + deltaBytes: current[sizeName] - historical[sizeName], + }, + ]) + ); + } + + const currentArtifactContents = new Map( + release.artifacts.map(({ file }) => [ + file, + fs.readFileSync(path.join(path.dirname(releasePath), file)), + ]) + ); + const roleCorrect = validateRoleCorrectTransfer({ + baseline, + metrics, + catalog, + release, + currentArtifactContents, + verifyGitProvenance: true, + }); + const candidateArchitecture = buildCandidateArchitectureSizeReport({ + metrics, + release, + catalog, + currentArtifactContents, + }); + enforceCandidateArchitectureSizeCeilings(candidateArchitecture); + + return { + baselinePath, + roleCorrectStatus: 'immutable-intermediate', + reviewRemediationStatus: 'immutable-report-only', + transferCapturesEnforced: false, + historicalDeltas, + frozenTransferReports: roleCorrect.captureReports, + candidateArchitecture, + productionGraphReport: buildProductionGraphReport(metrics, release), + sets: metrics.sets, + }; +} + +/** Keep the blocking CLI report reviewable while full mask evidence stays in build metrics. */ +export function summarizeBundleBudgetCommandReport(result) { + const architecture = result?.candidateArchitecture; + const firstDisplay = architecture?.firstDisplay; + if ( + !architecture || + !firstDisplay || + !Array.isArray(firstDisplay.masks) || + !Array.isArray(firstDisplay.permittedMasks) + ) { + fail('candidate architecture report is missing first-display mask evidence'); + } + return { + ...result, + candidateArchitecture: { + ...architecture, + firstDisplay: { + reachableMaskCount: firstDisplay.masks.length, + permittedMaskCount: firstDisplay.permittedMasks.length, + named: firstDisplay.named, + }, + }, + }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const result = checkBundleBudgets(parseArgs(process.argv.slice(2))); + process.stdout.write( + `${JSON.stringify(summarizeBundleBudgetCommandReport(result), null, 2)}\n` + ); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : error}\n`); + process.exitCode = 1; + } +} diff --git a/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs b/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs new file mode 100644 index 000000000..74c9edcf7 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs @@ -0,0 +1,435 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const packageRoot = path.resolve(import.meta.dirname, '..'); +const repositoryRoot = path.resolve(packageRoot, '../../..'); +const extensions = new Set([ + '.css', + '.html', + '.integrity', + '.js', + '.json', + '.log', + '.map', + '.md', + '.mjs', + '.rs', + '.sh', + '.sha256', + '.sri', + '.toml', + '.ts', + '.tsx', + '.txt', + '.yaml', + '.yml', +]); +const ignoredDirectories = new Set(['.git', 'coverage', 'dist', 'node_modules', 'target']); + +function relative(file) { + return path.relative(repositoryRoot, file).replaceAll(path.sep, '/'); +} + +function collect(directory, files = []) { + if (!fs.existsSync(directory)) return files; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue; + const target = path.join(directory, entry.name); + if (entry.isDirectory()) collect(target, files); + else if (extensions.has(path.extname(entry.name))) files.push(target); + } + return files; +} + +function lineNumber(source, offset) { + let line = 1; + for (let index = 0; index < offset; index += 1) { + if (source.charCodeAt(index) === 10) line += 1; + } + return line; +} + +const jsPackageFiles = collect(packageRoot); +const shippedTsjsFiles = collect(path.join(packageRoot, 'src')); +const productionTsjsFiles = shippedTsjsFiles.filter( + (file) => !/(?:_test|\.test)\.[cm]?[jt]sx?$/u.test(file) +); +export function generatedTsjsArtifactFiles(root = packageRoot) { + return collect(path.resolve(root, '../dist')); +} + +const generatedTsjsFiles = generatedTsjsArtifactFiles(); +const currentGuideFiles = collect(path.join(repositoryRoot, 'docs/guide')); +const browserTestFiles = collect( + path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser') +); +const productionRustFiles = [ + 'crates/trusted-server-core/src', + 'crates/trusted-server-adapter-fastly/src', + 'crates/trusted-server-adapter-axum/src', + 'crates/trusted-server-adapter-cloudflare/src', + 'crates/trusted-server-adapter-spin/src', +].flatMap((directory) => collect(path.join(repositoryRoot, directory))); +const thisScript = path.resolve(import.meta.filename); +const auxiliaryFiles = [ + ...jsPackageFiles, + ...collect(path.join(repositoryRoot, 'crates/trusted-server-integration-tests')), + ...collect(path.join(repositoryRoot, 'scripts')), + ...collect(path.join(repositoryRoot, '.github/workflows')), +].filter((file) => path.resolve(file) !== thisScript); +const legacySurfaceFiles = [...shippedTsjsFiles, ...generatedTsjsFiles, ...currentGuideFiles]; +const uniqueFiles = (files) => [...new Set(files)]; +const violations = []; + +function forbidSource(file, source, label, expression) { + expression.lastIndex = 0; + for (let match = expression.exec(source); match; match = expression.exec(source)) { + violations.push(`${relative(file)}:${lineNumber(source, match.index)}: ${label}`); + if (match[0].length === 0) expression.lastIndex += 1; + } +} + +function forbid(files, label, expression) { + for (const file of uniqueFiles(files)) { + const source = fs.readFileSync(file, 'utf8'); + forbidSource(file, source, label, expression); + } +} + +function token(...parts) { + return parts.join(''); +} + +const retiredRuntimeFiles = new Set([ + 'src/composition/browser.ts', + 'src/composition/critical_transport.ts', + 'src/composition/index.ts', + 'src/core/bootstrap_controller.ts', +]); +const retiredCutoverExpressions = [ + [ + 'non-canonical APS renderer route', + /\/integrations\/aps\/renderer(?!\/v2(?![A-Za-z0-9_./-]))/gu, + ], + [ + 'rendererUrl in a v4 Prebid response', + /(?:message\s*:\s*['"]Prebid Response['"]|\[['"]message['"]\]\s*=\s*[^;\n]*prebidResponse)[\s\S]{0,512}(?:rendererVersion\s*:\s*['"]4['"]|\[['"]rendererVersion['"]\]\s*=)[\s\S]{0,512}\brendererUrl\b/gu, + ], + ['retired APS owner start payload', /['"]TS APS Start['"]/gu], + ['raw TSJS integration global', /__tsjs_[A-Za-z0-9_]+/gu], + ['retired bundle activation attribute', /data-ts-gam-attribution/gu], + [ + 'retired TSJS public surface', + /\b(?:LegacyTsjsApi|TsjsApiV1|apsPrebidRenderers|renderAllAdUnits|renderAdUnit|renderLog|renderSeq|registerContextProvider|collectContext|installGuards)\b|tsjs:adRendered/gu, + ], + [ + 'retired TSJS namespace member', + /\btsjs(?:\.|\?\.)(?:adSlots|bids|getConfig|gptDiagnostics|renders|setConfig)\b/gu, + ], + ['retired TSJS public version', /\btsjs(?:\.|\?\.)version\s*=\s*['"]0\.1\.0['"]/gu], + ['retired creative global', /\b(?:tscreative|tsCreativeConfig)\b/gu], +]; + +export function findCutoverTextViolations(file, source) { + const normalized = file + .replaceAll('\\', '/') + .replace(/^.*\/crates\/trusted-server-js\/lib\//u, ''); + const found = []; + if (retiredRuntimeFiles.has(normalized)) found.push('retired second runtime file'); + for (const [label, expression] of retiredCutoverExpressions) { + expression.lastIndex = 0; + if (expression.test(source)) found.push(label); + } + return found; +} + +const forbiddenVendorBasename = + /^(?:gpt|pubads_impl|prebid-creative|prebid-universal-creative(?:[._-].*)?)\.(?:[cm]?js|html)(?:\.(?:integrity|map|sha256|sri))?$/iu; +const vendorBodyExpressions = [ + /\/[*!]\s*(?:@license\s+)?[^\n]{0,160}\bPrebid Universal Creative\b/giu, + /\/[*!][^\n]{0,160}@license[^\n]{0,160}\bGoogle Publisher Tag\b/giu, + /\/[*!][^\n]{0,160}(?:Copyright|@license)[^\n]{0,160}\bAmazon Publisher Services\b/giu, + /\b(?:apsRunner|gpt|puc)(?:Digest|Integrity|Sha(?:256|384|512)|Sri|Checksum|Version)\b/giu, + /\b(?:digest|integrity|sha(?:256|384|512)|sri|checksum)\b[^\n]{0,160}\b(?:prebid-creative\.js|Google Publisher Tag|Prebid Universal Creative|Amazon Publisher Services)\b/giu, + /\b(?:prebid-creative\.js|Google Publisher Tag|Prebid Universal Creative|Amazon Publisher Services)\b[^\n]{0,160}\b(?:digest|integrity|sha(?:256|384|512)|sri|checksum)\b/giu, +]; + +export function findVendorBoundaryViolations(file, source) { + const normalized = file.replaceAll('\\', '/'); + const basename = path.posix.basename(normalized); + const found = []; + if (forbiddenVendorBasename.test(basename)) found.push('vendor distributable filename'); + for (const expression of vendorBodyExpressions) { + expression.lastIndex = 0; + if (expression.test(source)) found.push('stored vendor body, checksum, or version metadata'); + } + return found; +} + +for (const file of uniqueFiles([ + ...productionTsjsFiles, + ...generatedTsjsFiles, + ...currentGuideFiles, + ...productionRustFiles, +])) { + const source = fs.readFileSync(file, 'utf8'); + const productionSource = file.endsWith('.rs') + ? (source.split('\n#[cfg(test)]')[0] ?? source) + : source; + for (const label of findCutoverTextViolations(relative(file), productionSource)) { + violations.push(`${relative(file)}:1: ${label}`); + } +} + +const vendorBoundaryFiles = [ + ...productionTsjsFiles, + ...generatedTsjsFiles, + ...productionRustFiles, + ...collect(path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser/fixtures')), + ...collect(path.join(repositoryRoot, 'crates/trusted-server-integration-tests/fixtures')), + ...collect(path.join(repositoryRoot, 'target/aps-tsjs-quality-evidence')), + ...collect(path.join(repositoryRoot, 'target/aps-tsjs-cutover-evidence')), + ...collect( + path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser/real-gam-evidence') + ), + ...collect( + path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser/playwright-report') + ), + ...collect( + path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser/test-results') + ), +]; +for (const file of uniqueFiles(vendorBoundaryFiles)) { + const source = fs.readFileSync(file, 'utf8'); + const productionSource = file.endsWith('.rs') + ? (source.split('\n#[cfg(test)]')[0] ?? source) + : source; + for (const label of findVendorBoundaryViolations(relative(file), productionSource)) { + violations.push(`${relative(file)}:1: ${label}`); + } +} + +const oldRuntimePrefix = token('__', 'tsjs', '_'); +const oldCreativeGlobal = token('ts', 'creative'); +const oldIntegrationConfigTransport = token('_', 'integration', 'Config'); +const legacyPublicTokens = [ + token('Legacy', 'TsjsApi'), + token('TsjsApi', 'V1'), + token('apsPrebid', 'Renderers'), + token('render', 'AllAdUnits'), + token('render', 'AdUnit'), + token('render', 'Log'), + token('render', 'Seq'), + token('tsjs:', 'adRendered'), + token('__tsRender', 'Generation'), + token('__tsRender', 'Bid'), + token('registerContext', 'Provider'), + token('collect', 'Context'), + token('install', 'Guards'), +]; + +forbid(legacySurfaceFiles, 'legacy window runtime flag', new RegExp(oldRuntimePrefix, 'g')); +forbid( + legacySurfaceFiles, + 'legacy creative global', + new RegExp(`(?:globalThis\\.)?${oldCreativeGlobal}|tsCreativeConfig`, 'g') +); +forbid( + [...legacySurfaceFiles, ...productionRustFiles], + 'legacy mutable integration config transport', + new RegExp(oldIntegrationConfigTransport, 'g') +); +for (const name of legacyPublicTokens) { + forbid( + legacySurfaceFiles, + `legacy TSJS surface ${name}`, + new RegExp(name.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') + ); +} +forbid( + legacySurfaceFiles, + 'legacy public GPT diagnostics surface', + /\b(?:window\.)?tsjs(?:\?\.|\.)gptDiagnostics\b/g +); + +forbid( + shippedTsjsFiles, + 'legacy mutable core configuration API', + /export\s+function\s+(?:setConfig|getConfig)\b/g +); +forbid( + shippedTsjsFiles, + 'temporary architecture lint allowlist', + /LEGACY_(?:ADTECH_GLOBAL|RESTRICTED_IMPORT)_ALLOWLIST/g +); +forbid( + shippedTsjsFiles, + 'integration-owned function sentinel', + /__(?:tsInitialLoadConfigHooked|tsInitialLoadHooked|tsPushed|tsSlotHandoffPatched|tsApsBidResponseListenerInstalled|tsRefreshWrapped|tsRemoveAdUnitWrapped|tsRenderTraceInstalled|tsjsPrebidShimInstalled)\b/g +); +forbid( + shippedTsjsFiles, + 'empty catch in migrated TSJS source', + /catch\s*(?:\([^)]*\))?\s*\{\s*\}/g +); +const coreTraceFile = path.join(packageRoot, 'src/core/trace.ts'); +forbidSource( + coreTraceFile, + fs.readFileSync(coreTraceFile, 'utf8'), + 'core render trace presentation leakage', + /\b(?:Document|HTMLElement|MutationObserver)\b|createElement|getElementById|querySelector|clipboard|data-ts-/g +); +forbid( + [...currentGuideFiles, ...browserTestFiles], + 'legacy mutable TSJS configuration call', + /\btsjs(?:\?\.)?\.setConfig\s*\(/g +); +forbid( + browserTestFiles, + 'legacy TSJS render function invocation', + /\.tsjs(?:\?\.)?\.(?:renderAdUnit|renderAllAdUnits)\s*\(/g +); +forbid( + productionRustFiles, + 'legacy window runtime flag in production documentation', + /\/\/\/.*window\.__tsjs_.*/g +); + +const routeAndConfigFiles = [...shippedTsjsFiles, ...currentGuideFiles]; +forbid(routeAndConfigFiles, 'deprecated page-bids route', /\/__ts\/page-bids/g); +forbid( + routeAndConfigFiles, + 'non-canonical APS renderer route', + /\/integrations\/aps\/renderer(?!\/v2(?![A-Za-z0-9_./-]))/g +); +const apsIntegrationFile = path.join( + repositoryRoot, + 'crates/trusted-server-core/src/integrations/aps.rs' +); +const apsIntegrationSource = fs.readFileSync(apsIntegrationFile, 'utf8'); +forbidSource( + apsIntegrationFile, + apsIntegrationSource.split('\n#[cfg(test)]')[0] ?? apsIntegrationSource, + 'non-canonical APS renderer route', + /\/integrations\/aps\/renderer(?!\/v2(?![A-Za-z0-9_./-]))/g +); +if ( + !apsIntegrationSource.includes( + 'pub const APS_RUNNER_ROUTE: &str = "/integrations/aps/runner.js";' + ) +) { + violations.push(`${relative(apsIntegrationFile)}:1: missing canonical APS runner route`); +} +if ( + /APS_RUNNER_ROUTE:\s*&str\s*=\s*"\/integrations\/aps\/runner\/v1\.js"/.test(apsIntegrationSource) +) { + violations.push(`${relative(apsIntegrationFile)}:1: versioned APS runner route is served`); +} +forbid(currentGuideFiles, 'APS pub_id compatibility alias', /\bpub_id\b/g); +forbidSource( + apsIntegrationFile, + apsIntegrationSource.split('\n#[cfg(test)]')[0] ?? apsIntegrationSource, + 'APS pub_id compatibility alias', + /\bpub_id\b/g +); +forbid( + [...shippedTsjsFiles, ...productionRustFiles, ...currentGuideFiles], + 'vendored or pinned APS runner asset', + /include_(?:bytes|str)!?[^\n]*runner|APS_RUNNER_(?:ASSET|DIGEST|SRI|VERSION)|runner[_-]cache|offline[_ -]runner|prebid-creative\.js[^\n]*(?:digest|integrity|version)/gi +); +forbid( + auxiliaryFiles, + 'APS runner downloader, updater, or pinned artifact metadata', + /APS_RUNNER_(?:ASSET|DIGEST|SRI|VERSION)|runner[_-]cache|offline[_ -]runner|prebid-creative\.js[^\n]*(?:digest|integrity|version)|(?:download|update)[^\n]*prebid-creative\.js/gi +); +const browserPackageManifests = [ + 'crates/trusted-server-integration-tests/browser/package.json', + 'crates/trusted-server-integration-tests/browser/package-lock.json', +]; +for (const manifest of browserPackageManifests) { + const source = fs.readFileSync(path.join(repositoryRoot, manifest), 'utf8'); + if (source.includes('prebid-universal-creative')) { + violations.push(`${manifest}:1: PUC package is vendored into the local harness`); + } +} +const realGamNetworkFile = path.join( + repositoryRoot, + 'crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts' +); +const realGamNetworkSource = fs.readFileSync(realGamNetworkFile, 'utf8'); +if (!/export const REAL_GAM_PUC_RELEASE = ['"]1\.17\.2['"];/u.test(realGamNetworkSource)) { + violations.push( + `${relative(realGamNetworkFile)}:1: protected conformance metadata must pin PUC 1.17.2` + ); +} +forbidSource( + realGamNetworkFile, + realGamNetworkSource, + 'PUC conformance metadata must not carry vendor bytes, URLs, or checksums', + /\bPUC_(?:ASSET|BODY|DIGEST|INTEGRITY|SCRIPT|SRI|URL|VERSION)\b/gu +); + +for (const manifest of [ + 'crates/trusted-server-adapter-fastly/Cargo.toml', + 'crates/trusted-server-adapter-axum/Cargo.toml', + 'crates/trusted-server-adapter-cloudflare/Cargo.toml', + 'crates/trusted-server-adapter-spin/Cargo.toml', +]) { + const source = fs.readFileSync(path.join(repositoryRoot, manifest), 'utf8'); + const defaultFeatures = source.match(/^default\s*=\s*\[([^\]]*)\]/m)?.[1] ?? ''; + if (defaultFeatures.includes('aps-runner-proxy-integration-test')) { + violations.push(`${manifest}:1: APS proxy test hook is enabled in a production feature set`); + } +} + +const forbiddenFiles = [ + 'crates/trusted-server-integration-tests/src/bin/generate-tsjs-prospective-fixture.rs', + 'crates/trusted-server-js/lib/src/composition/browser.ts', + 'crates/trusted-server-js/lib/src/composition/critical_transport.ts', + 'crates/trusted-server-js/lib/src/composition/index.ts', + 'crates/trusted-server-js/lib/src/core/bootstrap_controller.ts', + 'crates/trusted-server-js/lib/src/core/context.ts', + 'crates/trusted-server-js/lib/src/core/request.ts', + 'crates/trusted-server-js/lib/src/first_display/contracts.ts', + 'crates/trusted-server-js/lib/src/first_display/handoff.ts', + 'crates/trusted-server-js/lib/src/first_display/registration.ts', + 'crates/trusted-server-js/lib/src/first_display/transaction.ts', + 'crates/trusted-server-js/lib/src/integrations/gpt/bootstrap_fallback.ts', + 'crates/trusted-server-js/lib/test/core/context.test.ts', + 'crates/trusted-server-js/lib/test/core/bootstrap_controller.test.ts', + 'crates/trusted-server-js/lib/test/core/trace.test.ts', +]; +for (const file of forbiddenFiles) { + if (fs.existsSync(path.join(repositoryRoot, file))) { + violations.push(`${file}:1: unreachable legacy file remains`); + } +} + +const requiredReplacements = [ + ['crates/trusted-server-core/src/tsjs.rs', 'IntegrationConfigsV1'], + ['crates/trusted-server-js/lib/src/core/index.ts', '_claimRuntimeV1'], + ['crates/trusted-server-js/lib/src/integrations/didomi/module.ts', 'proxyPath'], + ['crates/trusted-server-js/lib/src/integrations/prebid/module.ts', 'clientSideBidders'], + ['crates/trusted-server-js/lib/src/integrations/sourcepoint/module.ts', 'rewriteSdk'], + [ + 'crates/trusted-server-js/lib/build-prebid-external.mjs', + 'assertNoLegacyRuntimeFlags(finalBundle)', + ], +]; +for (const [file, required] of requiredReplacements) { + const source = fs.readFileSync(path.join(repositoryRoot, file), 'utf8'); + if (!source.includes(required)) { + violations.push(`${file}:1: missing immutable boot replacement ${required}`); + } +} + +const executedAsMain = + process.argv[1] !== undefined && + path.resolve(process.argv[1]) === path.resolve(import.meta.filename); +if (violations.length > 0) { + console.error(`Hard-cutover absence check failed (${violations.length} violations):`); + for (const violation of violations.sort()) console.error(`- ${violation}`); + process.exitCode = 1; +} else if (executedAsMain) { + console.log('Hard-cutover absence check passed.'); +} diff --git a/crates/trusted-server-js/lib/scripts/check-retired-concept-audit.mjs b/crates/trusted-server-js/lib/scripts/check-retired-concept-audit.mjs new file mode 100644 index 000000000..549fef2bc --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/check-retired-concept-audit.mjs @@ -0,0 +1,613 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const MANIFEST_FENCE = /```json retired-rcjuly-tsjs-concept-manifest-v1\n([\s\S]*?)\n```/g; +const LEDGER_ID = /\| `(RCJ-[A-Z]+-[0-9]+)`/g; +const QUALITY_ID = 'RCJ-QUAL-01'; +const SOURCE_ROOT = 'crates/trusted-server-js/lib/src/'; +const SHA_40 = /^[0-9a-f]{40}$/; +const SHA_256 = /^[0-9a-f]{64}$/; +const RECORDED_RETIRED_SNAPSHOT = '905984e62a0858c53d9f0ff6dd3a1bf190cf311d'; +const RECORDED_INVENTORY_SHA256 = + 'b1e28c8b30f0b8d95e38c0f8f57394df4ad43f760ae7abf5631e2054228aef08'; +const RECORDED_MAIN_AUDIT_SHA = 'f6a2fb85ce623bf8a574e3941e1ee349acc3412d'; +const RECORDED_RC_BASELINE_SHA = '07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf'; +const RECORDED_HISTORICAL_MAIN_ROWS_SHA256 = + 'c9cd93ee97e61a5765a7a569b2ea5693cbbb671484120afe4726d3b05fdced68'; +const HISTORICAL_PERFORMANCE_SHA256 = + 'fe5d7f52dc47dc9608ca6b92b036c6b971845e6424b157768dc9403a62d2d6b4'; +const HISTORICAL_CLASSIFICATIONS = new Set(['main-owned', 'implementation-gap']); +const RC_CLASSIFICATIONS = new Set(['baseline-owned', 'implementation-gap']); +const RESULTS = new Set(['pass', 'fail']); +const HISTORICAL_IMPLEMENTATION_GAP_IDS = new Set([ + 'RCJ-APS-03', + 'RCJ-APS-04', + 'RCJ-GPT-04', + 'RCJ-QUAL-01', + 'RCJ-TRACE-01', +]); +const RC_IMPLEMENTATION_GAP_IDS = new Set([ + 'RCJ-APS-03', + 'RCJ-APS-04', + 'RCJ-QUAL-01', + 'RCJ-TRACE-01', +]); +const DISPOSITIONS = new Set(['preserve', 'rebuild', 'supersede', 'exclude']); +const HISTORICAL_CLASSIFICATION_KEYS = [ + 'classification', + 'command', + 'disposition', + 'id', + 'mainSha', + 'ownerPaths', + 'result', + 'testPath', +]; +const RC_CLASSIFICATION_KEYS = [ + 'baselineSha', + 'classification', + 'command', + 'disposition', + 'id', + 'ownerPaths', + 'result', + 'testPath', +]; +const AUDIT_FIXTURE_KEYS = ['historicalMain', 'rcBaseline', 'version']; +const HISTORICAL_MAIN_LAYER_KEYS = ['mainSha', 'rows']; +const RC_BASELINE_LAYER_KEYS = ['baselineSha', 'rows']; +const RETIRED_SOURCE_REFERENCE = /(?:rc[/-]july|905984e62a0858c53d9f0ff6dd3a1bf190cf311d)/i; +const EXECUTABLE_SHELL_FENCE = + /^ {0,3}```(?:bash|sh|shell)(?:[ \t][^\n]*)?\r?\n([\s\S]*?)^ {0,3}```[ \t]*$/gim; +const ALLOWED_RETIRED_RENAMES = new Set([ + 'git mv crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs crates/trusted-server-js/lib/scripts/check-retired-concept-audit.mjs', + 'git mv crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs', +]); + +function codePointCompare(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function sorted(values) { + return [...values].sort(codePointCompare); +} + +function inventorySha256(inventory) { + const source = inventory.map((entry) => `${entry}\n`).join(''); + return createHash('sha256').update(source, 'utf8').digest('hex'); +} + +function isAllowedRetiredRename(command) { + return ALLOWED_RETIRED_RENAMES.has(command.trim().replace(/\s+/g, ' ')); +} + +/** Return shell-fence logical lines containing a retired source reference. */ +export function auditRetiredPlanCommands(planSource) { + const violations = []; + for (const [fenceIndex, match] of [...planSource.matchAll(EXECUTABLE_SHELL_FENCE)].entries()) { + const executable = match[1].replace(/\\\r?\n/g, ' '); + for (const command of executable.split(/\r?\n/)) { + const trimmed = command.trim(); + if ( + trimmed.length === 0 || + !RETIRED_SOURCE_REFERENCE.test(trimmed) || + isAllowedRetiredRename(trimmed) + ) { + continue; + } + violations.push({ fence: fenceIndex + 1, command: trimmed }); + } + } + return violations; +} + +/** Return the number of executable shell fences examined by the plan-integrity scanner. */ +export function countExecutableShellFences(planSource) { + return [...planSource.matchAll(EXECUTABLE_SHELL_FENCE)].length; +} + +function extractManifest(specSource) { + const matches = [...specSource.matchAll(MANIFEST_FENCE)]; + if (matches.length !== 1 || typeof matches[0]?.[1] !== 'string') { + throw new Error( + `expected exactly one retired-rcjuly-tsjs-concept-manifest-v1 block, found ${matches.length}` + ); + } + + const manifest = JSON.parse(matches[0][1]); + if (manifest === null || typeof manifest !== 'object' || manifest.version !== 1) { + throw new Error('retired concept manifest must be a version 1 object'); + } + if (manifest.authority !== 'concept-audit-only') { + throw new Error('retired concept manifest authority must be concept-audit-only'); + } + if (!SHA_40.test(manifest.retiredSnapshot ?? '')) { + throw new Error( + 'retired concept manifest retiredSnapshot must be a lowercase 40-character SHA' + ); + } + if (manifest.retiredSnapshot !== RECORDED_RETIRED_SNAPSHOT) { + throw new Error(`retired concept manifest must name recorded retired snapshot`); + } + if (!Number.isInteger(manifest.inventoryCount) || !Array.isArray(manifest.inventory)) { + throw new Error('retired concept manifest inventoryCount/inventory has an invalid shape'); + } + if (!SHA_256.test(manifest.inventorySha256 ?? '')) { + throw new Error('retired concept manifest inventorySha256 must be a lowercase SHA-256'); + } + if (!Array.isArray(manifest.mappings)) { + throw new Error('retired concept manifest mappings must be an array'); + } + + const inventory = manifest.inventory; + if (inventory.some((entry) => typeof entry !== 'string' || entry.length === 0)) { + throw new Error('retired concept inventory entries must be non-empty strings'); + } + if (manifest.inventoryCount !== inventory.length) { + throw new Error( + `inventoryCount ${manifest.inventoryCount} does not match ${inventory.length} inventory paths` + ); + } + if (inventory.length !== 144) { + throw new Error( + `retired concept inventory must contain exactly 144 paths, found ${inventory.length}` + ); + } + const uniqueInventory = new Set(inventory); + if (uniqueInventory.size !== inventory.length) { + throw new Error('retired concept inventory paths must be unique'); + } + if (inventory.some((entry, index) => entry !== sorted(inventory)[index])) { + throw new Error('retired concept inventory paths must be code-point sorted'); + } + const computedInventorySha256 = inventorySha256(inventory); + if (computedInventorySha256 !== manifest.inventorySha256) { + throw new Error( + `retired concept inventory SHA-256 mismatch: expected ${manifest.inventorySha256}, computed ${computedInventorySha256}` + ); + } + if (manifest.inventorySha256 !== RECORDED_INVENTORY_SHA256) { + throw new Error('retired concept manifest must preserve the recorded inventory SHA-256'); + } + + for (const [index, mapping] of manifest.mappings.entries()) { + if (mapping === null || typeof mapping !== 'object') { + throw new Error(`mapping ${index} must be an object`); + } + if (!Array.isArray(mapping.ids) || mapping.ids.length === 0) { + throw new Error(`mapping ${index} must declare at least one id`); + } + if (mapping.ids.some((id) => typeof id !== 'string' || !/^RCJ-[A-Z]+-[0-9]+$/.test(id))) { + throw new Error(`mapping ${index} contains an invalid id`); + } + if ( + mapping.exact !== undefined && + (!Array.isArray(mapping.exact) || + mapping.exact.length === 0 || + mapping.exact.some((entry) => typeof entry !== 'string' || entry.length === 0)) + ) { + throw new Error(`mapping ${index} has invalid exact paths`); + } + if ( + mapping.prefix !== undefined && + (typeof mapping.prefix !== 'string' || mapping.prefix.length === 0) + ) { + throw new Error(`mapping ${index} has an invalid prefix`); + } + if ( + mapping.prefixes !== undefined && + (!Array.isArray(mapping.prefixes) || + mapping.prefixes.length === 0 || + mapping.prefixes.some((entry) => typeof entry !== 'string' || entry.length === 0)) + ) { + throw new Error(`mapping ${index} has invalid prefixes`); + } + if ( + mapping.exact === undefined && + mapping.prefix === undefined && + mapping.prefixes === undefined + ) { + throw new Error(`mapping ${index} must declare exact, prefix, or prefixes`); + } + } + + return manifest; +} + +function mappingMatches(file, mapping) { + return ( + (Array.isArray(mapping.exact) && mapping.exact.includes(file)) || + (typeof mapping.prefix === 'string' && file.startsWith(mapping.prefix)) || + (Array.isArray(mapping.prefixes) && mapping.prefixes.some((prefix) => file.startsWith(prefix))) + ); +} + +function mappingIdsForFile(file, mappings) { + const ids = new Set(); + for (const mapping of mappings) { + if (!mappingMatches(file, mapping)) continue; + for (const id of mapping.ids) ids.add(id); + } + return ids; +} + +function hasExactKeys(value, expectedKeys) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const keys = Object.keys(value).sort(codePointCompare); + return ( + keys.length === expectedKeys.length && keys.every((key, index) => key === expectedKeys[index]) + ); +} + +function auditEvidenceRows({ + rows, + ledgerIds, + layerLabel, + shaKey, + expectedSha, + exactKeys, + classifications, + ownedClassification, + implementationGapIds, +}) { + const failures = []; + if (!Array.isArray(rows)) { + return { + classifications: [], + classificationCounts: { implementationGap: 0, owned: 0 }, + classificationFailures: [`${layerLabel} rows must be an array`], + }; + } + if (rows.length !== 23) { + failures.push(`${layerLabel} expected exactly 23 classification rows, found ${rows.length}`); + } + const ids = rows.map((row) => row?.id).filter((id) => typeof id === 'string'); + const seen = new Set(); + const duplicateClassificationIds = sorted([ + ...new Set(ids.filter((id) => (seen.has(id) ? true : !seen.add(id)))), + ]); + const fixtureIds = new Set(ids); + const missingClassificationIds = sorted([...ledgerIds].filter((id) => !fixtureIds.has(id))); + const extraClassificationIds = sorted([...fixtureIds].filter((id) => !ledgerIds.has(id))); + if (duplicateClassificationIds.length > 0) { + failures.push(`duplicateClassificationIds: ${JSON.stringify(duplicateClassificationIds)}`); + } + if (missingClassificationIds.length > 0) { + failures.push(`missingClassificationIds: ${JSON.stringify(missingClassificationIds)}`); + } + if (extraClassificationIds.length > 0) { + failures.push(`extraClassificationIds: ${JSON.stringify(extraClassificationIds)}`); + } + + for (const [index, row] of rows.entries()) { + if (row === null || typeof row !== 'object' || Array.isArray(row)) { + failures.push(`${layerLabel} classification row ${index} must be an object`); + continue; + } + const keys = Object.keys(row).sort(codePointCompare); + if ( + keys.length !== exactKeys.length || + keys.some((key, keyIndex) => key !== exactKeys[keyIndex]) + ) { + failures.push( + `${layerLabel} classification row ${index} must contain the exact required fields` + ); + } + const label = typeof row.id === 'string' ? row.id : `row ${index}`; + if (typeof row.id !== 'string' || !/^RCJ-[A-Z]+-[0-9]+$/.test(row.id)) { + failures.push(`${layerLabel} classification row ${index} must contain a valid RCJ id`); + } + if (row[shaKey] !== expectedSha) { + failures.push(`${label} ${shaKey} must equal ${expectedSha}`); + } + if (!classifications.has(row.classification)) { + failures.push( + `${label} classification must be ${[...classifications].join(' or ')}, not ${JSON.stringify(row.classification)}` + ); + } + if (!Array.isArray(row.ownerPaths) || row.ownerPaths.length === 0) { + failures.push(`${label} ownerPaths must be a non-empty array`); + } else if ( + row.ownerPaths.some( + (ownerPath) => + typeof ownerPath !== 'string' || + ownerPath.length === 0 || + path.isAbsolute(ownerPath) || + RETIRED_SOURCE_REFERENCE.test(ownerPath) + ) + ) { + failures.push( + `${label} ownerPaths must name exact ${layerLabel} paths without historical source` + ); + } + if ( + typeof row.testPath !== 'string' || + row.testPath.length === 0 || + path.isAbsolute(row.testPath) || + RETIRED_SOURCE_REFERENCE.test(row.testPath) + ) { + failures.push(`${label} testPath must name an exact focused ${layerLabel} test path`); + } + if ( + typeof row.command !== 'string' || + row.command.trim().length === 0 || + RETIRED_SOURCE_REFERENCE.test(row.command) + ) { + failures.push(`${label} command must be reproducible and must not resolve historical source`); + } + if (!RESULTS.has(row.result)) { + failures.push(`${label} result must be pass or fail, not ${JSON.stringify(row.result)}`); + } + const expectedClassification = implementationGapIds.has(row.id) + ? 'implementation-gap' + : ownedClassification; + const expectedResult = implementationGapIds.has(row.id) ? 'fail' : 'pass'; + if (row.classification !== expectedClassification || row.result !== expectedResult) { + failures.push( + `${label} authoritative classification/result for ${layerLabel} must be ${expectedClassification}/${expectedResult}` + ); + } + if (!DISPOSITIONS.has(row.disposition)) { + failures.push(`${label} disposition is invalid`); + } + if (row.classification === ownedClassification && row.result !== 'pass') { + failures.push(`${label} ${ownedClassification} classification must record result pass`); + } + if (row.classification === 'implementation-gap' && row.result !== 'fail') { + failures.push(`${label} implementation-gap classification must record result fail`); + } + if ( + row.classification === 'proof-pending' || + row.classification === 'coverage-gap' || + row.result === 'proof-pending' || + row.result === 'coverage-gap' + ) { + failures.push(`${label} proof-pending and coverage-gap are not final classifications`); + } + } + + const classificationCounts = { + implementationGap: rows.filter((row) => row?.classification === 'implementation-gap').length, + owned: rows.filter((row) => row?.classification === ownedClassification).length, + }; + const expectedImplementationGapCount = implementationGapIds.size; + const expectedOwnedCount = 23 - expectedImplementationGapCount; + if ( + classificationCounts.owned !== expectedOwnedCount || + classificationCounts.implementationGap !== expectedImplementationGapCount + ) { + failures.push( + `authoritative classification counts for ${layerLabel} must be ${ownedClassification}=${expectedOwnedCount} and implementation-gap=${expectedImplementationGapCount}, found ${classificationCounts.owned}/${classificationCounts.implementationGap}` + ); + } + + return { classifications: rows, classificationCounts, classificationFailures: failures }; +} + +function auditClassifications({ auditFixturePath, ledgerIds, mainAuditSha, rcBaselineSha }) { + if (mainAuditSha !== RECORDED_MAIN_AUDIT_SHA) { + throw new Error(`MAIN_AUDIT_SHA must equal the recorded current-main SHA`); + } + if (!SHA_40.test(mainAuditSha ?? '')) { + throw new Error('MAIN_AUDIT_SHA must be the recorded lowercase 40-character current-main SHA'); + } + if (rcBaselineSha !== RECORDED_RC_BASELINE_SHA) { + throw new Error(`RC_BASELINE_SHA must equal the recorded rc baseline SHA`); + } + if (!SHA_40.test(rcBaselineSha ?? '')) { + throw new Error('RC_BASELINE_SHA must be the recorded lowercase 40-character rc baseline SHA'); + } + + const fixture = JSON.parse(fs.readFileSync(auditFixturePath, 'utf8')); + if (!hasExactKeys(fixture, AUDIT_FIXTURE_KEYS) || fixture.version !== 2) { + throw new Error('concept audit fixture must be an exact version 2 object'); + } + if (!hasExactKeys(fixture.historicalMain, HISTORICAL_MAIN_LAYER_KEYS)) { + throw new Error('concept audit fixture historicalMain must contain exact mainSha and rows'); + } + if (!hasExactKeys(fixture.rcBaseline, RC_BASELINE_LAYER_KEYS)) { + throw new Error('concept audit fixture rcBaseline must contain exact baselineSha and rows'); + } + if (fixture.historicalMain.mainSha !== mainAuditSha) { + throw new Error(`historicalMain mainSha must equal ${mainAuditSha}`); + } + if (fixture.rcBaseline.baselineSha !== rcBaselineSha) { + throw new Error(`rcBaseline baselineSha must equal ${rcBaselineSha}`); + } + + const historicalMain = auditEvidenceRows({ + rows: fixture.historicalMain.rows, + ledgerIds, + layerLabel: 'historical-main', + shaKey: 'mainSha', + expectedSha: mainAuditSha, + exactKeys: HISTORICAL_CLASSIFICATION_KEYS, + classifications: HISTORICAL_CLASSIFICATIONS, + ownedClassification: 'main-owned', + implementationGapIds: HISTORICAL_IMPLEMENTATION_GAP_IDS, + }); + const historicalRowsSha256 = createHash('sha256') + .update(JSON.stringify(fixture.historicalMain.rows)) + .digest('hex'); + if (historicalRowsSha256 !== RECORDED_HISTORICAL_MAIN_ROWS_SHA256) { + historicalMain.classificationFailures.push( + `immutable historical-main evidence must remain ${RECORDED_HISTORICAL_MAIN_ROWS_SHA256}` + ); + } + + const rcBaseline = auditEvidenceRows({ + rows: fixture.rcBaseline.rows, + ledgerIds, + layerLabel: 'rc-baseline', + shaKey: 'baselineSha', + expectedSha: rcBaselineSha, + exactKeys: RC_CLASSIFICATION_KEYS, + classifications: RC_CLASSIFICATIONS, + ownedClassification: 'baseline-owned', + implementationGapIds: RC_IMPLEMENTATION_GAP_IDS, + }); + historicalMain.classificationCounts = { + implementationGap: historicalMain.classificationCounts.implementationGap, + mainOwned: historicalMain.classificationCounts.owned, + }; + rcBaseline.classificationCounts = { + baselineOwned: rcBaseline.classificationCounts.owned, + implementationGap: rcBaseline.classificationCounts.implementationGap, + }; + + return { + fixtureVersion: fixture.version, + historicalMain: { + mainSha: mainAuditSha, + rowsSha256: historicalRowsSha256, + ...historicalMain, + }, + rcBaseline: { + baselineSha: rcBaselineSha, + ...rcBaseline, + }, + }; +} + +export function auditRetiredConceptAudit({ + specPath, + auditFixturePath, + historicalPerformanceFixturePath, + mainAuditSha = RECORDED_MAIN_AUDIT_SHA, + rcBaselineSha = RECORDED_RC_BASELINE_SHA, + planPath, +}) { + const specSource = fs.readFileSync(specPath, 'utf8'); + const manifest = extractManifest(specSource); + const orderedFiles = [...manifest.inventory]; + const unmappedFiles = orderedFiles.filter( + (file) => !manifest.mappings.some((mapping) => mappingMatches(file, mapping)) + ); + const qualityOnlySourceFiles = orderedFiles.filter((file) => { + if (!file.startsWith(SOURCE_ROOT)) return false; + const ids = mappingIdsForFile(file, manifest.mappings); + return ![...ids].some((id) => id !== QUALITY_ID); + }); + const deadMappings = manifest.mappings + .map((mapping, index) => ({ index, mapping })) + .filter(({ mapping }) => !orderedFiles.some((file) => mappingMatches(file, mapping))) + .map(({ index }) => index); + + const manifestIds = new Set(manifest.mappings.flatMap((mapping) => mapping.ids)); + const ledgerIds = new Set([...specSource.matchAll(LEDGER_ID)].map((match) => match[1])); + const manifestOnlyIds = sorted([...manifestIds].filter((id) => !ledgerIds.has(id))); + const ledgerOnlyIds = sorted([...ledgerIds].filter((id) => !manifestIds.has(id))); + + const result = { + authority: manifest.authority, + retiredSnapshot: manifest.retiredSnapshot, + inventorySha256: manifest.inventorySha256, + fileCount: orderedFiles.length, + mappingCount: manifest.mappings.length, + manifestIdCount: manifestIds.size, + ledgerIdCount: ledgerIds.size, + ledgerIds: sorted(ledgerIds), + unmappedFiles, + qualityOnlySourceFiles, + deadMappings, + manifestOnlyIds, + ledgerOnlyIds, + }; + if (auditFixturePath !== undefined) { + Object.assign( + result, + auditClassifications({ auditFixturePath, ledgerIds, mainAuditSha, rcBaselineSha }) + ); + } + if (planPath !== undefined) { + result.retiredPlanCommandViolations = auditRetiredPlanCommands( + fs.readFileSync(planPath, 'utf8') + ); + } + if (historicalPerformanceFixturePath !== undefined) { + result.historicalPerformanceEvidence = { + authority: 'report-only', + sha256: createHash('sha256') + .update(fs.readFileSync(historicalPerformanceFixturePath)) + .digest('hex'), + }; + } + return result; +} + +export function assertRetiredConceptAudit(result) { + const failures = []; + if (result.fileCount !== 144) failures.push(`expected 144 files, found ${result.fileCount}`); + if (result.mappingCount !== 38) { + failures.push(`expected 38 mappings, found ${result.mappingCount}`); + } + if (result.manifestIdCount !== 23 || result.ledgerIdCount !== 23) { + failures.push( + `expected 23 manifest/ledger ids, found ${result.manifestIdCount}/${result.ledgerIdCount}` + ); + } + for (const key of [ + 'unmappedFiles', + 'qualityOnlySourceFiles', + 'deadMappings', + 'manifestOnlyIds', + 'ledgerOnlyIds', + ]) { + if (result[key].length > 0) failures.push(`${key}: ${JSON.stringify(result[key])}`); + } + failures.push(...(result.historicalMain?.classificationFailures ?? [])); + failures.push(...(result.rcBaseline?.classificationFailures ?? [])); + if ((result.retiredPlanCommandViolations ?? []).length > 0) { + failures.push( + `retiredPlanCommandViolations: ${JSON.stringify(result.retiredPlanCommandViolations)}` + ); + } + if ( + result.historicalPerformanceEvidence !== undefined && + (result.historicalPerformanceEvidence.authority !== 'report-only' || + result.historicalPerformanceEvidence.sha256 !== HISTORICAL_PERFORMANCE_SHA256) + ) { + failures.push( + `historicalPerformanceEvidence must remain report-only at ${HISTORICAL_PERFORMANCE_SHA256}` + ); + } + if (failures.length > 0) throw new Error(failures.join('\n')); +} + +const scriptPath = fileURLToPath(import.meta.url); +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + const repositoryRoot = path.resolve(path.dirname(scriptPath), '../../../..'); + const specPath = path.join( + repositoryRoot, + 'docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md' + ); + const auditFixturePath = path.join( + repositoryRoot, + 'crates/trusted-server-js/lib/test/fixtures/contracts/current-main-concept-audit.json' + ); + const planPath = path.join( + repositoryRoot, + 'docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md' + ); + const historicalPerformanceFixturePath = path.join( + repositoryRoot, + 'crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json' + ); + const configuredMainAuditSha = process.env.MAIN_AUDIT_SHA ?? RECORDED_MAIN_AUDIT_SHA; + const configuredRcBaselineSha = process.env.RC_BASELINE_SHA ?? RECORDED_RC_BASELINE_SHA; + const result = auditRetiredConceptAudit({ + specPath, + auditFixturePath, + historicalPerformanceFixturePath, + mainAuditSha: configuredMainAuditSha, + rcBaselineSha: configuredRcBaselineSha, + planPath, + }); + assertRetiredConceptAudit(result); + process.stdout.write(`${JSON.stringify(result)}\n`); +} diff --git a/crates/trusted-server-js/lib/scripts/integration-inventory-v1.d.mts b/crates/trusted-server-js/lib/scripts/integration-inventory-v1.d.mts new file mode 100644 index 000000000..ce0f9ebe6 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/integration-inventory-v1.d.mts @@ -0,0 +1 @@ +export function discoverIntegrationModules(integrationsDirectory: string): string[]; diff --git a/crates/trusted-server-js/lib/scripts/integration-inventory-v1.mjs b/crates/trusted-server-js/lib/scripts/integration-inventory-v1.mjs new file mode 100644 index 000000000..8737f37c0 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/integration-inventory-v1.mjs @@ -0,0 +1,14 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** Discover the canonical integration bundle inventory used by build and runtime tests. */ +export function discoverIntegrationModules(integrationsDirectory) { + if (!fs.existsSync(integrationsDirectory)) return []; + return fs + .readdirSync(integrationsDirectory) + .filter((name) => { + const fullPath = path.join(integrationsDirectory, name); + return fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')); + }) + .sort(); +} diff --git a/crates/trusted-server-js/lib/scripts/print-release-id.mjs b/crates/trusted-server-js/lib/scripts/print-release-id.mjs new file mode 100644 index 000000000..13d37b845 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/print-release-id.mjs @@ -0,0 +1,89 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { computeReleaseId, RELEASE_SENTINEL } from './release-v1.mjs'; + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const distDirectory = path.resolve(directory, '..', '..', 'dist'); +const FIRST_DISPLAY_ARTIFACT_COUNT = 14; +const CORE_ARTIFACT_INDEX = 1 + FIRST_DISPLAY_ARTIFACT_COUNT; +const INTEGRATION_ARTIFACT_COUNT = 20; +const RELEASE_ARTIFACT_COUNT = CORE_ARTIFACT_INDEX + 1 + INTEGRATION_ARTIFACT_COUNT; +const value = JSON.parse(fs.readFileSync(path.join(distDirectory, 'tsjs-release-v1.json'), 'utf8')); +if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.keys(value).join(',') !== 'version,releaseId,artifacts' || + value.version !== 1 || + !/^[0-9a-f]{64}$/.test(value.releaseId) || + !Array.isArray(value.artifacts) || + value.artifacts.length !== RELEASE_ARTIFACT_COUNT +) { + throw new Error('Invalid tsjs-release-v1.json'); +} + +const normalized = []; +const files = new Set(); +for (const [index, artifact] of value.artifacts.entries()) { + if ( + typeof artifact !== 'object' || + artifact === null || + Array.isArray(artifact) || + Object.keys(artifact).join(',') !== 'id,role,phase,trigger,inputs,outputs,file,bytes,hash' || + !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(artifact.id) || + !['bootstrap', 'first_display_base', 'first_display_slice', 'core', 'integration'].includes( + artifact.role + ) || + !Array.isArray(artifact.inputs) || + !Array.isArray(artifact.outputs) || + !Number.isSafeInteger(artifact.bytes) || + artifact.bytes <= 0 || + !/^[0-9a-f]{64}$/.test(artifact.hash) || + files.has(artifact.file) + ) { + throw new Error('Invalid canonical artifact inventory'); + } + if ( + (index === 0 && + (artifact.id !== 'bootstrap' || artifact.role !== 'bootstrap' || artifact.phase !== null)) || + (index === 1 && + (artifact.id !== 'first_display' || + artifact.role !== 'first_display_base' || + artifact.phase !== 'first_display')) || + (index > 1 && + index < CORE_ARTIFACT_INDEX && + (artifact.role !== 'first_display_slice' || artifact.phase !== 'first_display')) || + (index === CORE_ARTIFACT_INDEX && + (artifact.id !== 'core' || artifact.role !== 'core' || artifact.phase !== null)) || + (index > CORE_ARTIFACT_INDEX && + (artifact.role !== 'integration' || !['takeover', 'deferred'].includes(artifact.phase))) + ) { + throw new Error('Invalid canonical artifact role/order'); + } + files.add(artifact.file); + const bytes = fs.readFileSync(path.join(distDirectory, artifact.file)); + const source = bytes.toString('utf8'); + if ( + bytes.byteLength !== artifact.bytes || + createHash('sha256').update(bytes).digest('hex') !== artifact.hash || + source.includes(RELEASE_SENTINEL) || + source.split(value.releaseId).length - 1 !== 1 + ) { + throw new Error(`Artifact release mismatch: ${artifact.file}`); + } + normalized.push({ + id: artifact.id, + role: artifact.role, + phase: artifact.phase ?? '', + trigger: artifact.trigger ?? '', + bytes: Buffer.from(source.replace(value.releaseId, RELEASE_SENTINEL)), + }); +} +if (computeReleaseId(normalized) !== value.releaseId) { + throw new Error('Release manifest does not match canonical artifact bytes'); +} + +process.stdout.write(`${value.releaseId}\n`); diff --git a/crates/trusted-server-js/lib/scripts/release-v1.mjs b/crates/trusted-server-js/lib/scripts/release-v1.mjs new file mode 100644 index 000000000..324ae4db3 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/release-v1.mjs @@ -0,0 +1,74 @@ +import { createHash } from 'node:crypto'; + +export const RELEASE_SENTINEL = '__TSJS_RELEASE_ID_SENTINEL_V1__'; + +const RELEASE_PREFIX = Buffer.from('tsjs-release-v1\0', 'ascii'); + +function u64(value) { + if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid release frame length'); + const bytes = Buffer.alloc(8); + bytes.writeBigUInt64BE(BigInt(value)); + return bytes; +} + +function framed(value) { + const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8'); + return Buffer.concat([u64(bytes.byteLength), bytes]); +} + +function validateArtifact(artifact, seen) { + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(artifact.id) || seen.has(artifact.id)) { + throw new Error('Invalid release bundle id'); + } + for (const field of ['role', 'phase', 'trigger']) { + if (typeof artifact[field] !== 'string') { + throw new Error(`Invalid release artifact ${field}: ${artifact.id}`); + } + } + seen.add(artifact.id); +} + +export function computeReleaseId(artifacts) { + const hasher = createHash('sha256'); + hasher.update(RELEASE_PREFIX); + hasher.update(u64(artifacts.length)); + const seen = new Set(); + for (const artifact of artifacts) { + validateArtifact(artifact, seen); + const bytes = Buffer.isBuffer(artifact.bytes) + ? artifact.bytes + : Buffer.from(artifact.bytes, 'utf8'); + if (bytes.toString('utf8').split(RELEASE_SENTINEL).length - 1 !== 1) { + throw new Error(`Expected exactly one release sentinel: ${artifact.id}`); + } + hasher.update(framed(artifact.id)); + hasher.update(framed(artifact.role)); + hasher.update(framed(artifact.phase)); + hasher.update(framed(artifact.trigger)); + hasher.update(framed(bytes)); + } + return hasher.digest('hex'); +} + +export function stampRelease(bytes, releaseId) { + if (!/^[0-9a-f]{64}$/.test(releaseId)) throw new Error('Invalid release id'); + const source = Buffer.isBuffer(bytes) ? bytes.toString('utf8') : String(bytes); + if (source.split(RELEASE_SENTINEL).length - 1 !== 1) { + throw new Error('Expected exactly one release sentinel'); + } + const stamped = source.replace(RELEASE_SENTINEL, releaseId); + if (stamped.includes(RELEASE_SENTINEL)) throw new Error('Release sentinel remains'); + return stamped; +} + +export function validateStampedRelease(bundles, releaseId, requiredIds) { + const byId = new Map(bundles.map((bundle) => [bundle.id, bundle.bytes])); + for (const id of requiredIds) { + const bytes = byId.get(id); + if (bytes === undefined) throw new Error(`Missing release bundle: ${id}`); + const source = Buffer.isBuffer(bytes) ? bytes.toString('utf8') : String(bytes); + if (source.includes(RELEASE_SENTINEL) || source.split(releaseId).length - 1 !== 1) { + throw new Error(`Bundle release mismatch: ${id}`); + } + } +} diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts new file mode 100644 index 000000000..e6f8a9a56 --- /dev/null +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -0,0 +1,3154 @@ +const EXTERNAL_READY_TIMEOUT_MS = 10_000; +const MAX_PENDING_OPERATIONS = 64; + +/** The live state of the publisher-owned `window.googletag` binding. */ +export type GoogletagBindingStatus = 'present' | 'pending' | 'incompatible'; + +/** The readiness state owned by one GPT operation. */ +export type GoogletagOperationStatus = GoogletagBindingStatus | 'timed_out'; + +/** Failure codes produced at the GPT adapter boundary. */ +export type GoogletagAdapterErrorCode = + | 'caller_aborted' + | 'external_artifact_incompatible' + | 'external_queue_full' + | 'external_ready_timeout' + | 'operation_disposed'; + +/** A typed failure contained by the GPT adapter. */ +export class GoogletagAdapterError extends Error { + public readonly code: GoogletagAdapterErrorCode; + + public constructor(code: GoogletagAdapterErrorCode) { + super(code); + this.name = 'GoogletagAdapterError'; + this.code = code; + } +} + +/** Immutable GPT definition used by the adapter-owned replacement transaction. */ +export interface GoogletagReplacementDefinition { + readonly adUnitPath: string; + readonly elementId: string; + readonly sizes: unknown; +} + +/** Reversible synchronous admission for one newly defined GPT identity. */ +export interface GoogletagReplacementCommitAdmission { + commit(): boolean; + rollback(): void; +} + +/** Outcome of one adapter-owned initial GPT slot-definition transaction. */ +export type GoogletagDefinitionResult = Readonly< + { status: 'discarded' } | { status: 'defined'; slot: object } +>; + +/** Failure to define or synchronously retire one adapter-owned GPT slot. */ +export class GoogletagDefinitionError extends Error { + public readonly code = 'gpt_definition_failed'; + public readonly cause: unknown; + public readonly orphanedSlot: object | undefined; + + public constructor(orphanedSlot?: object, cause?: unknown) { + super('gpt_definition_failed'); + this.name = 'GoogletagDefinitionError'; + this.orphanedSlot = orphanedSlot; + this.cause = cause; + } +} + +/** Successful outcome of one GPT destroy/redefine transaction. */ +export type GoogletagReplacementResult = Readonly< + { status: 'destroyed' } | { status: 'replaced'; slot: object } +>; + +/** Failure from a replacement transaction, including any candidate GPT could not destroy. */ +export class GoogletagReplacementError extends Error { + public readonly code = 'gpt_replacement_failed'; + public readonly cause: unknown; + public readonly oldSlotDestroyed: boolean; + public readonly orphanedSlot: object | undefined; + public readonly preserveOldQuarantine: boolean; + + public constructor( + orphanedSlot?: object, + oldSlotDestroyed = false, + cause?: unknown, + preserveOldQuarantine = false + ) { + super('gpt_replacement_failed'); + this.name = 'GoogletagReplacementError'; + this.orphanedSlot = orphanedSlot; + this.oldSlotDestroyed = oldSlotDestroyed; + this.cause = cause; + this.preserveOldQuarantine = preserveOldQuarantine; + } +} + +/** Internal signal that a defineSlot result is already owned by another live record. */ +export class GoogletagReplacementCandidateCollisionError extends Error { + public readonly candidate: object; + + public constructor(candidate: object) { + super('gpt_replacement_candidate_collision'); + this.name = 'GoogletagReplacementCandidateCollisionError'; + this.candidate = candidate; + } +} + +/** Observer called before a publisher-originated targeting mutation is forwarded. */ +export interface GoogletagTargetingObserver { + readonly beforePublisherMutation: (slot: object, key?: string) => void; +} + +/** Callable targeting observation release with an exact wrapper-identity latch. */ +export interface GoogletagTargetingObservation { + (): void; + readonly isCurrent: () => boolean; +} + +/** Reversible bookkeeping prepared before one publisher GPT call. */ +export interface GoogletagPublisherCallAdmission { + readonly commit: () => void; + readonly rollback: () => void; +} + +/** One publisher-originated GPT call observed outside Trusted Server operations. */ +export interface GoogletagPublisherCallObserver { + readonly defineSlot?: ( + call: Readonly + ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'handoff'; slot: object }>; + readonly destroySlots?: (call: Readonly) => void; + readonly display?: ( + call: Readonly + ) => + | Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }> + | Readonly<{ action: 'suppress' }>; + readonly refresh?: (call: Readonly) => + | Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }> + | Readonly<{ + action: 'replace'; + slots: readonly object[]; + admission?: GoogletagPublisherCallAdmission; + }> + | Readonly<{ + action: 'defer'; + slots: readonly object[]; + completion: PromiseLike; + admission?: GoogletagPublisherCallAdmission; + }> + | Readonly<{ action: 'suppress' }>; +} + +/** Narrow data supplied before one publisher `defineSlot` call. */ +export interface GoogletagPublisherDefineSlotCall { + readonly adUnitPath: unknown; + readonly elementId: unknown; + readonly initialLoadDisabled: boolean; + readonly sizes: unknown; +} + +/** Narrow data supplied after one successful publisher `destroySlots` call. */ +export interface GoogletagPublisherDestroySlotsCall { + readonly slots: readonly object[]; +} + +/** Narrow data supplied before one publisher `display` call. */ +export interface GoogletagPublisherDisplayCall { + readonly initialLoadDisabled: boolean; + readonly target: unknown; +} + +/** Narrow data supplied before one publisher `refresh` call. */ +export interface GoogletagPublisherRefreshCall { + readonly requestedSlots: readonly object[] | undefined; + readonly slots: readonly object[]; + readonly options?: unknown; +} + +/** The small GPT surface exposed to an accepted operation. */ +export interface GoogletagFacade { + adUnitPath?(slot: object): unknown; + bindingToken(): object; + clearTargeting(slot: object, key?: string): unknown; + /** Enable SRA and GPT services exactly once before the first TS-owned request. */ + enableServices(): void; + transactionalDefine( + definition: GoogletagReplacementDefinition, + isGenerationCurrent: () => boolean, + prepareCommit: (slot: object) => GoogletagReplacementCommitAdmission + ): GoogletagDefinitionResult; + display(slot: string | object): unknown; + getTargeting(slot: object, key: string): readonly string[]; + observeTargeting( + slot: object, + observer: GoogletagTargetingObserver + ): GoogletagTargetingObservation; + refresh(slots?: readonly object[], options?: Readonly<{ changeCorrelator: boolean }>): unknown; + serviceState(): Readonly<{ + apiReady: boolean; + initialLoadDisabled: boolean; + pubadsReady: boolean; + }>; + setTargeting(slot: object, key: string, value: string | readonly string[]): unknown; + slotElementId?(slot: object): unknown; + slots(): readonly object[]; + subscribe( + eventType: string, + listener: (event: unknown) => Readonly | void, + diagnosticsOwner?: boolean + ): () => void; + transactionalReplace( + oldSlot: object, + definition: GoogletagReplacementDefinition | undefined, + isGenerationCurrent: () => boolean, + prepareCommit: (replacement: object) => GoogletagReplacementCommitAdmission + ): GoogletagReplacementResult; +} + +/** Options owned by one GPT operation. */ +export interface GoogletagOperationOptions { + readonly signal?: AbortSignal; +} + +/** A disposable GPT operation and its readiness-scoped result. */ +export interface GoogletagOperation { + readonly status: GoogletagOperationStatus; + readonly result: Promise; + dispose(): void; +} + +/** Narrow GPT boundary consumed by kernel sessions and services. */ +export interface GoogletagAdapter { + bindingStatus(): GoogletagBindingStatus; + enqueueGamAttribution(): boolean; + adoptDiagnosticsState?(input: GoogletagDiagnosticsAdoptionV1): boolean; + diagnosticsIdentity(slot: object): Readonly | undefined; + traceToken(slot: object): GptSlotTokenV1 | undefined; + observeDiagnostics(observer: GoogletagDiagnosticsObserver): (() => void) | undefined; + observePublisherCalls(observer: GoogletagPublisherCallObserver): () => void; + run( + command: (googletag: Readonly) => T, + options?: GoogletagOperationOptions + ): GoogletagOperation; + notifyReady(): void; + dispose(): void; +} + +export interface GoogletagDiagnosticsAdoptionV1 { + readonly nextTraceTokenOrdinal: number; + readonly slots: readonly Readonly<{ + readonly nextCycleOrdinal: number; + readonly physicalSlot: object; + readonly records: readonly Readonly<{ + readonly ordinal: number; + readonly responseIdentifier: string | null; + readonly seen: readonly GoogletagDiagnosticsEventName[]; + readonly state: 'open' | 'completed' | 'retired'; + }>[]; + readonly traceToken: string; + readonly unknownPriorCycle: boolean; + }>[]; +} + +export type GoogletagDiagnosticsEventName = + | 'slotRequested' + | 'slotResponseReceived' + | 'slotRenderEnded' + | 'slotOnload' + | 'impressionViewable' + | 'slotVisibilityChanged'; + +/** Safe Ad Manager identifiers copied from one GPT render callback. */ +export interface GoogletagDiagnosticsAdManagerIdentity { + readonly lineItemId?: number; + readonly creativeId?: number; + readonly campaignId?: number; + readonly advertiserId?: number; + readonly sourceAgnosticLineItemId?: number; + readonly sourceAgnosticCreativeId?: number; + readonly yieldGroupIds?: readonly number[]; + readonly companyIds?: readonly number[]; +} + +export interface GoogletagDiagnosticsFact { + readonly kind: GoogletagDiagnosticsEventName; + readonly observedAtMs: number; + readonly slot: GoogletagDiagnosticsSlotSnapshot; + readonly isEmpty?: boolean; + readonly size?: readonly [number, number]; + readonly isBackfill?: boolean; + readonly slotContentChanged?: boolean; + readonly inViewPercentage?: number; + readonly responseIdentifier?: string; + readonly adManager?: GoogletagDiagnosticsAdManagerIdentity; +} + +export type GptSlotTokenV1 = string & { readonly __brand: 'GptSlotTokenV1' }; +export type GptTraceCycleOrdinalV1 = number & { + readonly __brand: 'GptTraceCycleOrdinalV1'; +}; + +/** Exact lifecycle-owned attribution accepted by the GPT diagnostics producer. */ +export interface GoogletagTraceCycleHandle { + readonly isRetired: () => boolean; +} + +const googletagTraceCycleHandles = new WeakSet(); + +/** Create one opaque adapter-branded handle for an accepted physical request cycle. */ +export function createGoogletagTraceCycleHandle( + isRetired: () => boolean +): Readonly { + if (typeof isRetired !== 'function') throw new TypeError('invalid GPT trace cycle retirement'); + const handle = Object.freeze({ isRetired }); + googletagTraceCycleHandles.add(handle); + return handle; +} + +function acceptedTraceCycleHandle(value: unknown): value is Readonly { + return ( + typeof value === 'object' && + value !== null && + Object.isFrozen(value) && + googletagTraceCycleHandles.has(value) + ); +} + +/** Frozen, non-authoritative identity and metadata captured from one physical GPT slot. */ +export interface GoogletagDiagnosticsSlotSnapshot { + readonly token: object; + readonly traceToken?: GptSlotTokenV1; + readonly runtimeSlotNumber?: number; + readonly cycleOrdinal?: GptTraceCycleOrdinalV1; + readonly elementId?: string; + readonly adUnitPath?: string; +} + +export type GoogletagDiagnosticsObserver = (fact: Readonly) => void; + +/** Browser surface owned by the concrete GPT adapter. */ +export interface GoogletagGlobalTarget { + googletag?: unknown; + performance?: unknown; +} + +export type GoogletagDiagnosticsFailureCode = + | 'trace_cycle_ambiguity' + | 'trace_cycle_collision' + | 'trace_cycle_exhausted' + | 'trace_cycle_invalid' + | 'trace_token_collision' + | 'trace_token_exhausted' + | 'trace_token_invalid'; + +/** Test seams and local reporting for diagnostics-only identity construction. */ +export interface GoogletagDiagnosticsIdentityOptions { + readonly initialTraceCycleOrdinal?: number; + readonly initialTraceTokenOrdinal?: number; + readonly mintTraceToken?: (ordinal: number) => unknown; + readonly reportDiagnosticsFailure?: (code: GoogletagDiagnosticsFailureCode) => void; +} + +interface CommandQueue { + readonly binding: object; + readonly push: (...arguments_: unknown[]) => unknown; +} + +interface PresentGoogletag { + readonly binding: object; + readonly commandQueue: CommandQueue; + readonly display: (...arguments_: unknown[]) => unknown; + readonly pubads: (...arguments_: unknown[]) => unknown; +} + +interface ProvisionalEffect { + promote(): void; + release(): void; +} + +interface AbortRegistration { + readonly binding: object; + readonly listener: () => void; + readonly remove: (...arguments_: unknown[]) => unknown; + attempted: boolean; + cleanupRequested: boolean; + installing: boolean; +} + +interface PendingOperation { + state: GoogletagOperationStatus; + settled: boolean; + pendingReservation: boolean; + timeout: ReturnType | undefined; + readonly command: (googletag: Readonly) => T; + readonly resolve: (value: T | PromiseLike) => void; + readonly reject: (reason: unknown) => void; + abortRegistration: AbortRegistration | undefined; + readinessBinding: object | undefined; + readonly provisionalEffects: ProvisionalEffect[]; +} + +interface SharedInitialLoadTracker { + disabled: boolean; + rootWrapped: boolean; + readonly owners: Set; + readonly restorers: Set<() => void>; + readonly services: WeakMap void>; +} + +interface TargetingObservation { + readonly isCurrent: () => boolean; + readonly observers: Set; + readonly restore: () => void; +} + +const sharedInitialLoadTrackers = new WeakMap(); +const mapDeleteIntrinsic = Map.prototype.delete; +const mapGetIntrinsic = Map.prototype.get; +const mapKeysIntrinsic = Map.prototype.keys; +const setDeleteIntrinsic = Set.prototype.delete; +const setAddIntrinsic = Set.prototype.add; +const setHasIntrinsic = Set.prototype.has; +const setSizeGetter = Object.getOwnPropertyDescriptor(Set.prototype, 'size')?.get as ( + this: Set +) => number; +const setValuesIntrinsic = Set.prototype.values; +const setIteratorNextIntrinsic = Object.getPrototypeOf(new Set().values()).next as ( + this: IterableIterator +) => IteratorResult; +const weakMapDeleteIntrinsic = WeakMap.prototype.delete; +const weakMapGetIntrinsic = WeakMap.prototype.get; +const weakMapSetIntrinsic = WeakMap.prototype.set; +const weakSetDeleteIntrinsic = WeakSet.prototype.delete; + +function mapValue(map: Map, key: K): V | undefined { + return Reflect.apply(mapGetIntrinsic, map, [key]) as V | undefined; +} + +function mapKeys(map: Map): IterableIterator { + return Reflect.apply(mapKeysIntrinsic, map, []) as IterableIterator; +} + +function deleteMapValue(map: Map, key: K): boolean { + return Reflect.apply(mapDeleteIntrinsic, map, [key]) as boolean; +} + +function deleteSetValue(set: Set, value: T): boolean { + return Reflect.apply(setDeleteIntrinsic, set, [value]) as boolean; +} + +function addSetValue(set: Set, value: T): void { + Reflect.apply(setAddIntrinsic, set, [value]); +} + +function setHasValue(set: Set, value: T): boolean { + return Reflect.apply(setHasIntrinsic, set, [value]) as boolean; +} + +function setValues(set: Set): IterableIterator { + return Reflect.apply(setValuesIntrinsic, set, []) as IterableIterator; +} + +function setValueSnapshot(set: Set): T[] { + const iterator = setValues(set); + const values: T[] = []; + while (true) { + const step = Reflect.apply(setIteratorNextIntrinsic, iterator, []) as IteratorResult; + if (step.done) return values; + values[values.length] = step.value; + } +} + +function setSize(set: Set): number { + return Reflect.apply(setSizeGetter, set, []) as number; +} + +function weakMapValue(map: WeakMap, key: K): V | undefined { + return Reflect.apply(weakMapGetIntrinsic, map, [key]) as V | undefined; +} + +function setWeakMapValue(map: WeakMap, key: K, value: V): void { + Reflect.apply(weakMapSetIntrinsic, map, [key, value]); +} + +function deleteWeakMapValue(map: WeakMap, key: K): boolean { + return Reflect.apply(weakMapDeleteIntrinsic, map, [key]) as boolean; +} + +function deleteWeakSetValue(set: WeakSet, value: T): boolean { + return Reflect.apply(weakSetDeleteIntrinsic, set, [value]) as boolean; +} + +function safeMember(binding: object, key: PropertyKey): unknown { + try { + return Reflect.get(binding, key); + } catch { + return undefined; + } +} + +function commandQueue(binding: object): CommandQueue | undefined { + const candidate = safeMember(binding, 'cmd'); + if ((typeof candidate !== 'object' || candidate === null) && typeof candidate !== 'function') { + return undefined; + } + const push = safeMember(candidate, 'push'); + return typeof push === 'function' + ? { + binding: candidate as object, + push: push as (...arguments_: unknown[]) => unknown, + } + : undefined; +} + +function inspectBinding( + value: unknown +): + | { readonly status: 'pending'; readonly binding?: object; readonly commandQueue?: CommandQueue } + | { readonly status: 'incompatible'; readonly binding?: object } + | { readonly status: 'present'; readonly value: PresentGoogletag } { + if (value === undefined || value === null) return { status: 'pending' }; + if ((typeof value !== 'object' || value === null) && typeof value !== 'function') { + return { status: 'incompatible' }; + } + const binding = value as object; + const queue = commandQueue(binding); + if (!queue) return { status: 'incompatible', binding }; + if (safeMember(binding, 'apiReady') !== true) { + return { status: 'pending', binding, commandQueue: queue }; + } + const display = safeMember(binding, 'display'); + const pubads = safeMember(binding, 'pubads'); + if (typeof display !== 'function' || typeof pubads !== 'function') { + return { status: 'incompatible', binding }; + } + return { + status: 'present', + value: { + binding, + commandQueue: queue, + display: display as (...arguments_: unknown[]) => unknown, + pubads: pubads as (...arguments_: unknown[]) => unknown, + }, + }; +} + +function readTarget(target: GoogletagGlobalTarget): unknown { + try { + return target.googletag; + } catch { + return false; + } +} + +function queueCommand(queue: CommandQueue, command: () => void, guard?: () => boolean): void { + if (guard && !guard()) throw new GoogletagAdapterError('external_artifact_incompatible'); + Reflect.apply(queue.push, queue.binding, [command]); + if (guard && !guard()) throw new GoogletagAdapterError('external_artifact_incompatible'); +} + +function asObject(value: unknown): object { + if ((typeof value !== 'object' || value === null) && typeof value !== 'function') { + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + return value; +} + +function createFacade( + binding: PresentGoogletag, + registerEffect: (dispose: () => void) => () => void, + isOperationCurrent: () => boolean, + isBindingCurrent: () => boolean, + initialLoadDisabled: (service: object) => boolean, + targetingObservations: WeakMap, + bindingToken: object, + markFirstDisplay: () => void, + invokeFacadeCall: ( + callable: (...arguments_: unknown[]) => unknown, + receiver: unknown, + arguments_: readonly unknown[] + ) => unknown, + consumeFacadeCall: (callable: (...arguments_: unknown[]) => unknown) => boolean, + publishDiagnostics: ( + eventType: string, + event: unknown, + handle: Readonly | undefined + ) => void +): Readonly { + const member = (external: object, key: PropertyKey): ((...args: unknown[]) => unknown) => { + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + const candidate = safeMember(external, key); + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + if (typeof candidate !== 'function') { + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + return candidate as (...args: unknown[]) => unknown; + }; + const call = (external: object, key: PropertyKey, argumentsList: readonly unknown[]): unknown => { + const callable = member(external, key); + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + const result = invokeFacadeCall(callable, external, argumentsList); + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + return result; + }; + const value = (external: object, key: PropertyKey): unknown => { + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + const result = safeMember(external, key); + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + return result; + }; + const service = (): object => asObject(call(binding.binding, 'pubads', [])); + const replaceObservedMethod = ( + slot: object, + key: 'clearTargeting' | 'setTargeting', + observer: GoogletagTargetingObserver + ): Readonly<{ isCurrent: () => boolean; restore: () => void }> | undefined => { + if (!isOperationCurrent()) return undefined; + const original = member(slot, key); + let descriptor: PropertyDescriptor | undefined; + let defineAttempted = false; + const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { + if (consumeFacadeCall(wrapper)) { + return Reflect.apply(original, this, arguments_); + } + try { + const mutationKey = typeof arguments_[0] === 'string' ? arguments_[0] : undefined; + observer.beforePublisherMutation(slot, mutationKey); + } catch { + // Bookkeeping must not change publisher call arguments, order, return, or throw. + } + return Reflect.apply(original, this, arguments_); + }; + const restore = (): void => { + if (!defineAttempted) return; + defineAttempted = false; + try { + const current = Object.getOwnPropertyDescriptor(slot, key); + if (!current || current.value !== wrapper) return; + if (descriptor) Reflect.defineProperty(slot, key, descriptor); + else Reflect.deleteProperty(slot, key); + } catch { + // Publisher replacement wins once the installed method no longer matches. + } + }; + const wrapperIsCurrent = (): boolean => { + try { + if (!defineAttempted) return false; + const current = Object.getOwnPropertyDescriptor(slot, key); + return current !== undefined && current.value === wrapper; + } catch { + return false; + } + }; + try { + descriptor = Object.getOwnPropertyDescriptor(slot, key); + if ( + descriptor && + (!Object.prototype.hasOwnProperty.call(descriptor, 'value') || + (descriptor.configurable !== true && descriptor.writable !== true)) + ) { + return undefined; + } + const replacement = descriptor + ? { ...descriptor, value: wrapper } + : { configurable: true, enumerable: true, value: wrapper, writable: true }; + if (!isOperationCurrent()) { + return undefined; + } + defineAttempted = true; + if (!Reflect.defineProperty(slot, key, replacement)) { + restore(); + return undefined; + } + if (!isOperationCurrent() || safeMember(slot, key) !== wrapper) { + restore(); + return undefined; + } + return Object.freeze({ isCurrent: wrapperIsCurrent, restore }); + } catch { + restore(); + return undefined; + } + }; + return Object.freeze({ + adUnitPath: (slot: object): unknown => call(slot, 'getAdUnitPath', []), + bindingToken: (): object => bindingToken, + clearTargeting: (slot: object, key?: string): unknown => + call(slot, 'clearTargeting', key === undefined ? [] : [key]), + enableServices: (): void => { + const currentService = service(); + if (value(binding.binding, 'pubadsReady') === true) return; + call(currentService, 'enableSingleRequest', []); + call(binding.binding, 'enableServices', []); + }, + transactionalDefine: ( + definition: GoogletagReplacementDefinition, + isGenerationCurrent: () => boolean, + prepareCommit: (slot: object) => GoogletagReplacementCommitAdmission + ): GoogletagDefinitionResult => { + if ( + typeof isGenerationCurrent !== 'function' || + typeof prepareCommit !== 'function' || + !isOperationCurrent() + ) { + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + const destroy = (slot: object): boolean => { + try { + return call(binding.binding, 'destroySlots', [[slot]]) === true; + } catch { + return false; + } + }; + const discarded = Object.freeze({ status: 'discarded' as const }); + let candidate: object | undefined; + let admission: GoogletagReplacementCommitAdmission | undefined; + let commitAttempted = false; + const discard = (slot: object, cause?: unknown): GoogletagDefinitionResult => { + if (!destroy(slot)) throw new GoogletagDefinitionError(slot, cause); + return discarded; + }; + try { + if (!isGenerationCurrent() || !isOperationCurrent()) return discarded; + const defined = call(binding.binding, 'defineSlot', [ + definition.adUnitPath, + definition.sizes, + definition.elementId, + ]); + if ((typeof defined !== 'object' || defined === null) && typeof defined !== 'function') { + throw new GoogletagDefinitionError(); + } + candidate = defined as object; + if (!isGenerationCurrent() || !isOperationCurrent()) { + const stale = candidate; + candidate = undefined; + return discard(stale); + } + admission = prepareCommit(candidate); + if ( + !admission || + typeof admission.commit !== 'function' || + typeof admission.rollback !== 'function' + ) { + throw new GoogletagDefinitionError(); + } + call(candidate, 'addService', [service()]); + if (!isGenerationCurrent() || !isOperationCurrent()) { + const stale = candidate; + candidate = undefined; + return discard(stale); + } + commitAttempted = true; + if (!admission.commit()) throw new GoogletagDefinitionError(); + if (!isGenerationCurrent() || !isOperationCurrent()) { + try { + admission.rollback(); + } finally { + commitAttempted = false; + } + const stale = candidate; + candidate = undefined; + return discard(stale); + } + return Object.freeze({ status: 'defined' as const, slot: candidate }); + } catch (error) { + if (commitAttempted) { + try { + admission?.rollback(); + } catch { + // Candidate retirement remains mandatory after bookkeeping rollback failure. + } + } + if (candidate) { + const failed = candidate; + if (!destroy(failed)) throw new GoogletagDefinitionError(failed, error); + } + if (error instanceof GoogletagDefinitionError) throw error; + throw new GoogletagDefinitionError(undefined, error); + } + }, + display: (slot: string | object): unknown => { + const display = member(binding.binding, 'display'); + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + markFirstDisplay(); + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + const result = invokeFacadeCall(display, binding.binding, [slot]); + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + return result; + }, + getTargeting: (slot: object, key: string): readonly string[] => { + const targeting = call(slot, 'getTargeting', [key]); + if (!Array.isArray(targeting) || targeting.some((entry) => typeof entry !== 'string')) { + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + return Object.freeze([...targeting]); + }, + observeTargeting: ( + slot: object, + observer: GoogletagTargetingObserver + ): GoogletagTargetingObservation => { + if ( + typeof observer !== 'object' || + observer === null || + typeof observer.beforePublisherMutation !== 'function' + ) { + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + let observation = weakMapValue(targetingObservations, slot); + if (!observation) { + const observers = new Set(); + const dispatcher: GoogletagTargetingObserver = Object.freeze({ + beforePublisherMutation: (mutatedSlot: object, key?: string): void => { + const currentObservers = setValueSnapshot(observers); + for (let index = 0; index < currentObservers.length; index += 1) { + const current = currentObservers[index]; + if (!current) continue; + try { + current.beforePublisherMutation(mutatedSlot, key); + } catch { + // One observer cannot prevent another or alter the publisher mutation. + } + } + }, + }); + const restoreSet = replaceObservedMethod(slot, 'setTargeting', dispatcher); + if (!restoreSet) throw new GoogletagAdapterError('external_artifact_incompatible'); + const restoreClear = replaceObservedMethod(slot, 'clearTargeting', dispatcher); + if (!restoreClear) { + restoreSet.restore(); + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + let restored = false; + observation = { + isCurrent: (): boolean => restoreSet.isCurrent() && restoreClear.isCurrent(), + observers, + restore: (): void => { + if (restored) return; + restored = true; + try { + restoreClear.restore(); + } finally { + restoreSet.restore(); + } + }, + }; + try { + setWeakMapValue(targetingObservations, slot, observation); + } catch (error) { + observation.restore(); + throw error; + } + } + try { + addSetValue(observation.observers, observer); + } catch (error) { + if (setSize(observation.observers) === 0) { + if (weakMapValue(targetingObservations, slot) === observation) { + deleteWeakMapValue(targetingObservations, slot); + } + observation.restore(); + } + throw error; + } + let active = true; + const releaseEffect = registerEffect(() => { + if (!active) return; + active = false; + deleteSetValue(observation!.observers, observer); + if (setSize(observation!.observers) === 0) { + if (weakMapValue(targetingObservations, slot) === observation) { + deleteWeakMapValue(targetingObservations, slot); + } + observation!.restore(); + } + }); + const release = (() => releaseEffect()) as GoogletagTargetingObservation; + Object.defineProperty(release, 'isCurrent', { + configurable: false, + enumerable: true, + value: (): boolean => { + try { + return active && observation?.isCurrent() === true; + } catch { + return false; + } + }, + writable: false, + }); + return Object.freeze(release); + }, + refresh: ( + slots?: readonly object[], + options?: Readonly<{ changeCorrelator: boolean }> + ): unknown => + call( + service(), + 'refresh', + slots === undefined + ? options === undefined + ? [] + : [undefined, options] + : options === undefined + ? [[...slots]] + : [[...slots], options] + ), + serviceState: () => { + const currentService = service(); + const initialLoadDisabledValue = initialLoadDisabled(currentService); + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + return Object.freeze({ + apiReady: value(binding.binding, 'apiReady') === true, + initialLoadDisabled: initialLoadDisabledValue, + pubadsReady: value(binding.binding, 'pubadsReady') === true, + }); + }, + setTargeting: (slot: object, key: string, value: string | readonly string[]): unknown => + call(slot, 'setTargeting', [key, Array.isArray(value) ? [...value] : value]), + slotElementId: (slot: object): unknown => call(slot, 'getSlotElementId', []), + slots: (): readonly object[] => { + const currentSlots = call(service(), 'getSlots', []); + if ( + !Array.isArray(currentSlots) || + currentSlots.some((slot) => typeof slot !== 'object' || slot === null) + ) { + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); + return Object.freeze([...currentSlots]); + }, + subscribe: ( + eventType: string, + listener: (event: unknown) => Readonly | void, + diagnosticsOwner = false + ): (() => void) => { + const currentService = service(); + const add = member(currentService, 'addEventListener'); + const remove = member(currentService, 'removeEventListener'); + const wrapped = (event: unknown): void => { + if (!isBindingCurrent()) return; + let handle: Readonly | void = undefined; + try { + handle = listener(event); + } catch { + // Publisher and service callbacks cannot escape the GPT boundary. + } + if (diagnosticsOwner) { + publishDiagnostics(eventType, event, undefined); + } else if (eventType === 'slotRequested' || eventType === 'slotRenderEnded') { + publishDiagnostics( + eventType, + event, + acceptedTraceCycleHandle(handle) ? handle : undefined + ); + } + }; + let attempted = false; + const rollback = (): void => { + if (!attempted) return; + attempted = false; + try { + Reflect.apply(remove, currentService, [eventType, wrapped]); + } catch { + // Transaction rollback remains best-effort and cannot replace the original failure. + } + }; + try { + if (!isOperationCurrent()) + throw new GoogletagAdapterError('external_artifact_incompatible'); + attempted = true; + Reflect.apply(add, currentService, [eventType, wrapped]); + if (!isOperationCurrent()) + throw new GoogletagAdapterError('external_artifact_incompatible'); + } catch (error) { + rollback(); + throw error; + } + let active = true; + return registerEffect(() => { + if (!active) return; + active = false; + rollback(); + }); + }, + transactionalReplace: ( + oldSlot: object, + definition: GoogletagReplacementDefinition | undefined, + isGenerationCurrent: () => boolean, + prepareCommit: (replacement: object) => GoogletagReplacementCommitAdmission + ): GoogletagReplacementResult => { + if ( + typeof isGenerationCurrent !== 'function' || + typeof prepareCommit !== 'function' || + !isOperationCurrent() + ) { + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + const destroy = (slot: object): boolean => { + try { + return call(binding.binding, 'destroySlots', [[slot]]) === true; + } catch { + return false; + } + }; + const destroyed = Object.freeze({ status: 'destroyed' as const }); + const cleanup = (candidate: object, cause?: unknown): never => { + if (!destroy(candidate)) { + throw new GoogletagReplacementError(candidate, true, cause); + } + throw new GoogletagReplacementError(undefined, true, cause); + }; + if (!destroy(oldSlot)) throw new GoogletagReplacementError(oldSlot); + let replacement: object | undefined; + let admission: GoogletagReplacementCommitAdmission | undefined; + let commitAttempted = false; + try { + if (definition === undefined || !isGenerationCurrent() || !isOperationCurrent()) { + return destroyed; + } + const candidate = call(binding.binding, 'defineSlot', [ + definition.adUnitPath, + definition.sizes, + definition.elementId, + ]); + if ( + (typeof candidate !== 'object' || candidate === null) && + typeof candidate !== 'function' + ) { + throw new GoogletagReplacementError(undefined, true); + } + replacement = candidate as object; + if (replacement === oldSlot) { + const invalid = replacement; + replacement = undefined; + cleanup(invalid); + } + if (!isGenerationCurrent() || !isOperationCurrent()) { + const stale = replacement as object; + replacement = undefined; + if (!destroy(stale)) throw new GoogletagReplacementError(stale, true); + return destroyed; + } + admission = prepareCommit(replacement as object); + if ( + !admission || + typeof admission.commit !== 'function' || + typeof admission.rollback !== 'function' + ) { + throw new GoogletagReplacementError(undefined, true); + } + call(replacement as object, 'addService', [service()]); + if (!isGenerationCurrent() || !isOperationCurrent()) { + const stale = replacement as object; + replacement = undefined; + if (!destroy(stale)) throw new GoogletagReplacementError(stale, true); + return destroyed; + } + commitAttempted = true; + if (!admission.commit()) throw new GoogletagReplacementError(undefined, true); + if (!isGenerationCurrent() || !isOperationCurrent()) { + let rollbackFailed = false; + let rollbackFailure: unknown; + try { + admission.rollback(); + } catch (error) { + rollbackFailed = true; + rollbackFailure = error; + } + commitAttempted = false; + const stale = replacement as object; + replacement = undefined; + if (!destroy(stale)) { + throw new GoogletagReplacementError(stale, true, rollbackFailure); + } + if (rollbackFailed) { + throw new GoogletagReplacementError(undefined, true, rollbackFailure); + } + return destroyed; + } + return Object.freeze({ status: 'replaced' as const, slot: replacement as object }); + } catch (error) { + if (commitAttempted) { + try { + admission?.rollback(); + } catch { + // Candidate cleanup remains mandatory even when service rollback is hostile. + } + } + if (error instanceof GoogletagReplacementCandidateCollisionError) { + throw new GoogletagReplacementError(undefined, true, error, true); + } + if (replacement) cleanup(replacement, error); + if (error instanceof GoogletagReplacementError) throw error; + throw new GoogletagReplacementError(undefined, true, error); + } + }, + }); +} + +/** Create the sole production reader/writer boundary for `window.googletag`. */ +export function createBrowserGoogletagAdapter( + target: GoogletagGlobalTarget = window as unknown as GoogletagGlobalTarget, + diagnosticsOptions: GoogletagDiagnosticsIdentityOptions = {} +): GoogletagAdapter { + const pending: PendingOperation[] = []; + const live = new Set>(); + const effects = new Set<() => void>(); + let armedBindings = new WeakSet(); + const targetingObservations = new WeakMap(); + const facadeCalls = new WeakMap<(...arguments_: unknown[]) => unknown, number>(); + const adapterMethodOrigins = new WeakMap< + (...arguments_: unknown[]) => unknown, + (...arguments_: unknown[]) => unknown + >(); + const bindingTokens = new WeakMap(); + interface TraceCycle { + readonly handle: Readonly; + readonly ordinal: GptTraceCycleOrdinalV1; + readonly seen: Set; + responseIdentifier?: string; + state: 'open' | 'completed' | 'retired'; + } + interface DiagnosticsSlotState { + readonly adUnitPath?: string; + readonly cycles: TraceCycle[]; + readonly elementId?: string; + nextCycleOrdinal: number; + readonly token: object; + readonly traceToken?: GptSlotTokenV1; + unknownPriorCycle: boolean; + } + const diagnosticsSlots = new WeakMap(); + const traceCycleHandleOwners = new WeakMap< + Readonly, + DiagnosticsSlotState + >(); + const mintTraceToken = + typeof diagnosticsOptions.mintTraceToken === 'function' + ? diagnosticsOptions.mintTraceToken + : undefined; + const mintedTraceTokens = mintTraceToken ? new Set() : undefined; + const reportedDiagnosticsFailures = new Set(); + const initialLoadReleases = new Map void>(); + const initialLoadOwner = Object.freeze({}); + let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; + let nextTraceTokenOrdinal = diagnosticsOptions.initialTraceTokenOrdinal ?? 1; + let diagnosticsAdoptionOpen = true; + let pendingReservations = 0; + let disposed = false; + let firstDisplayObserved = false; + let gamAttributionEnqueued = false; + + const enqueueGamAttribution = (): boolean => { + if (disposed) return false; + if (gamAttributionEnqueued) return true; + let binding = readTarget(target); + if (binding === undefined || binding === null) { + const created = { cmd: [] }; + try { + if ( + !Reflect.defineProperty(target, 'googletag', { + configurable: true, + enumerable: true, + value: created, + writable: true, + }) || + readTarget(target) !== created + ) { + return false; + } + } catch { + return false; + } + binding = created; + } + if ((typeof binding !== 'object' || binding === null) && typeof binding !== 'function') { + return false; + } + const queue = commandQueue(binding as object); + if (!queue) return false; + gamAttributionEnqueued = true; + try { + queueCommand(queue, () => { + try { + const current = readTarget(target); + const root = + (typeof current === 'object' && current !== null) || typeof current === 'function' + ? (current as object) + : (binding as object); + const setConfig = safeMember(root, 'setConfig'); + if (typeof setConfig === 'function') { + Reflect.apply(setConfig, root, [{ targeting: { ts: 'true' } }]); + } + } catch { + // Missing or hostile GPT targeting cannot block later queue work. + } + }); + return true; + } catch { + return false; + } + }; + + const reportDiagnosticsFailure = (code: GoogletagDiagnosticsFailureCode): void => { + try { + if (setHasValue(reportedDiagnosticsFailures, code)) return; + addSetValue(reportedDiagnosticsFailures, code); + } catch { + return; + } + try { + diagnosticsOptions.reportDiagnosticsFailure?.(code); + } catch { + // Local diagnostics reporting cannot affect GPT lifecycle behavior. + } + }; + + const createDiagnosticsSlotState = (physicalSlot: object): DiagnosticsSlotState => { + const optionalStringCall = (key: 'getSlotElementId' | 'getAdUnitPath'): string | undefined => { + const method = safeMember(physicalSlot, key); + if (typeof method !== 'function') return undefined; + try { + const value = Reflect.apply(method, physicalSlot, []); + return typeof value === 'string' && value.length > 0 ? value : undefined; + } catch { + return undefined; + } + }; + const ordinal = nextTraceTokenOrdinal; + let traceToken: GptSlotTokenV1 | undefined; + if (Number.isInteger(ordinal) && ordinal >= 1 && ordinal <= 4_294_967_295) { + let candidate: unknown; + try { + candidate = mintTraceToken ? mintTraceToken(ordinal) : `gt1_${ordinal.toString(36)}`; + } catch { + reportDiagnosticsFailure('trace_token_invalid'); + } + if ( + typeof candidate === 'string' && + /^gt1_[1-9a-z][0-9a-z]{0,6}$/.test(candidate) && + candidate.length <= 11 && + Number.parseInt(candidate.slice(4), 36) <= 4_294_967_295 + ) { + if (mintedTraceTokens && setHasValue(mintedTraceTokens, candidate)) { + reportDiagnosticsFailure('trace_token_collision'); + } else { + try { + if (mintedTraceTokens) addSetValue(mintedTraceTokens, candidate); + traceToken = candidate as GptSlotTokenV1; + nextTraceTokenOrdinal += 1; + } catch { + if (mintedTraceTokens) deleteSetValue(mintedTraceTokens, candidate); + reportDiagnosticsFailure('trace_token_invalid'); + } + } + } else if (candidate !== undefined) { + reportDiagnosticsFailure('trace_token_invalid'); + } + } else { + reportDiagnosticsFailure( + Number.isInteger(ordinal) && ordinal > 4_294_967_295 + ? 'trace_token_exhausted' + : 'trace_token_invalid' + ); + } + const elementId = optionalStringCall('getSlotElementId'); + const adUnitPath = optionalStringCall('getAdUnitPath'); + return { + ...(adUnitPath === undefined ? {} : { adUnitPath }), + cycles: [], + ...(elementId === undefined ? {} : { elementId }), + nextCycleOrdinal: diagnosticsOptions.initialTraceCycleOrdinal ?? 1, + token: Object.freeze(Object.create(null) as object), + ...(traceToken === undefined ? {} : { traceToken }), + unknownPriorCycle: false, + }; + }; + + const diagnosticsSlotState = (physicalSlot: object): DiagnosticsSlotState | undefined => { + if (disposed) return undefined; + diagnosticsAdoptionOpen = false; + try { + let state = weakMapValue(diagnosticsSlots, physicalSlot); + if (!state) { + state = createDiagnosticsSlotState(physicalSlot); + setWeakMapValue(diagnosticsSlots, physicalSlot, state); + if (weakMapValue(diagnosticsSlots, physicalSlot) !== state) return undefined; + } + return state; + } catch { + return undefined; + } + }; + + const adoptDiagnosticsState = (input: GoogletagDiagnosticsAdoptionV1): boolean => { + if ( + disposed || + !diagnosticsAdoptionOpen || + typeof input !== 'object' || + input === null || + !Number.isInteger(input.nextTraceTokenOrdinal) || + input.nextTraceTokenOrdinal < 1 || + input.nextTraceTokenOrdinal > 4_294_967_295 || + !Array.isArray(input.slots) || + input.slots.length > 256 + ) { + return false; + } + const physicalSlots = new Set(); + const traceTokens = new Set(); + const prepared: Array> = []; + let maximumTokenOrdinal = 0; + for (const adopted of input.slots) { + const tokenOrdinal = + typeof adopted?.traceToken === 'string' && + /^gt1_[1-9a-z][0-9a-z]{0,6}$/.test(adopted.traceToken) + ? Number.parseInt(adopted.traceToken.slice(4), 36) + : Number.NaN; + if ( + typeof adopted !== 'object' || + adopted === null || + (typeof adopted.physicalSlot !== 'object' && typeof adopted.physicalSlot !== 'function') || + adopted.physicalSlot === null || + physicalSlots.has(adopted.physicalSlot) || + !Number.isInteger(tokenOrdinal) || + tokenOrdinal < 1 || + tokenOrdinal > 4_294_967_295 || + traceTokens.has(adopted.traceToken) || + !Number.isInteger(adopted.nextCycleOrdinal) || + adopted.nextCycleOrdinal < 1 || + adopted.nextCycleOrdinal > 4_294_967_295 || + typeof adopted.unknownPriorCycle !== 'boolean' || + !Array.isArray(adopted.records) || + adopted.records.length > 10 + ) { + return false; + } + const ordinals = new Set(); + const cycles: TraceCycle[] = []; + let maximumCycleOrdinal = 0; + for (const record of adopted.records) { + const seen = Array.isArray(record?.seen) ? (record.seen as readonly unknown[]) : undefined; + const responseIdentifier = record?.responseIdentifier; + if ( + typeof record !== 'object' || + record === null || + !Number.isInteger(record.ordinal) || + record.ordinal < 1 || + record.ordinal > 4_294_967_295 || + ordinals.has(record.ordinal) || + (record.state !== 'open' && record.state !== 'completed' && record.state !== 'retired') || + !seen || + seen.length === 0 || + seen.length > 6 || + new Set(seen).size !== seen.length || + seen.some( + (event: unknown) => + event !== 'slotRequested' && + event !== 'slotResponseReceived' && + event !== 'slotRenderEnded' && + event !== 'slotOnload' && + event !== 'impressionViewable' && + event !== 'slotVisibilityChanged' + ) || + !seen.includes('slotRequested') || + (record.state === 'open' && seen.includes('slotRenderEnded')) || + (record.state === 'completed' && !seen.includes('slotRenderEnded')) || + (responseIdentifier !== null && + (typeof responseIdentifier !== 'string' || + responseIdentifier.length === 0 || + new TextEncoder().encode(responseIdentifier).byteLength > 256 || + [...responseIdentifier].some((character) => { + const code = character.charCodeAt(0); + return code <= 0x1f || code === 0x7f; + }))) + ) { + return false; + } + const retired = record.state === 'retired'; + const handle = createGoogletagTraceCycleHandle(() => retired); + cycles.push({ + handle, + ordinal: record.ordinal as GptTraceCycleOrdinalV1, + ...(responseIdentifier === null ? {} : { responseIdentifier }), + seen: new Set(seen as readonly GoogletagDiagnosticsEventName[]), + state: record.state, + }); + ordinals.add(record.ordinal); + maximumCycleOrdinal = Math.max(maximumCycleOrdinal, record.ordinal); + } + if (adopted.nextCycleOrdinal <= maximumCycleOrdinal) return false; + physicalSlots.add(adopted.physicalSlot); + traceTokens.add(adopted.traceToken); + maximumTokenOrdinal = Math.max(maximumTokenOrdinal, tokenOrdinal); + prepared.push({ + physicalSlot: adopted.physicalSlot, + state: { + cycles, + nextCycleOrdinal: adopted.nextCycleOrdinal, + token: Object.freeze(Object.create(null) as object), + traceToken: adopted.traceToken as GptSlotTokenV1, + unknownPriorCycle: adopted.unknownPriorCycle, + }, + }); + } + if (input.nextTraceTokenOrdinal <= maximumTokenOrdinal) return false; + try { + for (const adopted of prepared) { + setWeakMapValue(diagnosticsSlots, adopted.physicalSlot, adopted.state); + for (const cycle of adopted.state.cycles) { + setWeakMapValue(traceCycleHandleOwners, cycle.handle, adopted.state); + } + if (mintedTraceTokens) addSetValue(mintedTraceTokens, adopted.state.traceToken!); + } + nextTraceTokenOrdinal = input.nextTraceTokenOrdinal; + diagnosticsAdoptionOpen = false; + return true; + } catch { + reportDiagnosticsFailure('trace_cycle_invalid'); + return false; + } + }; + + const traceCycle = ( + state: DiagnosticsSlotState, + eventType: GoogletagDiagnosticsEventName, + responseIdentifier: string | undefined, + acceptedHandle: Readonly | undefined + ): GptTraceCycleOrdinalV1 | undefined => { + if (!state.traceToken) return undefined; + const isRetired = (handle: Readonly): boolean => { + try { + return handle.isRetired() === true; + } catch { + return true; + } + }; + for (let index = 0; index < state.cycles.length; index += 1) { + const cycle = state.cycles[index]; + if (cycle && cycle.state !== 'retired' && isRetired(cycle.handle)) { + cycle.state = 'retired'; + } + } + if (eventType === 'slotRequested') { + if (!acceptedHandle || isRetired(acceptedHandle)) return undefined; + if ( + weakMapValue(traceCycleHandleOwners, acceptedHandle) !== undefined || + state.cycles.some((cycle) => cycle.handle === acceptedHandle) || + state.cycles.some((cycle) => cycle.state === 'open') + ) { + reportDiagnosticsFailure('trace_cycle_collision'); + return undefined; + } + const ordinal = state.nextCycleOrdinal; + if (!Number.isInteger(ordinal) || ordinal < 1 || ordinal > 4_294_967_295) { + reportDiagnosticsFailure( + Number.isInteger(ordinal) && ordinal > 4_294_967_295 + ? 'trace_cycle_exhausted' + : 'trace_cycle_invalid' + ); + return undefined; + } + if (state.cycles.length >= 10) { + const pruneIndex = state.cycles.findIndex((cycle) => cycle.state !== 'open'); + if (pruneIndex < 0) { + reportDiagnosticsFailure('trace_cycle_collision'); + return undefined; + } + state.cycles.splice(pruneIndex, 1); + state.unknownPriorCycle = true; + } + const cycle: TraceCycle = { + handle: acceptedHandle, + ordinal: ordinal as GptTraceCycleOrdinalV1, + seen: new Set([eventType]), + state: 'open', + }; + try { + setWeakMapValue(traceCycleHandleOwners, acceptedHandle, state); + } catch { + reportDiagnosticsFailure('trace_cycle_invalid'); + return undefined; + } + state.cycles.push(cycle); + state.nextCycleOrdinal += 1; + return cycle.ordinal; + } + + let candidates: TraceCycle[] = []; + if (acceptedHandle !== undefined) { + candidates = state.cycles.filter( + (cycle) => cycle.handle === acceptedHandle && !cycle.seen.has(eventType) + ); + } else if (responseIdentifier !== undefined) { + candidates = state.cycles.filter( + (cycle) => cycle.responseIdentifier === responseIdentifier && !cycle.seen.has(eventType) + ); + if (candidates.length === 0) { + const open = state.cycles.filter( + (cycle) => + cycle.state === 'open' && + cycle.responseIdentifier === undefined && + !cycle.seen.has(eventType) + ); + if (open.length === 1) candidates = open; + } + } else if (!state.unknownPriorCycle) { + candidates = state.cycles.filter((cycle) => !cycle.seen.has(eventType)); + } + if (candidates.length !== 1) { + if (candidates.length > 1) reportDiagnosticsFailure('trace_cycle_ambiguity'); + return undefined; + } + const cycle = candidates[0]!; + cycle.seen.add(eventType); + if (responseIdentifier !== undefined && cycle.responseIdentifier === undefined) { + cycle.responseIdentifier = responseIdentifier; + } + if (eventType === 'slotRenderEnded') cycle.state = 'completed'; + return cycle.ordinal; + }; + + const diagnosticFact = ( + eventType: string, + event: unknown, + observedAtMs: number, + acceptedHandle: Readonly | undefined + ): Readonly | undefined => { + try { + if ((typeof event !== 'object' || event === null) && typeof event !== 'function') { + return undefined; + } + const slot = safeMember(event as object, 'slot'); + if ((typeof slot !== 'object' || slot === null) && typeof slot !== 'function') { + return undefined; + } + const physicalSlot = slot as object; + const state = diagnosticsSlotState(physicalSlot); + if (!state) return undefined; + const responseIdentifierValue = safeMember(event as object, 'responseIdentifier'); + const responseIdentifier = + typeof responseIdentifierValue === 'string' && responseIdentifierValue.length > 0 + ? responseIdentifierValue + : undefined; + const kind = eventType as GoogletagDiagnosticsEventName; + const cycleOrdinal = traceCycle(state, kind, responseIdentifier, acceptedHandle); + const safeSlot = Object.freeze({ + token: state.token, + ...(state.traceToken === undefined ? {} : { traceToken: state.traceToken }), + ...(state.traceToken === undefined + ? {} + : { runtimeSlotNumber: Number.parseInt(state.traceToken.slice(4), 36) }), + ...(cycleOrdinal === undefined ? {} : { cycleOrdinal }), + ...(state.elementId === undefined ? {} : { elementId: state.elementId }), + ...(state.adUnitPath === undefined ? {} : { adUnitPath: state.adUnitPath }), + }); + const base = { + kind, + observedAtMs, + slot: safeSlot, + ...(responseIdentifier === undefined ? {} : { responseIdentifier }), + }; + switch (eventType) { + case 'slotRequested': + case 'slotResponseReceived': + case 'slotOnload': + case 'impressionViewable': + return Object.freeze({ ...base, kind: eventType }); + case 'slotVisibilityChanged': { + const percentage = safeMember(event as object, 'inViewPercentage'); + return typeof percentage === 'number' && Number.isFinite(percentage) + ? Object.freeze({ ...base, kind: eventType, inViewPercentage: percentage }) + : Object.freeze({ ...base, kind: eventType }); + } + case 'slotRenderEnded': { + const isEmpty = safeMember(event as object, 'isEmpty'); + const isBackfill = safeMember(event as object, 'isBackfill'); + const slotContentChanged = safeMember(event as object, 'slotContentChanged'); + const sizeCandidate = safeMember(event as object, 'size'); + let size: readonly [number, number] | undefined; + if (Array.isArray(sizeCandidate) && sizeCandidate.length === 2) { + const width = safeMember(sizeCandidate, '0'); + const height = safeMember(sizeCandidate, '1'); + if ( + typeof width === 'number' && + Number.isFinite(width) && + typeof height === 'number' && + Number.isFinite(height) + ) { + size = Object.freeze([width, height]); + } + } + const positiveInteger = (name: string): number | undefined => { + const value = safeMember(event as object, name); + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 + ? value + : undefined; + }; + const positiveIntegerList = (name: string): readonly number[] | undefined => { + const value = safeMember(event as object, name); + if (!Array.isArray(value)) return undefined; + const result = value + .map((entry) => + typeof entry === 'number' && Number.isSafeInteger(entry) && entry > 0 + ? entry + : undefined + ) + .filter((entry): entry is number => entry !== undefined) + .slice(0, 8); + return result.length === 0 ? undefined : Object.freeze(result); + }; + const adManagerCandidate = { + lineItemId: positiveInteger('lineItemId'), + creativeId: positiveInteger('creativeId'), + campaignId: positiveInteger('campaignId'), + advertiserId: positiveInteger('advertiserId'), + sourceAgnosticLineItemId: positiveInteger('sourceAgnosticLineItemId'), + sourceAgnosticCreativeId: positiveInteger('sourceAgnosticCreativeId'), + yieldGroupIds: positiveIntegerList('yieldGroupIds'), + companyIds: positiveIntegerList('companyIds'), + }; + const adManager = Object.fromEntries( + Object.entries(adManagerCandidate).filter(([, value]) => value !== undefined) + ) as GoogletagDiagnosticsAdManagerIdentity; + return Object.freeze({ + ...base, + kind: eventType, + ...(typeof isEmpty === 'boolean' ? { isEmpty } : {}), + ...(size ? { size } : {}), + ...(typeof isBackfill === 'boolean' ? { isBackfill } : {}), + ...(typeof slotContentChanged === 'boolean' ? { slotContentChanged } : {}), + ...(Object.keys(adManager).length === 0 ? {} : { adManager: Object.freeze(adManager) }), + }); + } + default: + return undefined; + } + } catch { + return undefined; + } + }; + + const publishDiagnostics = ( + eventType: string, + event: unknown, + acceptedHandle: Readonly | undefined + ): void => { + const observer = diagnosticsObserver; + if (!observer || disposed) return; + let observedAtMs = 0; + try { + const performance = safeMember(target, 'performance'); + if ( + (typeof performance === 'object' && performance !== null) || + typeof performance === 'function' + ) { + const now = safeMember(performance as object, 'now'); + if (typeof now === 'function') { + const value = Reflect.apply(now, performance, []); + if (typeof value === 'number' && Number.isFinite(value)) observedAtMs = value; + } + } + } catch { + // A missing or hostile clock cannot suppress the observed GPT fact. + } + const fact = diagnosticFact(eventType, event, observedAtMs, acceptedHandle); + if (!fact) return; + try { + observer(fact); + } catch { + // Diagnostics observation cannot escape the GPT correctness callback. + } + }; + + const invokeFacadeCall = ( + callable: (...arguments_: unknown[]) => unknown, + receiver: unknown, + arguments_: readonly unknown[] + ): unknown => { + const depth = weakMapValue(facadeCalls, callable) ?? 0; + setWeakMapValue(facadeCalls, callable, depth + 1); + try { + return Reflect.apply(callable, receiver, arguments_); + } finally { + if (depth === 0) deleteWeakMapValue(facadeCalls, callable); + else setWeakMapValue(facadeCalls, callable, depth); + } + }; + const consumeFacadeCall = (callable: (...arguments_: unknown[]) => unknown): boolean => { + const depth = weakMapValue(facadeCalls, callable) ?? 0; + if (depth === 0) return false; + if (depth === 1) deleteWeakMapValue(facadeCalls, callable); + else setWeakMapValue(facadeCalls, callable, depth - 1); + return true; + }; + + const markFirstDisplay = (): void => { + if (firstDisplayObserved) return; + firstDisplayObserved = true; + try { + const performance = safeMember(target, 'performance'); + if ( + (typeof performance !== 'object' || performance === null) && + typeof performance !== 'function' + ) { + return; + } + const mark = safeMember(performance as object, 'mark'); + if (typeof mark !== 'function') return; + Reflect.apply(mark, performance, ['tsjs:first-display']); + const measure = safeMember(performance as object, 'measure'); + if (typeof measure !== 'function') return; + Reflect.apply(measure, performance, [ + 'tsjs:boot-to-first-display', + 'tsjs:bids-script', + 'tsjs:first-display', + ]); + } catch { + // Performance instrumentation cannot change GPT display behavior. + } + }; + + const registerAdapterEffect = (disposeEffect: () => void): void => { + const rollback = (): void => { + try { + deleteSetValue(effects, disposeEffect); + } catch { + // A hostile registry cannot retain the effect being rolled back. + } + try { + disposeEffect(); + } catch { + // Cleanup cannot replace the publication failure or escape disposal. + } + }; + if (disposed) { + rollback(); + return; + } + try { + effects.add(disposeEffect); + } catch (error) { + rollback(); + throw error; + } + if (disposed) rollback(); + }; + + const replaceMethod = ( + binding: object, + key: PropertyKey, + wrapper: (...arguments_: unknown[]) => unknown, + isCurrent: () => boolean + ): (() => void) | undefined => { + let descriptor: PropertyDescriptor | undefined; + let installed = false; + const restore = (): void => { + if (!installed) return; + installed = false; + try { + const current = Object.getOwnPropertyDescriptor(binding, key); + if (!current || current.value !== wrapper) return; + if (descriptor) Reflect.defineProperty(binding, key, descriptor); + else Reflect.deleteProperty(binding, key); + } catch { + // Publisher replacement wins over best-effort adapter restoration. + } + }; + try { + descriptor = Object.getOwnPropertyDescriptor(binding, key); + if (!isCurrent()) return undefined; + if ( + descriptor && + (!Object.prototype.hasOwnProperty.call(descriptor, 'value') || + (descriptor.configurable !== true && descriptor.writable !== true)) + ) { + return undefined; + } + const replacement = descriptor + ? { ...descriptor, value: wrapper } + : { configurable: true, enumerable: true, value: wrapper, writable: true }; + if (!isCurrent()) return undefined; + if (!Reflect.defineProperty(binding, key, replacement)) return undefined; + installed = true; + if (!isCurrent() || safeMember(binding, key) !== wrapper || !isCurrent()) { + restore(); + return undefined; + } + } catch { + restore(); + return undefined; + } + return restore; + }; + + const syncInitialLoadDisabled = ( + binding: object, + tracker: { disabled: boolean }, + isCurrent?: () => boolean + ): boolean => { + const getConfig = safeMember(binding, 'getConfig'); + if (isCurrent && !isCurrent()) return false; + if (typeof getConfig !== 'function') return false; + try { + const config = Reflect.apply(getConfig, binding, ['disableInitialLoad']); + if (isCurrent && !isCurrent()) return false; + if ((typeof config !== 'object' || config === null) && typeof config !== 'function') { + return false; + } + const value = safeMember(config, 'disableInitialLoad'); + if (isCurrent && !isCurrent()) return false; + if (value === undefined) return false; + tracker.disabled = value === true; + return true; + } catch { + return false; + } + }; + + const syncExplicitInitialLoad = (candidate: unknown, tracker: { disabled: boolean }): boolean => { + try { + if ( + (typeof candidate !== 'object' || candidate === null) && + typeof candidate !== 'function' + ) { + return false; + } + const descriptor = Object.getOwnPropertyDescriptor(candidate, 'disableInitialLoad'); + if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) return false; + tracker.disabled = descriptor.value === true; + return true; + } catch { + return false; + } + }; + + const releaseInitialLoadBinding = (binding: object): void => { + const release = mapValue(initialLoadReleases, binding); + if (!release) return; + try { + deleteMapValue(initialLoadReleases, binding); + } finally { + try { + deleteSetValue(effects, release); + } catch { + // A hostile registry cannot retain adapter ownership of an old binding. + } finally { + try { + release(); + } catch { + // One historical binding cannot interrupt release of later bindings. + } + } + } + }; + + const releaseHistoricalInitialLoadBindings = (current?: object): void => { + for (const binding of [...mapKeys(initialLoadReleases)]) { + if (binding === current) continue; + try { + releaseInitialLoadBinding(binding); + } catch { + // One historical binding cannot interrupt release of later bindings. + } + } + }; + + const rollbackNotificationArming = (binding: object): void => { + let released = false; + try { + deleteWeakSetValue(armedBindings, binding); + released = !armedBindings.has(binding); + } catch { + // A poisoned registry cannot prove that the exact marker was removed. + } + if (!released) armedBindings = new WeakSet(); + }; + + const ensureInitialLoadTracking = ( + expected: PresentGoogletag, + knownService?: object + ): SharedInitialLoadTracker | undefined => { + const expectedCurrent = (): boolean => { + if (disposed) return false; + const current = sameBinding(expected); + return !disposed && current; + }; + if (!expectedCurrent()) return undefined; + + let tracker = weakMapValue(sharedInitialLoadTrackers, expected.binding); + if (!tracker) { + tracker = { + disabled: false, + rootWrapped: false, + owners: new Set(), + restorers: new Set<() => void>(), + services: new WeakMap void>(), + }; + try { + sharedInitialLoadTrackers.set(expected.binding, tracker); + } catch (error) { + if (weakMapValue(sharedInitialLoadTrackers, expected.binding) === tracker) { + deleteWeakMapValue(sharedInitialLoadTrackers, expected.binding); + } + throw error; + } + } + const ownsInitialLoad = (): boolean => { + try { + return tracker!.owners.has(initialLoadOwner); + } catch { + return false; + } + }; + const trackingCurrent = (): boolean => { + if ( + disposed || + weakMapValue(sharedInitialLoadTrackers, expected.binding) !== tracker || + !ownsInitialLoad() + ) { + return false; + } + const current = sameBinding(expected); + return ( + !disposed && + current && + weakMapValue(sharedInitialLoadTrackers, expected.binding) === tracker && + ownsInitialLoad() + ); + }; + let adoptedHere = false; + let alreadyAdopted: boolean; + try { + alreadyAdopted = initialLoadReleases.has(expected.binding); + } catch { + if ( + tracker.owners.size === 0 && + weakMapValue(sharedInitialLoadTrackers, expected.binding) === tracker + ) { + deleteWeakMapValue(sharedInitialLoadTrackers, expected.binding); + } + return undefined; + } + if (!alreadyAdopted) { + if (!expectedCurrent()) { + if ( + tracker.owners.size === 0 && + weakMapValue(sharedInitialLoadTrackers, expected.binding) === tracker + ) { + deleteWeakMapValue(sharedInitialLoadTrackers, expected.binding); + } + return undefined; + } + try { + tracker.owners.add(initialLoadOwner); + } catch (error) { + try { + deleteSetValue(tracker.owners, initialLoadOwner); + } finally { + if ( + tracker.owners.size === 0 && + weakMapValue(sharedInitialLoadTrackers, expected.binding) === tracker + ) { + deleteWeakMapValue(sharedInitialLoadTrackers, expected.binding); + } + } + throw error; + } + const adoptedTracker = tracker; + const release = (): void => { + try { + if (mapValue(initialLoadReleases, expected.binding) === release) { + deleteMapValue(initialLoadReleases, expected.binding); + } + } finally { + const removedLastOwner = + deleteSetValue(adoptedTracker.owners, initialLoadOwner) && + adoptedTracker.owners.size === 0; + if (removedLastOwner) { + if (weakMapValue(sharedInitialLoadTrackers, expected.binding) === adoptedTracker) { + deleteWeakMapValue(sharedInitialLoadTrackers, expected.binding); + } + for (const restore of [...adoptedTracker.restorers].reverse()) { + try { + restore(); + } catch { + // One restoration cannot interrupt cleanup of the shared tracker. + } + } + } + } + }; + try { + initialLoadReleases.set(expected.binding, release); + } catch (error) { + try { + if (mapValue(initialLoadReleases, expected.binding) === release) { + deleteMapValue(initialLoadReleases, expected.binding); + } + } finally { + release(); + } + throw error; + } + registerAdapterEffect(release); + adoptedHere = true; + } + const installedHere: Array<() => void> = []; + const rollback = (): undefined => { + for (const restore of [...installedHere].reverse()) restore(); + if (adoptedHere) { + releaseInitialLoadBinding(expected.binding); + } + return undefined; + }; + if (!trackingCurrent()) return rollback(); + syncInitialLoadDisabled(expected.binding, tracker, trackingCurrent); + if (!trackingCurrent()) return rollback(); + if (!tracker.rootWrapped) { + const originalSetConfig = safeMember(expected.binding, 'setConfig'); + if (!trackingCurrent()) return rollback(); + if (typeof originalSetConfig === 'function') { + const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { + const result = Reflect.apply(originalSetConfig, this, arguments_); + if (!syncInitialLoadDisabled(expected.binding, tracker!)) { + syncExplicitInitialLoad(arguments_[0], tracker!); + } + return result; + }; + const restore = replaceMethod(expected.binding, 'setConfig', wrapper, trackingCurrent); + if (restore) { + let active = true; + const cleanup = (): void => { + if (!active) return; + active = false; + try { + deleteSetValue(tracker!.restorers, cleanup); + } finally { + tracker!.rootWrapped = false; + restore(); + } + }; + tracker.rootWrapped = true; + try { + tracker.restorers.add(cleanup); + } catch (error) { + cleanup(); + rollback(); + throw error; + } + installedHere.push(cleanup); + } + if (!trackingCurrent()) return rollback(); + } + } + + const trackService = (service: object): boolean => { + try { + if (tracker!.services.has(service)) return true; + } catch { + return false; + } + if (!trackingCurrent()) return false; + const originalDisable = safeMember(service, 'disableInitialLoad'); + if (!trackingCurrent()) return false; + if (typeof originalDisable !== 'function') return true; + const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { + const result = Reflect.apply(originalDisable, this, arguments_); + if (!syncInitialLoadDisabled(expected.binding, tracker!)) tracker!.disabled = true; + return result; + }; + const restore = replaceMethod(service, 'disableInitialLoad', wrapper, trackingCurrent); + if (restore) { + let active = true; + const cleanup = (): void => { + if (!active) return; + active = false; + try { + deleteSetValue(tracker!.restorers, cleanup); + } finally { + try { + if (weakMapValue(tracker!.services, service) === cleanup) { + deleteWeakMapValue(tracker!.services, service); + } + } finally { + restore(); + } + } + }; + try { + tracker!.services.set(service, cleanup); + } catch (error) { + try { + cleanup(); + } finally { + rollback(); + } + throw error; + } + try { + tracker!.restorers.add(cleanup); + } catch (error) { + cleanup(); + rollback(); + throw error; + } + installedHere.push(cleanup); + } + if (!trackingCurrent()) return false; + return true; + }; + + if (knownService) { + if (!trackService(knownService)) return rollback(); + } else { + if (!trackingCurrent()) return rollback(); + let service: unknown; + try { + service = Reflect.apply(expected.pubads, expected.binding, []); + } catch { + return rollback(); + } + if (!trackingCurrent()) return rollback(); + if ((typeof service === 'object' && service !== null) || typeof service === 'function') { + if (!trackService(service as object)) return rollback(); + } + } + if (!trackingCurrent()) return rollback(); + return tracker; + }; + + const currentBinding = (): ReturnType => { + for (let attempt = 0; attempt < 2; attempt += 1) { + const value = readTarget(target); + const inspected = inspectBinding(value); + if (readTarget(target) === value) { + const current = + inspected.status === 'present' ? inspected.value.binding : inspected.binding; + releaseHistoricalInitialLoadBindings(current); + return inspected; + } + } + releaseHistoricalInitialLoadBindings(); + return { status: 'incompatible' }; + }; + + const sameBinding = (expected: PresentGoogletag): boolean => { + const canonicalAdapterMethod = ( + candidate: (...arguments_: unknown[]) => unknown + ): ((...arguments_: unknown[]) => unknown) | undefined => { + let current = candidate; + for (let depth = 0; depth < 16; depth += 1) { + const origin = weakMapValue(adapterMethodOrigins, current); + if (!origin) return current; + if (origin === current) return undefined; + current = origin; + } + return undefined; + }; + const sameAdapterMethod = ( + left: (...arguments_: unknown[]) => unknown, + right: (...arguments_: unknown[]) => unknown + ): boolean => { + if (left === right) return true; + const canonicalLeft = canonicalAdapterMethod(left); + return canonicalLeft !== undefined && canonicalLeft === canonicalAdapterMethod(right); + }; + const matchesCapturedBinding = (): boolean => { + const inspected = inspectBinding(expected.binding); + return ( + inspected.status === 'present' && + inspected.value.commandQueue.binding === expected.commandQueue.binding && + inspected.value.commandQueue.push === expected.commandQueue.push && + sameAdapterMethod(inspected.value.display, expected.display) && + inspected.value.pubads === expected.pubads + ); + }; + if (readTarget(target) !== expected.binding) { + releaseInitialLoadBinding(expected.binding); + return false; + } + const firstMatch = matchesCapturedBinding(); + if (readTarget(target) !== expected.binding) { + releaseInitialLoadBinding(expected.binding); + return false; + } + const secondMatch = matchesCapturedBinding(); + if (readTarget(target) !== expected.binding) { + releaseInitialLoadBinding(expected.binding); + return false; + } + return firstMatch && secondMatch; + }; + + const removePending = (operation: PendingOperation): void => { + const index = pending.indexOf(operation); + if (index >= 0) pending.splice(index, 1); + }; + + const releasePendingReservation = (operation: PendingOperation): void => { + if (!operation.pendingReservation) return; + operation.pendingReservation = false; + if (pendingReservations > 0) pendingReservations -= 1; + }; + + const clearReadiness = (operation: PendingOperation): void => { + try { + if (operation.timeout !== undefined) { + clearTimeout(operation.timeout); + operation.timeout = undefined; + } + removePending(operation); + } finally { + releasePendingReservation(operation); + } + }; + + const detachAbort = (operation: PendingOperation): void => { + const registration = operation.abortRegistration; + if (!registration || !registration.attempted) return; + if (registration.installing) { + registration.cleanupRequested = true; + return; + } + registration.attempted = false; + operation.abortRegistration = undefined; + try { + Reflect.apply(registration.remove, registration.binding, ['abort', registration.listener]); + } catch { + // Hostile signal cleanup cannot strand operation settlement. + } + }; + + const clearOperation = (operation: PendingOperation): void => { + try { + clearReadiness(operation); + } finally { + try { + detachAbort(operation); + } finally { + deleteSetValue(live, operation); + } + } + }; + + const rollbackOperationEffects = (operation: PendingOperation): void => { + for (let index = operation.provisionalEffects.length - 1; index >= 0; index -= 1) { + operation.provisionalEffects[index]?.release(); + } + operation.provisionalEffects.length = 0; + }; + + const rejectOperation = (operation: PendingOperation, error: unknown): void => { + if (operation.settled) return; + operation.settled = true; + if (error instanceof GoogletagAdapterError && error.code === 'external_artifact_incompatible') { + operation.state = 'incompatible'; + } + try { + rollbackOperationEffects(operation); + } finally { + try { + clearOperation(operation); + } finally { + operation.reject(error); + } + } + }; + + const fail = (operation: PendingOperation, code: GoogletagAdapterErrorCode): void => { + if (operation.settled) return; + if (code === 'external_ready_timeout') operation.state = 'timed_out'; + if (code === 'external_artifact_incompatible') operation.state = 'incompatible'; + rejectOperation(operation, new GoogletagAdapterError(code)); + }; + + const dispatch = (operation: PendingOperation, binding: PresentGoogletag): void => { + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + operation.state = 'present'; + clearReadiness(operation); + const isDispatchCurrent = (): boolean => { + if (disposed || operation.settled) return false; + const current = sameBinding(binding); + return !disposed && !operation.settled && current; + }; + const registerOperationEffect = (disposeEffect: () => void): (() => void) => { + let released = false; + let promoted = false; + const release = (): void => { + if (promoted) { + try { + deleteSetValue(effects, release); + } catch { + // A hostile registry cannot prevent exact external cleanup. + } + } + if (released) return; + released = true; + try { + disposeEffect(); + } catch { + // One effect cleanup cannot escape the adapter boundary. + } + }; + const promote = (): void => { + if (released || promoted) return; + promoted = true; + try { + effects.add(release); + } catch (error) { + release(); + throw error; + } + if (!isDispatchCurrent()) { + release(); + throw new GoogletagAdapterError( + disposed ? 'operation_disposed' : 'external_artifact_incompatible' + ); + } + }; + const provisional = { promote, release }; + operation.provisionalEffects[operation.provisionalEffects.length] = provisional; + if (!isDispatchCurrent()) { + release(); + throw new GoogletagAdapterError( + disposed ? 'operation_disposed' : 'external_artifact_incompatible' + ); + } + return release; + }; + const promoteOperationEffects = (): void => { + for (const provisional of operation.provisionalEffects) provisional.promote(); + operation.provisionalEffects.length = 0; + }; + const completeOperation = (value: unknown): void => { + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + if (!isDispatchCurrent()) { + fail(operation, 'external_artifact_incompatible'); + return; + } + try { + promoteOperationEffects(); + } catch (error) { + if (!operation.settled) rejectOperation(operation, error); + return; + } + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + if (!isDispatchCurrent()) { + fail(operation, 'external_artifact_incompatible'); + return; + } + operation.settled = true; + try { + clearOperation(operation); + } finally { + operation.resolve(value); + } + }; + const settleCommandValue = (value: unknown): void => { + let then: unknown; + try { + if ((typeof value === 'object' && value !== null) || typeof value === 'function') { + then = Reflect.get(value, 'then'); + } + } catch (error) { + rejectOperation(operation, error); + return; + } + if (typeof then !== 'function') { + completeOperation(value); + return; + } + Promise.resolve(value).then( + (resolved) => completeOperation(resolved), + (error: unknown) => rejectOperation(operation, error) + ); + }; + let bindingToken = weakMapValue(bindingTokens, binding.binding); + if (!bindingToken) { + bindingToken = Object.freeze({}); + setWeakMapValue(bindingTokens, binding.binding, bindingToken); + } + const facade = createFacade( + binding, + registerOperationEffect, + isDispatchCurrent, + () => !disposed && sameBinding(binding), + (service) => { + const tracker = ensureInitialLoadTracking(binding, service); + return tracker?.disabled === true; + }, + targetingObservations, + bindingToken, + markFirstDisplay, + invokeFacadeCall, + consumeFacadeCall, + publishDiagnostics + ); + try { + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + if (operation.settled) return; + ensureInitialLoadTracking(binding); + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + if (operation.settled) return; + if (!isDispatchCurrent()) { + if (disposed) fail(operation, 'operation_disposed'); + else fail(operation, 'external_artifact_incompatible'); + return; + } + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + if (operation.settled) { + return; + } + queueCommand( + binding.commandQueue, + () => { + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + if (!isDispatchCurrent()) { + if (disposed) fail(operation, 'operation_disposed'); + else fail(operation, 'external_artifact_incompatible'); + return; + } + try { + const value = operation.command(facade); + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + if (!isDispatchCurrent()) { + if (disposed) fail(operation, 'operation_disposed'); + else fail(operation, 'external_artifact_incompatible'); + return; + } + settleCommandValue(value); + } catch (error) { + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + rejectOperation(operation, error); + } + }, + isDispatchCurrent + ); + } catch (error) { + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + rejectOperation(operation, error); + } + }; + + const notifyReady = (expectedBinding?: object): void => { + if (disposed) return; + const current = currentBinding(); + if (disposed) return; + if (current.status === 'present') { + for (const operation of [...pending]) dispatch(operation, current.value); + return; + } + if (current.status === 'pending') { + armNotification(); + return; + } + if (expectedBinding !== undefined && current.binding !== expectedBinding) { + return; + } + for (const operation of [...pending]) { + if ( + expectedBinding === undefined || + operation.readinessBinding === undefined || + operation.readinessBinding === expectedBinding + ) { + fail(operation, 'external_artifact_incompatible'); + } + } + }; + + const armNotification = (): void => { + const current = currentBinding(); + if (disposed) return; + if (current.status !== 'pending' || !current.binding || !current.commandQueue) { + return; + } + let alreadyArmed = false; + try { + alreadyArmed = armedBindings.has(current.binding); + } catch { + armedBindings = new WeakSet(); + } + if (alreadyArmed) return; + for (const operation of pending) operation.readinessBinding = current.binding; + try { + armedBindings.add(current.binding); + } catch { + rollbackNotificationArming(current.binding); + return; + } + let notificationActive = true; + const notify = (): void => { + if (!notificationActive) return; + notificationActive = false; + notifyReady(current.binding); + }; + try { + queueCommand(current.commandQueue, notify); + } catch { + notificationActive = false; + rollbackNotificationArming(current.binding); + } + }; + + const run = ( + command: (googletag: Readonly) => T, + options: GoogletagOperationOptions = {} + ): GoogletagOperation => { + if (disposed) throw new GoogletagAdapterError('operation_disposed'); + const current = currentBinding(); + if (disposed) throw new GoogletagAdapterError('operation_disposed'); + if (current.status === 'pending') { + if (pendingReservations >= MAX_PENDING_OPERATIONS) { + throw new GoogletagAdapterError('external_queue_full'); + } + pendingReservations += 1; + } + + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason: unknown) => void; + const result = new Promise((resolveResult, rejectResult) => { + resolve = resolveResult; + reject = rejectResult; + }); + const operation: PendingOperation = { + state: current.status, + settled: false, + pendingReservation: current.status === 'pending', + timeout: undefined, + command, + resolve, + reject, + abortRegistration: undefined, + readinessBinding: current.status === 'pending' ? current.binding : undefined, + provisionalEffects: [], + }; + const handle = Object.freeze({ + get status(): GoogletagOperationStatus { + return operation.state; + }, + result, + dispose: (): void => fail(operation as PendingOperation, 'operation_disposed'), + }); + + try { + live.add(operation as PendingOperation); + } catch (error) { + try { + deleteSetValue(live, operation as PendingOperation); + } catch { + // Publication rollback preserves the original registry failure. + } + releasePendingReservation(operation as PendingOperation); + throw error; + } + if (current.status === 'pending') { + pending[pending.length] = operation as PendingOperation; + operation.timeout = setTimeout( + () => fail(operation as PendingOperation, 'external_ready_timeout'), + EXTERNAL_READY_TIMEOUT_MS + ); + } + + if (disposed) { + fail(operation as PendingOperation, 'operation_disposed'); + return handle; + } + if (operation.settled) return handle; + + let signal: unknown; + try { + signal = options.signal; + } catch (error) { + rejectOperation(operation as PendingOperation, error); + return handle; + } + if (operation.settled) return handle; + if (disposed) { + fail(operation as PendingOperation, 'operation_disposed'); + return handle; + } + if (signal !== undefined) { + if ((typeof signal !== 'object' || signal === null) && typeof signal !== 'function') { + rejectOperation( + operation as PendingOperation, + new TypeError('Invalid AbortSignal') + ); + return handle; + } + let aborted: unknown; + let add: unknown; + let remove: unknown; + try { + aborted = Reflect.get(signal, 'aborted'); + if (operation.settled) return handle; + add = Reflect.get(signal, 'addEventListener'); + if (operation.settled) return handle; + remove = Reflect.get(signal, 'removeEventListener'); + } catch (error) { + if (!operation.settled) rejectOperation(operation as PendingOperation, error); + return handle; + } + if (operation.settled) return handle; + if (disposed) { + fail(operation as PendingOperation, 'operation_disposed'); + return handle; + } + if (aborted === true) { + fail(operation as PendingOperation, 'caller_aborted'); + return handle; + } + if (typeof add !== 'function' || typeof remove !== 'function') { + rejectOperation( + operation as PendingOperation, + new TypeError('Invalid AbortSignal') + ); + return handle; + } + const registration: AbortRegistration = { + binding: signal, + listener: () => fail(operation as PendingOperation, 'caller_aborted'), + remove: remove as (...arguments_: unknown[]) => unknown, + attempted: true, + cleanupRequested: false, + installing: true, + }; + operation.abortRegistration = registration; + try { + Reflect.apply(add, signal, ['abort', registration.listener, { once: true }]); + } catch (error) { + registration.installing = false; + detachAbort(operation as PendingOperation); + if (!operation.settled) rejectOperation(operation as PendingOperation, error); + return handle; + } + registration.installing = false; + if (registration.cleanupRequested || operation.settled || disposed) { + detachAbort(operation as PendingOperation); + } + if (operation.settled) return handle; + if (disposed) { + fail(operation as PendingOperation, 'operation_disposed'); + return handle; + } + let abortedAfterRegistration: unknown; + try { + abortedAfterRegistration = Reflect.get(signal, 'aborted'); + } catch (error) { + if (!operation.settled) rejectOperation(operation as PendingOperation, error); + return handle; + } + if (operation.settled) return handle; + if (disposed) { + fail(operation as PendingOperation, 'operation_disposed'); + return handle; + } + if (abortedAfterRegistration === true) { + fail(operation as PendingOperation, 'caller_aborted'); + return handle; + } + } + + if (current.status === 'incompatible') { + operation.settled = true; + clearOperation(operation as PendingOperation); + operation.reject(new GoogletagAdapterError('external_artifact_incompatible')); + } else if (current.status === 'present') { + dispatch(operation as PendingOperation, current.value); + } else { + armNotification(); + } + return handle; + }; + + const observePublisherCalls = (observer: GoogletagPublisherCallObserver): (() => void) => { + if (disposed) throw new GoogletagAdapterError('operation_disposed'); + if (typeof observer !== 'object' || observer === null) { + throw new TypeError('GPT publisher observer must be an object'); + } + const observerMethod = ( + key: Key + ): GoogletagPublisherCallObserver[Key] | undefined => { + const descriptor = Object.getOwnPropertyDescriptor(observer, key); + if (!descriptor) return undefined; + if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new TypeError('GPT publisher observer methods must be own data properties'); + } + if (descriptor.value !== undefined && typeof descriptor.value !== 'function') { + throw new TypeError('GPT publisher observer methods must be functions'); + } + return descriptor.value as GoogletagPublisherCallObserver[Key] | undefined; + }; + const defineObserver = observerMethod('defineSlot'); + const destroyObserver = observerMethod('destroySlots'); + const displayObserver = observerMethod('display'); + const refreshObserver = observerMethod('refresh'); + const current = currentBinding(); + if (current.status === 'pending' && current.commandQueue) { + const normalizedObserver: GoogletagPublisherCallObserver = Object.freeze({ + ...(defineObserver ? { defineSlot: defineObserver } : {}), + ...(destroyObserver ? { destroySlots: destroyObserver } : {}), + ...(displayObserver ? { display: displayObserver } : {}), + ...(refreshObserver ? { refresh: refreshObserver } : {}), + }); + let released = false; + let notificationActive = true; + let installedRelease: (() => void) | undefined; + const release = (): void => { + if (released) return; + released = true; + notificationActive = false; + try { + deleteSetValue(effects, release); + } catch { + // Exact deferred restoration still runs when bookkeeping is hostile. + } + installedRelease?.(); + }; + try { + queueCommand(current.commandQueue, () => { + if (!notificationActive || released || disposed) return; + notificationActive = false; + const ready = currentBinding(); + if (ready.status !== 'present') return; + try { + installedRelease = observePublisherCalls(normalizedObserver); + if (released) installedRelease(); + } catch { + // Readiness mediation cannot escape the publisher-owned command queue. + } + }); + } catch (error) { + notificationActive = false; + released = true; + throw error; + } + registerAdapterEffect(release); + return release; + } + if (current.status !== 'present') return (): void => undefined; + const service = Reflect.apply(current.value.pubads, current.value.binding, []); + if ((typeof service !== 'object' || service === null) && typeof service !== 'function') { + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + const serviceObject = service as object; + const currentBindingObject = current.value.binding; + const tracker = ensureInitialLoadTracking(current.value, serviceObject); + const stillCurrent = (): boolean => + !disposed && + readTarget(target) === currentBindingObject && + Reflect.apply(current.value.pubads, currentBindingObject, []) === serviceObject; + const safelyCurrent = (): boolean => { + try { + return stillCurrent(); + } catch { + return false; + } + }; + const publisherAdmission = (decision: unknown): GoogletagPublisherCallAdmission | undefined => { + if ((typeof decision !== 'object' || decision === null) && typeof decision !== 'function') { + return undefined; + } + const candidate = safeMember(decision as object, 'admission'); + if ( + (typeof candidate !== 'object' || candidate === null) && + typeof candidate !== 'function' + ) { + return undefined; + } + const commit = safeMember(candidate as object, 'commit'); + const rollback = safeMember(candidate as object, 'rollback'); + if (typeof commit !== 'function' || typeof rollback !== 'function') return undefined; + return Object.freeze({ + commit: (): void => { + Reflect.apply(commit, candidate, []); + }, + rollback: (): void => { + Reflect.apply(rollback, candidate, []); + }, + }); + }; + const commitAdmission = (admission: GoogletagPublisherCallAdmission | undefined): void => { + try { + admission?.commit(); + } catch { + // Post-native bookkeeping cannot alter the publisher return value. + } + }; + const rollbackAdmission = (admission: GoogletagPublisherCallAdmission | undefined): void => { + try { + admission?.rollback(); + } catch { + // Rollback cannot replace the exact publisher-native failure. + } + }; + const callWithAdmission = ( + original: (...arguments_: unknown[]) => unknown, + receiver: unknown, + arguments_: readonly unknown[], + admission: GoogletagPublisherCallAdmission | undefined + ): unknown => { + let result: unknown; + try { + result = Reflect.apply(original, receiver, arguments_); + } catch (error) { + rollbackAdmission(admission); + throw error; + } + commitAdmission(admission); + return result; + }; + const objectSlots = (candidate: unknown): readonly object[] | undefined => { + if ( + !Array.isArray(candidate) || + candidate.some( + (slot) => (typeof slot !== 'object' || slot === null) && typeof slot !== 'function' + ) + ) { + return undefined; + } + return Object.freeze([...candidate]) as readonly object[]; + }; + const allSlots = (): readonly object[] | undefined => { + const getSlots = safeMember(serviceObject, 'getSlots'); + if (typeof getSlots !== 'function') return undefined; + try { + return objectSlots(Reflect.apply(getSlots, serviceObject, [])); + } catch { + return undefined; + } + }; + const deferredRefreshes = new Set<() => void>(); + const restorers: Array<() => void> = []; + const install = ( + external: object, + key: PropertyKey, + mediate: ( + original: (...arguments_: unknown[]) => unknown, + receiver: unknown, + arguments_: readonly unknown[] + ) => unknown + ): void => { + const original = safeMember(external, key); + if (typeof original !== 'function') return; + const callable = original as (...arguments_: unknown[]) => unknown; + const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { + if (consumeFacadeCall(wrapper)) { + return Reflect.apply(callable, this, arguments_); + } + if (!safelyCurrent()) { + return Reflect.apply(callable, this, arguments_); + } + return mediate(callable, this, arguments_); + }; + setWeakMapValue(adapterMethodOrigins, wrapper, callable); + const restoreMethod = replaceMethod(external, key, wrapper, stillCurrent); + if (!restoreMethod) { + deleteWeakMapValue(adapterMethodOrigins, wrapper); + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + restorers[restorers.length] = (): void => { + try { + restoreMethod(); + } finally { + deleteWeakMapValue(adapterMethodOrigins, wrapper); + } + }; + }; + try { + install(currentBindingObject, 'defineSlot', (original, receiver, arguments_) => { + if (!defineObserver || arguments_.length !== 3) { + return Reflect.apply(original, receiver, arguments_); + } + try { + const decision = defineObserver( + Object.freeze({ + adUnitPath: arguments_[0], + sizes: arguments_[1], + elementId: arguments_[2], + initialLoadDisabled: tracker?.disabled === true, + }) + ); + if ( + decision?.action === 'handoff' && + ((typeof decision.slot === 'object' && decision.slot !== null) || + typeof decision.slot === 'function') + ) { + return decision.slot; + } + } catch { + // Observer failure must leave the publisher call native. + } + return Reflect.apply(original, receiver, arguments_); + }); + install(currentBindingObject, 'display', (original, receiver, arguments_) => { + if (displayObserver && arguments_.length === 1) { + let decision: ReturnType>; + try { + decision = displayObserver( + Object.freeze({ + target: arguments_[0], + initialLoadDisabled: tracker?.disabled === true, + }) + ); + } catch { + // Observer failure must leave the publisher call native. + return Reflect.apply(original, receiver, arguments_); + } + const admission = publisherAdmission(decision); + if (decision?.action === 'suppress') { + rollbackAdmission(admission); + return undefined; + } + return callWithAdmission(original, receiver, arguments_, admission); + } + return Reflect.apply(original, receiver, arguments_); + }); + install(serviceObject, 'refresh', (original, receiver, arguments_) => { + if (refreshObserver && arguments_.length <= 2) { + const requested = arguments_[0] === undefined ? undefined : objectSlots(arguments_[0]); + const effective = requested ?? (arguments_[0] === undefined ? allSlots() : undefined); + if (effective) { + let decision: ReturnType>; + try { + decision = refreshObserver( + Object.freeze({ + requestedSlots: requested, + slots: effective, + options: arguments_[1], + }) + ); + } catch { + // Observer failure must leave the publisher call native. + return Reflect.apply(original, receiver, arguments_); + } + const admission = publisherAdmission(decision); + if (decision?.action === 'suppress') { + rollbackAdmission(admission); + return undefined; + } + if (decision?.action === 'replace') { + const replacement = objectSlots(decision.slots); + if (replacement) { + return callWithAdmission( + original, + receiver, + [replacement, ...arguments_.slice(1)], + admission + ); + } + rollbackAdmission(admission); + return Reflect.apply(original, receiver, arguments_); + } + if (decision?.action === 'defer') { + const replacement = objectSlots(decision.slots); + const completion = safeMember(decision, 'completion'); + const then = + (typeof completion === 'object' && completion !== null) || + typeof completion === 'function' + ? safeMember(completion as object, 'then') + : undefined; + if (!replacement || typeof then !== 'function') { + rollbackAdmission(admission); + return Reflect.apply(original, receiver, arguments_); + } + let forwarded = false; + const forward = (): void => { + if (forwarded) return; + forwarded = true; + try { + deleteSetValue(deferredRefreshes, forward); + } catch { + // The exact-once latch remains authoritative under hostile bookkeeping. + } + try { + callWithAdmission(original, receiver, [replacement, arguments_[1]], admission); + } catch { + // A deferred native throw has no synchronous publisher frame to receive it. + } + }; + try { + addSetValue(deferredRefreshes, forward); + Promise.resolve(completion).then(forward, forward); + } catch { + try { + deleteSetValue(deferredRefreshes, forward); + } catch { + // Synchronous fail-open still owns the only native forward. + } + return callWithAdmission( + original, + receiver, + [replacement, arguments_[1]], + admission + ); + } + return undefined; + } + return callWithAdmission(original, receiver, arguments_, admission); + } + } + return Reflect.apply(original, receiver, arguments_); + }); + install(currentBindingObject, 'destroySlots', (original, receiver, arguments_) => { + let destroyedSlots: readonly object[] | undefined; + if (arguments_.length === 0 || (arguments_.length === 1 && arguments_[0] === undefined)) { + destroyedSlots = allSlots(); + } else if (arguments_.length === 1) { + destroyedSlots = objectSlots(arguments_[0]); + } + const result = Reflect.apply(original, receiver, arguments_); + if (result === true && destroyedSlots && destroyObserver) { + try { + destroyObserver(Object.freeze({ slots: destroyedSlots })); + } catch { + // Post-call bookkeeping cannot alter the publisher return value. + } + } + return result; + }); + } catch (error) { + for (let index = restorers.length - 1; index >= 0; index -= 1) restorers[index]?.(); + throw error; + } + let released = false; + const release = (): void => { + if (released) return; + released = true; + try { + deleteSetValue(effects, release); + } catch { + // Exact wrapper restoration still runs when bookkeeping is hostile. + } + const deferred = setValueSnapshot(deferredRefreshes); + for (let index = 0; index < deferred.length; index += 1) deferred[index]?.(); + for (let index = restorers.length - 1; index >= 0; index -= 1) restorers[index]?.(); + }; + registerAdapterEffect(release); + return release; + }; + + const observeDiagnostics = (observer: GoogletagDiagnosticsObserver): (() => void) | undefined => { + if (disposed || typeof observer !== 'function' || diagnosticsObserver) return undefined; + diagnosticsObserver = observer; + let active = true; + const release = (): void => { + if (!active) return; + active = false; + if (diagnosticsObserver === observer) diagnosticsObserver = undefined; + try { + deleteSetValue(effects, release); + } catch { + // Exact observer release remains authoritative under registry failure. + } + }; + try { + registerAdapterEffect(release); + } catch (error) { + release(); + throw error; + } + return release; + }; + + return Object.freeze({ + bindingStatus: (): GoogletagBindingStatus => currentBinding().status, + enqueueGamAttribution, + adoptDiagnosticsState, + diagnosticsIdentity: (slot: object): Readonly | undefined => { + const state = diagnosticsSlotState(slot); + if (!state?.traceToken) return undefined; + const currentCycle = state.cycles.reduce( + (latest, cycle) => (!latest || cycle.ordinal > latest.ordinal ? cycle : latest), + undefined + ); + return Object.freeze({ + token: state.token, + traceToken: state.traceToken, + runtimeSlotNumber: Number.parseInt(state.traceToken.slice(4), 36), + ...(currentCycle === undefined ? {} : { cycleOrdinal: currentCycle.ordinal }), + ...(state.elementId === undefined ? {} : { elementId: state.elementId }), + ...(state.adUnitPath === undefined ? {} : { adUnitPath: state.adUnitPath }), + }); + }, + traceToken: (slot: object): GptSlotTokenV1 | undefined => + diagnosticsSlotState(slot)?.traceToken, + observeDiagnostics, + observePublisherCalls, + run, + notifyReady, + dispose: (): void => { + if (disposed) return; + disposed = true; + try { + mintedTraceTokens?.clear(); + } catch { + // Diagnostics identity cleanup cannot interrupt independent adapter disposal. + } + for (const operation of [...live]) fail(operation, 'operation_disposed'); + try { + releaseHistoricalInitialLoadBindings(); + } catch { + // Initial-load registry failure cannot interrupt independent adapter effects. + } finally { + for (const disposeEffect of [...effects]) { + try { + deleteSetValue(effects, disposeEffect); + } catch { + // A hostile registry cannot interrupt cleanup of remaining effects. + } + try { + disposeEffect(); + } catch { + // One cleanup cannot interrupt the remaining adapter disposers. + } + } + } + }, + }); +} + +/** Create a side-effect-free GPT boundary for tests and unavailable environments. */ +export function createNoopGoogletagAdapter(): GoogletagAdapter { + return createBrowserGoogletagAdapter({}); +} diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts new file mode 100644 index 000000000..8cc141cfb --- /dev/null +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -0,0 +1,1407 @@ +import { TSJS_MESSAGE_PROTOCOL_V1 } from '../kernel/contracts/message_protocol'; + +const MAX_GLOBAL_MESSAGE_BYTES = 4_096; +const setDeleteIntrinsic = Set.prototype.delete; +const setValuesIntrinsic = Set.prototype.values; +const setIteratorNextIntrinsic = Reflect.get( + Object.getPrototypeOf(Reflect.apply(setValuesIntrinsic, new Set(), [])), + 'next' +) as (...arguments_: unknown[]) => unknown; +const weakMapGetIntrinsic = WeakMap.prototype.get; +const weakMapSetIntrinsic = WeakMap.prototype.set; +const weakSetAddIntrinsic = WeakSet.prototype.add; +const weakSetHasIntrinsic = WeakSet.prototype.has; + +function deleteSetValue(set: Set, value: T): boolean { + return Reflect.apply(setDeleteIntrinsic, set, [value]) as boolean; +} + +function snapshotSetValues(set: Set): readonly T[] { + const iterator = Reflect.apply(setValuesIntrinsic, set, []) as object; + const values: T[] = []; + let index = 0; + while (true) { + const step = Reflect.apply(setIteratorNextIntrinsic, iterator, []) as IteratorResult; + if (step.done) return values; + values[index] = step.value; + index += 1; + } +} + +export { TSJS_MESSAGE_PROTOCOL_V1 }; + +interface ProtocolMessageSchema { + readonly transport: 'global-json' | 'structured'; + readonly keys: readonly string[]; + readonly literals: Readonly>; + readonly maximumBytes?: number; +} + +function schema( + transport: ProtocolMessageSchema['transport'], + keys: readonly string[], + literals: Readonly>, + maximumBytes?: number +): ProtocolMessageSchema { + return Object.freeze({ + transport, + keys: Object.freeze([...keys]), + literals: Object.freeze({ ...literals }), + ...(maximumBytes === undefined ? {} : { maximumBytes }), + }); +} + +/** Exact top-level shapes for every protocol message and nested protocol record. */ +export const PROTOCOL_MESSAGE_SCHEMAS_V1 = Object.freeze({ + prebidRequest: schema('global-json', ['message', 'adId', 'adServerDomain'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.prebidRequest, + }), + ownerRegister: schema('global-json', ['message', 'adId', 'version', 'lifecycleTicket'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.ownerRegister, + version: 1, + }), + prebidResponse: schema( + 'structured', + ['message', 'adId', 'renderer', 'rendererVersion', 'tsOwner'], + { message: TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse, rendererVersion: '4' } + ), + prebidResponseRefused: schema('structured', ['message', 'adId', 'rendererVersion', 'tsOwner'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse, + rendererVersion: '4', + }), + tsOwnerReady: schema('structured', ['version', 'status', 'kind', 'lifecycleTicket'], { + version: 1, + status: TSJS_MESSAGE_PROTOCOL_V1.status.ready, + }), + tsOwnerRefused: schema('structured', ['version', 'status'], { + version: 1, + status: TSJS_MESSAGE_PROTOCOL_V1.status.refused, + }), + ownerRegistered: schema('structured', ['message', 'adId', 'version', 'lifecycleTicket'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.ownerRegistered, + version: 1, + }), + ownerRefused: schema('structured', ['message', 'adId', 'version'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.ownerRefused, + version: 1, + }), + apsTopMountStarted: schema('structured', ['message', 'version', 'lifecycleTicket'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsTopMountStarted, + version: 1, + }), + apsEnvelope: schema('structured', ['version', 'nonce', 'publisherOrigin', 'renderer'], { + version: 1, + }), + admStart: schema('structured', ['message', 'version', 'lifecycleTicket', 'source'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.admStart, + version: 1, + }), + ownerInserted: schema('structured', ['message', 'version', 'lifecycleTicket'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.ownerInserted, + version: 1, + }), + admLoaded: schema('structured', ['message', 'version', 'lifecycleTicket'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.admLoaded, + version: 1, + }), + admFailed: schema('structured', ['message', 'version', 'lifecycleTicket'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.admFailed, + version: 1, + }), + ownerSettledAccepted: schema('structured', ['message', 'version', 'lifecycleTicket', 'outcome'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.ownerSettled, + version: 1, + outcome: TSJS_MESSAGE_PROTOCOL_V1.outcome.accepted, + }), + ownerSettledFailed: schema( + 'structured', + ['message', 'version', 'lifecycleTicket', 'outcome', 'reason'], + { + message: TSJS_MESSAGE_PROTOCOL_V1.message.ownerSettled, + version: 1, + outcome: TSJS_MESSAGE_PROTOCOL_V1.outcome.failed, + } + ), + ownerSettledCancelled: schema( + 'structured', + ['message', 'version', 'lifecycleTicket', 'outcome', 'reason'], + { + message: TSJS_MESSAGE_PROTOCOL_V1.message.ownerSettled, + version: 1, + outcome: TSJS_MESSAGE_PROTOCOL_V1.outcome.cancelled, + } + ), + apsBootstrapReady: schema('global-json', ['message', 'version', 'bootstrapNonce'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsBootstrapReady, + version: 1, + }), + apsBootstrapConfigure: schema( + 'global-json', + ['message', 'version', 'bootstrapNonce', 'rendererNonce', 'creativeOrigin', 'tagType'], + { message: TSJS_MESSAGE_PROTOCOL_V1.message.apsBootstrapConfigure, version: 2 }, + 16_384 + ), + apsInnerReady: schema('global-json', ['message', 'version', 'rendererNonce'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsInnerReady, + version: 1, + }), + apsInnerBind: schema('global-json', ['message', 'version', 'rendererNonce'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsInnerBind, + version: 1, + }), + apsContainerReady: schema( + 'global-json', + ['message', 'version', 'bootstrapNonce', 'rendererNonce'], + { message: TSJS_MESSAGE_PROTOCOL_V1.message.apsContainerReady, version: 1 } + ), + apsDocumentAccepted: schema('structured', ['message', 'version', 'nonce'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsDocumentAccepted, + version: 1, + }), + apsRunnerLoaded: schema('structured', ['message', 'version', 'nonce'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsRunnerLoaded, + version: 1, + }), + apsRenderCompleted: schema('structured', ['message', 'version', 'nonce'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsRenderCompleted, + version: 1, + }), + apsRenderFailed: schema('structured', ['message', 'version', 'nonce', 'reason'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsRenderFailed, + version: 1, + }), +}); + +export type ProtocolMessageKind = keyof typeof PROTOCOL_MESSAGE_SCHEMAS_V1; +export type CaptureMessageListener = (event: MessageEvent) => void; + +/** Exact browser event surface owned by the cross-window messaging adapter. */ +export interface MessageEventTarget { + addEventListener(type: 'message', listener: CaptureMessageListener, capture: true): void; + removeEventListener(type: 'message', listener: CaptureMessageListener, capture: true): void; + readonly MessageChannel?: new () => { readonly port1: unknown; readonly port2: unknown }; +} + +/** A narrow owned endpoint for one transferred browser message port. */ +export interface MessagingPort { + post(message: unknown, transferred: readonly unknown[]): boolean; + listen( + messageListener: (event: unknown) => void, + messageErrorListener: (event: unknown) => void + ): () => void; + close(): void; +} + +/** One locally retained endpoint and one endpoint eligible for exact transfer. */ +export interface MessagingChannel { + readonly retained: MessagingPort; + readonly transferred: MessagingPort; +} + +/** Cross-window boundary consumed by the kernel's capability recognizer. */ +export interface MessagingAdapter { + createChannel(): MessagingChannel | undefined; + postWindow( + target: unknown, + message: unknown, + targetOrigin: string, + transferred: readonly MessagingPort[] + ): boolean; + installCaptureListener(listener: CaptureMessageListener): (() => void) | undefined; + inspectGlobalMessage(candidate: unknown): + | Readonly<{ + message: string; + adId?: string; + lifecycleTicket?: string; + }> + | undefined; + parseProtocolMessage( + kind: ProtocolMessageKind, + candidate: unknown + ): Readonly> | undefined; + extractTransferredPorts( + event: unknown, + expectedCount: 0 | 1 | 2 + ): readonly MessagingPort[] | undefined; + inspectTransferredPorts(event: unknown): + | Readonly<{ + exactShape: boolean; + originalCount: number; + ports: readonly MessagingPort[]; + }> + | undefined; +} + +/** Semantic validators injected by composition without reversing adapter layering. */ +export interface MessagingValidationOptions { + readonly validateApsRenderer?: (candidate: unknown) => boolean; + readonly expectedPublisherOrigin?: string; + readonly expectedRendererUrl?: string; +} + +const capabilityPatterns = Object.freeze({ + reservation: /^r1_[A-Za-z0-9_-]{22}$/, + ticket: /^t1_[A-Za-z0-9_-]{22}$/, + bootstrapNonce: /^b1_[A-Za-z0-9_-]{22}$/, + nonce: /^n1_[A-Za-z0-9_-]{22}$/, +}); +const apsRendererKeys = Object.freeze([ + 'type', + 'version', + 'accountId', + 'bidId', + 'tagType', + 'creativeUrl', + 'width', + 'height', + 'aaxResponse', +]); +const apsRendererKeysWithCreativeId = Object.freeze([...apsRendererKeys, 'creativeId']); +const encoder = new TextEncoder(); +const cancellationReasons = new Set(Object.values(TSJS_MESSAGE_PROTOCOL_V1.cancellation)); +const runnerFailureReasons = new Set(Object.values(TSJS_MESSAGE_PROTOCOL_V1.runnerFailure)); +const renderFailureReasons = new Set([ + 'auction_timeout', + 'auction_disabled', + 'consent_denied', + 'slot_not_eligible', + 'provider_timeout', + 'provider_error', + 'invalid_provider_response', + 'mediation_failed', + 'winner_not_renderable', + 'internal_error', + 'network_error', + 'http_error', + 'invalid_response', + 'slot_unresolved', + 'descriptor_invalid', + 'invalid_dimensions', + 'dimensions_out_of_range', + 'no_render_source', + 'registry_full', + 'capability_registry_full', + 'external_queue_full', + 'external_ready_timeout', + 'external_artifact_incompatible', + 'prebid_admission_failed', + 'prebid_contract_violation', + 'prebid_selection_timeout', + 'reservation_collision', + 'identity_generation_failed', + 'cycle_unattributable', + 'slot_quarantined', + 'gpt_request_failed', + 'gpt_request_timeout', + 'gpt_completion_timeout', + 'reconciliation_capacity', + 'gam_empty', + 'bridge_claim_timeout', + 'bridge_id_mismatch', + 'owner_registration_timeout', + 'owner_insertion_timeout', + 'renderer_document_no_load', + 'runner_no_load', + 'runner_failed', + 'adm_document_no_load', + 'abi_mismatch', + 'bundle_partial', +]); + +function validUnicodeScalars(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) return false; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function boundedString( + value: unknown, + maximumBytes: number, + options: { readonly controls?: boolean; readonly empty?: boolean } = {} +): value is string { + let hasControl = false; + if (typeof value === 'string' && options.controls !== true) { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) { + hasControl = true; + break; + } + } + } + return ( + typeof value === 'string' && + (options.empty === true || value.length > 0) && + validUnicodeScalars(value) && + !hasControl && + encoder.encode(value).byteLength <= maximumBytes + ); +} + +function capability(value: unknown, kind: keyof typeof capabilityPatterns): value is string { + return typeof value === 'string' && capabilityPatterns[kind].test(value); +} + +function dimension(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + Number.isInteger(value) && + value >= 1 && + value <= 4096 + ); +} + +function exactHttpOrigin(value: unknown): value is string { + if (!boundedString(value, 2_048)) return false; + try { + const parsed = new URL(value); + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + parsed.username === '' && + parsed.password === '' && + parsed.origin === value && + parsed.pathname === '/' && + parsed.search === '' && + parsed.hash === '' + ); + } catch { + return false; + } +} + +function skipWhitespace(source: string, start: number): number { + let index = start; + while (index < source.length && /\s/.test(source[index] ?? '')) index += 1; + return index; +} + +function scanString(source: string, start: number): number | undefined { + if (source[start] !== '"') return undefined; + let index = start + 1; + while (index < source.length) { + const character = source[index]; + if (character === '"') return index + 1; + if (character === '\\') { + index += 1; + if (index >= source.length) return undefined; + if (source[index] === 'u') { + if (!/^[0-9a-fA-F]{4}$/.test(source.slice(index + 1, index + 5))) return undefined; + index += 4; + } + } else if (character !== undefined && character.charCodeAt(0) < 0x20) { + return undefined; + } + index += 1; + } + return undefined; +} + +function scanJsonValue(source: string, start: number): number | undefined { + let index = skipWhitespace(source, start); + if (source[index] === '"') return scanString(source, index); + if (source[index] === '[') { + index = skipWhitespace(source, index + 1); + if (source[index] === ']') return index + 1; + while (index < source.length) { + const end = scanJsonValue(source, index); + if (end === undefined) return undefined; + index = skipWhitespace(source, end); + if (source[index] === ']') return index + 1; + if (source[index] !== ',') return undefined; + index = skipWhitespace(source, index + 1); + } + return undefined; + } + if (source[index] === '{') { + const keys = new Set(); + index = skipWhitespace(source, index + 1); + if (source[index] === '}') return index + 1; + while (index < source.length) { + const keyEnd = scanString(source, index); + if (keyEnd === undefined) return undefined; + let key: string; + try { + key = JSON.parse(source.slice(index, keyEnd)) as string; + } catch { + return undefined; + } + if (keys.has(key)) return undefined; + keys.add(key); + index = skipWhitespace(source, keyEnd); + if (source[index] !== ':') return undefined; + const valueEnd = scanJsonValue(source, index + 1); + if (valueEnd === undefined) return undefined; + index = skipWhitespace(source, valueEnd); + if (source[index] === '}') return index + 1; + if (source[index] !== ',') return undefined; + index = skipWhitespace(source, index + 1); + } + return undefined; + } + const match = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec( + source.slice(index) + ); + return match ? index + match[0].length : undefined; +} + +function parseGlobalJson(candidate: unknown, maximumBytes = MAX_GLOBAL_MESSAGE_BYTES): unknown { + if ( + typeof candidate !== 'string' || + new TextEncoder().encode(candidate).byteLength > maximumBytes + ) { + return undefined; + } + const end = scanJsonValue(candidate, 0); + if (end === undefined || skipWhitespace(candidate, end) !== candidate.length) return undefined; + try { + return JSON.parse(candidate); + } catch { + return undefined; + } +} + +function inspectGlobalMessage( + candidate: unknown +): Readonly<{ message: string; adId?: string; lifecycleTicket?: string }> | undefined { + try { + const decoded = typeof candidate === 'string' ? parseGlobalJson(candidate) : candidate; + if (typeof decoded !== 'object' || decoded === null) return undefined; + const prototype = Object.getPrototypeOf(decoded); + if (prototype !== Object.prototype && prototype !== null) return undefined; + const descriptors = Object.getOwnPropertyDescriptors(decoded); + const message = descriptors['message']; + if (!message || !Object.prototype.hasOwnProperty.call(message, 'value')) return undefined; + if (typeof message.value !== 'string') return undefined; + const adId = descriptors['adId']; + const lifecycleTicket = descriptors['lifecycleTicket']; + if (adId && !Object.prototype.hasOwnProperty.call(adId, 'value')) return undefined; + if (lifecycleTicket && !Object.prototype.hasOwnProperty.call(lifecycleTicket, 'value')) { + return undefined; + } + return Object.freeze({ + message: message.value, + ...(adId && typeof adId.value === 'string' ? { adId: adId.value } : {}), + ...(lifecycleTicket && typeof lifecycleTicket.value === 'string' + ? { lifecycleTicket: lifecycleTicket.value } + : {}), + }); + } catch { + return undefined; + } +} + +function exactRecord( + candidate: unknown, + keys: readonly string[] +): Readonly> | undefined { + try { + if (typeof candidate !== 'object' || candidate === null) { + return undefined; + } + const prototype = Object.getPrototypeOf(candidate); + if (prototype !== Object.prototype && prototype !== null) return undefined; + const ownKeys = Reflect.ownKeys(candidate); + const descriptors = Object.getOwnPropertyDescriptors(candidate); + if ( + ownKeys.length !== keys.length || + ownKeys.some((key) => typeof key !== 'string') || + keys.some((key) => !ownKeys.includes(key)) + ) { + return undefined; + } + const accepted: Record = Object.create(null) as Record; + for (const key of keys) { + const descriptor = descriptors[key]; + if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + return undefined; + } + accepted[key] = descriptor.value; + } + return Object.freeze(accepted); + } catch { + return undefined; + } +} + +function admSource(candidate: unknown): boolean { + const source = exactRecord(candidate, ['type', 'version', 'adm', 'width', 'height']); + return ( + source !== undefined && + source['type'] === 'adm' && + source['version'] === 1 && + boundedString(source['adm'], 512 * 1024, { controls: true }) && + dimension(source['width']) && + dimension(source['height']) + ); +} + +function canonicalApsRenderer(candidate: unknown): Readonly> | undefined { + const renderer = + exactRecord(candidate, apsRendererKeys) ?? + exactRecord(candidate, apsRendererKeysWithCreativeId); + if (!renderer) return undefined; + for (const value of Object.values(renderer)) { + if (!['string', 'number'].includes(typeof value)) return undefined; + } + return renderer; +} + +function canonicalApsEnvelope( + candidate: unknown, + options: MessagingValidationOptions +): Readonly> | undefined { + const envelope = exactRecord(candidate, ['version', 'nonce', 'publisherOrigin', 'renderer']); + if ( + envelope === undefined || + envelope['version'] !== 1 || + !capability(envelope['nonce'], 'nonce') || + !exactHttpOrigin(envelope['publisherOrigin']) || + options.expectedPublisherOrigin === undefined || + envelope['publisherOrigin'] !== options.expectedPublisherOrigin + ) { + return undefined; + } + const renderer = canonicalApsRenderer(envelope['renderer']); + if (!renderer || options.validateApsRenderer?.(renderer) !== true) return undefined; + return replaceNested(envelope, ['version', 'nonce', 'publisherOrigin', 'renderer'], { renderer }); +} + +function parseTsOwner(candidate: unknown): Readonly> | undefined { + const ready = exactRecord(candidate, ['version', 'status', 'kind', 'lifecycleTicket']); + if (ready) { + return ready['version'] === 1 && + ready['status'] === TSJS_MESSAGE_PROTOCOL_V1.status.ready && + (ready['kind'] === TSJS_MESSAGE_PROTOCOL_V1.kind.aps || + ready['kind'] === TSJS_MESSAGE_PROTOCOL_V1.kind.adm) && + capability(ready['lifecycleTicket'], 'ticket') + ? ready + : undefined; + } + const refused = exactRecord(candidate, ['version', 'status']); + return refused !== undefined && + refused['version'] === 1 && + refused['status'] === TSJS_MESSAGE_PROTOCOL_V1.status.refused + ? refused + : undefined; +} + +function validProtocolFields( + kind: ProtocolMessageKind, + record: Readonly>, + _options: MessagingValidationOptions +): boolean { + const ticket = (): boolean => capability(record['lifecycleTicket'], 'ticket'); + const nonce = (): boolean => capability(record['nonce'], 'nonce'); + switch (kind) { + case 'prebidRequest': + return ( + capability(record['adId'], 'reservation') && boundedString(record['adServerDomain'], 2_048) + ); + case 'ownerRegister': + return capability(record['adId'], 'reservation') && ticket(); + case 'prebidResponse': { + const owner = parseTsOwner(record['tsOwner']); + return ( + capability(record['adId'], 'reservation') && + boundedString(record['renderer'], 64 * 1024, { controls: true }) && + owner?.['status'] === TSJS_MESSAGE_PROTOCOL_V1.status.ready + ); + } + case 'prebidResponseRefused': { + const owner = parseTsOwner(record['tsOwner']); + return ( + capability(record['adId'], 'reservation') && + owner?.['status'] === TSJS_MESSAGE_PROTOCOL_V1.status.refused + ); + } + case 'tsOwnerReady': + return ( + (record['kind'] === TSJS_MESSAGE_PROTOCOL_V1.kind.aps || + record['kind'] === TSJS_MESSAGE_PROTOCOL_V1.kind.adm) && + ticket() + ); + case 'tsOwnerRefused': + return true; + case 'ownerRegistered': + return capability(record['adId'], 'reservation') && ticket(); + case 'ownerRefused': + return capability(record['adId'], 'reservation'); + case 'apsTopMountStarted': + return ticket(); + case 'apsEnvelope': + return true; + case 'admStart': + return ticket() && admSource(record['source']); + case 'ownerInserted': + case 'admLoaded': + case 'admFailed': + return ticket(); + case 'ownerSettledAccepted': + return ticket(); + case 'ownerSettledFailed': + return ( + ticket() && + typeof record['reason'] === 'string' && + renderFailureReasons.has(record['reason']) + ); + case 'ownerSettledCancelled': + return ( + ticket() && + typeof record['reason'] === 'string' && + cancellationReasons.has(record['reason']) + ); + case 'apsBootstrapReady': + return capability(record['bootstrapNonce'], 'bootstrapNonce'); + case 'apsBootstrapConfigure': + return ( + capability(record['bootstrapNonce'], 'bootstrapNonce') && + capability(record['rendererNonce'], 'nonce') && + exactHttpOrigin(record['creativeOrigin']) && + (record['creativeOrigin'] as string).startsWith('https://') && + (record['tagType'] === 'iframe' || record['tagType'] === 'script') + ); + case 'apsInnerReady': + case 'apsInnerBind': + return capability(record['rendererNonce'], 'nonce'); + case 'apsContainerReady': + return ( + capability(record['bootstrapNonce'], 'bootstrapNonce') && + capability(record['rendererNonce'], 'nonce') + ); + case 'apsDocumentAccepted': + case 'apsRunnerLoaded': + case 'apsRenderCompleted': + return nonce(); + case 'apsRenderFailed': + return ( + nonce() && + typeof record['reason'] === 'string' && + runnerFailureReasons.has(record['reason']) + ); + } +} + +function replaceNested( + record: Readonly>, + keys: readonly string[], + replacements: Readonly> +): Readonly> { + const output: Record = Object.create(null) as Record; + for (const key of keys) { + output[key] = Object.prototype.hasOwnProperty.call(replacements, key) + ? replacements[key] + : record[key]; + } + return Object.freeze(output); +} + +function canonicalProtocolRecord( + kind: ProtocolMessageKind, + record: Readonly>, + keys: readonly string[], + options: MessagingValidationOptions +): Readonly> | undefined { + if (kind === 'prebidResponse' || kind === 'prebidResponseRefused') { + const owner = parseTsOwner(record['tsOwner']); + return owner ? replaceNested(record, keys, { tsOwner: owner }) : undefined; + } + if (kind === 'apsEnvelope') return canonicalApsEnvelope(record, options); + if (kind === 'admStart') { + const source = exactRecord(record['source'], ['type', 'version', 'adm', 'width', 'height']); + return source ? replaceNested(record, keys, { source }) : undefined; + } + return record; +} + +function parseProtocolMessage( + kind: ProtocolMessageKind, + candidate: unknown, + options: MessagingValidationOptions +): Readonly> | undefined { + try { + const messageSchema = ( + PROTOCOL_MESSAGE_SCHEMAS_V1 as Readonly> + )[kind]; + if (!messageSchema) return undefined; + const decoded = + messageSchema.transport === 'global-json' + ? parseGlobalJson(candidate, messageSchema.maximumBytes) + : candidate; + const accepted = exactRecord(decoded, messageSchema.keys); + if (!accepted) return undefined; + for (const [key, literal] of Object.entries(messageSchema.literals)) { + if (accepted[key] !== literal) return undefined; + } + const canonical = canonicalProtocolRecord(kind, accepted, messageSchema.keys, options); + if (!canonical) return undefined; + if (!validProtocolFields(kind, canonical, options)) return undefined; + if ( + kind === 'prebidResponse' && + encoder.encode(JSON.stringify(canonical)).byteLength > 72 * 1024 + ) { + return undefined; + } + return canonical; + } catch { + return undefined; + } +} + +interface CapturedPortClose { + readonly binding: object; + readonly closePort: (...arguments_: unknown[]) => unknown; +} + +interface RawPort extends CapturedPortClose { + readonly add: (...arguments_: unknown[]) => unknown; + readonly postMessage: (...arguments_: unknown[]) => unknown; + readonly remove: (...arguments_: unknown[]) => unknown; + readonly start?: (...arguments_: unknown[]) => unknown; +} + +interface RawPortInspection { + readonly close: CapturedPortClose | undefined; + readonly raw: RawPort | undefined; +} + +interface WrappedPortState { + readonly raw: RawPort; + readonly transferable: boolean; + closed: boolean; + transferred: boolean; + transferring: boolean; +} + +interface TransferReservation { + readonly rawTransfers: readonly object[]; + readonly states: readonly WrappedPortState[]; +} + +const wrappedPortStates = new WeakMap(); +const ownedPortBindings = new WeakSet(); + +function getWrappedPortState(port: MessagingPort): WrappedPortState | undefined { + return Reflect.apply(weakMapGetIntrinsic, wrappedPortStates, [port]) as + WrappedPortState | undefined; +} + +function setWrappedPortState(port: MessagingPort, state: WrappedPortState): void { + Reflect.apply(weakMapSetIntrinsic, wrappedPortStates, [port, state]); +} + +function ownsPortBinding(binding: object): boolean { + return Reflect.apply(weakSetHasIntrinsic, ownedPortBindings, [binding]) as boolean; +} + +function claimPortBinding(binding: object): void { + Reflect.apply(weakSetAddIntrinsic, ownedPortBindings, [binding]); +} + +function portCandidateBinding(candidate: unknown): object | undefined { + return (typeof candidate === 'object' && candidate !== null) || typeof candidate === 'function' + ? (candidate as object) + : undefined; +} + +function claimPortCandidate(candidate: unknown): boolean { + const binding = portCandidateBinding(candidate); + if (!binding || ownsPortBinding(binding)) return false; + claimPortBinding(binding); + return true; +} + +function inspectRawPort(candidate: unknown): RawPortInspection { + const binding = portCandidateBinding(candidate); + if (!binding) return { close: undefined, raw: undefined }; + let closePort: unknown; + try { + closePort = Reflect.get(binding, 'close'); + } catch { + return { close: undefined, raw: undefined }; + } + if (typeof closePort !== 'function') return { close: undefined, raw: undefined }; + const callableClose = closePort as (...arguments_: unknown[]) => unknown; + const close: CapturedPortClose = { binding, closePort: callableClose }; + try { + const add = Reflect.get(binding, 'addEventListener'); + const postMessage = Reflect.get(binding, 'postMessage'); + const remove = Reflect.get(binding, 'removeEventListener'); + const start = Reflect.get(binding, 'start'); + if ( + typeof add !== 'function' || + typeof postMessage !== 'function' || + typeof remove !== 'function' || + (start !== undefined && typeof start !== 'function') + ) { + return { close, raw: undefined }; + } + return { + close, + raw: { binding, add, closePort: callableClose, postMessage, remove, start }, + }; + } catch { + return { close, raw: undefined }; + } +} + +function closeRawPort(candidate: unknown): void { + try { + if ((typeof candidate !== 'object' || candidate === null) && typeof candidate !== 'function') { + return; + } + const close = Reflect.get(candidate, 'close'); + if (typeof close === 'function') Reflect.apply(close, candidate, []); + } catch { + // Closing one invalid port cannot interrupt cleanup of the remaining ports. + } +} + +function closeCapturedRawPort(raw: CapturedPortClose): void { + try { + Reflect.apply(raw.closePort, raw.binding, []); + } catch { + // A captured endpoint close cannot interrupt channel-construction cleanup. + } +} + +function wrapPort(raw: RawPort, transferable = false): MessagingPort { + const listeners = new Set<() => void>(); + const state: WrappedPortState = { + raw, + transferable, + closed: false, + transferred: false, + transferring: false, + }; + const port: MessagingPort = Object.freeze({ + post: (message: unknown, transferred: readonly unknown[]): boolean => { + if (state.transferable || state.closed || state.transferred || state.transferring) { + return false; + } + const reservation = reserveTransferPorts(transferred); + if (!reservation) return false; + try { + Reflect.apply(raw.postMessage, raw.binding, [message, reservation.rawTransfers]); + } catch { + rollbackTransferReservation(reservation); + return false; + } + commitTransferReservation(reservation); + return true; + }, + listen: ( + messageListener: (event: unknown) => void, + messageErrorListener: (event: unknown) => void + ): (() => void) => { + if (state.transferable || state.closed || state.transferred || state.transferring) { + return () => undefined; + } + const wrappedMessage = (event: unknown): void => { + if (state.closed || state.transferred) return; + try { + messageListener(event); + } catch { + // Channel callbacks cannot escape the messaging boundary. + } + }; + const wrappedMessageError = (event: unknown): void => { + if (state.closed || state.transferred) return; + try { + messageErrorListener(event); + } catch { + // Message deserialization failures remain contained by the channel boundary. + } + }; + let messageAttempted = false; + let messageErrorAttempted = false; + let setupInProgress = true; + const rollback = (): void => { + if (messageErrorAttempted) { + messageErrorAttempted = false; + try { + Reflect.apply(raw.remove, raw.binding, ['messageerror', wrappedMessageError]); + } catch { + // One listener cleanup cannot interrupt rollback of the other listener. + } + } + if (messageAttempted) { + messageAttempted = false; + try { + Reflect.apply(raw.remove, raw.binding, ['message', wrappedMessage]); + } catch { + // Listener cleanup remains best-effort during terminal port disposal. + } + } + }; + let active = true; + const dispose = (): void => { + if (!active) return; + active = false; + try { + deleteSetValue(listeners, dispose); + } finally { + if (!setupInProgress) rollback(); + } + }; + const stopClosedSetup = (): boolean => { + if (!state.closed && !state.transferred && !state.transferring && active) return false; + setupInProgress = false; + rollback(); + return true; + }; + try { + listeners.add(dispose); + } catch { + setupInProgress = false; + active = false; + try { + deleteSetValue(listeners, dispose); + } catch { + // Failed bookkeeping cannot retain listener ownership. + } finally { + rollback(); + } + return dispose; + } + try { + messageAttempted = true; + Reflect.apply(raw.add, raw.binding, ['message', wrappedMessage]); + if (stopClosedSetup()) return dispose; + messageErrorAttempted = true; + Reflect.apply(raw.add, raw.binding, ['messageerror', wrappedMessageError]); + if (stopClosedSetup()) return dispose; + if (raw.start) { + Reflect.apply(raw.start, raw.binding, []); + if (stopClosedSetup()) return dispose; + } + } catch { + setupInProgress = false; + active = false; + try { + deleteSetValue(listeners, dispose); + } catch { + // Failed bookkeeping cannot interrupt exact listener rollback. + } finally { + rollback(); + } + return dispose; + } + setupInProgress = false; + return dispose; + }, + close: (): void => { + if (state.closed || state.transferred || state.transferring) return; + state.closed = true; + let disposers: readonly (() => void)[] = []; + try { + disposers = snapshotSetValues(listeners); + } catch { + // The captured native iterator should be total for the private native Set. + } + for (let index = 0; index < disposers.length; index += 1) { + try { + disposers[index]?.(); + } catch { + // One listener cleanup cannot skip the remaining listeners or raw close. + } + } + try { + Reflect.apply(raw.closePort, raw.binding, []); + } catch { + // Closing remains best-effort and idempotent. + } + }, + }); + setWrappedPortState(port, state); + return port; +} + +function snapshotPortArray(candidate: unknown): + | { + readonly exactShape: boolean; + readonly originalCount: number; + readonly valid: boolean; + readonly values: readonly unknown[]; + } + | undefined { + try { + if (!Array.isArray(candidate) || Object.getPrototypeOf(candidate) !== Array.prototype) { + return undefined; + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(candidate, 'length'); + if ( + !lengthDescriptor || + !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') || + typeof lengthDescriptor.value !== 'number' || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 + ) { + return undefined; + } + const length = lengthDescriptor.value; + const ownKeys = Reflect.ownKeys(candidate); + const values: unknown[] = []; + let exactShape = ownKeys.length === length + 1; + let valid = length <= 2 && exactShape; + if (length <= 2) { + for (let keyIndex = 0; keyIndex < ownKeys.length; keyIndex += 1) { + const key = ownKeys[keyIndex]; + if (key === 'length') continue; + let expected = false; + for (let valueIndex = 0; valueIndex < length; valueIndex += 1) { + if (key === String(valueIndex)) { + expected = true; + break; + } + } + if (!expected) { + exactShape = false; + valid = false; + } + } + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index)); + if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + exactShape = false; + valid = false; + continue; + } + values[values.length] = descriptor.value; + } + } else { + for (let keyIndex = 0; keyIndex < ownKeys.length; keyIndex += 1) { + const key = ownKeys[keyIndex]; + if (key === 'length') continue; + if (typeof key !== 'string') { + exactShape = false; + continue; + } + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || index >= length || String(index) !== key) { + exactShape = false; + continue; + } + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + values[values.length] = descriptor.value; + } else { + exactShape = false; + } + } + } + return { exactShape, originalCount: length, valid, values }; + } catch { + return undefined; + } +} + +function reserveTransferPorts(transferred: readonly unknown[]): TransferReservation | undefined { + const snapshot = snapshotPortArray(transferred); + if (!snapshot?.valid) return undefined; + const states: WrappedPortState[] = []; + const rawTransfers: object[] = []; + for (let index = 0; index < snapshot.values.length; index += 1) { + const port = snapshot.values[index]; + const state = getWrappedPortState(port as MessagingPort); + if (!state || !state.transferable || state.closed || state.transferred || state.transferring) { + return undefined; + } + for (let prior = 0; prior < states.length; prior += 1) { + if (states[prior] === state) return undefined; + } + states[index] = state; + rawTransfers[index] = state.raw.binding; + } + for (let index = 0; index < states.length; index += 1) { + const state = states[index]; + if (state) state.transferring = true; + } + return { rawTransfers, states }; +} + +function rollbackTransferReservation(reservation: TransferReservation): void { + for (let index = 0; index < reservation.states.length; index += 1) { + const state = reservation.states[index]; + if (state) state.transferring = false; + } +} + +function commitTransferReservation(reservation: TransferReservation): void { + for (let index = 0; index < reservation.states.length; index += 1) { + const state = reservation.states[index]; + if (!state) continue; + state.transferring = false; + state.transferred = true; + } +} + +function extractTransferredPortsInRange( + event: unknown, + minimumCount: 0 | 1 | 2, + maximumCount: 0 | 1 | 2 +): readonly MessagingPort[] | undefined { + let candidates: unknown; + try { + if (typeof event !== 'object' || event === null) return undefined; + candidates = Reflect.get(event, 'ports'); + } catch { + return undefined; + } + const snapshot = snapshotPortArray(candidates); + if (!snapshot) return undefined; + const inspections: Array = []; + const claimed: boolean[] = []; + let accepted = + snapshot.valid && + snapshot.values.length >= minimumCount && + snapshot.values.length <= maximumCount; + for (let index = 0; index < snapshot.values.length; index += 1) { + const candidate = snapshot.values[index]; + const candidateClaimed = claimPortCandidate(candidate); + claimed[index] = candidateClaimed; + if (!candidateClaimed) { + accepted = false; + continue; + } + const inspection = inspectRawPort(candidate); + inspections[index] = inspection; + if (!inspection.raw) accepted = false; + } + if (!accepted) { + for (let index = 0; index < snapshot.values.length; index += 1) { + if (!claimed[index]) continue; + const captured = inspections[index]?.close; + if (captured) closeCapturedRawPort(captured); + else closeRawPort(snapshot.values[index]); + } + return undefined; + } + const wrapped: MessagingPort[] = []; + try { + for (let index = 0; index < inspections.length; index += 1) { + const raw = inspections[index]?.raw; + if (!raw) throw new Error('Accepted raw port inspection is unavailable'); + wrapped[index] = wrapPort(raw); + } + return Object.freeze(wrapped); + } catch { + for (let index = 0; index < inspections.length; index += 1) { + const captured = inspections[index]?.close; + if (claimed[index] && captured) closeCapturedRawPort(captured); + } + return undefined; + } +} + +function extractTransferredPorts( + event: unknown, + expectedCount: 0 | 1 | 2 +): readonly MessagingPort[] | undefined { + return extractTransferredPortsInRange(event, expectedCount, expectedCount); +} + +function inspectTransferredPorts(event: unknown): + | Readonly<{ + exactShape: boolean; + originalCount: number; + ports: readonly MessagingPort[]; + }> + | undefined { + let candidates: unknown; + try { + if (typeof event !== 'object' || event === null) return undefined; + candidates = Reflect.get(event, 'ports'); + } catch { + return undefined; + } + const snapshot = snapshotPortArray(candidates); + if (!snapshot) return undefined; + const wrapped: MessagingPort[] = []; + try { + for (let index = 0; index < snapshot.values.length; index += 1) { + const candidate = snapshot.values[index]; + if (!claimPortCandidate(candidate)) continue; + const inspection = inspectRawPort(candidate); + if (!inspection.raw) { + if (inspection.close) closeCapturedRawPort(inspection.close); + else closeRawPort(candidate); + continue; + } + wrapped[wrapped.length] = wrapPort(inspection.raw); + } + return Object.freeze({ + exactShape: snapshot.exactShape, + originalCount: snapshot.originalCount, + ports: Object.freeze(wrapped), + }); + } catch { + for (let index = 0; index < wrapped.length; index += 1) wrapped[index]?.close(); + return undefined; + } +} + +function createChannel(target: MessageEventTarget): MessagingChannel | undefined { + let first: unknown; + let second: unknown; + let retainedInspection: RawPortInspection | undefined; + let transferredInspection: RawPortInspection | undefined; + let claimedRetained = false; + let claimedTransferred = false; + const cleanup = (): void => { + if (claimedRetained) { + const captured = retainedInspection?.close; + if (captured) closeCapturedRawPort(captured); + else closeRawPort(first); + } + if (claimedTransferred) { + const captured = transferredInspection?.close; + if (captured) closeCapturedRawPort(captured); + else closeRawPort(second); + } + }; + try { + const constructor = Reflect.get(target, 'MessageChannel'); + if (typeof constructor !== 'function') return undefined; + const channel = Reflect.construct(constructor, [] as never[]) as object; + first = Reflect.get(channel, 'port1'); + claimedRetained = claimPortCandidate(first); + if (claimedRetained) retainedInspection = inspectRawPort(first); + second = Reflect.get(channel, 'port2'); + if (second !== first) claimedTransferred = claimPortCandidate(second); + if (claimedTransferred) transferredInspection = inspectRawPort(second); + if (first === second) { + if (claimedRetained) { + cleanup(); + } + return undefined; + } + const retainedRaw = retainedInspection?.raw; + const transferredRaw = transferredInspection?.raw; + if (!claimedRetained || !claimedTransferred || !retainedRaw || !transferredRaw) { + cleanup(); + return undefined; + } + return Object.freeze({ + retained: wrapPort(retainedRaw), + transferred: wrapPort(transferredRaw, true), + }); + } catch { + cleanup(); + return undefined; + } +} + +function postWindow( + target: unknown, + message: unknown, + targetOrigin: string, + transferred: readonly MessagingPort[] +): boolean { + let postMessage: unknown; + try { + if ( + ((typeof target !== 'object' || target === null) && typeof target !== 'function') || + typeof targetOrigin !== 'string' || + targetOrigin.length === 0 || + targetOrigin.length > 2_048 + ) { + return false; + } + postMessage = Reflect.get(target, 'postMessage'); + if (typeof postMessage !== 'function') return false; + } catch { + return false; + } + const reservation = reserveTransferPorts(transferred); + if (!reservation) return false; + try { + Reflect.apply(postMessage, target, [message, targetOrigin, reservation.rawTransfers]); + } catch { + rollbackTransferReservation(reservation); + return false; + } + commitTransferReservation(reservation); + return true; +} + +/** + * Create the production messaging boundary. + * + * Listener installation is deliberately synchronous so core can reserve a + * capability message before any integration activation or TS-owned injection. + */ +export function createBrowserMessagingAdapter( + target: MessageEventTarget = window as unknown as MessageEventTarget, + validation: MessagingValidationOptions = {} +): MessagingAdapter { + return Object.freeze({ + createChannel: () => createChannel(target), + postWindow, + installCaptureListener(listener: CaptureMessageListener): (() => void) | undefined { + let add: unknown; + let remove: unknown; + try { + add = Reflect.get(target, 'addEventListener'); + remove = Reflect.get(target, 'removeEventListener'); + } catch { + return undefined; + } + if (typeof add !== 'function' || typeof remove !== 'function') return undefined; + const wrapped: CaptureMessageListener = (event): void => { + try { + listener(event); + } catch { + // Capture listener failures cannot escape the global dispatcher boundary. + } + }; + let attempted = false; + const rollback = (): void => { + if (!attempted) return; + attempted = false; + try { + Reflect.apply(remove, target, ['message', wrapped, true]); + } catch { + // Capture listener cleanup remains best-effort. + } + }; + try { + attempted = true; + Reflect.apply(add, target, ['message', wrapped, true]); + } catch { + rollback(); + return undefined; + } + return () => { + rollback(); + }; + }, + inspectGlobalMessage, + parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => + parseProtocolMessage(kind, candidate, validation), + extractTransferredPorts, + inspectTransferredPorts, + }); +} + +/** Create a side-effect-free messaging boundary for tests and non-DOM runtimes. */ +export function createNoopMessagingAdapter(): MessagingAdapter { + return Object.freeze({ + createChannel: () => undefined, + postWindow: () => false, + installCaptureListener: () => undefined, + inspectGlobalMessage, + parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => + parseProtocolMessage(kind, candidate, {}), + extractTransferredPorts, + inspectTransferredPorts, + }); +} diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts new file mode 100644 index 000000000..99bca22fd --- /dev/null +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -0,0 +1,1687 @@ +const ARTIFACT_PROPERTY = '__trustedServerArtifactV1'; +const EXTERNAL_READY_TIMEOUT_MS = 10_000; +const MAX_PENDING_OPERATIONS = 64; +const MAX_NAME_BYTES = 128; +const MAX_EID_SOURCE_BYTES = 256; +const setDeleteIntrinsic = Set.prototype.delete; +const weakSetDeleteIntrinsic = WeakSet.prototype.delete; + +function deleteSetValue(set: Set, value: T): boolean { + return Reflect.apply(setDeleteIntrinsic, set, [value]) as boolean; +} + +function deleteWeakSetValue(set: WeakSet, value: T): boolean { + return Reflect.apply(weakSetDeleteIntrinsic, set, [value]) as boolean; +} + +/** The live state of the publisher-owned `window.pbjs` binding. */ +export type PrebidBindingStatus = 'present' | 'pending' | 'incompatible'; + +/** The readiness state owned by one Prebid operation. */ +export type PrebidOperationStatus = PrebidBindingStatus | 'timed_out'; + +/** Failure codes produced at the Prebid adapter boundary. */ +export type PrebidAdapterErrorCode = + | 'caller_aborted' + | 'external_artifact_incompatible' + | 'external_queue_full' + | 'external_ready_timeout' + | 'operation_disposed'; + +/** A typed failure contained by the Prebid adapter. */ +export class PrebidAdapterError extends Error { + public readonly code: PrebidAdapterErrorCode; + + public constructor(code: PrebidAdapterErrorCode) { + super(code); + this.name = 'PrebidAdapterError'; + this.code = code; + } +} + +/** A version-pinned response callback exposed some, but not all, bid state. */ +export class PrebidAdmissionContractError extends Error { + public readonly code = 'prebid_partial_publication'; + public readonly cause: unknown; + + public constructor(cause?: unknown) { + super('prebid_partial_publication'); + this.name = 'PrebidAdmissionContractError'; + this.cause = cause; + } +} + +/** Exact capability-free TS bid accepted by the version-pinned adapter boundary. */ +export interface PreparedTrustedBidV1 { + readonly auctionId: string; + readonly adUnitCode: string; + readonly bid: Readonly<{ + readonly requestId: string; + readonly adId: string; + readonly cpm: number; + readonly width: number; + readonly height: number; + readonly ad: ''; + readonly ttl: 300; + readonly creativeId: string; + readonly netRevenue: true; + readonly currency: 'USD'; + readonly bidderCode: 'trustedServer'; + readonly meta: Readonly<{ + readonly advertiserDomains: readonly string[]; + readonly tsAuctionId: string; + readonly tsBidId: string; + readonly tsAdmHash?: string; + }>; + }>; +} + +export type PrebidTrustedBidAdmissionResult = 'admitted' | 'not_admitted'; + +/** One exact bidder request owned by a captured Prebid auction callback. */ +export interface PrebidTrustedServerBidRequestV1 { + readonly adUnitCode: string; + readonly requestId: string; +} + +/** Private request delivered by the custom TS bidder adapter. */ +export interface PrebidTrustedServerAuctionV1 { + readonly auctionId: string; + readonly bids: readonly PrebidTrustedServerBidRequestV1[]; + complete(): void; +} + +/** The exact recursively frozen external Prebid artifact stamp. */ +export interface ExternalPrebidArtifactV1 { + readonly abi: 1; + readonly artifactReleaseId: string; + readonly prebidVersion: '10.26.0'; + readonly moduleStems: readonly string[]; + readonly bidderCodes: readonly string[]; + readonly bidderAliases: readonly Readonly<{ code: string; moduleStem: string }>[]; + readonly userIdModules: readonly Readonly<{ + moduleName: string; + configNames: readonly string[]; + eidSources: readonly string[]; + }>[]; +} + +/** Required configured behavior that the artifact stamp must cover. */ +export interface PrebidArtifactRequirements { + readonly configuredClientSideBidders?: readonly string[]; + readonly requiredUserIdModules?: readonly Readonly<{ + moduleName: string; + configNames?: readonly string[]; + eidSources?: readonly string[]; + }>[]; +} + +/** Read-only Prebid queries valid only while one subscribed event callback is active. */ +export interface PrebidEventFacade { + highestBids(adUnitCode?: string): readonly object[]; +} + +/** The small Prebid surface exposed to an accepted operation. */ +export interface PrebidFacade { + addAdUnits(adUnits: readonly unknown[]): unknown; + highestBids(adUnitCode?: string): readonly object[]; + processQueue(): unknown; + registerBidAdapter(adapter: unknown, bidderCode: string, spec?: object): unknown; + registerTrustedServerBidder( + listener: (auction: Readonly) => void + ): () => void; + renderAd(targetDocument: object, adId: string): unknown; + requestBids(options: object): unknown; + setTargetingForGpt(adUnitCodes: readonly string[]): unknown; + subscribe( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ): () => void; +} + +/** Options owned by one Prebid operation. */ +export interface PrebidOperationOptions { + readonly signal?: AbortSignal; +} + +/** A disposable Prebid operation and its readiness-scoped result. */ +export interface PrebidOperation { + readonly status: PrebidOperationStatus; + readonly result: Promise; + dispose(): void; +} + +/** Narrow Prebid boundary consumed by kernel sessions and services. */ +export interface PrebidAdapter { + bindingStatus(): PrebidBindingStatus; + admitTrustedBid(preparedBid: Readonly): PrebidTrustedBidAdmissionResult; + run( + command: (prebid: Readonly) => T, + options?: PrebidOperationOptions + ): PrebidOperation; + notifyReady(): void; + dispose(): void; +} + +/** Browser surface owned by the concrete Prebid adapter. */ +export interface PrebidGlobalTarget { + pbjs?: unknown; +} + +interface CommandQueue { + push(command: () => void): unknown; +} + +interface PresentPrebid { + readonly binding: object; + readonly commandQueue: CommandQueue; + readonly stamp: ExternalPrebidArtifactV1; +} + +interface ProvisionalEffect { + promote(): void; + release(): void; +} + +interface AbortRegistration { + readonly binding: object; + readonly listener: () => void; + readonly remove: (...arguments_: unknown[]) => unknown; + attempted: boolean; + cleanupRequested: boolean; + installing: boolean; +} + +interface PendingOperation { + state: PrebidOperationStatus; + settled: boolean; + pendingReservation: boolean; + timeout: ReturnType | undefined; + readonly command: (prebid: Readonly) => T; + readonly resolve: (value: T | PromiseLike) => void; + readonly reject: (reason: unknown) => void; + abortRegistration: AbortRegistration | undefined; + readinessBinding: object | undefined; + readonly provisionalEffects: ProvisionalEffect[]; +} + +interface ActiveTrustedServerAdmission { + readonly addBidResponse: (...arguments_: unknown[]) => unknown; + readonly binding: PresentPrebid; + readonly requests: readonly CapturedTrustedServerBidRequest[]; + readonly admittedIds: Set; + readonly admittedRequests: Set; + readonly attemptedRequests: Set; + readonly registration: object; + readonly violatedRequests: Set; + complete(): void; +} + +interface CapturedTrustedServerBidRequest extends PrebidTrustedServerBidRequestV1 { + readonly adUnitId: string; + readonly transactionId: string; +} + +const encoder = new TextEncoder(); + +function validUnicodeScalars(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) return false; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function safeMember(binding: object, key: PropertyKey): unknown { + try { + return Reflect.get(binding, key); + } catch { + return undefined; + } +} + +function safeOwnDescriptor(binding: object, key: PropertyKey): PropertyDescriptor | undefined { + try { + return Object.getOwnPropertyDescriptor(binding, key); + } catch { + return undefined; + } +} + +function frozenRecordValues( + value: unknown, + keys: readonly string[] +): Readonly> | undefined { + if (typeof value !== 'object' || value === null) { + return undefined; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== null && Object.getPrototypeOf(prototype) !== null) return undefined; + if (!Object.isFrozen(value)) return undefined; + let ownKeys: PropertyKey[]; + let descriptors: Record; + try { + ownKeys = Reflect.ownKeys(value); + descriptors = Object.getOwnPropertyDescriptors(value); + } catch { + return undefined; + } + if (ownKeys.length !== keys.length || ownKeys.some((key) => typeof key !== 'string')) { + return undefined; + } + if (keys.some((key) => !ownKeys.includes(key))) return undefined; + const values: Record = {}; + for (const key of keys) { + const descriptor = descriptors[key]; + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') || + descriptor.enumerable !== true || + descriptor.writable !== false || + descriptor.configurable !== false + ) { + return undefined; + } + values[key] = descriptor.value; + } + return values; +} + +function validString(value: unknown, maximumBytes: number, lowercase = false): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + validUnicodeScalars(value) && + encoder.encode(value).byteLength <= maximumBytes && + (!lowercase || value === value.toLowerCase()) + ); +} + +function frozenArrayValues(value: unknown, maximumLength: number): readonly unknown[] | undefined { + if (!Array.isArray(value)) return undefined; + if (!Object.isFrozen(value)) return undefined; + const descriptors = Object.getOwnPropertyDescriptors(value); + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if ( + !lengthDescriptor || + !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') || + typeof lengthDescriptor.value !== 'number' || + lengthDescriptor.value > maximumLength || + lengthDescriptor.enumerable !== false || + lengthDescriptor.writable !== false || + lengthDescriptor.configurable !== false + ) { + return undefined; + } + const length = lengthDescriptor.value; + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.length !== length + 1 || ownKeys.some((key) => typeof key !== 'string')) { + return undefined; + } + const values: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = descriptors[String(index)]; + if ( + !descriptor || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') || + descriptor.enumerable !== true || + descriptor.writable !== false || + descriptor.configurable !== false + ) { + return undefined; + } + values.push(descriptor.value); + } + return values; +} + +function frozenSortedStrings( + value: unknown, + maximumLength: number, + maximumBytes: number, + lowercase = false +): value is readonly string[] { + const values = frozenArrayValues(value, maximumLength); + if (!values) return false; + let previous: string | undefined; + for (const entry of values) { + if (!validString(entry, maximumBytes, lowercase)) return false; + if (previous !== undefined && previous >= entry) return false; + previous = entry; + } + return true; +} + +/** + * Validate the exact supported artifact version without embedding Prebid's core + * marker in the TS-owned shim. The external artifact remains the sole artifact + * that carries the human-readable Prebid version string. + */ +function isExpectedPrebidVersion( + value: unknown +): value is ExternalPrebidArtifactV1['prebidVersion'] { + return ( + typeof value === 'string' && + value.length === 7 && + value.charCodeAt(0) === 49 && + value.charCodeAt(1) === 48 && + value.charCodeAt(2) === 46 && + value.charCodeAt(3) === 50 && + value.charCodeAt(4) === 54 && + value.charCodeAt(5) === 46 && + value.charCodeAt(6) === 48 + ); +} + +function validateStamp( + candidate: unknown, + requirements: PrebidArtifactRequirements +): candidate is ExternalPrebidArtifactV1 { + try { + const stamp = frozenRecordValues(candidate, [ + 'abi', + 'artifactReleaseId', + 'prebidVersion', + 'moduleStems', + 'bidderCodes', + 'bidderAliases', + 'userIdModules', + ]); + if (!stamp) return false; + if ( + stamp.abi !== 1 || + !isExpectedPrebidVersion(stamp.prebidVersion) || + typeof stamp.artifactReleaseId !== 'string' || + !/^[0-9a-f]{64}$/.test(stamp.artifactReleaseId) || + !frozenSortedStrings(stamp.moduleStems, 256, MAX_NAME_BYTES) || + !frozenSortedStrings(stamp.bidderCodes, 512, MAX_NAME_BYTES) + ) { + return false; + } + const moduleStems = frozenArrayValues(stamp.moduleStems, 256) as readonly string[]; + const bidderCodes = frozenArrayValues(stamp.bidderCodes, 512) as readonly string[]; + const bidderAliases = frozenArrayValues(stamp.bidderAliases, 512); + const userIdModules = frozenArrayValues(stamp.userIdModules, 128); + if (!bidderAliases || !userIdModules) return false; + + let previousAlias = ''; + for (const aliasCandidate of bidderAliases) { + const alias = frozenRecordValues(aliasCandidate, ['code', 'moduleStem']); + if (!alias) return false; + if ( + !validString(alias.code, MAX_NAME_BYTES) || + !validString(alias.moduleStem, MAX_NAME_BYTES) + ) { + return false; + } + const identity = `${alias.code}\u0000${alias.moduleStem}`; + if (previousAlias !== '' && previousAlias >= identity) return false; + previousAlias = identity; + if (!bidderCodes.includes(alias.code) || !moduleStems.includes(alias.moduleStem)) { + return false; + } + } + + let previousModule = ''; + const admittedUserIdModules: Array<{ + moduleName: string; + configNames: readonly string[]; + eidSources: readonly string[]; + }> = []; + for (const moduleCandidate of userIdModules) { + const userIdModule = frozenRecordValues(moduleCandidate, [ + 'moduleName', + 'configNames', + 'eidSources', + ]); + if (!userIdModule) return false; + if ( + !validString(userIdModule.moduleName, MAX_NAME_BYTES) || + (previousModule !== '' && previousModule >= userIdModule.moduleName) || + !moduleStems.includes(userIdModule.moduleName) || + !frozenSortedStrings(userIdModule.configNames, 64, MAX_NAME_BYTES) || + !frozenSortedStrings(userIdModule.eidSources, 64, MAX_EID_SOURCE_BYTES, true) + ) { + return false; + } + previousModule = userIdModule.moduleName; + admittedUserIdModules.push({ + moduleName: userIdModule.moduleName, + configNames: frozenArrayValues(userIdModule.configNames, 64) as readonly string[], + eidSources: frozenArrayValues(userIdModule.eidSources, 64) as readonly string[], + }); + } + + for (const bidder of requirements.configuredClientSideBidders ?? []) { + if (!bidderCodes.includes(bidder)) return false; + } + for (const required of requirements.requiredUserIdModules ?? []) { + const included = admittedUserIdModules.find( + (module) => module.moduleName === required.moduleName + ); + if ( + !included || + (required.configNames ?? []).some((name) => !included.configNames.includes(name)) || + (required.eidSources ?? []).some((source) => !included.eidSources.includes(source)) + ) { + return false; + } + } + return true; + } catch { + return false; + } +} + +function validatePreparedBid(candidate: unknown): Readonly | undefined { + try { + const prepared = frozenRecordValues(candidate, ['auctionId', 'adUnitCode', 'bid']); + if ( + !prepared || + !validString(prepared.auctionId, 128) || + !validString(prepared.adUnitCode, 256) + ) { + return undefined; + } + const bid = frozenRecordValues(prepared.bid, [ + 'requestId', + 'adId', + 'cpm', + 'width', + 'height', + 'ad', + 'ttl', + 'creativeId', + 'netRevenue', + 'currency', + 'bidderCode', + 'meta', + ]); + if ( + !bid || + !validString(bid.requestId, 128) || + typeof bid.adId !== 'string' || + !/^r1_[A-Za-z0-9_-]{22}$/u.test(bid.adId) || + typeof bid.cpm !== 'number' || + !Number.isFinite(bid.cpm) || + bid.cpm < 0 || + typeof bid.width !== 'number' || + !Number.isInteger(bid.width) || + bid.width < 1 || + bid.width > 4096 || + typeof bid.height !== 'number' || + !Number.isInteger(bid.height) || + bid.height < 1 || + bid.height > 4096 || + bid.ad !== '' || + bid.ttl !== 300 || + !validString(bid.creativeId, 256) || + bid.netRevenue !== true || + bid.currency !== 'USD' || + !validString(bid.bidderCode, MAX_NAME_BYTES) + ) { + return undefined; + } + const metaKeys = Object.prototype.hasOwnProperty.call(bid.meta, 'tsAdmHash') + ? ['advertiserDomains', 'tsAuctionId', 'tsBidId', 'tsAdmHash'] + : ['advertiserDomains', 'tsAuctionId', 'tsBidId']; + const meta = frozenRecordValues(bid.meta, metaKeys); + const advertiserDomains = meta && frozenArrayValues(meta.advertiserDomains, 16); + if ( + !meta || + !advertiserDomains || + advertiserDomains.some((domain) => !validString(domain, 256)) || + meta.tsAuctionId !== prepared.auctionId || + !validString(meta.tsBidId, 256) || + (meta.tsAdmHash !== undefined && !validString(meta.tsAdmHash, 128)) + ) { + return undefined; + } + return candidate as Readonly; + } catch { + return undefined; + } +} + +const REQUIRED_API_METHODS = [ + 'addAdUnits', + 'getBidResponsesForAdUnitCode', + 'getHighestCpmBids', + 'offEvent', + 'onEvent', + 'processQueue', + 'registerBidAdapter', + 'renderAd', + 'requestBids', + 'setTargetingForGPTAsync', +] as const; + +function commandQueue(binding: object): CommandQueue | undefined { + const candidate = safeMember(binding, 'que'); + if ((typeof candidate !== 'object' || candidate === null) && typeof candidate !== 'function') { + return undefined; + } + return typeof safeMember(candidate, 'push') === 'function' + ? (candidate as CommandQueue) + : undefined; +} + +function inspectBinding( + value: unknown, + requirements: PrebidArtifactRequirements +): + | { readonly status: 'pending'; readonly binding?: object; readonly commandQueue?: CommandQueue } + | { readonly status: 'incompatible'; readonly binding?: object } + | { readonly status: 'present'; readonly value: PresentPrebid } { + if (value === undefined || value === null) return { status: 'pending' }; + if (typeof value !== 'object' && typeof value !== 'function') { + return { status: 'incompatible' }; + } + const binding = value as object; + const queue = commandQueue(binding); + if (!queue) return { status: 'incompatible', binding }; + const descriptor = safeOwnDescriptor(binding, ARTIFACT_PROPERTY); + if (!descriptor) { + const hasRealApi = REQUIRED_API_METHODS.some( + (method) => safeMember(binding, method) !== undefined + ); + return hasRealApi + ? { status: 'incompatible', binding } + : { status: 'pending', binding, commandQueue: queue }; + } + if ( + !Object.prototype.hasOwnProperty.call(descriptor, 'value') || + descriptor.enumerable !== false || + descriptor.writable !== false || + descriptor.configurable !== false || + !validateStamp(descriptor.value, requirements) || + REQUIRED_API_METHODS.some((method) => typeof safeMember(binding, method) !== 'function') + ) { + return { status: 'incompatible', binding }; + } + return { + status: 'present', + value: { binding, commandQueue: queue, stamp: descriptor.value }, + }; +} + +function readTarget(target: PrebidGlobalTarget): unknown { + try { + return target.pbjs; + } catch { + return false; + } +} + +function queueCommand(queue: CommandQueue, command: () => void, guard?: () => boolean): void { + const push = safeMember(queue as object, 'push'); + if (guard && !guard()) throw new PrebidAdapterError('external_artifact_incompatible'); + if (typeof push !== 'function') throw new PrebidAdapterError('external_artifact_incompatible'); + if (guard && !guard()) throw new PrebidAdapterError('external_artifact_incompatible'); + Reflect.apply(push, queue, [command]); + if (guard && !guard()) throw new PrebidAdapterError('external_artifact_incompatible'); +} + +/** Create the sole production reader/writer boundary for `window.pbjs`. */ +export function createBrowserPrebidAdapter( + target: PrebidGlobalTarget = window as unknown as PrebidGlobalTarget, + requirements: PrebidArtifactRequirements = {} +): PrebidAdapter { + const pending: PendingOperation[] = []; + const live = new Set>(); + const effects = new Set<() => void>(); + const activeAdmissions = new Map(); + const trustedBidderRegistrations = new Map(); + let armedBindings = new WeakSet(); + let diagnosedBindings = new WeakSet(); + let diagnosedUnbound = false; + let pendingReservations = 0; + let disposed = false; + + const rollbackDiagnosticOwnership = (binding: object): void => { + let released = false; + try { + deleteWeakSetValue(diagnosedBindings, binding); + released = !diagnosedBindings.has(binding); + } catch { + // A poisoned registry cannot prove that the exact marker was removed. + } + if (!released) diagnosedBindings = new WeakSet(); + }; + + const currentBinding = (): ReturnType => { + const inspected = inspectBinding(readTarget(target), requirements); + if (inspected.status === 'incompatible') { + let shouldDiagnose = !diagnosedUnbound; + if (inspected.binding) { + try { + shouldDiagnose = !diagnosedBindings.has(inspected.binding); + } catch { + shouldDiagnose = false; + } + } + if (shouldDiagnose) { + let diagnosticOwned = false; + if (inspected.binding) { + try { + diagnosedBindings.add(inspected.binding); + } catch { + // A stateful add may still have published diagnostic ownership. + } + try { + diagnosticOwned = diagnosedBindings.has(inspected.binding); + } catch { + rollbackDiagnosticOwnership(inspected.binding); + } + } else { + diagnosedUnbound = true; + diagnosticOwned = diagnosedUnbound; + } + if (diagnosticOwned) { + try { + console.warn('[tsjs-prebid] external Prebid artifact is incompatible'); + } catch { + // Diagnostics cannot change readiness behavior. + } + } + } + } + return inspected; + }; + + const sameBinding = (expected: PresentPrebid): boolean => { + if (readTarget(target) !== expected.binding) return false; + const descriptor = safeOwnDescriptor(expected.binding, ARTIFACT_PROPERTY); + return ( + descriptor !== undefined && + Object.prototype.hasOwnProperty.call(descriptor, 'value') && + descriptor.value === expected.stamp && + descriptor.enumerable === false && + descriptor.writable === false && + descriptor.configurable === false + ); + }; + + const callBound = ( + expected: PresentPrebid, + key: PropertyKey, + argumentsList: readonly unknown[], + isCurrent: () => boolean + ): unknown => { + if (!isCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); + const member = safeMember(expected.binding, key); + if (!isCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); + if (typeof member !== 'function') + throw new PrebidAdapterError('external_artifact_incompatible'); + if (!isCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); + const result = Reflect.apply(member, expected.binding, argumentsList); + if (!isCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); + return result; + }; + + const bidderRequestSnapshot = ( + candidate: unknown + ): + | Readonly<{ + auctionId: string; + bids: readonly PrebidTrustedServerBidRequestV1[]; + requests: readonly CapturedTrustedServerBidRequest[]; + }> + | undefined => { + try { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) { + return undefined; + } + const auctionId = safeOwnDescriptor(candidate, 'auctionId'); + const bidsDescriptor = safeOwnDescriptor(candidate, 'bids'); + if ( + !auctionId || + !Object.prototype.hasOwnProperty.call(auctionId, 'value') || + !validString(auctionId.value, 128) || + !bidsDescriptor || + !Object.prototype.hasOwnProperty.call(bidsDescriptor, 'value') || + !Array.isArray(bidsDescriptor.value) || + bidsDescriptor.value.length === 0 || + bidsDescriptor.value.length > 256 + ) { + return undefined; + } + const bids: PrebidTrustedServerBidRequestV1[] = []; + const requests: CapturedTrustedServerBidRequest[] = []; + const identities = new Set(); + for (const rawBid of bidsDescriptor.value as unknown[]) { + if (typeof rawBid !== 'object' || rawBid === null || Array.isArray(rawBid)) + return undefined; + const adUnitCode = safeOwnDescriptor(rawBid, 'adUnitCode'); + const adUnitId = safeOwnDescriptor(rawBid, 'adUnitId'); + const bidAuctionId = safeOwnDescriptor(rawBid, 'auctionId'); + const requestId = safeOwnDescriptor(rawBid, 'bidId'); + const source = safeOwnDescriptor(rawBid, 'src'); + const transactionId = safeOwnDescriptor(rawBid, 'transactionId'); + if ( + !adUnitCode || + !Object.prototype.hasOwnProperty.call(adUnitCode, 'value') || + !validString(adUnitCode.value, 256) || + !adUnitId || + !Object.prototype.hasOwnProperty.call(adUnitId, 'value') || + !validString(adUnitId.value, 128) || + !bidAuctionId || + !Object.prototype.hasOwnProperty.call(bidAuctionId, 'value') || + bidAuctionId.value !== auctionId.value || + !requestId || + !Object.prototype.hasOwnProperty.call(requestId, 'value') || + !validString(requestId.value, 128) || + !source || + !Object.prototype.hasOwnProperty.call(source, 'value') || + source.value !== 'client' || + !transactionId || + !Object.prototype.hasOwnProperty.call(transactionId, 'value') || + !validString(transactionId.value, 128) + ) { + return undefined; + } + const identity = `${adUnitCode.value}\u0000${requestId.value}`; + if (identities.has(identity)) return undefined; + identities.add(identity); + bids.push(Object.freeze({ adUnitCode: adUnitCode.value, requestId: requestId.value })); + requests.push( + Object.freeze({ + adUnitCode: adUnitCode.value, + adUnitId: adUnitId.value, + requestId: requestId.value, + transactionId: transactionId.value, + }) + ); + } + return Object.freeze({ + auctionId: auctionId.value, + bids: Object.freeze(bids), + requests: Object.freeze(requests), + }); + } catch { + return undefined; + } + }; + + const responseCount = ( + binding: PresentPrebid, + auctionId: string, + adUnitCode: string, + adId: string, + requestId: string, + isCurrent: () => boolean + ): number => { + const response = callBound(binding, 'getBidResponsesForAdUnitCode', [adUnitCode], isCurrent); + if (!Array.isArray(response)) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + const bids = safeMember(response, 'bids'); + if (bids !== response) throw new PrebidAdapterError('external_artifact_incompatible'); + let matches = 0; + for (const bid of response) { + if (typeof bid !== 'object' || bid === null) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + if ( + safeMember(bid, 'auctionId') === auctionId && + safeMember(bid, 'adId') === adId && + safeMember(bid, 'requestId') === requestId && + safeMember(bid, 'adUnitCode') === adUnitCode + ) { + matches += 1; + } + } + return matches; + }; + + const admitTrustedBid = ( + candidate: Readonly + ): PrebidTrustedBidAdmissionResult => { + if (disposed) throw new PrebidAdapterError('operation_disposed'); + const prepared = validatePreparedBid(candidate); + if (!prepared) return 'not_admitted'; + const context = activeAdmissions.get(prepared.auctionId); + if (!context) return 'not_admitted'; + if (!sameBinding(context.binding)) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + const requestIdentity = `${prepared.adUnitCode}\u0000${prepared.bid.requestId}`; + const request = context.requests.find( + (candidateRequest) => + candidateRequest.adUnitCode === prepared.adUnitCode && + candidateRequest.requestId === prepared.bid.requestId + ); + if (!request) { + return 'not_admitted'; + } + if ( + context.admittedIds.has(prepared.bid.adId) || + context.admittedRequests.has(requestIdentity) || + context.violatedRequests.has(requestIdentity) + ) { + throw new PrebidAdmissionContractError(); + } + if (context.attemptedRequests.has(requestIdentity)) return 'not_admitted'; + const isCurrent = (): boolean => !disposed && sameBinding(context.binding); + const before = responseCount( + context.binding, + prepared.auctionId, + prepared.adUnitCode, + prepared.bid.adId, + prepared.bid.requestId, + isCurrent + ); + if (before !== 0) { + context.violatedRequests.add(requestIdentity); + throw new PrebidAdmissionContractError(); + } + context.attemptedRequests.add(requestIdentity); + + let responseEvents = 0; + const responseListener = (event: unknown): void => { + if ( + typeof event === 'object' && + event !== null && + safeMember(event, 'auctionId') === prepared.auctionId && + safeMember(event, 'adId') === prepared.bid.adId && + safeMember(event, 'requestId') === prepared.bid.requestId && + safeMember(event, 'adUnitCode') === prepared.adUnitCode + ) { + responseEvents += 1; + } + }; + callBound(context.binding, 'onEvent', ['bidResponse', responseListener], isCurrent); + let callbackFailure: unknown; + try { + const mutableBid = { + ...prepared.bid, + adUnitId: request.adUnitId, + auctionId: prepared.auctionId, + getSize: (): string => `${prepared.bid.width}x${prepared.bid.height}`, + mediaType: 'banner', + meta: { + ...prepared.bid.meta, + advertiserDomains: [...prepared.bid.meta.advertiserDomains], + }, + source: 'client', + transactionId: request.transactionId, + }; + Reflect.apply(context.addBidResponse, undefined, [prepared.adUnitCode, mutableBid]); + } catch (error) { + callbackFailure = error; + } + let cleanupFailure: unknown; + try { + callBound(context.binding, 'offEvent', ['bidResponse', responseListener], isCurrent); + } catch (error) { + cleanupFailure = error; + } + let after: number; + try { + after = responseCount( + context.binding, + prepared.auctionId, + prepared.adUnitCode, + prepared.bid.adId, + prepared.bid.requestId, + isCurrent + ); + } catch (error) { + context.violatedRequests.add(requestIdentity); + throw new PrebidAdmissionContractError(error); + } + if (cleanupFailure !== undefined) { + context.violatedRequests.add(requestIdentity); + throw new PrebidAdmissionContractError(cleanupFailure); + } + if (callbackFailure !== undefined) { + if (responseEvents !== 0 || after !== 0) { + context.violatedRequests.add(requestIdentity); + throw new PrebidAdmissionContractError(callbackFailure); + } + throw callbackFailure; + } + if (responseEvents === 0 && after === 0) return 'not_admitted'; + if (responseEvents !== 1 || after !== 1) { + context.violatedRequests.add(requestIdentity); + throw new PrebidAdmissionContractError(); + } + context.admittedIds.add(prepared.bid.adId); + context.admittedRequests.add(requestIdentity); + return 'admitted'; + }; + + const highestBids = ( + binding: PresentPrebid, + adUnitCode: string | undefined, + isCurrent: () => boolean + ): readonly object[] => { + const value = callBound( + binding, + 'getHighestCpmBids', + adUnitCode === undefined ? [] : [adUnitCode], + isCurrent + ); + if (!Array.isArray(value) || value.some((bid) => typeof bid !== 'object' || bid === null)) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + if (!isCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); + return Object.freeze([...value]); + }; + + const registerTrustedServerBidder = ( + binding: PresentPrebid, + listener: (auction: Readonly) => void, + registerOperationEffect: (disposeEffect: () => void) => () => void, + isOperationCurrent: () => boolean + ): (() => void) => { + if (typeof listener !== 'function') { + throw new TypeError('Trusted Server bidder listener must be a function'); + } + if (trustedBidderRegistrations.has(binding.binding)) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + const registration = Object.freeze({}); + trustedBidderRegistrations.set(binding.binding, registration); + let active = true; + const completeRegistrationAuctions = (): void => { + for (const context of [...activeAdmissions.values()]) { + if (context.registration !== registration) continue; + context.complete(); + } + }; + let release: () => void; + try { + release = registerOperationEffect(() => { + active = false; + if (trustedBidderRegistrations.get(binding.binding) === registration) { + trustedBidderRegistrations.delete(binding.binding); + } + completeRegistrationAuctions(); + }); + } catch (error) { + if (trustedBidderRegistrations.get(binding.binding) === registration) { + trustedBidderRegistrations.delete(binding.binding); + } + throw error; + } + const bidder = Object.freeze({ + callBids: (rawRequest: unknown, rawAddBidResponse: unknown, rawDone: unknown): void => { + const done = typeof rawDone === 'function' ? rawDone : undefined; + let completed = false; + const finish = (): void => { + if (completed) return; + completed = true; + try { + Reflect.apply(done ?? (() => undefined), undefined, []); + } catch { + // Prebid completion cannot escape the registered adapter boundary. + } + }; + const request = bidderRequestSnapshot(rawRequest); + if ( + !active || + !sameBinding(binding) || + !request || + typeof rawAddBidResponse !== 'function' || + !done || + activeAdmissions.has(request.auctionId) + ) { + finish(); + return; + } + const context: ActiveTrustedServerAdmission = { + addBidResponse: rawAddBidResponse as (...arguments_: unknown[]) => unknown, + binding, + requests: request.requests, + admittedIds: new Set(), + admittedRequests: new Set(), + attemptedRequests: new Set(), + registration, + violatedRequests: new Set(), + complete: (): void => { + if (activeAdmissions.get(request.auctionId) !== context) return; + activeAdmissions.delete(request.auctionId); + finish(); + }, + }; + activeAdmissions.set(request.auctionId, context); + const auction = Object.freeze({ + auctionId: request.auctionId, + bids: request.bids, + complete: context.complete, + }); + try { + listener(auction); + } catch { + context.complete(); + } + }, + }); + const bidderFactory = (): Readonly => bidder; + try { + callBound( + binding, + 'registerBidAdapter', + [bidderFactory, 'trustedServer'], + isOperationCurrent + ); + return release; + } catch (error) { + release(); + throw error; + } + }; + + const createFacade = ( + binding: PresentPrebid, + registerOperationEffect: (disposeEffect: () => void) => () => void, + isOperationCurrent: () => boolean, + isBindingCurrent: () => boolean + ): Readonly => + Object.freeze({ + addAdUnits: (adUnits: readonly unknown[]): unknown => + callBound(binding, 'addAdUnits', [[...adUnits]], isOperationCurrent), + highestBids: (adUnitCode?: string): readonly object[] => + highestBids(binding, adUnitCode, isOperationCurrent), + processQueue: (): unknown => callBound(binding, 'processQueue', [], isOperationCurrent), + registerBidAdapter: (adapter: unknown, bidderCode: string, spec?: object): unknown => + callBound( + binding, + 'registerBidAdapter', + spec === undefined ? [adapter, bidderCode] : [adapter, bidderCode, spec], + isOperationCurrent + ), + registerTrustedServerBidder: ( + listener: (auction: Readonly) => void + ): (() => void) => + registerTrustedServerBidder(binding, listener, registerOperationEffect, isOperationCurrent), + renderAd: (targetDocument: object, adId: string): unknown => + callBound(binding, 'renderAd', [targetDocument, adId], isOperationCurrent), + requestBids: (options: object): unknown => + callBound(binding, 'requestBids', [options], isOperationCurrent), + setTargetingForGpt: (adUnitCodes: readonly string[]): unknown => + callBound(binding, 'setTargetingForGPTAsync', [[...adUnitCodes]], isOperationCurrent), + subscribe: ( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ): (() => void) => { + if (!isOperationCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); + const add = safeMember(binding.binding, 'onEvent'); + if (!isOperationCurrent() || typeof add !== 'function') + throw new PrebidAdapterError('external_artifact_incompatible'); + const remove = safeMember(binding.binding, 'offEvent'); + if (!isOperationCurrent() || typeof remove !== 'function') + throw new PrebidAdapterError('external_artifact_incompatible'); + const wrapped = (event: unknown): void => { + if (!isBindingCurrent()) return; + let callbackActive = true; + const isEventCurrent = (): boolean => callbackActive && isBindingCurrent(); + const eventFacade: Readonly = Object.freeze({ + highestBids: (adUnitCode?: string): readonly object[] => + highestBids(binding, adUnitCode, isEventCurrent), + }); + try { + listener(event, eventFacade); + } catch { + // Publisher callbacks cannot escape the Prebid boundary. + } finally { + callbackActive = false; + } + }; + let attempted = false; + const rollback = (): void => { + if (!attempted) return; + attempted = false; + try { + Reflect.apply(remove, binding.binding, [eventType, wrapped]); + } catch { + // Transaction rollback remains best-effort and cannot replace the original failure. + } + }; + try { + if (!isOperationCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); + attempted = true; + Reflect.apply(add, binding.binding, [eventType, wrapped]); + if (!isOperationCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); + } catch (error) { + rollback(); + throw error; + } + let active = true; + return registerOperationEffect(() => { + if (!active) return; + active = false; + rollback(); + }); + }, + }); + + const removePending = (operation: PendingOperation): void => { + const index = pending.indexOf(operation); + if (index >= 0) pending.splice(index, 1); + }; + + const releasePendingReservation = (operation: PendingOperation): void => { + if (!operation.pendingReservation) return; + operation.pendingReservation = false; + if (pendingReservations > 0) pendingReservations -= 1; + }; + + const clearReadiness = (operation: PendingOperation): void => { + try { + if (operation.timeout !== undefined) { + clearTimeout(operation.timeout); + operation.timeout = undefined; + } + removePending(operation); + } finally { + releasePendingReservation(operation); + } + }; + + const detachAbort = (operation: PendingOperation): void => { + const registration = operation.abortRegistration; + if (!registration || !registration.attempted) return; + if (registration.installing) { + registration.cleanupRequested = true; + return; + } + registration.attempted = false; + operation.abortRegistration = undefined; + try { + Reflect.apply(registration.remove, registration.binding, ['abort', registration.listener]); + } catch { + // Hostile signal cleanup cannot strand operation settlement. + } + }; + + const rollbackNotificationArming = (binding: object): void => { + let released = false; + try { + deleteWeakSetValue(armedBindings, binding); + released = !armedBindings.has(binding); + } catch { + // A poisoned registry cannot prove that the exact marker was removed. + } + if (!released) armedBindings = new WeakSet(); + }; + + const clearOperation = (operation: PendingOperation): void => { + try { + clearReadiness(operation); + } finally { + try { + detachAbort(operation); + } finally { + deleteSetValue(live, operation); + } + } + }; + + const rollbackOperationEffects = (operation: PendingOperation): void => { + for (let index = operation.provisionalEffects.length - 1; index >= 0; index -= 1) { + operation.provisionalEffects[index]?.release(); + } + operation.provisionalEffects.length = 0; + }; + + const rejectOperation = (operation: PendingOperation, error: unknown): void => { + if (operation.settled) return; + operation.settled = true; + if (error instanceof PrebidAdapterError && error.code === 'external_artifact_incompatible') { + operation.state = 'incompatible'; + } + try { + rollbackOperationEffects(operation); + } finally { + try { + clearOperation(operation); + } finally { + operation.reject(error); + } + } + }; + + const fail = (operation: PendingOperation, code: PrebidAdapterErrorCode): void => { + if (operation.settled) return; + if (code === 'external_ready_timeout') operation.state = 'timed_out'; + if (code === 'external_artifact_incompatible') operation.state = 'incompatible'; + rejectOperation(operation, new PrebidAdapterError(code)); + }; + + const dispatch = (operation: PendingOperation, binding: PresentPrebid): void => { + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + operation.state = 'present'; + clearReadiness(operation); + const isDispatchCurrent = (): boolean => { + if (disposed || operation.settled) return false; + const current = sameBinding(binding); + return !disposed && !operation.settled && current; + }; + const registerOperationEffect = (disposeEffect: () => void): (() => void) => { + let released = false; + let promoted = false; + const release = (): void => { + if (promoted) { + try { + deleteSetValue(effects, release); + } catch { + // A hostile registry cannot prevent exact external cleanup. + } + } + if (released) return; + released = true; + try { + disposeEffect(); + } catch { + // One effect cleanup cannot escape the adapter boundary. + } + }; + const promote = (): void => { + if (released || promoted) return; + promoted = true; + try { + effects.add(release); + } catch (error) { + release(); + throw error; + } + if (!isDispatchCurrent()) { + release(); + throw new PrebidAdapterError( + disposed ? 'operation_disposed' : 'external_artifact_incompatible' + ); + } + }; + const provisional = { promote, release }; + operation.provisionalEffects[operation.provisionalEffects.length] = provisional; + if (!isDispatchCurrent()) { + release(); + throw new PrebidAdapterError( + disposed ? 'operation_disposed' : 'external_artifact_incompatible' + ); + } + return release; + }; + const promoteOperationEffects = (): void => { + for (const provisional of operation.provisionalEffects) provisional.promote(); + operation.provisionalEffects.length = 0; + }; + const completeOperation = (value: unknown): void => { + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + if (!isDispatchCurrent()) { + fail(operation, 'external_artifact_incompatible'); + return; + } + try { + promoteOperationEffects(); + } catch (error) { + if (!operation.settled) rejectOperation(operation, error); + return; + } + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + if (!isDispatchCurrent()) { + fail(operation, 'external_artifact_incompatible'); + return; + } + operation.settled = true; + try { + clearOperation(operation); + } finally { + operation.resolve(value); + } + }; + const settleCommandValue = (value: unknown): void => { + let then: unknown; + try { + if ((typeof value === 'object' && value !== null) || typeof value === 'function') { + then = Reflect.get(value, 'then'); + } + } catch (error) { + rejectOperation(operation, error); + return; + } + if (typeof then !== 'function') { + completeOperation(value); + return; + } + Promise.resolve(value).then( + (resolved) => completeOperation(resolved), + (error: unknown) => rejectOperation(operation, error) + ); + }; + const facade = createFacade( + binding, + registerOperationEffect, + isDispatchCurrent, + () => !disposed && sameBinding(binding) + ); + try { + if (!isDispatchCurrent()) { + if (disposed) fail(operation, 'operation_disposed'); + else fail(operation, 'external_artifact_incompatible'); + return; + } + queueCommand( + binding.commandQueue, + () => { + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + if (!isDispatchCurrent()) { + if (disposed) fail(operation, 'operation_disposed'); + else fail(operation, 'external_artifact_incompatible'); + return; + } + try { + const value = operation.command(facade); + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + if (!isDispatchCurrent()) { + if (disposed) fail(operation, 'operation_disposed'); + else fail(operation, 'external_artifact_incompatible'); + return; + } + settleCommandValue(value); + } catch (error) { + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + rejectOperation(operation, error); + } + }, + isDispatchCurrent + ); + if (!operation.settled && !isDispatchCurrent()) { + if (disposed) fail(operation, 'operation_disposed'); + else fail(operation, 'external_artifact_incompatible'); + } + } catch (error) { + if (operation.settled) return; + if (disposed) { + fail(operation, 'operation_disposed'); + return; + } + rejectOperation(operation, error); + } + }; + + const notifyReady = (expectedBinding?: object): void => { + if (disposed) return; + const current = currentBinding(); + if (disposed) return; + if (current.status === 'present') { + for (const operation of [...pending]) dispatch(operation, current.value); + return; + } + if (current.status === 'pending') { + armNotification(); + return; + } + if (expectedBinding !== undefined && current.binding !== expectedBinding) { + return; + } + for (const operation of [...pending]) { + if ( + expectedBinding === undefined || + operation.readinessBinding === undefined || + operation.readinessBinding === expectedBinding + ) { + fail(operation, 'external_artifact_incompatible'); + } + } + }; + + const armNotification = (): void => { + const current = currentBinding(); + if (disposed) return; + if (current.status !== 'pending' || !current.binding || !current.commandQueue) { + return; + } + let alreadyArmed = false; + try { + alreadyArmed = armedBindings.has(current.binding); + } catch { + armedBindings = new WeakSet(); + } + if (alreadyArmed) return; + for (const operation of pending) operation.readinessBinding = current.binding; + try { + armedBindings.add(current.binding); + } catch { + rollbackNotificationArming(current.binding); + return; + } + let notificationActive = true; + const notify = (): void => { + if (!notificationActive) return; + notificationActive = false; + notifyReady(current.binding); + }; + try { + queueCommand(current.commandQueue, notify); + } catch { + notificationActive = false; + rollbackNotificationArming(current.binding); + } + }; + + const run = ( + command: (prebid: Readonly) => T, + options: PrebidOperationOptions = {} + ): PrebidOperation => { + if (disposed) throw new PrebidAdapterError('operation_disposed'); + const current = currentBinding(); + if (disposed) throw new PrebidAdapterError('operation_disposed'); + if (current.status === 'pending') { + if (pendingReservations >= MAX_PENDING_OPERATIONS) { + throw new PrebidAdapterError('external_queue_full'); + } + pendingReservations += 1; + } + + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason: unknown) => void; + const result = new Promise((resolveResult, rejectResult) => { + resolve = resolveResult; + reject = rejectResult; + }); + const operation: PendingOperation = { + state: current.status, + settled: false, + pendingReservation: current.status === 'pending', + timeout: undefined, + command, + resolve, + reject, + abortRegistration: undefined, + readinessBinding: current.status === 'pending' ? current.binding : undefined, + provisionalEffects: [], + }; + const handle = Object.freeze({ + get status(): PrebidOperationStatus { + return operation.state; + }, + result, + dispose: (): void => fail(operation as PendingOperation, 'operation_disposed'), + }); + + try { + live.add(operation as PendingOperation); + } catch (error) { + try { + deleteSetValue(live, operation as PendingOperation); + } catch { + // Publication rollback preserves the original registry failure. + } + releasePendingReservation(operation as PendingOperation); + throw error; + } + if (current.status === 'pending') { + pending[pending.length] = operation as PendingOperation; + operation.timeout = setTimeout( + () => fail(operation as PendingOperation, 'external_ready_timeout'), + EXTERNAL_READY_TIMEOUT_MS + ); + } + + if (disposed) { + fail(operation as PendingOperation, 'operation_disposed'); + return handle; + } + if (operation.settled) return handle; + + let signal: unknown; + try { + signal = options.signal; + } catch (error) { + rejectOperation(operation as PendingOperation, error); + return handle; + } + if (operation.settled) return handle; + if (signal !== undefined) { + if ((typeof signal !== 'object' || signal === null) && typeof signal !== 'function') { + rejectOperation( + operation as PendingOperation, + new TypeError('Invalid AbortSignal') + ); + return handle; + } + let aborted: unknown; + let add: unknown; + let remove: unknown; + try { + aborted = Reflect.get(signal, 'aborted'); + if (operation.settled) return handle; + add = Reflect.get(signal, 'addEventListener'); + if (operation.settled) return handle; + remove = Reflect.get(signal, 'removeEventListener'); + } catch (error) { + if (!operation.settled) rejectOperation(operation as PendingOperation, error); + return handle; + } + if (operation.settled) return handle; + if (disposed) { + fail(operation as PendingOperation, 'operation_disposed'); + return handle; + } + if (aborted === true) { + fail(operation as PendingOperation, 'caller_aborted'); + return handle; + } + if (typeof add !== 'function' || typeof remove !== 'function') { + rejectOperation( + operation as PendingOperation, + new TypeError('Invalid AbortSignal') + ); + return handle; + } + const registration: AbortRegistration = { + binding: signal, + listener: () => fail(operation as PendingOperation, 'caller_aborted'), + remove: remove as (...arguments_: unknown[]) => unknown, + attempted: true, + cleanupRequested: false, + installing: true, + }; + operation.abortRegistration = registration; + try { + Reflect.apply(add, signal, ['abort', registration.listener, { once: true }]); + } catch (error) { + registration.installing = false; + detachAbort(operation as PendingOperation); + if (!operation.settled) rejectOperation(operation as PendingOperation, error); + return handle; + } + registration.installing = false; + if (registration.cleanupRequested || operation.settled || disposed) { + detachAbort(operation as PendingOperation); + } + if (operation.settled) return handle; + if (disposed) { + fail(operation as PendingOperation, 'operation_disposed'); + return handle; + } + let abortedAfterRegistration: unknown; + try { + abortedAfterRegistration = Reflect.get(signal, 'aborted'); + } catch (error) { + if (!operation.settled) rejectOperation(operation as PendingOperation, error); + return handle; + } + if (operation.settled) return handle; + if (disposed) { + fail(operation as PendingOperation, 'operation_disposed'); + return handle; + } + if (abortedAfterRegistration === true) { + fail(operation as PendingOperation, 'caller_aborted'); + return handle; + } + } + + if (current.status === 'incompatible') { + fail(operation as PendingOperation, 'external_artifact_incompatible'); + } else if (current.status === 'present') { + dispatch(operation as PendingOperation, current.value); + } else { + armNotification(); + } + return handle; + }; + + return Object.freeze({ + admitTrustedBid, + bindingStatus: (): PrebidBindingStatus => currentBinding().status, + run, + notifyReady, + dispose: (): void => { + if (disposed) return; + disposed = true; + for (const operation of [...live]) fail(operation, 'operation_disposed'); + for (const disposeEffect of [...effects]) { + try { + deleteSetValue(effects, disposeEffect); + } catch { + // A hostile registry cannot interrupt cleanup of remaining effects. + } + try { + disposeEffect(); + } catch { + // One cleanup cannot interrupt the remaining adapter disposers. + } + } + }, + }); +} + +/** Create a side-effect-free Prebid boundary for tests and unavailable environments. */ +export function createNoopPrebidAdapter(): PrebidAdapter { + return createBrowserPrebidAdapter({}); +} diff --git a/crates/trusted-server-js/lib/src/composition/browser_test.ts b/crates/trusted-server-js/lib/src/composition/browser_test.ts new file mode 100644 index 000000000..a14697cfc --- /dev/null +++ b/crates/trusted-server-js/lib/src/composition/browser_test.ts @@ -0,0 +1,2018 @@ +import { + createBrowserGoogletagAdapter, + createNoopGoogletagAdapter, + type GoogletagAdapter, + type GoogletagDiagnosticsFact, + type GoogletagGlobalTarget, +} from '../adapters/googletag'; +import { + createBrowserMessagingAdapter, + createNoopMessagingAdapter, + type MessageEventTarget, + type MessagingAdapter, + type MessagingValidationOptions, +} from '../adapters/messaging'; +import { + createBrowserPrebidAdapter, + createNoopPrebidAdapter, + type PrebidAdapter, + type PrebidGlobalTarget, + type PrebidTrustedServerAuctionV1, +} from '../adapters/prebid'; +import { parseTrustedServerAuctionResponseV1 } from '../core/auction'; +import { snapshotTsjsBootV1 } from '../core/contracts/boot'; +import type { + BootManifestV1, + BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, + CreativeBootV1, + DiagnosticsBootV1, +} from '../core/types'; +import { + createRenderTraceStore, + type RenderTraceGptFactV1, + type RenderTraceRuntimeOwner, +} from '../core/trace'; +import { + parseBidRenderSourceV1, + parseBrowserAuctionProjectionV1, +} from '../core/contracts/auction_projection'; +import { validateApsRenderer } from '../core/contracts/aps_renderer'; +import { validateRequestAdsOptions } from '../core/contracts/request_ads'; +import { log } from '../core/log'; +import { + AdUnitRegistrationError, + addAdUnitsResult, + prepareProgrammaticAdUnits, + serializeAuctionRequestBody, +} from '../core/registry'; +import { prepareAdmIframe } from '../core/render'; +import { + APS_RENDERER_V2_PATH, + renderDirectApsAttempt, + renderPucApsAttempt, +} from '../integrations/aps/render'; +import { installClickGuard } from '../integrations/creative/click'; +import { installDynamicIframeProxy } from '../integrations/creative/iframe'; +import { installDynamicImageProxy } from '../integrations/creative/image'; +import { createCreativeStartup } from '../integrations/creative/startup'; +import { createDataDomeRuntime } from '../integrations/datadome/module'; +import { createDidomiRuntime } from '../integrations/didomi/module'; +import { createGoogleTagManagerRuntime } from '../integrations/google_tag_manager/module'; +import { + publishGptWinner, + startGptSlotOperation, + type GptSlotOperationInput, + type GptWinnerPublicationInput, + type GptWinnerPublicationResult, +} from '../integrations/gpt/module'; +import { createGptStartup } from '../integrations/gpt/startup'; +import { + activateGptDiagnosticsFactCapture, + createGptDiagnosticsFactBuffer, + projectGptTraceFact, + type GptDiagnosticsFactBuffer, +} from '../integrations/gpt/diagnostics_facts'; +import { + createPrebidSelectionCoordinator, + publishPrebidBid, + type PrebidSelectionCoordinator, +} from '../integrations/render_runtime/prebid_selection'; +import { + createPrebidRefreshPolicy, + createPrebidSyntheticRefreshRunner, + preparePrebidRegisteredRefreshAuction, +} from '../integrations/prebid/refresh'; +import { createPrebidStartup } from '../integrations/prebid/startup'; +import { createLockrRuntime } from '../integrations/lockr/module'; +import { createOsanoRuntime } from '../integrations/osano/module'; +import { createPermutiveRuntime } from '../integrations/permutive/module'; +import { createSourcepointRuntime } from '../integrations/sourcepoint/module'; +import { createTestlightRuntime } from '../integrations/testlight/module'; +import { + createBrowserNavigationIdentityIssuer, + mintBrowserLifecycleTicket, +} from '../kernel/identity'; +import { + createDiagnosticsIngress, + type DiagnosticsIngress, + type DiagnosticsObservation, +} from '../kernel/diagnostics'; +import type { + NavigationIdentityIssuerFactory, + RenderAttemptScope, + RuntimeSession, +} from '../kernel/sessions'; +import { createRuntimeSession } from '../kernel/sessions'; +import { trustedDocumentHttpOrigin } from '../shared/origin'; +import type { + CoreActivationContext, + IntegrationCatalogEntry, + IntegrationRegistration, +} from '../kernel/integration_registry'; +import { RELEASE_CATALOG } from '../kernel/release_catalog'; +import { createRuntime, type Runtime, type RuntimeOptions } from '../kernel/runtime'; +import { + createAuctionContextRegistry, + type AuctionContextContributor, + type AuctionContextRegistry, + type ContextContributorOwner, +} from '../services/context'; +import { + createAuctionBatchService, + type AuctionBatchFetcher, + type AuctionBatchService, +} from '../services/auction_batch'; +import { + createPageBidsController, + type PageBidsController, + prepareInitialAuctionProjection, +} from '../services/projections'; +import { createReservationService, type ReservationService } from '../services/reservations'; +import { + bindCommittedArtifactGuard, + bindCommittedArtifactRetirement, + createArtifactHostPositionLeaseRegistry, + createCommittedArtifactStore, + createBootstrapNonceRegistry, + createRenderAttempt, + createRendererNonceRegistry, + createSlotOperation, + renderDirectAdmAttempt, + type RenderAttempt, + type ArtifactHostPositionLeaseRegistry, + type CommittedArtifactStore, + type BootstrapNonceRegistry, + type RendererNonceRegistry, + type SlotOperationCreationResult, +} from '../services/render'; +import { resizeCollapsedPucShell } from '../core/puc_shell'; +import { createPucBridge, type PucBridge, type PucBridgeOptions } from '../services/puc_bridge'; +import { + createBrowserSlotReconciliationBoundary, + createSlotService, + type SlotRecord, + type SlotRegistrationFailure, + type SlotService, +} from '../services/slots'; +import { createTargetingService, type TargetingService } from '../services/targeting'; + +function isEffectivelyVisible(element: Element | null): boolean { + try { + if (!element || !(element instanceof HTMLElement) || !element.isConnected) return false; + const rectangle = element.getBoundingClientRect(); + if (rectangle.width <= 0 || rectangle.height <= 0) return false; + let current: HTMLElement | null = element; + while (current) { + const style = getComputedStyle(current); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + Number.parseFloat(style.opacity || '1') === 0 + ) { + return false; + } + current = current.parentElement; + } + return true; + } catch { + return false; + } +} + +export interface BrowserAdapters { + readonly googletag: GoogletagAdapter; + readonly messaging: MessagingAdapter; + readonly prebid: PrebidAdapter; +} + +export interface BrowserComposition { + readonly adapters: Readonly; +} + +export const BROWSER_TEST_DIAGNOSTICS_PROVIDER_ID = 'browser_test_diagnostics_provider'; +export const BROWSER_TEST_TRACE_PROVIDER_ID = 'browser_test_trace_provider'; +const TRUSTED_BROWSER_TEST_RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + +function installBrowserTestRuntimeScript(runtimeDocument: Document): void { + if (runtimeDocument.currentScript) return; + const script = runtimeDocument.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL(TRUSTED_BROWSER_TEST_RUNTIME_SRC, runtimeDocument.location.origin).href; + runtimeDocument.head.insertBefore(script, null); + Object.defineProperty(runtimeDocument, 'currentScript', { + configurable: true, + value: script, + }); +} + +export interface BrowserServices { + readonly artifacts: CommittedArtifactStore; + readonly hostPositions: ArtifactHostPositionLeaseRegistry; + readonly auctionBatches: AuctionBatchService; + readonly bootstrapNonces: BootstrapNonceRegistry; + readonly pucBridge: PucBridge; + readonly reservations: ReservationService; + readonly rendererNonces: RendererNonceRegistry; + readonly renderDirectAdm: (attempt: RenderAttempt, container: HTMLElement) => boolean; + readonly renderDirectAps: (attempt: RenderAttempt, container: HTMLElement) => boolean; + readonly slots: SlotService; + readonly targeting: TargetingService; +} + +export type BrowserAdapterTarget = GoogletagGlobalTarget & PrebidGlobalTarget & MessageEventTarget; + +export interface BrowserCompositionOptions { + readonly adapters?: Partial; + readonly messagingValidation?: MessagingValidationOptions; + readonly target?: BrowserAdapterTarget; +} + +export interface BrowserRuntimeComposition extends BrowserComposition { + readonly runtime: Runtime; + /** Build the explicit test-only provider for the diagnostics capability chain. */ + readonly createDiagnosticsCapabilityProviderRegistrationForTest: () => IntegrationRegistration; + /** Build the explicit test-only provider for an overlay-only trace capability chain. */ + readonly createTraceCapabilityProviderRegistrationForTest: () => IntegrationRegistration; + /** Return the lazily activated session for tests; this is not a `tsjs` field. */ + readonly runtimeSessionForTest: () => RuntimeSession | undefined; + /** Construct a controller for the current navigation in coordinated-cutover tests. */ + readonly pageBidsControllerForTest: () => PageBidsController | undefined; + /** Return one frozen slot-id inventory for coordinated-cutover tests. */ + readonly projectionSlotsForTest: () => readonly string[] | undefined; + /** Return the lazily activated context registry for coordinated-cutover tests. */ + readonly auctionContextRegistryForTest: () => AuctionContextRegistry | undefined; + /** Return runtime-owned slot operations only in coordinated-cutover tests. */ + readonly slotServiceForTest: () => SlotService | undefined; + /** Return runtime-owned targeting operations only in coordinated-cutover tests. */ + readonly targetingServiceForTest: () => TargetingService | undefined; + /** Return runtime-owned reservation operations only in coordinated-cutover tests. */ + readonly reservationServiceForTest: () => ReservationService | undefined; + /** Return runtime-owned renderer nonces only in coordinated-cutover tests. */ + readonly rendererNonceRegistryForTest: () => RendererNonceRegistry | undefined; + /** Return the single runtime-owned PUC bridge only in coordinated-cutover tests. */ + readonly pucBridgeForTest: () => PucBridge | undefined; + /** Join one candidate GPT attempt through the runtime-owned services in tests. */ + readonly startGptSlotOperationForTest: ( + input: Omit + ) => SlotOperationCreationResult; + /** Publish one candidate server winner through the ordered GPT transaction in tests. */ + readonly publishGptWinnerForTest: ( + input: Omit< + GptWinnerPublicationInput, + | 'createSlotOperation' + | 'googletag' + | 'navigation' + | 'pucBridge' + | 'reservations' + | 'slots' + | 'targeting' + > + ) => Promise; +} + +export interface BrowserCoreActivations { + readonly correctnessGptListeners: ( + context: CoreActivationContext, + adapters: Readonly, + services: Readonly + ) => void; +} + +export interface TestBrowserRuntimeCompositionOptions extends BrowserCompositionOptions { + readonly auctionFetcherForTest?: AuctionBatchFetcher; + readonly coreActivations?: BrowserCoreActivations; + readonly creativeActivationForTest?: (config: Readonly) => () => void; + readonly creativeStartupForTest?: (config: Readonly) => void; + readonly createIdentityIssuerForTest?: NavigationIdentityIssuerFactory; + readonly admittedProgrammaticSlotsForTest?: readonly string[]; + readonly gptStartupForTest?: (config: unknown) => void; + readonly pageBidsFetcherForTest?: PageBidsFetcher; + readonly prebidStartupForTest?: (config: unknown) => void; + readonly pucSchedulerForTest?: PucBridgeOptions['scheduler']; +} + +const TEST_CONFIG_ORDER = Object.freeze([ + 'aps', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'lockr', + 'osano', + 'permutive', + 'prebid', + 'sourcepoint', + 'testlight', +]); + +function testConfigProduct(id: string): string | undefined { + if (id === 'gpt' || id === 'gpt_later') return 'gpt'; + if (id === 'osano_consent' || id === 'osano_lifecycle') return 'osano'; + if (id === 'permutive_context' || id === 'permutive_lifecycle') return 'permutive'; + if (id === 'prebid' || id === 'prebid_later') return 'prebid'; + if (id === 'sourcepoint_consent' || id === 'sourcepoint_lifecycle') return 'sourcepoint'; + return TEST_CONFIG_ORDER.includes(id) ? id : undefined; +} + +function defaultBrowserTestConfig(id: string): Readonly> { + if (id === 'didomi') return { proxyPath: '/integrations/didomi/consent/' }; + if (id === 'gpt') return { gamAttributionEnabled: false, pageBidsEnabled: true }; + if (id === 'prebid') { + return { + accountId: 'test', + timeout: 1_000, + debug: false, + bidders: [], + clientSideBidders: [], + excludedGamAdUnitPathSuffixes: [], + }; + } + if (id === 'sourcepoint') return { rewriteSdk: true }; + return {}; +} + +function testIntegrationConfigs(manifest: unknown): Readonly> { + const integrations = + typeof manifest === 'object' && + manifest !== null && + Array.isArray((manifest as { integrations?: unknown }).integrations) + ? (manifest as { integrations: Array<{ id?: unknown }> }).integrations + : []; + const selected = new Set( + integrations.flatMap(({ id }) => (typeof id === 'string' ? [testConfigProduct(id)] : [])) + ); + return { + version: 1, + entries: TEST_CONFIG_ORDER.filter((id) => selected.has(id)).map((id) => ({ + id, + config: defaultBrowserTestConfig(id), + })), + }; +} + +function capturedBrowserTestRuntimeOptions(options: RuntimeOptions): RuntimeOptions { + try { + if ( + typeof options.boot !== 'object' || + options.boot === null || + Array.isArray(options.boot) || + Object.getPrototypeOf(options.boot) !== Object.prototype + ) { + return options; + } + const fields = options.boot as Readonly>; + const candidate = Object.prototype.hasOwnProperty.call(fields, 'abi') + ? fields + : { + abi: 1, + releaseId: options.releaseId, + manifest: options.manifest, + auctionProjection: fields['auctionProjection'], + integrations: Object.prototype.hasOwnProperty.call(fields, 'integrations') + ? fields['integrations'] + : testIntegrationConfigs(options.manifest), + creative: fields['creative'], + diagnostics: fields['diagnostics'], + }; + const boot = snapshotTsjsBootV1(candidate, options.releaseId); + if (!boot) return options; + const catalog = options.catalog?.map((entry): IntegrationCatalogEntry => { + const canonical = RELEASE_CATALOG.find(({ id }) => id === entry.id); + return Object.freeze({ + ...entry, + config: canonical?.config ?? entry.config ?? null, + }); + }); + return { + ...options, + boot, + manifest: boot.manifest, + ...(catalog === undefined ? {} : { catalog: Object.freeze(catalog) }), + }; + } catch { + return options; + } +} + +interface AcceptedBrowserBoot { + readonly auctionProjection: object; + readonly creative: Readonly; + readonly diagnostics: Readonly; + readonly manifest: Readonly; +} + +interface PreparedBrowserServices { + readonly createAttempt: ( + owner: RenderAttemptScope, + parentAttemptId?: string + ) => ReturnType; + readonly publisherOrigin: string; + readonly renderProjectedFallback: (attempt: RenderAttempt) => boolean; + readonly rendererUrl: string; + readonly services: Readonly>; +} + +interface PageBidsResponse { + readonly ok: boolean; + readonly json: () => Promise; +} + +type PageBidsFetcher = ( + input: string, + init: Readonly<{ + credentials: 'include'; + headers: Readonly<{ 'X-TSJS-Page-Bids': '1' }>; + signal: AbortSignal; + }> +) => PromiseLike; + +interface PageBidsNavigationLifecycle { + readonly activate: () => () => void; + readonly start: () => void; +} + +type GptProjectionPublisher = ( + navigation: NonNullable, + projection: Readonly, + requestClass: string +) => void; + +const noopGptProjectionPublisher: GptProjectionPublisher = () => undefined; + +function resolveProjectedSlotElement( + placement: Readonly +): HTMLElement | undefined { + try { + if (typeof document === 'undefined') return undefined; + const exact = document.getElementById(placement.divId); + if (exact instanceof HTMLElement) return exact; + const prefixMatches = [...document.querySelectorAll('[id]')].filter( + (element) => element.id.startsWith(placement.divId) && !element.id.endsWith('-container') + ); + if (prefixMatches.length === 1) return prefixMatches[0]; + const visible = prefixMatches.filter((element) => isEffectivelyVisible(element)); + if (visible.length === 1) return visible[0]; + const active = visible.filter((element) => { + const bounds = element.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + return active.length === 1 ? active[0] : undefined; + } catch { + return undefined; + } +} + +function currentBrowserPath(): string | undefined { + try { + return `${window.location.pathname}${window.location.search}`; + } catch { + return undefined; + } +} + +function restoreHistoryMethod( + name: 'pushState' | 'replaceState', + previous: PropertyDescriptor | undefined, + installed: History['pushState'] +): void { + try { + const current = Object.getOwnPropertyDescriptor(window.history, name); + if (!current || !('value' in current) || current.value !== installed) return; + if (previous) Object.defineProperty(window.history, name, previous); + else Reflect.deleteProperty(window.history, name); + } catch { + // A publisher replacement remains authoritative; the disposed wrapper is inert. + } +} + +/** Own the canonical page-bids fetch and one replacement session per SPA navigation. */ +function createPageBidsNavigationLifecycle(options: { + readonly fetcher?: PageBidsFetcher; + readonly onProjectionCommitted?: ( + navigation: NonNullable, + projection: Readonly + ) => void; + readonly runtimeSession: () => RuntimeSession | undefined; + readonly services: () => Readonly | undefined; + readonly projectionParser: () => ((candidate: unknown) => object | undefined) | undefined; +}): PageBidsNavigationLifecycle { + let active = false; + let disposed = false; + let started = false; + let appliedPath: string | undefined; + let currentPath: string | undefined; + let release: (() => void) | undefined; + + const rollBackPath = ( + path: string, + navigation?: NonNullable + ): void => { + if (currentPath !== path || (navigation && !navigation.isCurrent())) return; + currentPath = appliedPath; + }; + + const requestProjection = async (path: string): Promise => { + const session = options.runtimeSession(); + const replacement = session?.replaceNavigation(); + if (!replacement?.ok) { + rollBackPath(path); + return; + } + const navigation = replacement.value; + const services = options.services(); + const parseProjection = options.projectionParser(); + if (!services || !parseProjection) { + rollBackPath(path, navigation); + return; + } + const controller = createPageBidsController({ + navigation, + parseProjection, + slotRegistry: services.slots.projectionRegistry(navigation), + }); + const fetcher = options.fetcher ?? globalThis.fetch; + if (typeof fetcher !== 'function') { + rollBackPath(path, navigation); + return; + } + let committed = false; + try { + const response = await fetcher(`/_ts/page-bids?path=${encodeURIComponent(path)}`, { + credentials: 'include', + headers: { 'X-TSJS-Page-Bids': '1' }, + signal: navigation.signal, + }); + if (!navigation.isCurrent()) return; + if (!response.ok) { + rollBackPath(path, navigation); + return; + } + const candidate = await response.json(); + if (!navigation.isCurrent()) return; + const result = controller.commit(candidate); + if (result.status === 'committed') { + committed = true; + appliedPath = path; + const projection = navigation.currentAuctionProjection; + if (projection) options.onProjectionCommitted?.(navigation, projection); + } + if (result.status === 'rejected' && result.reason !== 'stale') { + rollBackPath(path, navigation); + log.warn('page-bids: rejected navigation projection', result.reason); + } + } catch (error) { + if (!navigation.signal.aborted) { + if (!committed) rollBackPath(path, navigation); + log.warn('page-bids: projection request failed', error); + } + } + }; + + const navigateIfChanged = (): void => { + if (!active || !started || disposed) return; + const path = currentBrowserPath(); + if (path === undefined || path === currentPath) return; + currentPath = path; + void requestProjection(path); + }; + + return Object.freeze({ + activate: (): (() => void) => { + if (active || disposed) throw new Error('Page-bids navigation owner is unavailable'); + const history = window.history; + const previousPushState = Object.getOwnPropertyDescriptor(history, 'pushState'); + const previousReplaceState = Object.getOwnPropertyDescriptor(history, 'replaceState'); + const pushState = history.pushState; + const replaceState = history.replaceState; + const wrap = (original: History['pushState']): History['pushState'] => + function wrappedHistoryState( + this: History, + data: unknown, + unused: string, + url?: string | URL | null + ): void { + Reflect.apply(original, this, [data, unused, url]); + navigateIfChanged(); + }; + const wrappedPushState = wrap(pushState); + const wrappedReplaceState = wrap(replaceState); + const onPopState = (): void => navigateIfChanged(); + try { + Object.defineProperty(history, 'pushState', { + configurable: true, + enumerable: previousPushState?.enumerable ?? false, + value: wrappedPushState, + writable: true, + }); + Object.defineProperty(history, 'replaceState', { + configurable: true, + enumerable: previousReplaceState?.enumerable ?? false, + value: wrappedReplaceState, + writable: true, + }); + window.addEventListener('popstate', onPopState); + active = true; + } catch (error) { + restoreHistoryMethod('replaceState', previousReplaceState, wrappedReplaceState); + restoreHistoryMethod('pushState', previousPushState, wrappedPushState); + throw error; + } + let released = false; + release = (): void => { + if (released) return; + released = true; + disposed = true; + active = false; + window.removeEventListener('popstate', onPopState); + restoreHistoryMethod('replaceState', previousReplaceState, wrappedReplaceState); + restoreHistoryMethod('pushState', previousPushState, wrappedPushState); + }; + return release; + }, + start: (): void => { + if (!active || disposed) return; + currentPath = currentBrowserPath(); + appliedPath = currentPath; + started = true; + }, + }); +} + +interface ComposedPrebidRefreshConfig { + readonly clientSideBidders: readonly string[]; + readonly excludedGamAdUnitPathSuffixes: readonly string[]; +} + +const EMPTY_PREBID_REFRESH_CONFIG: ComposedPrebidRefreshConfig = Object.freeze({ + clientSideBidders: Object.freeze([]), + excludedGamAdUnitPathSuffixes: Object.freeze([]), +}); + +function composedPrebidRefreshConfig(candidate: unknown): ComposedPrebidRefreshConfig { + try { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) { + return EMPTY_PREBID_REFRESH_CONFIG; + } + const strings = (name: string): readonly string[] => { + const descriptor = Object.getOwnPropertyDescriptor(candidate, name); + if (!descriptor || !('value' in descriptor) || !Array.isArray(descriptor.value)) { + return Object.freeze([]); + } + const values: string[] = []; + for (let index = 0; index < descriptor.value.length; index += 1) { + const value = descriptor.value[index]; + if (typeof value !== 'string') return Object.freeze([]); + values.push(value); + } + return Object.freeze(values); + }; + return Object.freeze({ + clientSideBidders: strings('clientSideBidders'), + excludedGamAdUnitPathSuffixes: strings('excludedGamAdUnitPathSuffixes'), + }); + } catch { + return EMPTY_PREBID_REFRESH_CONFIG; + } +} + +function composedPrebidRefreshAuction( + physicalSlots: readonly object[], + navigation: RuntimeSession['currentNavigation'], + slots: SlotService, + config: ComposedPrebidRefreshConfig +): unknown { + if (!navigation?.isCurrent()) return undefined; + const records = slots.snapshotRegisteredSlots(navigation); + if (!records) return undefined; + const resolved = new Map>(); + for (let slotIndex = 0; slotIndex < physicalSlots.length; slotIndex += 1) { + const physicalSlot = physicalSlots[slotIndex]; + if (!physicalSlot) return undefined; + let matched: SlotRecord | undefined; + for (let recordIndex = 0; recordIndex < records.length; recordIndex += 1) { + const record = records[recordIndex]; + if ( + !record || + !slots.isBoundGptSlot(navigation.generation, record.registeredSlotId, physicalSlot) + ) { + continue; + } + if (matched) return undefined; + matched = record; + } + const source = matched?.directAuctionUnit; + if (!source || !Object.isFrozen(source)) { + return undefined; + } + resolved.set(physicalSlot, source); + } + return preparePrebidRegisteredRefreshAuction({ + clientSideBidders: config.clientSideBidders, + resolveAdUnit: (slot) => resolved.get(slot), + slots: physicalSlots, + }); +} + +function registerScopedContextContributor( + registry: AuctionContextRegistry, + runtimeOwner: RuntimeSession, + integrationId: string, + contributor: AuctionContextContributor +): (() => void) | undefined { + let active = true; + let releaseRegistration: (() => void) | undefined; + const owner: ContextContributorOwner = Object.freeze({ + generation: Object.freeze({}), + isCurrent: () => active && runtimeOwner.isCurrent(), + onDispose: (kind: string, callback: () => void) => { + if (kind !== 'auction-context-contributor' || !active || releaseRegistration) { + throw new Error('Auction context contributor disposer is unavailable'); + } + releaseRegistration = callback; + }, + }); + if (!registry.register(integrationId, contributor, owner)) { + active = false; + releaseRegistration?.(); + return undefined; + } + return (): void => { + if (!active) return; + active = false; + const release = releaseRegistration; + releaseRegistration = undefined; + release?.(); + }; +} + +/** + * Construct concrete browser dependencies in one place. + * + * Task 6 keeps this test-only composition disconnected from the shipped core; + * the coordinated production switch occurs only after the runtime is complete. + */ +export function createBrowserComposition( + options: BrowserCompositionOptions = {} +): BrowserComposition { + const defaultValidator = (candidate: unknown): boolean => + validateApsRenderer(candidate) !== undefined; + const browserMessagingValidation = (): MessagingValidationOptions => { + try { + const expectedPublisherOrigin = window.location.origin; + return { + expectedPublisherOrigin, + expectedRendererUrl: new URL('/integrations/aps/renderer/v2', expectedPublisherOrigin).href, + validateApsRenderer: defaultValidator, + ...options.messagingValidation, + }; + } catch { + return { validateApsRenderer: defaultValidator, ...options.messagingValidation }; + } + }; + const googletag = + options.adapters?.googletag ?? + (options.target + ? createBrowserGoogletagAdapter(options.target, { + reportDiagnosticsFailure: (code) => + log.warn('GPT diagnostics identity unavailable', code), + }) + : createBrowserGoogletagAdapter(undefined, { + reportDiagnosticsFailure: (code) => + log.warn('GPT diagnostics identity unavailable', code), + })); + const messaging = + options.adapters?.messaging ?? + (options.target + ? createBrowserMessagingAdapter(options.target, { + validateApsRenderer: defaultValidator, + ...options.messagingValidation, + }) + : createBrowserMessagingAdapter(undefined, browserMessagingValidation())); + const prebid = + options.adapters?.prebid ?? + (options.target ? createBrowserPrebidAdapter(options.target) : createBrowserPrebidAdapter()); + + return Object.freeze({ + adapters: Object.freeze({ googletag, messaging, prebid }), + }); +} + +/** Construct a side-effect-free dependency set for kernel and service tests. */ +export function createNoopBrowserComposition(): BrowserComposition { + return Object.freeze({ + adapters: Object.freeze({ + googletag: createNoopGoogletagAdapter(), + messaging: createNoopMessagingAdapter(), + prebid: createNoopPrebidAdapter(), + }), + }); +} + +/** + * Construct the sole browser runtime composition without claiming a global. + * + * The core entry point owns the one production claim; tests may construct the + * same composition against explicit targets and adapters. + */ +export function createTestBrowserRuntimeComposition( + providedRuntimeOptions: RuntimeOptions, + compositionOptions: TestBrowserRuntimeCompositionOptions +): BrowserRuntimeComposition { + const runtimeOptions = capturedBrowserTestRuntimeOptions(providedRuntimeOptions); + const runtimeDocument = + runtimeOptions.document ?? (typeof document === 'undefined' ? undefined : document); + if (runtimeDocument) installBrowserTestRuntimeScript(runtimeDocument); + const composition = createBrowserComposition(compositionOptions); + const providedBindings = runtimeOptions.getBindings; + let browserServices: Readonly | undefined; + let gptProjectionPublisher = noopGptProjectionPublisher; + let projectionParser: ((candidate: unknown) => object | undefined) | undefined; + let runtimeSession: RuntimeSession | undefined; + let creativeBoot: Readonly | undefined; + let diagnosticsBoot: Readonly | undefined; + let diagnosticsIngress: DiagnosticsIngress | undefined; + let gptDiagnosticsFacts: GptDiagnosticsFactBuffer | undefined; + let renderTrace: RenderTraceRuntimeOwner | undefined; + const renderTraceSlotsByNavigation = new Map>(); + const consumeCoreObservation = (observation: DiagnosticsObservation): void => { + if ( + observation['kind'] === 'slotRequested' || + observation['kind'] === 'slotResponseReceived' || + observation['kind'] === 'slotRenderEnded' || + observation['kind'] === 'slotOnload' || + observation['kind'] === 'impressionViewable' || + observation['kind'] === 'slotVisibilityChanged' + ) { + try { + renderTrace?.observeGptFact( + observation as unknown as Readonly, + (elementId) => { + if (typeof elementId !== 'string' || elementId === '') return undefined; + const slots = browserServices?.slots; + const slot = + slots?.resolveDomAlias(elementId) ?? slots?.resolveRegisteredSlot(elementId); + if (!slot?.traceToken) return undefined; + let element: HTMLElement | undefined; + if (typeof document !== 'undefined') { + const matches = [...document.querySelectorAll('[id]')].filter( + (candidate) => candidate.id === elementId + ); + if (matches.length === 1) element = matches[0]; + } + return Object.freeze({ + slotId: slot.registeredSlotId, + navigationGeneration: slot.navigationGeneration, + traceToken: slot.traceToken, + ...(element === undefined + ? {} + : { elementId: element.id, visible: isEffectivelyVisible(element) }), + }); + } + ); + } catch { + // Render tracing never affects an already-committed adapter observation. + } + return; + } + if ( + observation['kind'] !== 'render_attempt' || + typeof observation['slotId'] !== 'string' || + (observation['path'] !== 'auction' && observation['path'] !== 'ssat') || + typeof observation['rendered'] !== 'boolean' || + typeof observation['injected'] !== 'boolean' + ) { + return; + } + const state = observation['state']; + const terminal = observation['outcome']; + const terminalRecord = + typeof terminal === 'object' && terminal !== null + ? (terminal as Readonly>) + : undefined; + const attributableEmpty = + state === 'failed' && + terminalRecord?.['outcome'] === 'failed' && + terminalRecord['reason'] === 'gam_empty'; + if (state !== 'accepted' && !attributableEmpty) return; + if ((state === 'accepted') !== observation['rendered']) return; + const servedFrom = observation['servedFrom']; + if (servedFrom !== undefined && servedFrom !== 'inline' && servedFrom !== 'pbs-cache') return; + try { + const slotId = observation['slotId']; + const slot = browserServices?.slots.resolveRegisteredSlot(slotId); + const identifiers = slot + ? new Set([slot.registeredSlotId, ...slot.domAliases]) + : new Set([slotId]); + const elements = new Set(); + if (typeof document !== 'undefined') { + for (const identifier of identifiers) { + const element = document.getElementById(identifier); + if (element instanceof HTMLElement) elements.add(element); + } + } + const element = elements.size === 1 ? [...elements][0] : undefined; + const optionalString = (name: 'adId' | 'bidId' | 'creativeId'): string | undefined => { + const value = observation[name]; + return typeof value === 'string' && value !== '' ? value : undefined; + }; + const adId = optionalString('adId'); + const bidId = optionalString('bidId'); + const creativeId = optionalString('creativeId'); + const navigation = runtimeSession?.currentNavigation; + if (navigation?.isCurrent()) { + const tracedSlots = renderTraceSlotsByNavigation.get(navigation.generation) ?? new Set(); + tracedSlots.add(slotId); + renderTraceSlotsByNavigation.set(navigation.generation, tracedSlots); + } + renderTrace?.record({ + slotId, + path: observation['path'], + rendered: observation['rendered'], + injected: observation['injected'], + ...(element === undefined + ? {} + : { elementId: element.id, visible: isEffectivelyVisible(element) }), + ...(adId === undefined ? {} : { adId }), + ...(bidId === undefined ? {} : { bidId }), + ...(creativeId === undefined ? {} : { creativeId }), + ...(servedFrom === undefined ? {} : { servedFrom }), + }); + } catch { + // Render diagnostics never affect the already-committed attempt. + } + }; + const diagnosticsForPublish = (): Readonly => { + const trace = renderTrace; + if (!trace) throw new Error('Render diagnostics are unavailable'); + return Object.freeze({ renderTrace: trace.diagnostics }); + }; + const defaultCreativeRuntime = + typeof document === 'undefined' + ? Object.freeze({ + activate: (_config: Readonly) => () => undefined, + start: (_config: Readonly) => undefined, + }) + : createCreativeStartup({ + document, + installClickGuard: () => installClickGuard(false), + installDynamicIframeProxy: () => installDynamicIframeProxy(false), + installDynamicImageProxy: () => installDynamicImageProxy(false), + }); + const creativeRuntime = Object.freeze({ + activate: compositionOptions.creativeActivationForTest ?? defaultCreativeRuntime.activate, + start: compositionOptions.creativeStartupForTest ?? defaultCreativeRuntime.start, + }); + const startGpt = compositionOptions.gptStartupForTest ?? (() => undefined); + const pageBidsNavigation = createPageBidsNavigationLifecycle({ + ...(compositionOptions.pageBidsFetcherForTest + ? { fetcher: compositionOptions.pageBidsFetcherForTest } + : {}), + onProjectionCommitted: (navigation, projection) => + gptProjectionPublisher( + navigation, + projection as Readonly, + 'page-bids' + ), + projectionParser: () => projectionParser, + runtimeSession: () => runtimeSession, + services: () => browserServices, + }); + const gptRuntime = createGptStartup({ + googletag: composition.adapters.googletag, + slots: () => { + const slots = browserServices?.slots; + if (!slots) throw new Error('GPT slot service is unavailable'); + return slots; + }, + start: startGpt, + }); + const gptIntegrationRuntime = Object.freeze({ + activate: (): (() => void) => { + const releaseGpt = gptRuntime.activate(); + let releaseNavigation: (() => void) | undefined; + try { + releaseNavigation = pageBidsNavigation.activate(); + } catch (error) { + releaseGpt(); + throw error; + } + return (): void => { + releaseNavigation?.(); + releaseGpt(); + }; + }, + start: (config: unknown): void => { + gptRuntime.start(config); + pageBidsNavigation.start(); + const navigation = runtimeSession?.currentNavigation; + const projection = navigation?.currentAuctionProjection; + if (navigation && projection) { + gptProjectionPublisher( + navigation, + projection as Readonly, + 'initial' + ); + } + }, + }); + let prebidCoordinator: PrebidSelectionCoordinator | undefined; + let prebidRefreshConfig = EMPTY_PREBID_REFRESH_CONFIG; + const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); + const prebidRefreshRunner = createPrebidSyntheticRefreshRunner({ + prebid: composition.adapters.prebid, + prepareAuction: (slots, navigation) => { + const slotService = browserServices?.slots; + if (!slotService) return undefined; + return composedPrebidRefreshAuction(slots, navigation, slotService, prebidRefreshConfig); + }, + }); + const prebidRefreshPolicy = createPrebidRefreshPolicy({ + currentNavigation: () => runtimeSession?.currentNavigation, + excludedGamAdUnitPathSuffixes: () => prebidRefreshConfig.excludedGamAdUnitPathSuffixes, + googletag: composition.adapters.googletag, + runSyntheticAuction: prebidRefreshRunner, + }); + const completePrebidAuction = (auction: Readonly): void => { + try { + auction.complete(); + } catch { + // The private bidder completion boundary cannot escape into publisher code. + } + }; + const publishPrebidAuction = (auction: Readonly): void => { + const navigation = runtimeSession?.currentNavigation; + const reservations = browserServices?.reservations; + const coordinator = prebidCoordinator; + if (!navigation || !reservations || !coordinator || !navigation.isCurrent()) { + completePrebidAuction(auction); + return; + } + try { + const projection = navigation.currentAuctionProjection as + Readonly | undefined; + if (!projection || projection.auction.auctionId !== auction.auctionId) return; + for (let index = 0; index < auction.bids.length; index += 1) { + const request = auction.bids[index]; + if (!request) continue; + const winners = projection.auction.results.filter( + (result) => result.slot === request.adUnitCode && result.outcome === 'winner' + ); + if (winners.length !== 1) continue; + const winner = winners[0]; + if (!winner || winner.outcome !== 'winner') continue; + const bids = projection.bids.filter( + (bid) => bid.slot === request.adUnitCode && bid.candidateId === winner.candidateId + ); + if (bids.length !== 1) continue; + const bid = bids[0]; + if (!bid) continue; + const publication = publishPrebidBid({ + admitTrustedBid: (preparedBid) => + composition.adapters.prebid.admitTrustedBid(preparedBid), + auctionId: auction.auctionId, + adUnitCode: request.adUnitCode, + bid, + generatedBid: Object.freeze({ + requestId: request.requestId, + adId: request.requestId, + cpm: bid.cpm, + width: bid.renderSource.width, + height: bid.renderSource.height, + }), + navigation, + reservations, + trackAdmittedBid: coordinator.track, + }); + if ( + !publication.ok && + (publication.reason === 'prebid_admission_failed' || + publication.reason === 'prebid_contract_violation') + ) { + coordinator.settlePublicationFailure( + navigation, + auction.auctionId, + request.adUnitCode, + publication.reason + ); + } + } + } catch { + // Invalid/stale projection state publishes no Prebid bid. + } finally { + completePrebidAuction(auction); + } + }; + const prebidRuntime = createPrebidStartup({ + dispose: () => { + prebidCoordinator?.dispose(); + prebidCoordinator = undefined; + }, + onAuction: publishPrebidAuction, + onAuctionEnd: (event, prebid) => prebidCoordinator?.auctionEnded(event, prebid), + prebid: composition.adapters.prebid, + refresh: Object.freeze({ + configure: (config: unknown): void => { + prebidRefreshConfig = composedPrebidRefreshConfig(config); + }, + install: gptRuntime.installRefreshPolicy, + policy: prebidRefreshPolicy, + }), + start: startPrebid, + }); + const getBindings: NonNullable = (id) => { + const provided = providedBindings?.(id); + let config: unknown; + if (provided !== undefined) { + const descriptor = Object.getOwnPropertyDescriptor(provided, 'config'); + if (!descriptor || !('value' in descriptor)) return provided; + config = descriptor.value; + } + if (id === 'creative' && config === undefined) config = creativeBoot; + if (id === 'gpt_diagnostics' && config === undefined) config = diagnosticsBoot?.gpt; + const interfaces = runtimeSession?.interfaces; + if (!interfaces) throw new Error(`Integration interfaces are unavailable for ${id}`); + return Object.freeze({ + config, + interfaces, + }); + }; + let preparedBrowserServices: PreparedBrowserServices | undefined; + let auctionContextRegistry: AuctionContextRegistry | undefined; + const dataDomeRuntime = createDataDomeRuntime(); + const didomiRuntime = createDidomiRuntime(); + const googleTagManagerRuntime = createGoogleTagManagerRuntime(); + const lockrRuntime = createLockrRuntime(); + const osanoConsentRuntime = createOsanoRuntime(); + const osanoLifecycleRuntime = createOsanoRuntime(); + const permutiveContextRuntime = createPermutiveRuntime({ + registerContext: (contributor) => { + const registry = auctionContextRegistry; + const owner = runtimeSession; + return registry && owner + ? registerScopedContextContributor(registry, owner, 'permutive_context', contributor) + : undefined; + }, + }); + const permutiveLifecycleRuntime = createPermutiveRuntime({ + registerContext: () => undefined, + }); + const sourcepointConsentRuntime = createSourcepointRuntime(); + const sourcepointLifecycleRuntime = createSourcepointRuntime(); + const testlightRuntime = createTestlightRuntime({ + enqueue: (callback) => { + const queue = (runtimeOptions.target as { readonly que?: unknown }).que; + if (!Array.isArray(queue) || typeof queue.push !== 'function') { + throw new Error('Testlight TSJS queue is unavailable'); + } + queue.push(callback); + }, + started: () => log.info('Testlight integration initialized'), + target: window as typeof window & { testlight?: { que?: unknown[] } }, + }); + let auctionBatchService: AuctionBatchService | undefined; + const publishProjectionThroughGpt = async ( + navigation: NonNullable, + projection: Readonly, + requestClass: string + ): Promise => { + const prepared = preparedBrowserServices; + const services = browserServices; + if (!prepared || !services || !navigation.isCurrent() || projection.slots.length === 0) return; + const physicalBySlot = new Map< + string, + Readonly<{ operation: 'display' | 'refresh'; slot: object }> + >(); + const operation = composition.adapters.googletag.run( + (gpt) => { + for (let index = 0; index < projection.slots.length; index += 1) { + const placement = projection.slots[index]; + if (!placement || !navigation.isCurrent()) break; + const element = resolveProjectedSlotElement(placement); + if (!element) continue; + const definition = Object.freeze({ + adUnitPath: placement.gamUnitPath, + elementId: element.id, + sizes: placement.formats, + }); + const existing = gpt.slots().filter((slot) => gpt.slotElementId?.(slot) === element.id); + if (existing.length > 1) continue; + const publisherSlot = existing[0]; + if (publisherSlot) { + const adopted = services.slots.adoptGptSlot(navigation.generation, placement.slot, { + definition, + elementIdPrefix: placement.divId, + ownership: 'publisher', + slot: publisherSlot, + }); + if (adopted.ok) { + physicalBySlot.set( + placement.slot, + Object.freeze({ operation: 'refresh', slot: publisherSlot }) + ); + } + continue; + } + const defined = gpt.transactionalDefine( + definition, + () => navigation.isCurrent(), + (candidate) => { + let committed = false; + return Object.freeze({ + commit: (): boolean => { + const adopted = services.slots.adoptGptSlot( + navigation.generation, + placement.slot, + { + definition, + elementIdPrefix: placement.divId, + ownership: 'trusted_server', + slot: candidate, + } + ); + committed = adopted.ok; + return committed; + }, + rollback: (): void => { + if (!committed) return; + committed = false; + services.slots.recordPublisherDestruction(candidate); + }, + }); + } + ); + if (defined.status === 'defined') { + physicalBySlot.set( + placement.slot, + Object.freeze({ operation: 'display', slot: defined.slot }) + ); + } + } + }, + { signal: navigation.signal } + ); + try { + await operation.result; + } catch (error) { + if (!navigation.signal.aborted) log.warn('GPT projection: slot binding failed', error); + } + if (!navigation.isCurrent()) return; + const batch = navigation.createAuctionBatch(`gpt:${projection.auction.auctionId}`); + if (!batch) return; + let winnerIndex = 0; + for (let index = 0; index < projection.auction.results.length; index += 1) { + const decision = projection.auction.results[index]; + const placement = projection.slots[index]; + if (!decision || !placement || decision.outcome !== 'winner') continue; + const bid = projection.bids[winnerIndex]; + winnerIndex += 1; + if (!bid || !navigation.isCurrent()) continue; + if (!('rendererReservationId' in bid)) continue; + const owner = batch.createRenderAttempt(decision.slot); + if (!owner.ok) continue; + const created = prepared.createAttempt(owner.value); + if (!created.ok) continue; + const binding = physicalBySlot.get(decision.slot); + if (!binding) { + created.value.fail('slot_unresolved'); + continue; + } + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: created.value.id, + slot: created.value.slot, + navigationGeneration: created.value.navigationGeneration, + dispose: () => undefined, + }); + const published = await publishGptWinner({ + artifact, + attempt: created.value, + bid, + createSlotOperation, + googletag: composition.adapters.googletag, + navigation, + operation: binding.operation, + owner: owner.value, + placement, + pucBridge: services.pucBridge, + requestClass, + reservations: services.reservations, + slot: binding.slot, + slots: services.slots, + targeting: services.targeting, + createFallback: (parentAttemptId) => { + const fallbackOwner = batch.createRenderAttempt(decision.slot); + if (!fallbackOwner.ok) { + return Object.freeze({ + ok: false as const, + reason: + fallbackOwner.reason === 'identity_generation_failed' + ? ('identity_generation_failed' as const) + : fallbackOwner.reason === 'stale_owner' + ? ('stale_owner' as const) + : ('invalid_attempt' as const), + }); + } + const fallback = prepared.createAttempt(fallbackOwner.value, parentAttemptId); + if (!fallback.ok) return fallback; + if ( + !fallback.value.admitDirectWinner( + bid.renderSource, + Object.freeze({ selectedCpm: bid.cpm }) + ) + ) { + fallback.value.fail('winner_not_renderable'); + return fallback; + } + if (!prepared.renderProjectedFallback(fallback.value)) { + fallback.value.fail('winner_not_renderable'); + } + return fallback; + }, + }); + if (!published.ok && navigation.isCurrent()) { + log.warn('GPT projection: winner publication failed', published.reason); + } + } + }; + const frozenSlotResult = (result: Record): Readonly> => + Object.freeze(result); + const combineRequestResults = ( + requestedSlots: readonly string[], + records: readonly (SlotRecord | undefined)[], + validResults: readonly Readonly>[] + ): Readonly<{ slots: readonly Readonly>[] }> => { + let validIndex = 0; + return Object.freeze({ + slots: Object.freeze( + requestedSlots.map((slot, index) => { + if (!records[index]) { + return frozenSlotResult({ + slot, + path: 'primary', + outcome: 'failed', + reason: 'slot_unresolved', + }); + } + const result = validResults[validIndex]; + validIndex += 1; + return ( + result ?? + frozenSlotResult({ + slot, + path: 'primary', + outcome: 'failed', + reason: 'internal_error', + }) + ); + }) + ), + }); + }; + const registrationError = (reason: SlotRegistrationFailure): AdUnitRegistrationError => { + switch (reason) { + case 'invalid_slot_id': + return new AdUnitRegistrationError('invalid_code'); + case 'registry_capacity': + return new AdUnitRegistrationError('registry_capacity'); + case 'duplicate_slot': + case 'slot_quarantined': + case 'stale_owner': + return new AdUnitRegistrationError('slot_collision'); + } + }; + const addProgrammaticAdUnits = (candidate: unknown): unknown => { + const navigation = runtimeSession?.currentNavigation; + const slots = browserServices?.slots; + if (!navigation || !slots) throw new AdUnitRegistrationError('slot_collision'); + let snapshot: readonly SlotRecord[] | undefined; + try { + snapshot = slots.snapshotRegisteredSlots(navigation); + } catch { + throw new AdUnitRegistrationError('slot_collision'); + } + if (!snapshot) throw new AdUnitRegistrationError('slot_collision'); + const knownSlots = new Set(snapshot.map(({ registeredSlotId }) => registeredSlotId)); + const prepared = prepareProgrammaticAdUnits(candidate, knownSlots); + let registered: ReturnType; + try { + registered = slots.register( + navigation, + prepared.map((unit) => ({ + directAuctionUnit: unit, + registeredSlotId: unit.code, + source: 'programmatic' as const, + })) + ); + } catch { + throw new AdUnitRegistrationError('slot_collision'); + } + if (!registered.ok) throw registrationError(registered.reason); + return addAdUnitsResult(prepared); + }; + const requestDirectAds = (candidate?: unknown): Promise => { + let validated: ReturnType; + try { + validated = validateRequestAdsOptions(candidate); + } catch (error) { + return Promise.reject(error); + } + const navigation = runtimeSession?.currentNavigation; + const slots = browserServices?.slots; + const snapshot = navigation && slots?.snapshotRegisteredSlots(navigation); + if (!navigation || !slots || !snapshot) { + const requested = validated.slots ?? Object.freeze([]); + return Promise.resolve( + Object.freeze({ + slots: Object.freeze( + requested.map((slot) => + frozenSlotResult({ + slot, + path: 'primary', + outcome: 'cancelled', + reason: 'navigation_disposed', + }) + ) + ), + }) + ); + } + + const recordsById = new Map(snapshot.map((record) => [record.registeredSlotId, record])); + const requestedSlots = Object.freeze( + validated.slots + ? Array.from(validated.slots) + : snapshot.map(({ registeredSlotId }) => registeredSlotId) + ); + const selectedRecords = Object.freeze(requestedSlots.map((slot) => recordsById.get(slot))); + const validRecords = selectedRecords.filter( + (record): record is SlotRecord => record !== undefined + ); + if (validRecords.length === 0) { + return Promise.resolve(combineRequestResults(requestedSlots, selectedRecords, [])); + } + + const context = auctionContextRegistry?.snapshot() ?? Object.freeze({}); + const adUnits = validRecords.map((record) => + record.directAuctionUnit + ? record.directAuctionUnit + : Object.freeze({ + code: record.registeredSlotId, + mediaTypes: Object.freeze({}), + bids: Object.freeze([]), + }) + ); + let requestBody: string; + try { + const serialized = serializeAuctionRequestBody(adUnits, context); + if (!serialized) throw new Error('auction request body exceeds limit'); + requestBody = serialized; + } catch { + return Promise.resolve( + combineRequestResults( + requestedSlots, + selectedRecords, + validRecords.map((record) => + frozenSlotResult({ + slot: record.registeredSlotId, + path: 'primary', + outcome: 'failed', + reason: 'internal_error', + }) + ) + ) + ); + } + const batches = auctionBatchService; + if (!batches) { + return Promise.resolve( + combineRequestResults( + requestedSlots, + selectedRecords, + validRecords.map((record) => + frozenSlotResult({ + slot: record.registeredSlotId, + path: 'primary', + outcome: 'cancelled', + reason: 'navigation_disposed', + }) + ) + ) + ); + } + const batch = batches.create({ + navigation, + requestBody, + ...(validated.signal ? { signal: validated.signal } : {}), + slots: Object.freeze(validRecords.map(({ registeredSlotId }) => registeredSlotId)), + timeoutMs: validated.timeoutMs, + }); + return batch.result.then((result) => + combineRequestResults(requestedSlots, selectedRecords, result.slots) + ); + }; + const runtimeOwner = createRuntime({ + ...runtimeOptions, + getBindings, + getDiagnosticsForPublish: diagnosticsForPublish, + kernel: { + addAdUnits: addProgrammaticAdUnits, + diagnostics: runtimeOptions.kernel.diagnostics, + requestAds: requestDirectAds, + }, + prepareOwner: (context) => { + const boot = context.boot as unknown as AcceptedBrowserBoot; + creativeBoot = boot.creative; + diagnosticsBoot = boot.diagnostics; + const parseProjection = (candidate: unknown): object | undefined => + parseBrowserAuctionProjectionV1(candidate); + const initialProjection = prepareInitialAuctionProjection( + boot.auctionProjection, + parseProjection + ); + if (!initialProjection) throw new Error('Accepted boot projection is unavailable'); + const preparedRenderTrace = createRenderTraceStore({ + onPresentationError: (error) => log.warn('render diagnostics: presentation failed', error), + onSubscriberError: (error) => log.warn('render diagnostics: subscriber failed', error), + }); + const preparedDiagnosticsIngress = createDiagnosticsIngress({ + reduce: consumeCoreObservation, + reportError: (error) => log.warn('diagnostics ingress: reducer failed', error), + }); + renderTrace = preparedRenderTrace; + diagnosticsIngress = preparedDiagnosticsIngress; + const preparedGptDiagnosticsFacts = boot.diagnostics.gpt.active + ? createGptDiagnosticsFactBuffer({ + onConsumerError: (error) => log.warn('gpt diagnostics: fact consumer failed', error), + }) + : undefined; + gptDiagnosticsFacts = preparedGptDiagnosticsFacts; + context.onDispose(() => { + preparedGptDiagnosticsFacts?.dispose(); + preparedDiagnosticsIngress.dispose(); + preparedRenderTrace.dispose(); + if (gptDiagnosticsFacts === preparedGptDiagnosticsFacts) { + gptDiagnosticsFacts = undefined; + } + if (diagnosticsIngress === preparedDiagnosticsIngress) diagnosticsIngress = undefined; + if (renderTrace === preparedRenderTrace) renderTrace = undefined; + }); + const reconciliation = + typeof document === 'undefined' || typeof MutationObserver === 'undefined' + ? undefined + : createBrowserSlotReconciliationBoundary(document, MutationObserver); + const artifacts = createCommittedArtifactStore(); + const hostPositions = createArtifactHostPositionLeaseRegistry(); + const slotService = createSlotService({ + bindCommittedArtifactRetirement, + disposeCommittedArtifact: (navigationGeneration, registeredSlotId, expectedArtifact) => { + const artifact = artifacts.current(registeredSlotId); + if ( + artifact === expectedArtifact && + artifact.navigationGeneration === navigationGeneration + ) { + artifacts.release(artifact); + } + }, + googletag: composition.adapters.googletag, + ...(reconciliation ? { reconciliation } : {}), + warnPublisherHandoffMismatch: (message, details) => log.warn(message, details), + }); + const targetingService = createTargetingService(); + const reservationService = createReservationService({ + prepareRenderSource: (candidate) => { + const source = parseBidRenderSourceV1(candidate); + return source?.type === 'pbs_cache' ? undefined : source; + }, + }); + const bootstrapNonces = createBootstrapNonceRegistry(); + const rendererNonces = createRendererNonceRegistry(); + // A real document origin is authoritative. Only an opaque srcdoc may + // fall back to the server-stamped base; publisher script must not be able + // to redirect the APS endpoint by predefining that creative-only stamp. + const publisherOrigin = trustedDocumentHttpOrigin(window.location.origin); + if (!publisherOrigin) throw new Error('Trusted publisher origin is unavailable'); + const rendererUrl = new URL(APS_RENDERER_V2_PATH, publisherOrigin).href; + const renderDirectAdm = Object.freeze( + (attempt: RenderAttempt, container: HTMLElement): boolean => { + try { + return renderDirectAdmAttempt({ + attempt, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin, + }); + } catch { + return false; + } + } + ); + const renderDirectAps = Object.freeze( + (attempt: RenderAttempt, container: HTMLElement): boolean => { + try { + return renderDirectApsAttempt({ + attempt, + bindArtifactGuard: bindCommittedArtifactGuard, + bootstrapNonces, + container, + messaging: composition.adapters.messaging, + nonces: rendererNonces, + publisherOrigin, + }); + } catch { + return false; + } + } + ); + const resolveDirectContainer = (record: SlotRecord): HTMLElement | undefined => { + try { + if (typeof document === 'undefined') return undefined; + const identifiers = + record.source === 'programmatic' + ? new Set([record.registeredSlotId]) + : new Set(record.domAliases); + if (identifiers.size === 0) return undefined; + const matches = new Set(); + const elements = document.querySelectorAll('[id]'); + for (let index = 0; index < elements.length; index += 1) { + const element = elements.item(index); + if (element instanceof HTMLElement && identifiers.has(element.id)) { + matches.add(element); + } + } + return matches.size === 1 ? Array.from(matches)[0] : undefined; + } catch { + return undefined; + } + }; + const fetchAuction = compositionOptions.auctionFetcherForTest ?? globalThis.fetch; + const createOwnedAttempt = (owner: RenderAttemptScope, parentAttemptId?: string) => + createRenderAttempt({ + artifacts, + owner, + ...(parentAttemptId === undefined ? {} : { parentAttemptId }), + prepareRenderSource: (candidate) => { + const source = parseBidRenderSourceV1(candidate); + return source && source.type !== 'pbs_cache' ? Object.freeze(source) : undefined; + }, + publishDiagnostics: preparedDiagnosticsIngress.publish, + reservations: reservationService, + }); + const renderProjectedFallback = (attempt: RenderAttempt): boolean => { + const record = slotService.resolveRegisteredSlot(attempt.slot); + const container = record && resolveDirectContainer(record); + if (!container) { + attempt.fail('slot_unresolved'); + return false; + } + if (attempt.renderSource?.type === 'aps') return renderDirectAps(attempt, container); + if (attempt.renderSource?.type === 'adm') return renderDirectAdm(attempt, container); + attempt.fail('winner_not_renderable'); + return false; + }; + const batchCoordinator = createAuctionBatchService({ + createAttempt: createOwnedAttempt, + fetcher: (input, init) => { + if (typeof fetchAuction !== 'function') return Promise.reject(new Error('unavailable')); + return fetchAuction(input, init); + }, + parseResponse: parseTrustedServerAuctionResponseV1, + renderWinner: renderProjectedFallback, + }); + const services = Object.freeze({ + artifacts, + auctionBatches: batchCoordinator, + bootstrapNonces, + hostPositions, + reservations: reservationService, + rendererNonces, + renderDirectAdm, + renderDirectAps, + slots: slotService, + targeting: targetingService, + }); + preparedBrowserServices = Object.freeze({ + createAttempt: createOwnedAttempt, + publisherOrigin, + renderProjectedFallback, + rendererUrl, + services, + }); + const session = createRuntimeSession({ + createIdentityIssuer: + compositionOptions.createIdentityIssuerForTest ?? createBrowserNavigationIdentityIssuer, + interfaces: Object.freeze({ + adapters: composition.adapters, + creative: creativeRuntime, + datadome: dataDomeRuntime, + didomi: didomiRuntime, + google_tag_manager: googleTagManagerRuntime, + ...(preparedGptDiagnosticsFacts + ? { + 'gpt.events.v1': Object.freeze({ + subscribe: preparedGptDiagnosticsFacts.activate, + }), + } + : {}), + 'trace.v1': Object.freeze({ + record: preparedRenderTrace.record, + enrich: preparedRenderTrace.enrich, + prune: preparedRenderTrace.prune, + diagnostics: preparedRenderTrace.diagnostics, + observations: Object.freeze({ + publish: preparedDiagnosticsIngress.publish, + }), + }), + 'trace.presentation.v1': Object.freeze({ + attachPresentation: preparedRenderTrace.attachPresentation, + }), + gpt: gptIntegrationRuntime, + lockr: lockrRuntime, + osano_consent: osanoConsentRuntime, + osano_lifecycle: osanoLifecycleRuntime, + permutive_context: permutiveContextRuntime, + permutive_lifecycle: permutiveLifecycleRuntime, + prebid: prebidRuntime, + sourcepoint_consent: sourcepointConsentRuntime, + sourcepoint_lifecycle: sourcepointLifecycleRuntime, + testlight: testlightRuntime, + ...services, + }), + onNavigationDispose: (navigationGeneration) => { + artifacts.disposeNavigation(navigationGeneration); + preparedRenderTrace.pruneNavigation(navigationGeneration); + for (const registeredSlotId of renderTraceSlotsByNavigation.get(navigationGeneration) ?? + []) { + preparedRenderTrace.prune(registeredSlotId); + } + renderTraceSlotsByNavigation.delete(navigationGeneration); + }, + }); + context.onDispose(() => { + batchCoordinator.dispose(); + session.dispose(); + artifacts.dispose(); + reservationService.dispose(); + bootstrapNonces.dispose(); + rendererNonces.dispose(); + slotService.dispose(); + targetingService.dispose(); + composition.adapters.googletag.dispose(); + composition.adapters.prebid.dispose(); + if (runtimeSession === session) { + runtimeSession = undefined; + preparedBrowserServices = undefined; + browserServices = undefined; + auctionBatchService = undefined; + auctionContextRegistry = undefined; + projectionParser = undefined; + creativeBoot = undefined; + diagnosticsBoot = undefined; + renderTraceSlotsByNavigation.clear(); + } + }); + const navigation = session.startInitialNavigation(initialProjection); + if (!navigation.ok) throw new Error(navigation.reason); + + const acceptedInitialProjection = initialProjection as Readonly; + const initialRegistrations = [ + ...acceptedInitialProjection.slots.map((placement) => ({ + domAliases: Object.freeze([placement.divId]), + registeredSlotId: placement.slot, + source: 'server' as const, + })), + ...(compositionOptions.admittedProgrammaticSlotsForTest ?? []).map((registeredSlotId) => ({ + registeredSlotId, + source: 'programmatic' as const, + })), + ]; + if (!slotService.register(navigation.value, initialRegistrations).ok) { + throw new Error('Initial slots exceed the shared registry'); + } + const contextRegistry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(boot.manifest.integrations.map(({ id }) => id)), + onContributorFailure: (failure) => log.warn('auction context: contributor failed', failure), + runtimeOwner: session, + }); + runtimeSession = session; + auctionBatchService = batchCoordinator; + auctionContextRegistry = contextRegistry; + projectionParser = parseProjection; + return runtimeOptions.prepareOwner?.(context); + }, + activateCore: (context) => { + const prepared = preparedBrowserServices; + if (!prepared) throw new Error('Browser services are unavailable'); + const facts = gptDiagnosticsFacts; + const ingress = diagnosticsIngress; + const pucBridge = createPucBridge({ + messaging: composition.adapters.messaging, + mintLifecycleTicket: mintBrowserLifecycleTicket, + mountAps: (input) => + renderPucApsAttempt({ + ...input, + bindArtifactGuard: bindCommittedArtifactGuard, + bootstrapNonces: prepared.services.bootstrapNonces, + hostPositions: prepared.services.hostPositions, + messaging: composition.adapters.messaging, + nonces: prepared.services.rendererNonces, + publisherOrigin: prepared.publisherOrigin, + }), + ...(compositionOptions.pucSchedulerForTest + ? { scheduler: compositionOptions.pucSchedulerForTest } + : {}), + reservations: prepared.services.reservations, + resizeCollapsedShell: resizeCollapsedPucShell, + slots: prepared.services.slots, + }); + context.onDispose(() => pucBridge.dispose()); + browserServices = Object.freeze({ ...prepared.services, pucBridge }); + gptProjectionPublisher = (navigation, projection, requestClass): void => { + void publishProjectionThroughGpt(navigation, projection, requestClass).catch((error) => { + if (navigation.isCurrent()) log.warn('GPT projection: coordinator failed', error); + }); + }; + context.onDispose(() => { + gptProjectionPublisher = noopGptProjectionPublisher; + }); + const coordinator = createPrebidSelectionCoordinator({ + activateAttempt: ({ attempt, owner, preparedBid }): boolean => { + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: attempt.navigationGeneration, + dispose: () => undefined, + }); + const input = Object.freeze({ + artifact, + attempt, + owner, + reservationId: preparedBid.bid.adId, + }); + return pucBridge.registerGamAttempt(input); + }, + createAttempt: prepared.createAttempt, + reservations: prepared.services.reservations, + }); + prebidCoordinator = coordinator; + context.onDispose(() => { + coordinator.dispose(); + if (prebidCoordinator === coordinator) prebidCoordinator = undefined; + }); + browserServices.slots.activate(); + browserServices.slots.start(); + if (facts && ingress) { + const releaseCapture = activateGptDiagnosticsFactCapture( + composition.adapters.googletag, + Object.freeze({ + publish: (fact: Readonly): boolean => { + const projected = projectGptTraceFact(fact); + if (projected) ingress.publish(projected); + return facts.publish(fact); + }, + }) + ); + if (!releaseCapture) throw new Error('GPT diagnostics capture is unavailable'); + context.onDispose(releaseCapture); + } + compositionOptions.coreActivations?.correctnessGptListeners( + context, + composition.adapters, + browserServices + ); + return runtimeOptions.activateCore?.(context); + }, + }); + return Object.freeze({ + adapters: composition.adapters, + runtime: runtimeOwner, + createDiagnosticsCapabilityProviderRegistrationForTest: () => { + const prepare = () => { + const facts = gptDiagnosticsFacts; + const trace = renderTrace; + const observations = diagnosticsIngress; + if (!facts || !trace || !observations) { + throw new TypeError('Test diagnostics capabilities are unavailable'); + } + return Object.freeze({ + activate: () => undefined, + interfaces: Object.freeze({ + 'gpt.events.v1': Object.freeze({ subscribe: facts.activate }), + 'trace.v1': Object.freeze({ + record: trace.record, + enrich: trace.enrich, + prune: trace.prune, + diagnostics: trace.diagnostics, + observations: Object.freeze({ + publish: observations.publish, + }), + }), + 'trace.presentation.v1': Object.freeze({ + attachPresentation: trace.attachPresentation, + }), + }), + }); + }; + return Object.freeze({ + abi: 1, + id: BROWSER_TEST_DIAGNOSTICS_PROVIDER_ID, + phase: 'takeover', + releaseId: runtimeOptions.releaseId, + prepareSync: prepare, + prepare, + }); + }, + createTraceCapabilityProviderRegistrationForTest: () => { + const prepare = () => { + const trace = renderTrace; + const observations = diagnosticsIngress; + if (!trace || !observations) { + throw new TypeError('Test trace capability is unavailable'); + } + return Object.freeze({ + activate: () => undefined, + interfaces: Object.freeze({ + 'trace.v1': Object.freeze({ + record: trace.record, + enrich: trace.enrich, + prune: trace.prune, + diagnostics: trace.diagnostics, + observations: Object.freeze({ + publish: observations.publish, + }), + }), + 'trace.presentation.v1': Object.freeze({ + attachPresentation: trace.attachPresentation, + }), + }), + }); + }; + return Object.freeze({ + abi: 1, + id: BROWSER_TEST_TRACE_PROVIDER_ID, + phase: 'takeover', + releaseId: runtimeOptions.releaseId, + prepareSync: prepare, + prepare, + }); + }, + runtimeSessionForTest: () => runtimeSession, + pageBidsControllerForTest: (): PageBidsController | undefined => { + const navigation = runtimeSession?.currentNavigation; + if (!navigation || !browserServices || !projectionParser) return undefined; + return createPageBidsController({ + navigation, + parseProjection: projectionParser, + slotRegistry: browserServices.slots.projectionRegistry(navigation), + }); + }, + projectionSlotsForTest: () => browserServices?.slots.registeredSlotIdsForTest(), + auctionContextRegistryForTest: () => auctionContextRegistry, + slotServiceForTest: () => browserServices?.slots, + targetingServiceForTest: () => browserServices?.targeting, + reservationServiceForTest: () => browserServices?.reservations, + rendererNonceRegistryForTest: () => browserServices?.rendererNonces, + pucBridgeForTest: () => browserServices?.pucBridge, + publishGptWinnerForTest: ( + input: Omit< + GptWinnerPublicationInput, + | 'createSlotOperation' + | 'googletag' + | 'navigation' + | 'pucBridge' + | 'reservations' + | 'slots' + | 'targeting' + > + ): Promise => { + const services = browserServices; + const navigation = runtimeSession?.currentNavigation; + if (!services || !navigation) { + return Promise.resolve(Object.freeze({ ok: false, reason: 'gpt_request_failed' })); + } + return publishGptWinner({ + ...input, + createSlotOperation, + googletag: composition.adapters.googletag, + navigation, + pucBridge: services.pucBridge, + reservations: services.reservations, + slots: services.slots, + targeting: services.targeting, + }); + }, + startGptSlotOperationForTest: ( + input: Omit + ): SlotOperationCreationResult => { + const services = browserServices; + if (!services) return Object.freeze({ ok: false, reason: 'invalid_attempt' }); + return startGptSlotOperation({ + ...input, + createSlotOperation, + pucBridge: services.pucBridge, + slots: services.slots, + }); + }, + }); +} diff --git a/crates/trusted-server-js/lib/src/composition/browser_test_gpt_diagnostics.ts b/crates/trusted-server-js/lib/src/composition/browser_test_gpt_diagnostics.ts new file mode 100644 index 000000000..e9e362bab --- /dev/null +++ b/crates/trusted-server-js/lib/src/composition/browser_test_gpt_diagnostics.ts @@ -0,0 +1,94 @@ +import type { GptDiagnosticsApi } from '../core/types'; +import { GptDiagnosticsApiController } from '../integrations/gpt_diagnostics/api'; +import { GptDiagnosticsBadgeManager } from '../integrations/gpt_diagnostics/badges'; +import { GptDiagnosticsBindingManager } from '../integrations/gpt_diagnostics/binding'; +import type { GptDiagnosticsFactBuffer } from '../integrations/gpt/diagnostics_facts'; +import { GptDiagnosticsObserver } from '../integrations/gpt_diagnostics/observer'; +import { GptDiagnosticsOverlay } from '../integrations/gpt_diagnostics/overlay'; +import { GptDiagnosticsStore } from '../integrations/gpt_diagnostics/store'; + +type GptDiagnosticsWindow = Window & typeof globalThis; + +export interface GptDiagnosticsRuntimeOptions { + readonly document?: Document | undefined; + readonly window?: GptDiagnosticsWindow | undefined; +} + +export interface GptDiagnosticsRuntime { + readonly activate: () => () => void; + readonly currentApi: () => GptDiagnosticsApi | undefined; +} + +function isolate(callback: () => void): void { + try { + callback(); + } catch { + // Test-composition cleanup cannot retain another independently owned resource. + } +} + +/** Legacy-composition harness excluded from every production artifact entry. */ +export function createGptDiagnosticsRuntime( + facts: Pick, + options: GptDiagnosticsRuntimeOptions = {} +): GptDiagnosticsRuntime { + const targetWindow = options.window ?? (window as GptDiagnosticsWindow); + const targetDocument = options.document ?? document; + let active: Readonly<{ api: GptDiagnosticsApi; release: () => void }> | undefined; + return Object.freeze({ + activate: (): (() => void) => { + if (active) throw new Error('GPT diagnostics runtime is already active'); + const store = new GptDiagnosticsStore(); + const observer = new GptDiagnosticsObserver(store); + let releaseFacts: (() => void) | undefined; + let bindings: GptDiagnosticsBindingManager | undefined; + let badges: GptDiagnosticsBadgeManager | undefined; + let overlay: GptDiagnosticsOverlay | undefined; + let apiController: GptDiagnosticsApiController | undefined; + const cleanup = (): void => { + isolate(() => releaseFacts?.()); + isolate(() => apiController?.destroy()); + isolate(() => overlay?.destroy()); + isolate(() => badges?.destroy()); + isolate(() => bindings?.destroy()); + }; + try { + observer.start(); + releaseFacts = facts.activate((fact) => observer.consume(fact)); + if (!releaseFacts) throw new Error('GPT diagnostics fact consumer is unavailable'); + bindings = new GptDiagnosticsBindingManager(store, { + window: targetWindow, + document: targetDocument, + }); + badges = new GptDiagnosticsBadgeManager(store, bindings, { + window: targetWindow, + document: targetDocument, + }); + overlay = new GptDiagnosticsOverlay(store, bindings, { + window: targetWindow, + document: targetDocument, + onExport: () => apiController?.api.export(), + onBadgeLayerChange: (layer) => badges?.setLayer(layer), + }); + apiController = new GptDiagnosticsApiController(store, bindings, overlay, { + window: targetWindow, + document: targetDocument, + }); + } catch (error) { + cleanup(); + throw error; + } + const api = apiController.api; + let released = false; + const release = (): void => { + if (released) return; + released = true; + if (active?.release === release) active = undefined; + cleanup(); + }; + active = Object.freeze({ api, release }); + return release; + }, + currentApi: (): GptDiagnosticsApi | undefined => active?.api, + }); +} diff --git a/crates/trusted-server-js/lib/src/composition/runtime_transport.ts b/crates/trusted-server-js/lib/src/composition/runtime_transport.ts new file mode 100644 index 000000000..baee20201 --- /dev/null +++ b/crates/trusted-server-js/lib/src/composition/runtime_transport.ts @@ -0,0 +1,23 @@ +import { startProductionRuntime } from '../core/index'; +import { EMBEDDED_RELEASE_ID, EMBEDDED_RUNTIME_CATALOG } from '../core/release'; +import { createRenderRuntimeIntegrationRegistration } from '../integrations/render_runtime/module'; +import { createRuntime, type RuntimeOptions } from '../kernel/runtime'; + +const createRuntimeComposition = (runtimeOptions: RuntimeOptions) => + Object.freeze({ + runtime: createRuntime({ + ...runtimeOptions, + catalog: EMBEDDED_RUNTIME_CATALOG, + }), + }); + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + startProductionRuntime(createRuntimeComposition); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createRenderRuntimeIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); + } +} diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index a02684362..55ecf0d26 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -2,10 +2,35 @@ // and parses OpenRTB seatbid responses. Used by both the core requestAds flow // and the Prebid.js trustedServer adapter. -import { parseApsRendererDescriptor } from '../integrations/aps/render'; - +import { parseApsRendererDescriptor } from './contracts/aps_renderer'; +import { + MAX_AUCTION_RESULTS, + MAX_BROWSER_AUCTION_PROJECTION_BYTES, + isAuctionCandidateIdV1, + isAuctionProviderIdV1, + isRendererReservationIdV1, + jsonUtf8ByteLength, + ownDataArray, + ownDataObject, + parseAuctionDecisionSetV1 as parseDecisionSet, + parseBidRenderSourceV1 as parseRenderSource, + validBoundedString, + validDimension, +} from './contracts/auction_projection'; import { log } from './log'; -import type { ApsRendererV1 } from './types'; +import type { + ApsRendererV1, + AuctionDecisionSetV1, + BidRenderSourceV1, + BrowserAuctionProjectionV1, + SlotAuctionDecisionV1, +} from './types'; + +export { + MAX_BROWSER_AUCTION_PROJECTION_BYTES, + isRendererReservationIdV1, + parseBrowserAuctionProjectionV1, +} from './contracts/auction_projection'; // --------------------------------------------------------------------------- // Types @@ -45,9 +70,9 @@ export interface AuctionBid { /** Matches the `impid` in the response — corresponds to adUnit `code`. */ impid: string; /** Creative HTML (already rewritten with proxy URLs by the server). */ - adm: string; + adm?: string | undefined; /** Typed APS renderer descriptor, when the bid does not carry `adm`. */ - renderer?: ApsRendererV1; + renderer?: ApsRendererV1 | undefined; /** CPM price. */ price: number; /** Creative width. */ @@ -60,6 +85,203 @@ export interface AuctionBid { creativeId: string; /** Advertiser domains. */ adomain: string[]; + /** Server-side auction ID used for render tracing. */ + auctionId?: string | undefined; + /** Upstream OpenRTB bid ID used for render tracing. */ + bidId?: string | undefined; + /** Trace hash of the delivered creative markup. */ + admHash?: string | undefined; +} + +interface TrustedServerAuctionBidBaseV1 { + candidateId: string; + impid: string; + provider: string; + price: number; + width: number; + height: number; +} + +export type TrustedServerAuctionBidV1 = + | (TrustedServerAuctionBidBaseV1 & { + rendererReservationId: string; + renderSource: Exclude; + adm?: string | undefined; + }) + | (TrustedServerAuctionBidBaseV1 & { + renderSource: Extract; + }); + +export interface TrustedServerAuctionResponseV1 { + auction: AuctionDecisionSetV1; + bids: TrustedServerAuctionBidV1[]; +} + +/* Projection and render-source contracts live in core/contracts/auction_projection.ts. */ + +/** Parse the coordinated-cutover `/auction` wire without activating it in production yet. */ +export function parseTrustedServerAuctionResponseV1( + value: unknown +): TrustedServerAuctionResponseV1 | undefined { + const body = ownDataObject(value, ['id', 'seatbid', 'cur', 'ext']); + if (!body || typeof body.id !== 'string' || body.cur !== 'USD') return undefined; + const responseExt = ownDataObject(body.ext, ['trusted_server']); + const trustedResponseExt = ownDataObject(responseExt?.trusted_server, ['slot_results']); + const auction = parseDecisionSet(trustedResponseExt?.slot_results); + const seatbids = ownDataArray(body.seatbid, MAX_AUCTION_RESULTS); + if (!auction || body.id !== auction.auctionId || !seatbids) return undefined; + + const bids: TrustedServerAuctionBidV1[] = []; + for (const rawSeat of seatbids) { + const seat = ownDataObject(rawSeat, ['seat', 'bid']); + if (!seat || !isAuctionProviderIdV1(seat.seat)) return undefined; + const rawBids = ownDataArray(seat.bid, MAX_AUCTION_RESULTS - bids.length); + if (!rawBids || rawBids.length === 0) return undefined; + for (const rawBid of rawBids) { + const rawBidRecord = ownDataObject(rawBid); + if (!rawBidRecord) return undefined; + const bid = ownDataObject(rawBid, [ + 'id', + 'impid', + 'price', + ...(Object.prototype.hasOwnProperty.call(rawBidRecord, 'adm') ? ['adm'] : []), + 'w', + 'h', + 'ext', + ]); + const extension = ownDataObject(bid?.ext, ['trusted_server']); + const trusted = ownDataObject(extension?.trusted_server, [ + 'candidate_id', + 'slot_id', + 'render_source', + ]); + if ( + !bid || + !trusted || + !validBoundedString(bid.impid, 256) || + !isAuctionCandidateIdV1(trusted.candidate_id) || + trusted.slot_id !== bid.impid || + typeof bid.price !== 'number' || + !Number.isFinite(bid.price) || + bid.price < 0 || + typeof bid.w !== 'number' || + typeof bid.h !== 'number' + ) { + return undefined; + } + const renderSource = parseRenderSource(trusted.render_source); + if (!renderSource || renderSource.width !== bid.w || renderSource.height !== bid.h) { + return undefined; + } + if ( + (renderSource.type === 'pbs_cache' && bid.id !== renderSource.cacheId) || + (renderSource.type !== 'pbs_cache' && + (!isRendererReservationIdV1(bid.id) || !validDimension(bid.w) || !validDimension(bid.h))) + ) { + return undefined; + } + if ( + (renderSource.type === 'adm' && + Object.prototype.hasOwnProperty.call(bid, 'adm') && + bid.adm !== renderSource.adm) || + (renderSource.type !== 'adm' && Object.prototype.hasOwnProperty.call(bid, 'adm')) + ) { + return undefined; + } + const base = { + candidateId: trusted.candidate_id, + impid: bid.impid, + provider: seat.seat, + price: bid.price, + width: bid.w, + height: bid.h, + }; + if (renderSource.type === 'pbs_cache') { + bids.push({ ...base, renderSource }); + } else { + bids.push({ + ...base, + rendererReservationId: bid.id as string, + renderSource, + ...(renderSource.type === 'adm' ? { adm: renderSource.adm } : {}), + }); + } + } + } + + const winners = auction.results.filter( + (result): result is Extract => + result.outcome === 'winner' + ); + const candidates = new Set(); + const reservations = new Set(); + if ( + winners.length !== bids.length || + bids.some((bid) => { + if ( + candidates.has(bid.candidateId) || + ('rendererReservationId' in bid && reservations.has(bid.rendererReservationId)) + ) { + return true; + } + candidates.add(bid.candidateId); + if ('rendererReservationId' in bid) reservations.add(bid.rendererReservationId); + const winner = winners.find((entry) => entry.candidateId === bid.candidateId); + return !winner || winner.slot !== bid.impid; + }) || + winners.some((winner) => !bids.some((bid) => bid.candidateId === winner.candidateId)) + ) { + return undefined; + } + + const bidsByCandidate = new Map(bids.map((bid) => [bid.candidateId, bid])); + const orderedBids: TrustedServerAuctionBidV1[] = []; + for (const winner of winners) { + const bid = bidsByCandidate.get(winner.candidateId); + if (!bid) return undefined; + orderedBids.push(bid); + } + + const canonicalBids: BrowserAuctionProjectionV1['bids'] = []; + for (let index = 0; index < orderedBids.length; index += 1) { + const bid = orderedBids[index]; + if (!bid) return undefined; + const base = { + candidateId: bid.candidateId, + slot: bid.impid, + provider: bid.provider, + cpm: bid.price, + currency: 'USD' as const, + targeting: {}, + }; + if (!('rendererReservationId' in bid)) { + canonicalBids.push({ + ...base, + upstreamBidId: bid.renderSource.cacheId, + renderSource: bid.renderSource, + }); + } else { + canonicalBids.push({ + ...base, + upstreamBidId: + bid.renderSource.type === 'aps' ? bid.renderSource.bidId : bid.rendererReservationId, + rendererReservationId: bid.rendererReservationId, + renderSource: bid.renderSource, + }); + } + } + const canonicalProjection: BrowserAuctionProjectionV1 = { + version: 1, + auction, + // Direct `/auction` units are programmatic DOM placements, not GAM slots. + slots: [], + bids: canonicalBids, + }; + if (jsonUtf8ByteLength(canonicalProjection) > MAX_BROWSER_AUCTION_PROJECTION_BYTES) { + return undefined; + } + + return { auction, bids: orderedBids }; } // --------------------------------------------------------------------------- @@ -69,7 +291,7 @@ export interface AuctionBid { /** * Build an {@link AdRequest} from an array of ad-unit-like objects. * - * Accepts both plain tsjs `AdUnit` objects and Prebid-style `BidRequest` + * Accepts direct-auction programmatic units and Prebid-style `BidRequest` * objects (which carry `adUnitCode` instead of `code`). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/crates/trusted-server-js/lib/src/core/bootstrap.ts b/crates/trusted-server-js/lib/src/core/bootstrap.ts new file mode 100644 index 000000000..ff687b481 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/bootstrap.ts @@ -0,0 +1,903 @@ +declare const __TSJS_SERVER_BOOT_TRANSPORT_V1__: unknown; + +import { + prepareFirstDisplayBase, + type FirstDisplayAgent, + type FirstDisplayAgentRegistrationHostV1, +} from '../first_display/agent'; +import { + registerFirstDisplayBrowserRoute, + type FirstDisplayBrowserRouteRuleV1, +} from '../first_display/leaf/browser_route_owner'; +import type { InitialSliceInstaller } from '../first_display/slices/definition'; +import type { FirstDisplaySliceId } from '../kernel/release_catalog'; +import type { FirstDisplaySliceActivationContext } from '../shared/first_display_transaction'; +import type { FirstDisplayAgentCaptureFinalizerV1 } from '../shared/first_display_handoff'; +import type { ClaimedFirstDisplayTakeoverV1, FirstDisplayTakeoverClaim } from '../shared/takeover'; + +import { + deepFreezeTransportV1, + snapshotServerBootTransportV1, + type ServerBootTransportSnapshotV1, +} from './contracts/server_boot_transport'; +import { EMBEDDED_RELEASE_ID } from './release_id'; + +const RELEASE = EMBEDDED_RELEASE_ID; + +interface ComponentRegistration { + readonly id: Exclude; + readonly install: InitialSliceInstaller; +} + +type BootstrapTarget = object & { boot?: unknown; que?: unknown }; + +interface BootstrapInputSnapshotV1 extends ServerBootTransportSnapshotV1 { + readonly target: BootstrapTarget; +} + +type BootFailureReason = 'abi_mismatch' | 'bundle_partial'; +type BootClaimOutcome = 'kernel' | BootFailureReason; + +class RuntimeUnavailableError extends Error { + public readonly code = 'runtime_unavailable'; + + public constructor( + public readonly releaseId: string, + public readonly reason: BootFailureReason + ) { + super('TSJS runtime is unavailable'); + this.name = 'TsjsUnavailableError'; + } +} + +function prepareIngress(target: BootstrapTarget): unknown[] { + const descriptor = Object.getOwnPropertyDescriptor(target, 'que'); + const previous = + descriptor && 'value' in descriptor && Array.isArray(descriptor.value) ? descriptor.value : []; + const queue = Object.isExtensible(previous) ? previous : previous.slice(); + Object.defineProperty(queue, 'push', { + configurable: true, + value: Array.prototype.push, + writable: true, + }); + Object.defineProperty(target, 'que', { + configurable: true, + enumerable: true, + value: queue, + writable: false, + }); + return queue; +} + +function fallbackFields( + boot: BootstrapInputSnapshotV1['boot'], + reason: BootFailureReason, + initialDisplayCommitted: boolean +): Readonly> { + const unavailable = (): never => { + throw new RuntimeUnavailableError(RELEASE, reason); + }; + const safeBoot = { + abi: 1, + releaseId: RELEASE, + manifest: { + version: 1, + releaseId: RELEASE, + firstDisplay: boot.manifest.firstDisplay, + runtimeSrc: boot.manifest.runtimeSrc, + integrations: [], + }, + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], + bids: [], + }, + integrations: { version: 1, entries: [] }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }; + deepFreezeTransportV1(safeBoot); + let level = 'warn'; + const levels = ['silent', 'error', 'warn', 'info', 'debug']; + const observe = (..._values: readonly unknown[]): void => undefined; + const fields: Record = { + version: '1.0.0', + releaseId: RELEASE, + boot: safeBoot, + log: Object.freeze({ + setLevel: (value: string) => { + if (!levels.includes(value)) throw new TypeError('Invalid TSJS log level'); + level = value; + }, + getLevel: () => level, + error: observe, + warn: observe, + info: observe, + debug: observe, + }), + _registerIntegration: () => false, + addAdUnits: unavailable, + requestAds: async (): Promise => unavailable(), + }; + Object.defineProperty(fields, '_internal', { + value: Object.freeze({ + state: 'fallback', + releaseId: RELEASE, + reason, + initialDisplayCommitted, + }), + }); + return Object.freeze(fields); +} + +function publishFallback( + target: BootstrapInputSnapshotV1['target'], + ingress: unknown[], + fields: Readonly> +): boolean { + try { + const fieldNames = Object.getOwnPropertyNames(fields); + for (const name of Object.getOwnPropertyNames(target)) { + if (!Object.getOwnPropertyDescriptor(target, name)?.configurable) return false; + } + const callbacks: Array<() => void> = []; + for (let index = 0; index < ingress.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(ingress, String(index)); + if (descriptor && 'value' in descriptor && typeof descriptor.value === 'function') { + callbacks.push(descriptor.value as () => void); + } + } + const invoke = (callback: unknown): number => { + if (typeof callback === 'function') { + try { + Reflect.apply(callback, target, []); + } catch { + // One publisher callback cannot block the remaining queue. + } + } + return 0; + }; + const queue: unknown[] = []; + Object.defineProperty(queue, 'push', { value: invoke }); + Object.freeze(queue); + ingress.length = 0; + Object.defineProperty(ingress, 'push', { value: invoke }); + Object.freeze(ingress); + for (const name of Object.getOwnPropertyNames(target)) Reflect.deleteProperty(target, name); + for (const name of fieldNames) { + const descriptor = Object.getOwnPropertyDescriptor(fields, name)!; + Object.defineProperty(target, name, { + enumerable: descriptor.enumerable ?? false, + value: descriptor.value, + }); + } + Object.defineProperty(target, 'que', { enumerable: true, value: queue }); + for (const callback of callbacks) queue.push(callback); + return true; + } catch { + return false; + } +} + +function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSnapshotV1): void { + const ingress = prepareIngress(target); + const firstDisplay = boot.manifest.firstDisplay; + const NativeHtmlScriptElement = window.HTMLScriptElement; + const nativeArrayIsArray = Array.isArray; + const nativeObjectDefineProperty = Object.defineProperty; + const nativeObjectFreeze = Object.freeze; + const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const nativeObjectIsFrozen = Object.isFrozen; + const nativeReflectApply = Reflect.apply; + const nativeReflectOwnKeys = Reflect.ownKeys; + const nativeCurrentScriptGetter = (() => { + try { + const constructor = document.defaultView?.Document; + const descriptor = constructor + ? nativeObjectGetOwnPropertyDescriptor(constructor.prototype, 'currentScript') + : undefined; + return typeof descriptor?.get === 'function' ? descriptor.get : undefined; + } catch { + return undefined; + } + })(); + const currentScript = (): HTMLScriptElement | null => { + if (!nativeCurrentScriptGetter) return null; + try { + const value = nativeReflectApply(nativeCurrentScriptGetter, document, []); + return value instanceof NativeHtmlScriptElement ? value : null; + } catch { + return null; + } + }; + let runtimeReadSource: HTMLScriptElement | undefined; + let runtimeReadLive = false; + let runtimeNamespaceSealed = false; + const sealRuntimeNamespace = (): void => { + if (runtimeNamespaceSealed) return; + nativeObjectDefineProperty(window, 'tsjs', { + configurable: false, + enumerable: true, + get: () => { + if (!runtimeReadLive || currentScript() !== runtimeReadSource) return target; + runtimeReadLive = false; + return runtimeReadSource; + }, + }); + runtimeNamespaceSealed = true; + }; + const trustedOrigin = (): string | undefined => { + try { + if (/^https?:\/\//.test(location.origin)) return location.origin; + if (location.origin !== 'null') return undefined; + const stamp = nativeObjectGetOwnPropertyDescriptor(window, '__tsCreativeOrigin'); + if ( + !stamp || + stamp.configurable || + stamp.enumerable || + !('value' in stamp) || + stamp.writable || + typeof stamp.value !== 'string' + ) { + return undefined; + } + const parsed = new URL(stamp.value); + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + parsed.username === '' && + parsed.password === '' && + parsed.origin === stamp.value + ? parsed.origin + : undefined; + } catch { + return undefined; + } + }; + const authentic = (script: HTMLScriptElement, source: string, id: string): boolean => { + try { + const origin = trustedOrigin(); + if (!origin) return false; + const expected = new URL(source, origin); + const matches = document.querySelectorAll(`script#${id}`); + return ( + expected.origin === origin && + expected.hash === '' && + script.id === id && + script.isConnected && + script.ownerDocument === document && + script.src === expected.href && + matches.length === 1 && + matches[0] === script + ); + } catch { + return false; + } + }; + let bootClaimState: 'available' | 'claiming' | 'claimed' = 'available'; + let bootClaimCompleted = false; + let completeClaimedBoot: ((outcome: BootClaimOutcome) => void) | undefined; + const completeBootClaim = (outcome: BootClaimOutcome): void => { + const acceptedOutcome = + outcome === 'kernel' || outcome === 'abi_mismatch' || outcome === 'bundle_partial'; + if ( + bootClaimState !== 'claimed' || + bootClaimCompleted || + !completeClaimedBoot || + !acceptedOutcome + ) { + return; + } + bootClaimCompleted = true; + const complete = completeClaimedBoot; + completeClaimedBoot = undefined; + complete(outcome); + }; + const claimedBoot = nativeObjectFreeze({ + boot, + integrity, + complete: completeBootClaim, + currentScript, + }); + const claimBoot = (source: unknown): Readonly | undefined => { + if (bootClaimState !== 'available') return undefined; + bootClaimState = 'claiming'; + let accepted = false; + try { + const expectedId = firstDisplay === null ? 'trustedserver-js' : 'trustedserver-js-runtime'; + accepted = + source instanceof NativeHtmlScriptElement && + currentScript() === source && + authentic(source, boot.manifest.runtimeSrc, expectedId); + } catch { + // The claim stays reserved only until this authentication attempt fails. + } + if (!accepted) { + bootClaimState = 'available'; + return undefined; + } + bootClaimState = 'claimed'; + return claimedBoot; + }; + const installRuntimeClaim = ( + script: HTMLScriptElement, + mode: 'direct' | 'takeover', + bind: (input: unknown) => unknown + ): boolean => { + if (nativeObjectGetOwnPropertyDescriptor(script, '_claimRuntimeV1')) return false; + let live = true; + const claim = (source: unknown): Readonly> | undefined => { + if (!live) return undefined; + live = false; + const claimed = claimBoot(source); + if (!claimed) { + live = true; + return undefined; + } + return nativeObjectFreeze({ ...claimed, target, source, mode, bind }); + }; + nativeObjectDefineProperty(script, '_claimRuntimeV1', { + configurable: false, + enumerable: false, + value: claim, + writable: false, + }); + runtimeReadSource = script; + runtimeReadLive = true; + return true; + }; + if (firstDisplay === null) { + let terminal = false; + let cancelRuntime: (() => void) | undefined; + let directClaimState: 'available' | 'claiming' | 'claimed' = 'available'; + let claimObserver: MutationObserver | undefined; + let timer: number | undefined; + const fallback = (reason: BootFailureReason = 'bundle_partial'): void => { + if (terminal) return; + terminal = true; + runtimeReadLive = false; + if (timer !== undefined) window.clearTimeout(timer); + try { + cancelRuntime?.(); + } catch { + // Continue to the terminal shell after releasing the persistent owner. + } + claimObserver?.disconnect(); + claimObserver = undefined; + publishFallback(target, ingress, fallbackFields(boot, reason, false)); + }; + completeClaimedBoot = (outcome) => { + if (outcome === 'abi_mismatch') fallback(outcome); + }; + const claim = ( + source: unknown, + cancel: unknown + ): ((outcome: 'kernel' | 'runtime_fallback' | 'failed_start') => void) | undefined => { + if (terminal || directClaimState !== 'available') return undefined; + directClaimState = 'claiming'; + let accepted = false; + try { + accepted = + source instanceof NativeHtmlScriptElement && + currentScript() === source && + authentic(source, boot.manifest.runtimeSrc, 'trustedserver-js') && + typeof cancel === 'function'; + } catch { + // The reservation rolls back only when this authentication attempt fails. + } + if (!accepted) { + directClaimState = 'available'; + return undefined; + } + directClaimState = 'claimed'; + cancelRuntime = cancel as () => void; + return (outcome): void => { + if (terminal) return; + if (outcome === 'failed_start') { + fallback(); + return; + } + terminal = true; + if (timer !== undefined) window.clearTimeout(timer); + claimObserver?.disconnect(); + claimObserver = undefined; + }; + }; + const installDirectClaims = (script: HTMLScriptElement): boolean => { + try { + if ( + terminal || + !authentic(script, boot.manifest.runtimeSrc, 'trustedserver-js') || + !installRuntimeClaim(script, 'direct', (cancel) => claim(script, cancel)) + ) { + return false; + } + claimObserver?.disconnect(); + claimObserver = undefined; + return true; + } catch { + return false; + } + }; + try { + sealRuntimeNamespace(); + nativeObjectDefineProperty(target, 'boot', { + configurable: true, + enumerable: true, + value: boot, + writable: false, + }); + claimObserver = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if ( + node instanceof NativeHtmlScriptElement && + node.id === 'trustedserver-js' && + installDirectClaims(node) + ) { + return; + } + } + } + }); + claimObserver.observe(document.documentElement, { childList: true, subtree: true }); + const existing = document.querySelectorAll('script#trustedserver-js'); + if (existing.length === 1 && existing[0] instanceof NativeHtmlScriptElement) { + installDirectClaims(existing[0]); + } + performance.mark('tsjs:bids-script'); + timer = window.setTimeout(fallback, 10_000); + } catch { + fallback(); + } + return; + } + if (outline === null) { + publishFallback(target, ingress, fallbackFields(boot, 'abi_mismatch', false)); + return; + } + const disposers: Array<() => void> = []; + const registrations: ComponentRegistration[] = []; + let current = true; + let terminal = false; + let agent: FirstDisplayAgent | undefined; + let agentScript: HTMLScriptElement | undefined; + let runtimeScript: HTMLScriptElement | undefined; + let takeoverClaim: FirstDisplayTakeoverClaim | undefined; + let bootstrapTimer: number | undefined; + let takeoverTimer: number | undefined; + let takeoverStartedAt = 0; + + const clear = (handle: number | undefined): void => { + if (handle !== undefined) window.clearTimeout(handle); + }; + const removePrivate = (key: string): void => { + try { + const descriptor = nativeObjectGetOwnPropertyDescriptor(target, key); + if (descriptor?.configurable) Reflect.deleteProperty(target, key); + } catch { + // Generation invalidation makes an unremovable private field inert. + } + }; + const disposeAgent = (): void => { + removePrivate('_registerFirstDisplay'); + while (disposers.length > 0) { + const dispose = disposers.pop(); + try { + dispose?.(); + } catch { + // Continue releasing every independently owned provisional effect. + } + } + registrations.length = 0; + agent = undefined; + }; + const commitFallback = (reason: BootFailureReason): void => { + if (terminal) return; + const initialDisplayCommitted = agent?.initialDisplayCommitted ?? false; + terminal = true; + runtimeReadLive = false; + current = false; + clear(bootstrapTimer); + clear(takeoverTimer); + takeoverClaim = undefined; + if (runtimeScript) { + runtimeScript.onload = null; + runtimeScript.onerror = null; + runtimeScript.remove(); + } + disposeAgent(); + publishFallback(target, ingress, fallbackFields(boot, reason, initialDisplayCommitted)); + }; + completeClaimedBoot = (outcome) => { + if (outcome !== 'kernel') commitFallback(outcome); + }; + const now = (): number => performance.now(); + const startedAtMs = now(); + let runtimeScriptPolicy: + Readonly<{ createScriptURL: (value: string) => unknown }> | null | undefined; + const admittedScriptUrls = new Set([ + new URL(boot.manifest.runtimeSrc, location.origin).href, + ...boot.manifest.integrations.flatMap((entry) => + entry.phase === 'deferred' ? [new URL(entry.src, location.origin).href] : [] + ), + ]); + const trustedRuntimeScriptUrl = (expectedUrl: string): unknown => { + if (runtimeScriptPolicy === undefined) { + runtimeScriptPolicy = null; + try { + const trustedTypes = ( + window as Window & { + trustedTypes?: { + createPolicy: ( + name: string, + rules: Readonly<{ createScriptURL: (value: string) => string }> + ) => Readonly<{ createScriptURL: (value: string) => unknown }>; + }; + } + ).trustedTypes; + if (trustedTypes) { + runtimeScriptPolicy = trustedTypes.createPolicy('trusted-server#tsjs-v1', { + createScriptURL: (value: string) => { + if (!admittedScriptUrls.has(value)) throw new TypeError('tsjs'); + return value; + }, + }); + } + } catch { + runtimeScriptPolicy = null; + } + } + return runtimeScriptPolicy ? runtimeScriptPolicy.createScriptURL(expectedUrl) : expectedUrl; + }; + const bridge = (selected: FirstDisplayAgent): void => { + agent = selected; + let claimState: 'available' | 'claiming' | 'claimed' = 'available'; + const claim: FirstDisplayTakeoverClaim = ( + source, + finalize + ): ClaimedFirstDisplayTakeoverV1 | undefined => { + if (claimState !== 'available') return undefined; + claimState = 'claiming'; + let accepted = false; + try { + accepted = + source === runtimeScript && + currentScript() === source && + source instanceof NativeHtmlScriptElement && + authentic(source, boot.manifest.runtimeSrc, 'trustedserver-js-runtime'); + } catch { + // The reservation rolls back only when this authentication attempt fails. + } + if (!accepted) { + claimState = 'available'; + return undefined; + } + claimState = 'claimed'; + try { + if (terminal || !current || selected.state !== 'painted') { + throw new TypeError('tsjs'); + } + const finalized = selected.finalizeHandoff(finalize); + if (!finalized) throw new TypeError('tsjs'); + return nativeObjectFreeze([ + finalized, + outline, + () => current && !terminal && now() - takeoverStartedAt < 10_000, + () => + Boolean( + runtimeScript && + authentic(runtimeScript, boot.manifest.runtimeSrc, 'trustedserver-js-runtime') + ), + () => selected.mutationRevision, + () => selected.detachCommittedArtifacts(), + disposeAgent, + (reason) => commitFallback(reason), + () => { + terminal = true; + try { + clear(takeoverTimer); + } catch { + // The committed persistent owner remains terminal if timer cleanup fails. + } + }, + trustedRuntimeScriptUrl, + currentScript, + ]); + } catch (error) { + commitFallback('bundle_partial'); + throw error; + } + }; + if (takeoverClaim) throw new TypeError('tsjs'); + takeoverClaim = claim; + }; + const protectedPaint = (): void => { + try { + if (terminal || !agent || agent.state !== 'painted' || runtimeScript) { + throw new TypeError('tsjs'); + } + takeoverStartedAt = now(); + takeoverTimer = window.setTimeout(() => commitFallback('bundle_partial'), 10_000); + sealRuntimeNamespace(); + const script = document.createElement('script'); + script.id = 'trustedserver-js-runtime'; + script.async = true; + if (!takeoverClaim) throw new TypeError('tsjs'); + if ( + !installRuntimeClaim(script, 'takeover', (finalize) => { + const claim = takeoverClaim; + takeoverClaim = undefined; + return claim?.(script, finalize as FirstDisplayAgentCaptureFinalizerV1); + }) + ) { + throw new TypeError('tsjs'); + } + if (agentScript?.nonce) script.nonce = agentScript.nonce; + const expectedUrl = new URL(boot.manifest.runtimeSrc, location.origin).href; + script.src = trustedRuntimeScriptUrl(expectedUrl) as string; + if (script.src !== expectedUrl) throw new TypeError('tsjs'); + script.onerror = () => commitFallback('bundle_partial'); + runtimeScript = script; + (document.head ?? document.documentElement).append(script); + if ( + !authentic(script, boot.manifest.runtimeSrc, script.id) || + !agent.observeNativeMutation() + ) { + throw new TypeError('tsjs'); + } + } catch { + commitFallback('bundle_partial'); + } + }; + const binding = ( + id: string, + observe: (key: unknown, value: unknown) => void, + register: ((protocol: unknown) => () => void) | undefined + ): readonly [bindings: unknown, config: unknown] => { + let bindings: unknown; + if (id === 'gpt_initial') { + bindings = nativeObjectFreeze({ browser: window, observe, register }); + } else if (id === 'render_owner_initial') { + bindings = nativeObjectFreeze({ observe, register }); + } else if (id === 'aps_initial') { + bindings = nativeObjectFreeze({ + observe, + publisherOrigin: location.origin, + register, + }); + } else if (id === 'creative_initial') { + bindings = nativeObjectFreeze({ observe }); + } else if (id === 'google_tag_manager_initial') { + bindings = nativeObjectFreeze({ + observe, + register: (rule: FirstDisplayBrowserRouteRuleV1) => + registerFirstDisplayBrowserRoute(rule, true), + }); + } else if ( + id === 'datadome_initial' || + id === 'lockr_initial' || + id === 'permutive_initial' || + id === 'sourcepoint_initial' + ) { + bindings = nativeObjectFreeze({ observe, register: registerFirstDisplayBrowserRoute }); + } else if (id === 'testlight_initial') { + bindings = nativeObjectFreeze({ + enqueue: (callback: () => void) => { + ingress.push(callback); + }, + observe, + target: window, + }); + } else { + bindings = register + ? nativeObjectFreeze({ observe, register }) + : nativeObjectFreeze({ observe }); + } + const product = id.endsWith('_initial') ? id.slice(0, -'_initial'.length) : ''; + let config: unknown = + id === 'creative_initial' + ? boot.creative + : id === 'render_owner_initial' + ? nativeObjectFreeze({}) + : undefined; + if (config === undefined && product !== '') { + for (const entry of boot.integrations.entries) { + if (entry.id === product) { + config = entry.config; + break; + } + } + } + return nativeObjectFreeze([bindings, config]); + }; + const host: FirstDisplayAgentRegistrationHostV1 = nativeObjectFreeze({ + options: nativeObjectFreeze({ + batch: nativeObjectFreeze({ + version: 1, + projectionDigest: outline.projectionDigest, + projection: boot.auctionProjection, + }), + startedAtMs, + performance, + paint: nativeObjectFreeze({ + hidden: () => document.visibilityState === 'hidden', + requestFrame: (callback: () => void) => window.requestAnimationFrame(() => callback()), + scheduleHidden: (callback: () => void) => window.setTimeout(callback, 0), + }), + onProtectedPaint: protectedPaint, + onSettled: () => clear(bootstrapTimer), + onFailure: commitFallback, + gptInput: nativeObjectFreeze([ + window, + (handle: unknown) => window.clearTimeout(handle as number), + document, + (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), + boot.diagnostics.gpt.active, + ] as const), + handoff: nativeObjectFreeze({ + releaseId: RELEASE, + generation: outline.generation, + integrationConfigDigest: outline.integrationConfigDigest, + slices: firstDisplay.slices as readonly FirstDisplaySliceId[], + }), + now, + onAgentReady: bridge, + }), + sliceBindings: binding, + }); + + const fail = (): false => { + commitFallback('abi_mismatch'); + return false; + }; + const activateComponents = (): boolean => { + removePrivate('_registerFirstDisplay'); + const base = prepareFirstDisplayBase(host); + let afterActivate: (() => void) | undefined; + let ownershipOpen = true; + try { + const context: FirstDisplaySliceActivationContext = nativeObjectFreeze({ + own: (dispose: () => void) => { + if (!ownershipOpen || terminal || typeof dispose !== 'function') { + throw new TypeError('tsjs'); + } + disposers.push(dispose); + }, + afterActivate: (callback: () => void) => { + if (!ownershipOpen || terminal || afterActivate || typeof callback !== 'function') { + throw new TypeError('tsjs'); + } + afterActivate = callback; + }, + }); + base.activate(context); + for (const component of registrations) { + base.sliceHost.activate(component.id, context.own, component.install); + if (terminal || !current) return false; + } + } finally { + ownershipOpen = false; + } + afterActivate?.(); + if (terminal || !current) return false; + return Boolean(agent && !terminal); + }; + let registrationOpen = true; + let registrationClaiming = false; + const register = function (this: unknown, candidate: unknown, _source?: unknown): boolean { + if (!registrationOpen || registrationClaiming) return false; + registrationClaiming = true; + try { + const expectedId = firstDisplay.slices[registrations.length + 1]; + const source = currentScript(); + if ( + terminal || + this !== target || + !(source instanceof NativeHtmlScriptElement) || + (agentScript && source !== agentScript) || + !authentic(source, firstDisplay.src, 'trustedserver-js') || + !nativeArrayIsArray(candidate) + ) { + registrationClaiming = false; + return fail(); + } + if (terminal || !current) return false; + const keys = nativeReflectOwnKeys(candidate); + const length = nativeObjectGetOwnPropertyDescriptor(candidate, 'length'); + if ( + keys.length !== 5 || + keys[0] !== '0' || + keys[1] !== '1' || + keys[2] !== '2' || + keys[3] !== '3' || + keys[4] !== 'length' || + !length || + !('value' in length) || + length.value !== 4 + ) { + registrationClaiming = false; + return fail(); + } + const fields: unknown[] = []; + for (let index = 0; index < 4; index += 1) { + const descriptor = nativeObjectGetOwnPropertyDescriptor(candidate, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + registrationClaiming = false; + return fail(); + } + fields.push(descriptor.value); + } + if ( + fields[0] !== 1 || + fields[1] !== expectedId || + fields[2] !== RELEASE || + typeof fields[3] !== 'function' + ) { + registrationClaiming = false; + return fail(); + } + nativeObjectFreeze(candidate); + if (!nativeObjectIsFrozen(candidate)) { + registrationClaiming = false; + return fail(); + } + agentScript = source; + registrations.push({ + id: fields[1] as Exclude, + install: fields[3] as InitialSliceInstaller, + }); + registrationClaiming = false; + if (registrations.length !== firstDisplay.slices.length - 1) return true; + registrationOpen = false; + return activateComponents(); + } catch { + registrationClaiming = false; + return fail(); + } + }; + + try { + nativeObjectDefineProperty(target, 'boot', { + configurable: true, + enumerable: true, + value: boot, + writable: false, + }); + nativeObjectDefineProperty(target, '_registerFirstDisplay', { + configurable: true, + enumerable: false, + value: register, + writable: false, + }); + performance.mark('tsjs:bids-script'); + bootstrapTimer = window.setTimeout(() => commitFallback('bundle_partial'), 10_000); + if (firstDisplay.slices.length === 1) activateComponents(); + } catch { + commitFallback('abi_mismatch'); + } +} + +function bootstrapTarget(): BootstrapTarget | undefined { + try { + const namespace = window as unknown as { tsjs?: unknown }; + const current = namespace.tsjs; + if ((typeof current === 'object' || typeof current === 'function') && current !== null) { + return current as BootstrapTarget; + } + if (current) return undefined; + const target: BootstrapTarget = {}; + namespace.tsjs = target; + return target; + } catch { + return undefined; + } +} + +const transport = snapshotServerBootTransportV1( + typeof __TSJS_SERVER_BOOT_TRANSPORT_V1__ === 'undefined' + ? undefined + : __TSJS_SERVER_BOOT_TRANSPORT_V1__, + RELEASE +); +const target = transport && bootstrapTarget(); +if (transport && target) installBootstrap(Object.freeze({ ...transport, target })); diff --git a/crates/trusted-server-js/lib/src/core/config.ts b/crates/trusted-server-js/lib/src/core/config.ts deleted file mode 100644 index c0bbe7428..000000000 --- a/crates/trusted-server-js/lib/src/core/config.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Global configuration storage for the tsjs runtime (logging, debug, etc.). -import { log, LogLevel } from './log'; - -export interface Config { - debug?: boolean; - logLevel?: 'silent' | 'error' | 'warn' | 'info' | 'debug'; - [key: string]: unknown; -} - -let CONFIG: Config = {}; - -// Merge publisher-provided config and adjust the log level accordingly. -export function setConfig(cfg: Config): void { - CONFIG = { ...CONFIG, ...cfg }; - const debugFlag = cfg.debug; - const l = cfg.logLevel as LogLevel | undefined; - if (typeof l === 'string') log.setLevel(l); - else if (debugFlag === true) log.setLevel('debug'); - log.info('setConfig:', cfg); -} - -// Return a defensive copy so callers can't mutate shared state. -export function getConfig(): Config { - return { ...CONFIG }; -} diff --git a/crates/trusted-server-js/lib/src/core/context.ts b/crates/trusted-server-js/lib/src/core/context.ts deleted file mode 100644 index 9ee4ddff5..000000000 --- a/crates/trusted-server-js/lib/src/core/context.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Context provider registry: lets integrations contribute data to auction requests -// without core needing integration-specific knowledge. -import { log } from './log'; - -/** - * A context provider returns key-value pairs to merge into the auction - * request's `config` payload, or `undefined` to contribute nothing. - */ -export type ContextProvider = () => Record | undefined; - -const providers = new Map(); - -/** - * Register a context provider that will be called before every auction request. - * Integrations call this at import time to inject their data (e.g. segments, - * identifiers) into the auction payload without core needing to know about them. - * - * Re-registering with the same `id` replaces the previous provider, preventing - * duplicate accumulation in SPA environments. - */ -export function registerContextProvider(id: string, provider: ContextProvider): void { - providers.set(id, provider); - log.debug('context: registered provider', { id, total: providers.size }); -} - -/** - * Collect context from all registered providers. Called by core's `requestAds` - * to build the `config` object sent to `/auction`. - * - * Each provider's returned keys are merged (later providers win on collision). - * Providers that throw or return `undefined` are silently skipped. - */ -export function collectContext(): Record { - const context: Record = {}; - for (const provider of providers.values()) { - try { - const data = provider(); - if (data) Object.assign(context, data); - } catch { - log.debug('context: provider threw, skipping'); - } - } - return context; -} diff --git a/crates/trusted-server-js/lib/src/core/contracts/aps_renderer.ts b/crates/trusted-server-js/lib/src/core/contracts/aps_renderer.ts new file mode 100644 index 000000000..e4874e6fb --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/aps_renderer.ts @@ -0,0 +1,156 @@ +import type { ApsRendererV1 } from '../types'; + +import { + classifyApsRendererDescriptorV1, + classifyApsRendererV1, +} from './generated/renderer_validator_v1'; + +type ValidatedRendererCacheEntry = { + publisherOrigin: string; + renderer: ApsRendererV1; +}; + +const validatedRendererCache = new WeakMap(); +const rendererNoncePattern = /^n1_[A-Za-z0-9_-]{22}$/; + +function isRecord(value: unknown): value is Record { + try { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } catch { + return false; + } +} + +function exactDataRecord( + value: unknown, + expectedKeys: readonly string[] +): Readonly> | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return undefined; + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expectedKeys.length || + keys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) + ) { + return undefined; + } + const record: Record = {}; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + !descriptor || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + return undefined; + } + record[key] = descriptor.value; + } + return Object.freeze(record); + } catch { + return undefined; + } +} + +function exactHttpOrigin(value: unknown): value is string { + if (typeof value !== 'string' || new TextEncoder().encode(value).byteLength > 2_048) { + return false; + } + try { + const parsed = new URL(value); + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + parsed.hostname !== '' && + parsed.username === '' && + parsed.password === '' && + parsed.origin === value && + parsed.pathname === '/' && + parsed.search === '' && + parsed.hash === '' + ); + } catch { + return false; + } +} + +export interface ApsDocumentEnvelopeV1 { + readonly version: 1; + readonly nonce: string; + readonly publisherOrigin: string; + readonly renderer: Readonly; +} + +/** Parse only the versioned descriptor shape; decoded-envelope trust checks happen separately. */ +export function parseApsRendererDescriptor(value: unknown): ApsRendererV1 | undefined { + try { + if (classifyApsRendererDescriptorV1(value) !== 'accepted') return undefined; + return value as unknown as ApsRendererV1; + } catch { + return undefined; + } +} + +/** Fully validate the exact APS envelope and cross-check every duplicated descriptor field. */ +export function validateApsRenderer( + value: unknown, + publisherOrigin = window.location.origin +): ApsRendererV1 | undefined { + try { + if (isRecord(value)) { + const cached = validatedRendererCache.get(value); + if (cached?.publisherOrigin === publisherOrigin) return cached.renderer; + } + + if (classifyApsRendererV1(value, publisherOrigin) !== 'accepted') return undefined; + const renderer = value as ApsRendererV1; + const validated = Object.freeze({ ...renderer }) as ApsRendererV1; + validatedRendererCache.set(value as object, { publisherOrigin, renderer: validated }); + validatedRendererCache.set(validated, { publisherOrigin, renderer: validated }); + return validated; + } catch { + return undefined; + } +} + +/** Parse, fully validate, copy, and freeze the one inner-document envelope. */ +export function parseApsDocumentEnvelopeV1( + candidate: unknown, + expectedNonce: string, + expectedPublisherOrigin: string +): Readonly | undefined { + try { + if (!rendererNoncePattern.test(expectedNonce) || !exactHttpOrigin(expectedPublisherOrigin)) { + return undefined; + } + const envelope = exactDataRecord(candidate, [ + 'version', + 'nonce', + 'publisherOrigin', + 'renderer', + ]); + if ( + envelope?.['version'] !== 1 || + envelope['nonce'] !== expectedNonce || + envelope['publisherOrigin'] !== expectedPublisherOrigin + ) { + return undefined; + } + const renderer = validateApsRenderer(envelope['renderer'], expectedPublisherOrigin); + if (!renderer) return undefined; + return Object.freeze({ + version: 1, + nonce: expectedNonce, + publisherOrigin: expectedPublisherOrigin, + renderer, + }); + } catch { + return undefined; + } +} diff --git a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts new file mode 100644 index 000000000..5a8676529 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts @@ -0,0 +1,612 @@ +import type { + AdmRenderSourceV1, + AuctionDecisionSetV1, + AuctionSlotFailureReason, + BaselinePbsCacheSourceV1, + BidRenderSourceV1, + BrowserAuctionBidV1, + BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, + SlotAuctionDecisionV1, +} from '../types'; + +import { validateApsRenderer } from './aps_renderer'; +import { validBoundedString } from './bounded_string'; + +export { validBoundedString }; + +export const MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024; + +export const MAX_AUCTION_RESULTS = 256; +const MAX_TARGETING_ENTRIES = 32; +const MAX_SLOT_FORMATS = 64; +const MAX_ADM_BYTES = 512 * 1024; +const MAX_URL_BYTES = 4096; +const reflectApplyIntrinsic = Reflect.apply; +const objectGetOwnPropertyNamesIntrinsic = Object.getOwnPropertyNames; +const objectKeysIntrinsic = Object.keys; +const regExpTestIntrinsic = RegExp.prototype.test; +const candidateIdPattern = /^[A-Za-z0-9_-]{12}$/; +const reservationIdPattern = /^r1_[A-Za-z0-9_-]{22}$/; +const auctionIdPattern = /^[A-Za-z0-9._:-]{1,128}$/; +const providerPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const targetingKeyPattern = /^[A-Za-z0-9_]{1,20}$/; +const auctionFailureReasons = new Set([ + 'auction_disabled', + 'consent_denied', + 'slot_not_eligible', + 'provider_timeout', + 'provider_error', + 'invalid_provider_response', + 'mediation_failed', + 'winner_not_renderable', + 'identity_generation_failed', + 'internal_error', +]); + +function hasString(values: readonly string[], expected: string): boolean { + for (let index = 0; index < values.length; index += 1) { + if (values[index] === expected) return true; + } + return false; +} + +export function ownDataObject( + value: unknown, + expectedKeys?: readonly string[] +): Record | undefined { + try { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const names = reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [ + value, + ]) as string[]; + if (expectedKeys) { + if (names.length !== expectedKeys.length) return undefined; + for (let index = 0; index < expectedKeys.length; index += 1) { + const expected = expectedKeys[index]; + if (expected === undefined || !hasString(names, expected)) return undefined; + } + } + const snapshot: Record = Object.create(null) as Record; + for (let index = 0; index < names.length; index += 1) { + const name = names[index]; + if (name === undefined) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + snapshot[name] = descriptor.value; + } + return snapshot; + } catch { + return undefined; + } +} + +export function ownDataArray(value: unknown, maximum: number): unknown[] | undefined { + try { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + if (value.length > maximum || Object.getOwnPropertySymbols(value).length !== 0) + return undefined; + const names = reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [ + value, + ]) as string[]; + if (names.length !== value.length + 1 || !hasString(names, 'length')) return undefined; + const snapshot: unknown[] = []; + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + snapshot.push(descriptor.value); + } + return snapshot; + } catch { + return undefined; + } +} + +function matches(pattern: RegExp, value: string): boolean { + return reflectApplyIntrinsic(regExpTestIntrinsic, pattern, [value]) as boolean; +} + +export function validDimension(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + Number.isInteger(value) && + value >= 1 && + value <= 4096 + ); +} + +export function isAuctionCandidateIdV1(value: unknown): value is string { + return typeof value === 'string' && matches(candidateIdPattern, value); +} + +export function isAuctionProviderIdV1(value: unknown): value is string { + return typeof value === 'string' && matches(providerPattern, value); +} + +function boundedJsonBytes(left: number, right: number, maximum: number): number { + return left > maximum - right ? maximum + 1 : left + right; +} + +function encodedJsonStringBytes(value: string, maximum: number): number { + let bytes = 2; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + let encodedBytes: number; + if (code === 0x22 || code === 0x5c) encodedBytes = 2; + else if (code <= 0x1f) { + encodedBytes = + code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6; + } else if (code <= 0x7f) encodedBytes = 1; + else if (code <= 0x7ff) encodedBytes = 2; + else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + encodedBytes = 4; + index += 1; + } else encodedBytes = 6; + } else if (code >= 0xdc00 && code <= 0xdfff) encodedBytes = 6; + else encodedBytes = 3; + bytes = boundedJsonBytes(bytes, encodedBytes, maximum); + if (bytes > maximum) return bytes; + } + return bytes; +} + +interface JsonMeasureSnapshot { + readonly array: boolean; + readonly entries: readonly Readonly<{ key: string; value: unknown }>[]; +} + +interface JsonMeasureFrame extends JsonMeasureSnapshot { + readonly source: object; + bytes: number; + index: number; +} + +function jsonPrimitiveBytes(value: unknown): number | undefined { + if (value === null) return 4; + if (typeof value === 'boolean') return value ? 4 : 5; + if (typeof value === 'number' && Number.isFinite(value)) return `${value}`.length; + return typeof value === 'string' + ? encodedJsonStringBytes(value, MAX_BROWSER_AUCTION_PROJECTION_BYTES) + : undefined; +} + +function snapshotJsonForMeasurement(value: object): JsonMeasureSnapshot | undefined { + const array = Array.isArray(value); + const values = array ? ownDataArray(value, MAX_AUCTION_RESULTS) : undefined; + if (array && !values) return undefined; + const record = array ? undefined : ownDataObject(value); + if (!array && !record) return undefined; + return { + array, + entries: array + ? values!.map((entry, index) => ({ key: String(index), value: entry })) + : (reflectApplyIntrinsic(objectKeysIntrinsic, Object, [record]) as string[]).map((key) => ({ + key, + value: record![key], + })), + }; +} + +/** Measure exact own JSON data without consulting accessors or inherited `toJSON` hooks. */ +export function jsonUtf8ByteLength(value: unknown): number { + const primitive = jsonPrimitiveBytes(value); + if (primitive !== undefined) return primitive; + if (typeof value !== 'object' || value === null) return Number.POSITIVE_INFINITY; + const root = snapshotJsonForMeasurement(value); + if (!root) return Number.POSITIVE_INFINITY; + const memo = new WeakMap(); + const active = new Set(); + active.add(value); + const stack: JsonMeasureFrame[] = [{ ...root, bytes: 2, index: 0, source: value }]; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (!frame) return Number.POSITIVE_INFINITY; + if (frame.index >= frame.entries.length) { + memo.set(frame.source, frame.bytes); + active.delete(frame.source); + stack.pop(); + const parent = stack[stack.length - 1]; + if (!parent) return frame.bytes; + parent.bytes = boundedJsonBytes( + parent.bytes, + frame.bytes, + MAX_BROWSER_AUCTION_PROJECTION_BYTES + ); + if (parent.bytes > MAX_BROWSER_AUCTION_PROJECTION_BYTES) return parent.bytes; + continue; + } + const entry = frame.entries[frame.index]; + const entryIndex = frame.index; + frame.index += 1; + if (!entry) return Number.POSITIVE_INFINITY; + const keyBytes = frame.array ? 0 : jsonPrimitiveBytes(entry.key); + if (keyBytes === undefined) return Number.POSITIVE_INFINITY; + frame.bytes = boundedJsonBytes( + frame.bytes, + (entryIndex === 0 ? 0 : 1) + (frame.array ? 0 : keyBytes + 1), + MAX_BROWSER_AUCTION_PROJECTION_BYTES + ); + if (frame.bytes > MAX_BROWSER_AUCTION_PROJECTION_BYTES) return frame.bytes; + const childPrimitive = jsonPrimitiveBytes(entry.value); + if (childPrimitive !== undefined) { + frame.bytes = boundedJsonBytes( + frame.bytes, + childPrimitive, + MAX_BROWSER_AUCTION_PROJECTION_BYTES + ); + if (frame.bytes > MAX_BROWSER_AUCTION_PROJECTION_BYTES) return frame.bytes; + continue; + } + if (typeof entry.value !== 'object' || entry.value === null || active.has(entry.value)) { + return Number.POSITIVE_INFINITY; + } + const completed = memo.get(entry.value); + if (completed !== undefined) { + frame.bytes = boundedJsonBytes(frame.bytes, completed, MAX_BROWSER_AUCTION_PROJECTION_BYTES); + if (frame.bytes > MAX_BROWSER_AUCTION_PROJECTION_BYTES) return frame.bytes; + continue; + } + const child = snapshotJsonForMeasurement(entry.value); + if (!child) return Number.POSITIVE_INFINITY; + active.add(entry.value); + stack.push({ ...child, bytes: 2, index: 0, source: entry.value }); + } + return Number.POSITIVE_INFINITY; +} + +/** Whether a value is one exact server-minted renderer reservation identity. */ +export function isRendererReservationIdV1(value: unknown): value is string { + return typeof value === 'string' && matches(reservationIdPattern, value); +} + +/** Validate and copy one exact browser render-source contract. */ +export function parseBidRenderSourceV1(value: unknown): BidRenderSourceV1 | undefined { + const record = ownDataObject(value); + if (!record || typeof record.type !== 'string') return undefined; + + if (record.type === 'aps') { + const keys = Object.prototype.hasOwnProperty.call(record, 'creativeId') + ? [ + 'type', + 'version', + 'accountId', + 'bidId', + 'creativeId', + 'tagType', + 'creativeUrl', + 'aaxResponse', + 'width', + 'height', + ] + : [ + 'type', + 'version', + 'accountId', + 'bidId', + 'tagType', + 'creativeUrl', + 'aaxResponse', + 'width', + 'height', + ]; + if (!ownDataObject(value, keys)) return undefined; + const renderer = validateApsRenderer(record); + if (!renderer) return undefined; + return { + type: 'aps', + version: 1, + accountId: renderer.accountId, + bidId: renderer.bidId, + ...(renderer.creativeId === undefined ? {} : { creativeId: renderer.creativeId }), + tagType: renderer.tagType, + creativeUrl: renderer.creativeUrl, + aaxResponse: renderer.aaxResponse, + width: renderer.width, + height: renderer.height, + }; + } + + if (record.type === 'adm') { + const source = ownDataObject(value, ['type', 'version', 'adm', 'width', 'height']); + if ( + !source || + source.version !== 1 || + !validBoundedString(source.adm, MAX_ADM_BYTES, { allowControls: true }) || + !validDimension(source.width) || + !validDimension(source.height) + ) { + return undefined; + } + return { + type: 'adm', + version: 1, + adm: source.adm, + width: source.width, + height: source.height, + } satisfies AdmRenderSourceV1; + } + + if (record.type === 'pbs_cache') { + const source = ownDataObject(value, [ + 'type', + 'version', + 'cacheId', + 'cacheHost', + 'cachePath', + 'width', + 'height', + ]); + if ( + !source || + source.version !== 1 || + !validBoundedString(source.cacheId, 4096, { allowControls: true }) || + !validBoundedString(source.cacheHost, MAX_URL_BYTES, { allowControls: true }) || + !validBoundedString(source.cachePath, MAX_URL_BYTES, { allowControls: true }) || + typeof source.width !== 'number' || + !Number.isInteger(source.width) || + source.width < 0 || + source.width > 0xffff_ffff || + typeof source.height !== 'number' || + !Number.isInteger(source.height) || + source.height < 0 || + source.height > 0xffff_ffff + ) { + return undefined; + } + return { + type: 'pbs_cache', + version: 1, + cacheId: source.cacheId, + cacheHost: source.cacheHost, + cachePath: source.cachePath, + width: source.width, + height: source.height, + } satisfies BaselinePbsCacheSourceV1; + } + + return undefined; +} + +/** Validate and copy one exact auction decision-set contract. */ +export function parseAuctionDecisionSetV1(value: unknown): AuctionDecisionSetV1 | undefined { + const record = ownDataObject(value, ['version', 'auctionId', 'results']); + if (!record || record.version !== 1 || typeof record.auctionId !== 'string') return undefined; + if (!matches(auctionIdPattern, record.auctionId)) return undefined; + const results = ownDataArray(record.results, MAX_AUCTION_RESULTS); + if (!results) return undefined; + + const parsed: SlotAuctionDecisionV1[] = []; + const slots = new Set(); + const candidates = new Set(); + for (let index = 0; index < results.length; index += 1) { + const raw = results[index]; + const base = ownDataObject(raw); + if (!base || !validBoundedString(base.slot, 256) || slots.has(base.slot)) return undefined; + slots.add(base.slot); + if (base.outcome === 'winner') { + const winner = ownDataObject(raw, ['slot', 'outcome', 'candidateId']); + if ( + !winner || + !isAuctionCandidateIdV1(winner.candidateId) || + candidates.has(winner.candidateId) + ) { + return undefined; + } + candidates.add(winner.candidateId); + parsed.push({ slot: base.slot, outcome: 'winner', candidateId: winner.candidateId }); + } else if (base.outcome === 'no_bid') { + if (!ownDataObject(raw, ['slot', 'outcome'])) return undefined; + parsed.push({ slot: base.slot, outcome: 'no_bid' }); + } else if (base.outcome === 'failed') { + const failed = ownDataObject(raw, ['slot', 'outcome', 'reason']); + if ( + !failed || + typeof failed.reason !== 'string' || + !auctionFailureReasons.has(failed.reason as AuctionSlotFailureReason) + ) { + return undefined; + } + parsed.push({ + slot: base.slot, + outcome: 'failed', + reason: failed.reason as AuctionSlotFailureReason, + }); + } else { + return undefined; + } + } + + return { version: 1, auctionId: record.auctionId, results: parsed }; +} + +function parseTargeting(value: unknown): Record | undefined { + const record = ownDataObject(value); + if (!record) return undefined; + const entries = Object.entries(record); + if (entries.length > MAX_TARGETING_ENTRIES) return undefined; + const targeting: Record = {}; + entries.sort((leftEntry, rightEntry) => { + const left = leftEntry[0]; + const right = rightEntry[0]; + return left < right ? -1 : left > right ? 1 : 0; + }); + for (let index = 0; index < entries.length; index += 1) { + const pair = entries[index]; + if (!pair) return undefined; + const key = pair[0]; + const entry = pair[1]; + if ( + key === 'hb_adid' || + !matches(targetingKeyPattern, key) || + !validBoundedString(entry, 160, { maximumScalars: 40 }) + ) { + return undefined; + } + Object.defineProperty(targeting, key, { + value: entry, + enumerable: true, + writable: true, + configurable: true, + }); + } + return targeting; +} + +function parseBrowserBid(value: unknown): BrowserAuctionBidV1 | undefined { + const rawBid = ownDataObject(value); + if (!rawBid) return undefined; + const hasReservation = Object.prototype.hasOwnProperty.call(rawBid, 'rendererReservationId'); + const bid = ownDataObject( + value, + hasReservation + ? [ + 'candidateId', + 'slot', + 'provider', + 'upstreamBidId', + 'cpm', + 'currency', + 'targeting', + 'rendererReservationId', + 'renderSource', + ] + : [ + 'candidateId', + 'slot', + 'provider', + 'upstreamBidId', + 'cpm', + 'currency', + 'targeting', + 'renderSource', + ] + ); + if ( + !bid || + !isAuctionCandidateIdV1(bid.candidateId) || + !validBoundedString(bid.slot, 256) || + !isAuctionProviderIdV1(bid.provider) || + !validBoundedString(bid.upstreamBidId, 64) || + typeof bid.cpm !== 'number' || + !Number.isFinite(bid.cpm) || + bid.cpm < 0 || + bid.currency !== 'USD' + ) { + return undefined; + } + const targeting = parseTargeting(bid.targeting); + const renderSource = parseBidRenderSourceV1(bid.renderSource); + if (!targeting || !renderSource) return undefined; + const base = { + candidateId: bid.candidateId, + slot: bid.slot, + provider: bid.provider, + upstreamBidId: bid.upstreamBidId, + cpm: bid.cpm, + currency: 'USD' as const, + targeting, + }; + if (renderSource.type === 'pbs_cache') { + if (hasReservation) return undefined; + return { ...base, renderSource }; + } + if (!hasReservation || !isRendererReservationIdV1(bid.rendererReservationId)) return undefined; + return { ...base, rendererReservationId: bid.rendererReservationId, renderSource }; +} + +function parseBrowserSlot(value: unknown): BrowserAuctionSlotV1 | undefined { + const slot = ownDataObject(value, ['slot', 'gamUnitPath', 'divId', 'formats', 'targeting']); + if ( + !slot || + !validBoundedString(slot.slot, 256) || + !validBoundedString(slot.gamUnitPath, 256) || + !validBoundedString(slot.divId, 256) + ) { + return undefined; + } + const rawFormats = ownDataArray(slot.formats, MAX_SLOT_FORMATS); + if (!rawFormats || rawFormats.length === 0) return undefined; + const formats: Array = []; + for (let index = 0; index < rawFormats.length; index += 1) { + const pair = ownDataArray(rawFormats[index], 2); + if (!pair || pair.length !== 2 || !validDimension(pair[0]) || !validDimension(pair[1])) { + return undefined; + } + formats.push([pair[0], pair[1]]); + } + const targeting = parseTargeting(slot.targeting); + if (!targeting) return undefined; + return { + slot: slot.slot, + gamUnitPath: slot.gamUnitPath, + divId: slot.divId, + formats, + targeting, + }; +} + +/** Validate, canonicalize, and deep-copy a complete browser auction projection. */ +export function parseBrowserAuctionProjectionV1( + value: unknown +): BrowserAuctionProjectionV1 | undefined { + try { + const record = ownDataObject(value, ['version', 'auction', 'slots', 'bids']); + if (!record || record.version !== 1) return undefined; + const auction = parseAuctionDecisionSetV1(record.auction); + const rawSlots = ownDataArray(record.slots, MAX_AUCTION_RESULTS); + const rawBids = ownDataArray(record.bids, MAX_AUCTION_RESULTS); + if (!auction || !rawSlots || !rawBids || rawSlots.length !== auction.results.length) { + return undefined; + } + const slots: BrowserAuctionSlotV1[] = []; + const slotIds = new Set(); + for (let index = 0; index < rawSlots.length; index += 1) { + const slot = parseBrowserSlot(rawSlots[index]); + if (!slot || slotIds.has(slot.slot) || slot.slot !== auction.results[index]?.slot) { + return undefined; + } + slotIds.add(slot.slot); + slots.push(slot); + } + const bids: BrowserAuctionBidV1[] = []; + const candidateIds = new Set(); + const reservationIds = new Set(); + for (let index = 0; index < rawBids.length; index += 1) { + const raw = rawBids[index]; + const bid = parseBrowserBid(raw); + if ( + !bid || + candidateIds.has(bid.candidateId) || + ('rendererReservationId' in bid && reservationIds.has(bid.rendererReservationId)) + ) { + return undefined; + } + candidateIds.add(bid.candidateId); + if ('rendererReservationId' in bid) reservationIds.add(bid.rendererReservationId); + bids.push(bid); + } + + let winnerIndex = 0; + for (let index = 0; index < auction.results.length; index += 1) { + const result = auction.results[index]; + if (!result || result.outcome !== 'winner') continue; + const bid = bids[winnerIndex]; + if (bid?.candidateId !== result.candidateId || bid.slot !== result.slot) return undefined; + winnerIndex += 1; + } + if (winnerIndex !== bids.length) return undefined; + + const projection: BrowserAuctionProjectionV1 = { version: 1, auction, slots, bids }; + if (jsonUtf8ByteLength(projection) > MAX_BROWSER_AUCTION_PROJECTION_BYTES) { + return undefined; + } + return projection; + } catch { + return undefined; + } +} diff --git a/crates/trusted-server-js/lib/src/core/contracts/boot.ts b/crates/trusted-server-js/lib/src/core/contracts/boot.ts new file mode 100644 index 000000000..3b4b40a87 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/boot.ts @@ -0,0 +1,396 @@ +import { + snapshotTakeoverOutlineV1, + type TakeoverOutlineV1, +} from '../../shared/first_display_contracts'; +import type { + BootManifestIntegrationV1, + BootManifestV1, + IntegrationConfigIdV1, + TsjsBootV1, +} from '../types'; +import { INTEGRATION_CONFIG_IDS_V1 } from '../types'; + +import { parseBrowserAuctionProjectionV1 } from './auction_projection'; +import { + canonicalIntegrationConfigDigestV1, + snapshotIntegrationConfigsV1, +} from './integration_configs'; + +const FIRST_DISPLAY_SRC = + /^\/static\/tsjs=tsjs-first-display\.min\.js\?m=([0-9a-f]{4})&v=[0-9a-f]{64}$/; +const RUNTIME_SRC = /^\/static\/tsjs=tsjs-unified\.min\.js\?v=[0-9a-f]{64}$/; +const DEFERRED_SRC = /^\/static\/tsjs=tsjs-[a-z0-9][a-z0-9_-]{0,63}\.min\.js\?v=[0-9a-f]{64}$/; +const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; +const RELEASE_ID = /^[0-9a-f]{64}$/; +const FIRST_DISPLAY_IDS = Object.freeze([ + 'first_display', + 'render_owner_initial', + 'aps_initial', + 'creative_initial', + 'datadome_initial', + 'didomi_initial', + 'google_tag_manager_initial', + 'gpt_initial', + 'lockr_initial', + 'osano_initial', + 'permutive_initial', + 'sourcepoint_initial', + 'prebid_initial', + 'testlight_initial', +] as const); + +export interface BootstrapInputSnapshotV1 { + readonly target: object & { boot?: unknown; que?: unknown }; + readonly boot: Readonly; + readonly outline: TakeoverOutlineV1 | null; +} + +function ownPlainDataRecord(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; + const result: Record = {}; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + Object.defineProperty(result, key, { + configurable: true, + enumerable: true, + value: descriptor.value, + writable: true, + }); + } + return result; +} + +function exactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === keys.length && actual.every((key) => keys.includes(key)); +} + +function snapshotArray(value: unknown, maximum: number): readonly unknown[] | undefined { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + const length = Object.getOwnPropertyDescriptor(value, 'length'); + if (!length || !('value' in length) || !Number.isSafeInteger(length.value)) return undefined; + if (length.value < 0 || length.value > maximum) return undefined; + const keys = Reflect.ownKeys(value); + if (keys.length !== length.value + 1) return undefined; + const result: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result.push(descriptor.value); + } + return result; +} + +function snapshotManifest(candidate: unknown, releaseId: string): BootManifestV1 | undefined { + const root = ownPlainDataRecord(candidate); + if ( + !root || + !exactKeys(root, ['version', 'releaseId', 'firstDisplay', 'runtimeSrc', 'integrations']) || + root.version !== 1 || + root.releaseId !== releaseId || + typeof root.runtimeSrc !== 'string' || + !RUNTIME_SRC.test(root.runtimeSrc) + ) { + return undefined; + } + + const rawIntegrations = snapshotArray(root.integrations, 20); + if (!rawIntegrations) return undefined; + const integrations: BootManifestIntegrationV1[] = []; + const ids = new Set(); + let sawDeferred = false; + for (const candidateEntry of rawIntegrations) { + const entry = ownPlainDataRecord(candidateEntry); + if ( + !entry || + typeof entry.id !== 'string' || + !INTEGRATION_ID.test(entry.id) || + ids.has(entry.id) + ) { + return undefined; + } + ids.add(entry.id); + if (entry.phase === 'takeover') { + if (sawDeferred || !exactKeys(entry, ['id', 'phase'])) return undefined; + integrations.push(Object.freeze({ id: entry.id, phase: 'takeover' })); + continue; + } + if ( + entry.phase !== 'deferred' || + !exactKeys(entry, ['id', 'phase', 'trigger', 'src']) || + entry.trigger !== 'first_display_or_idle' || + typeof entry.src !== 'string' || + !DEFERRED_SRC.test(entry.src) + ) { + return undefined; + } + sawDeferred = true; + integrations.push( + Object.freeze({ + id: entry.id, + phase: 'deferred', + trigger: 'first_display_or_idle', + src: entry.src, + }) + ); + } + + let firstDisplay: BootManifestV1['firstDisplay']; + if (root.firstDisplay === null) { + firstDisplay = null; + } else { + const fields = ownPlainDataRecord(root.firstDisplay); + const slices = snapshotArray(fields?.slices, FIRST_DISPLAY_IDS.length); + if ( + !fields || + !exactKeys(fields, ['src', 'slices']) || + typeof fields.src !== 'string' || + !slices || + slices.length === 0 || + slices.some((id) => typeof id !== 'string') + ) { + return undefined; + } + const match = FIRST_DISPLAY_SRC.exec(fields.src); + if (!match) return undefined; + const mask = Number.parseInt(match[1]!, 16); + const selected = FIRST_DISPLAY_IDS.filter((_id, index) => (mask & (1 << index)) !== 0); + if ( + (mask & 1) === 0 || + mask >>> FIRST_DISPLAY_IDS.length !== 0 || + ((mask & 4) !== 0 && (mask & 2) === 0) || + ((mask & 2) !== 0 && (mask & 128) === 0) || + selected.length !== slices.length || + selected.some((id, index) => id !== slices[index]) + ) { + return undefined; + } + firstDisplay = Object.freeze({ + src: fields.src, + slices: Object.freeze([...slices] as string[]), + }); + } + + return Object.freeze({ + version: 1, + releaseId, + firstDisplay, + runtimeSrc: root.runtimeSrc, + integrations: Object.freeze(integrations), + }); +} + +function productForModule(id: string): IntegrationConfigIdV1 | undefined { + if (id === 'gpt' || id === 'gpt_later') return 'gpt'; + if (id === 'osano_consent' || id === 'osano_lifecycle') return 'osano'; + if (id === 'permutive_context' || id === 'permutive_lifecycle') return 'permutive'; + if (id === 'prebid' || id === 'prebid_later') return 'prebid'; + if (id === 'sourcepoint_consent' || id === 'sourcepoint_lifecycle') return 'sourcepoint'; + return INTEGRATION_CONFIG_IDS_V1.includes(id as IntegrationConfigIdV1) + ? (id as IntegrationConfigIdV1) + : undefined; +} + +function configMatchesManifest(boot: TsjsBootV1): boolean { + const selected = new Set( + boot.manifest.integrations.flatMap(({ id }) => { + const product = productForModule(id); + return product ? [product] : []; + }) + ); + const expected = INTEGRATION_CONFIG_IDS_V1.filter((id) => selected.has(id)); + return ( + expected.length === boot.integrations.entries.length && + expected.every((id, index) => boot.integrations.entries[index]?.id === id) + ); +} + +function recursivelyFreeze(value: unknown): void { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return; + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor && 'value' in descriptor) recursivelyFreeze(descriptor.value); + } + Object.freeze(value); +} + +function recursivelyFrozenPlainData(value: unknown, seen = new Set()): boolean { + if (typeof value !== 'object' || value === null) return true; + if (seen.has(value) || !Object.isFrozen(value)) return false; + seen.add(value); + const isArray = Array.isArray(value); + if (Object.getPrototypeOf(value) !== (isArray ? Array.prototype : Object.prototype)) return false; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') return false; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + !descriptor || + ((!isArray || key !== 'length') && (!descriptor.enumerable || !('value' in descriptor))) + ) { + return false; + } + if ('value' in descriptor && !recursivelyFrozenPlainData(descriptor.value, seen)) return false; + } + return true; +} + +/** Validate and copy the complete server-authored boot contract before browser effects. */ +export function snapshotTsjsBootV1(candidate: unknown, releaseId: string): TsjsBootV1 | undefined { + try { + if (!RELEASE_ID.test(releaseId)) return undefined; + const root = ownPlainDataRecord(candidate); + if ( + !root || + !exactKeys(root, [ + 'abi', + 'releaseId', + 'manifest', + 'auctionProjection', + 'integrations', + 'creative', + 'diagnostics', + ]) || + root.abi !== 1 || + root.releaseId !== releaseId + ) { + return undefined; + } + const manifest = snapshotManifest(root.manifest, releaseId); + const auctionProjection = parseBrowserAuctionProjectionV1(root.auctionProjection); + const integrations = snapshotIntegrationConfigsV1(root.integrations); + const creative = ownPlainDataRecord(root.creative); + const diagnostics = ownPlainDataRecord(root.diagnostics); + const gpt = ownPlainDataRecord(diagnostics?.gpt); + if ( + !manifest || + !auctionProjection || + !integrations || + !creative || + !exactKeys(creative, ['version', 'enabled', 'clickGuard', 'renderGuard']) || + creative.version !== 1 || + typeof creative.enabled !== 'boolean' || + typeof creative.clickGuard !== 'boolean' || + typeof creative.renderGuard !== 'boolean' || + (!creative.enabled && (creative.clickGuard || creative.renderGuard)) || + !diagnostics || + !exactKeys(diagnostics, ['version', 'renderTraceOverlay', 'gpt']) || + diagnostics.version !== 1 || + typeof diagnostics.renderTraceOverlay !== 'boolean' || + !gpt || + !exactKeys(gpt, ['active']) || + typeof gpt.active !== 'boolean' + ) { + return undefined; + } + recursivelyFreeze(auctionProjection); + const boot: TsjsBootV1 = Object.freeze({ + abi: 1, + releaseId, + manifest, + auctionProjection, + integrations, + creative: Object.freeze({ + version: 1, + enabled: creative.enabled, + clickGuard: creative.clickGuard, + renderGuard: creative.renderGuard, + }), + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: diagnostics.renderTraceOverlay, + gpt: Object.freeze({ active: gpt.active }), + }), + }); + const has = (id: string): boolean => manifest.integrations.some((entry) => entry.id === id); + if ( + !configMatchesManifest(boot) || + has('creative') !== + (boot.creative.enabled && (boot.creative.clickGuard || boot.creative.renderGuard)) || + has('gpt_diagnostics') !== boot.diagnostics.gpt.active || + has('diagnostics_presentation') !== + (boot.diagnostics.renderTraceOverlay || boot.diagnostics.gpt.active) + ) { + return undefined; + } + return boot; + } catch { + return undefined; + } +} + +/** Revalidate one recursively frozen boot and return the independent accepted snapshot. */ +export function snapshotFrozenTsjsBootV1( + candidate: unknown, + releaseId: string +): TsjsBootV1 | undefined { + try { + const accepted = snapshotTsjsBootV1(candidate, releaseId); + return accepted && + recursivelyFrozenPlainData(candidate) && + JSON.stringify(candidate) === JSON.stringify(accepted) + ? accepted + : undefined; + } catch { + return undefined; + } +} + +/** Revalidate a bootstrap snapshot while retaining its exact object identity. */ +export function retainTsjsBootSnapshotV1( + candidate: unknown, + releaseId: string +): Readonly | undefined { + return snapshotFrozenTsjsBootV1(candidate, releaseId) + ? (candidate as Readonly) + : undefined; +} + +/** Capture the exact server lexical, including the one retained immutable boot copy. */ +export function snapshotBootstrapInputV1( + candidate: unknown, + releaseId: string +): BootstrapInputSnapshotV1 | undefined { + try { + const root = ownPlainDataRecord(candidate); + if (!root || !exactKeys(root, ['target', 'boot', 'outline'])) return undefined; + if ( + (typeof root.target !== 'object' && typeof root.target !== 'function') || + root.target === null + ) { + return undefined; + } + const boot = snapshotTsjsBootV1(root.boot, releaseId); + if (!boot) return undefined; + let outline: TakeoverOutlineV1 | null = null; + if (root.outline !== null) { + const acceptedOutline = snapshotTakeoverOutlineV1(root.outline); + if (!acceptedOutline) return undefined; + outline = acceptedOutline; + } + const firstDisplay = boot.manifest.firstDisplay; + if ( + (firstDisplay === null && outline !== null) || + (firstDisplay !== null && + (!outline || + outline.releaseId !== releaseId || + outline.integrationConfigDigest !== + canonicalIntegrationConfigDigestV1(boot.integrations) || + outline.slotCount !== boot.auctionProjection.slots.length || + outline.outcomeCount !== boot.auctionProjection.auction.results.length || + outline.slices.length !== firstDisplay.slices.length || + outline.slices.some((id, index) => id !== firstDisplay.slices[index]))) + ) { + return undefined; + } + return Object.freeze({ + target: root.target as BootstrapInputSnapshotV1['target'], + boot, + outline, + }); + } catch { + return undefined; + } +} diff --git a/crates/trusted-server-js/lib/src/core/contracts/bounded_string.ts b/crates/trusted-server-js/lib/src/core/contracts/bounded_string.ts new file mode 100644 index 000000000..ef89f69e5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/bounded_string.ts @@ -0,0 +1,44 @@ +const reflectApplyIntrinsic = Reflect.apply; +const textEncoder = new TextEncoder(); +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; + +function unicodeScalarCount(value: string): number | undefined { + let scalars = 0; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return undefined; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return undefined; + } + scalars += 1; + } + return scalars; +} + +function hasAsciiControl(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +/** Validate a nonempty, well-formed UTF-16 string against byte and scalar bounds. */ +export function validBoundedString( + value: unknown, + maximumBytes: number, + options: { allowControls?: boolean; maximumScalars?: number } = {} +): value is string { + if (typeof value !== 'string' || value.length === 0) return false; + const scalarCount = unicodeScalarCount(value); + return ( + scalarCount !== undefined && + (options.allowControls === true || !hasAsciiControl(value)) && + (reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [value]) as Uint8Array) + .length <= maximumBytes && + (options.maximumScalars === undefined || scalarCount <= options.maximumScalars) + ); +} diff --git a/crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_document_v1.ts b/crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_document_v1.ts new file mode 100644 index 000000000..cd2b0dff9 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_document_v1.ts @@ -0,0 +1,5 @@ +// @generated by scripts/generate-aps-renderer-contract.mjs +// schema-sha256: 3f82e9c8d57719c29810a0ed181f4fe2779919c65605ae0cd7c61bd6d865b027 +// corpus-sha256: 3aea612e3316e6df4852e80cb3aa8882ca43d842455a22ba29e63fa88291c7b9 +export const APS_RENDERER_VALIDATOR_ES5_V1 = + "// @generated by scripts/generate-aps-renderer-contract.mjs\n// schema-sha256: 3f82e9c8d57719c29810a0ed181f4fe2779919c65605ae0cd7c61bd6d865b027\n// corpus-sha256: 3aea612e3316e6df4852e80cb3aa8882ca43d842455a22ba29e63fa88291c7b9\nvar DESCRIPTOR_KEYS = [\"aaxResponse\",\"accountId\",\"bidId\",\"creativeUrl\",\"height\",\"tagType\",\"type\",\"version\",\"width\"];\nvar DESCRIPTOR_KEYS_WITH_CREATIVE_ID = [\"aaxResponse\",\"accountId\",\"bidId\",\"creativeId\",\"creativeUrl\",\"height\",\"tagType\",\"type\",\"version\",\"width\"];\nvar ENVELOPE_ROOT_KEYS = [\"seatbid\"];\nvar ENVELOPE_SEAT_KEYS = [\"bid\"];\nvar ENVELOPE_BID_KEYS = [\"ext\",\"h\",\"id\",\"price\",\"w\"];\nvar ENVELOPE_EXT_KEYS = [\"creativeurl\",\"tagtype\"];\nvar MAX_ACCOUNT_ID_BYTES = 1024;\nvar MAX_BID_ID_BYTES = 64;\nvar MAX_CREATIVE_ID_BYTES = 1024;\nvar MAX_CREATIVE_URL_BYTES = 4096;\nvar MAX_RENDER_ENVELOPE_BYTES = 262144;\nvar MAX_RENDER_ENVELOPE_BASE64_BYTES = 349528;\nvar STANDARD_BASE64_PATTERN = \"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$\";\nvar RENDER_DIMENSION_MIN = 1;\nvar RENDER_DIMENSION_MAX = 4096;\nfunction apsExactRecord(value, expectedKeys) {\n if (!value || typeof value !== 'object' || Array.isArray(value)) return false;\n var prototype = Object.getPrototypeOf(value);\n if (prototype !== Object.prototype && prototype !== null) return false;\n if (typeof Object.getOwnPropertySymbols === 'function' && Object.getOwnPropertySymbols(value).length !== 0) return false;\n var actual = Object.getOwnPropertyNames(value).sort();\n if (actual.length !== expectedKeys.length) return false;\n for (var index = 0; index < actual.length; index += 1) {\n var propertyName = actual[index];\n if (propertyName === undefined || propertyName !== expectedKeys[index]) return false;\n var property = Object.getOwnPropertyDescriptor(value, propertyName);\n if (!property || !Object.prototype.hasOwnProperty.call(property, 'value')) return false;\n }\n return true;\n}\n\nfunction apsUtf8Length(value) {\n return (new TextEncoder()).encode(value).length;\n}\n\nfunction apsHasAsciiControl(value) {\n return /[\\x00-\\x1f\\x7f]/.test(value);\n}\n\nfunction apsDimensionResult(value) {\n if (typeof value !== 'number' || !isFinite(value) || Math.floor(value) !== value || value <= 0) {\n return 'invalid_dimensions';\n }\n if (value < RENDER_DIMENSION_MIN || value > RENDER_DIMENSION_MAX) {\n return 'dimensions_out_of_range';\n }\n return 'accepted';\n}\n\nfunction apsValidCreativeUrl(value, publisherOrigin) {\n try {\n var url = new URL(value);\n return url.protocol === 'https:' && url.hostname !== '' && url.username === '' &&\n url.password === '' && url.origin !== publisherOrigin;\n } catch (_error) {\n return false;\n }\n}\n\nfunction apsDecodeEnvelope(value) {\n if (value.length === 0 || value.length > MAX_RENDER_ENVELOPE_BASE64_BYTES ||\n value.length % 4 !== 0 || !(new RegExp(STANDARD_BASE64_PATTERN)).test(value)) {\n return undefined;\n }\n try {\n var binary = atob(value);\n if (binary.length > MAX_RENDER_ENVELOPE_BYTES || btoa(binary) !== value) return undefined;\n var bytes = new Uint8Array(binary.length);\n for (var index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);\n return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));\n } catch (_error) {\n return undefined;\n }\n}\n\nfunction classifyApsRendererDescriptorV1(\n value\n) {\n var renderer = value;\n if (!apsExactRecord(renderer, DESCRIPTOR_KEYS) &&\n !apsExactRecord(renderer, DESCRIPTOR_KEYS_WITH_CREATIVE_ID)) return 'descriptor_invalid';\n if (renderer.type !== 'aps' || renderer.version !== 1 ||\n typeof renderer.accountId !== 'string' || renderer.accountId.length === 0 ||\n apsUtf8Length(renderer.accountId) > MAX_ACCOUNT_ID_BYTES ||\n typeof renderer.bidId !== 'string' || renderer.bidId.length === 0 ||\n apsUtf8Length(renderer.bidId) > MAX_BID_ID_BYTES ||\n apsHasAsciiControl(renderer.bidId)) return 'descriptor_invalid';\n if (Object.prototype.hasOwnProperty.call(renderer, 'creativeId') &&\n (typeof renderer.creativeId !== 'string' || renderer.creativeId.length === 0 ||\n apsUtf8Length(renderer.creativeId) > MAX_CREATIVE_ID_BYTES)) return 'descriptor_invalid';\n if (renderer.tagType !== 'iframe' && renderer.tagType !== 'script') return 'descriptor_invalid';\n\n var widthResult = apsDimensionResult(renderer.width);\n if (widthResult !== 'accepted') return widthResult;\n var heightResult = apsDimensionResult(renderer.height);\n if (heightResult !== 'accepted') return heightResult;\n\n if (typeof renderer.creativeUrl !== 'string' ||\n apsUtf8Length(renderer.creativeUrl) > MAX_CREATIVE_URL_BYTES ||\n typeof renderer.aaxResponse !== 'string' ||\n renderer.aaxResponse.length > MAX_RENDER_ENVELOPE_BASE64_BYTES) return 'descriptor_invalid';\n return 'accepted';\n}\n\nfunction classifyApsRendererV1(\n value,\n publisherOrigin\n) {\n var renderer = value;\n var descriptorResult =\n classifyApsRendererDescriptorV1(renderer);\n if (descriptorResult !== 'accepted') return descriptorResult;\n if (!apsValidCreativeUrl(renderer.creativeUrl, publisherOrigin)) return 'descriptor_invalid';\n\n var decoded = apsDecodeEnvelope(renderer.aaxResponse);\n if (!apsExactRecord(decoded, ENVELOPE_ROOT_KEYS) || !Array.isArray(decoded.seatbid) ||\n decoded.seatbid.length !== 1) return 'descriptor_invalid';\n var seat = decoded.seatbid[0];\n if (!apsExactRecord(seat, ENVELOPE_SEAT_KEYS) || !Array.isArray(seat.bid) ||\n seat.bid.length !== 1) return 'descriptor_invalid';\n var bid = seat.bid[0];\n if (!apsExactRecord(bid, ENVELOPE_BID_KEYS) ||\n !apsExactRecord(bid.ext, ENVELOPE_EXT_KEYS)) return 'descriptor_invalid';\n\n var bidWidthResult = apsDimensionResult(bid.w);\n if (bidWidthResult !== 'accepted') return bidWidthResult;\n var bidHeightResult = apsDimensionResult(bid.h);\n if (bidHeightResult !== 'accepted') return bidHeightResult;\n if (bid.id !== renderer.bidId || bid.w !== renderer.width || bid.h !== renderer.height ||\n bid.ext.creativeurl !== renderer.creativeUrl || bid.ext.tagtype !== renderer.tagType ||\n typeof bid.price !== 'number' || !isFinite(bid.price) || bid.price < 0) {\n return 'descriptor_invalid';\n }\n return 'accepted';\n}\n"; diff --git a/crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_v1.ts b/crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_v1.ts new file mode 100644 index 000000000..dec409ae6 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_v1.ts @@ -0,0 +1,141 @@ +// @generated by scripts/generate-aps-renderer-contract.mjs +// schema-sha256: 3f82e9c8d57719c29810a0ed181f4fe2779919c65605ae0cd7c61bd6d865b027 +// corpus-sha256: 3aea612e3316e6df4852e80cb3aa8882ca43d842455a22ba29e63fa88291c7b9 +/* eslint-disable */ +export type ApsRendererValidationResult = 'accepted' | 'descriptor_invalid' | 'invalid_dimensions' | 'dimensions_out_of_range'; +var DESCRIPTOR_KEYS = ["aaxResponse","accountId","bidId","creativeUrl","height","tagType","type","version","width"]; +var DESCRIPTOR_KEYS_WITH_CREATIVE_ID = ["aaxResponse","accountId","bidId","creativeId","creativeUrl","height","tagType","type","version","width"]; +var ENVELOPE_ROOT_KEYS = ["seatbid"]; +var ENVELOPE_SEAT_KEYS = ["bid"]; +var ENVELOPE_BID_KEYS = ["ext","h","id","price","w"]; +var ENVELOPE_EXT_KEYS = ["creativeurl","tagtype"]; +var MAX_ACCOUNT_ID_BYTES = 1024; +var MAX_BID_ID_BYTES = 64; +var MAX_CREATIVE_ID_BYTES = 1024; +var MAX_CREATIVE_URL_BYTES = 4096; +var MAX_RENDER_ENVELOPE_BYTES = 262144; +var MAX_RENDER_ENVELOPE_BASE64_BYTES = 349528; +var STANDARD_BASE64_PATTERN = "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"; +export { MAX_ACCOUNT_ID_BYTES, MAX_BID_ID_BYTES, MAX_CREATIVE_ID_BYTES, MAX_RENDER_ENVELOPE_BASE64_BYTES }; +export const RENDER_DIMENSION_MIN = 1; +export const RENDER_DIMENSION_MAX = 4096; +function apsExactRecord(value: any, expectedKeys: string[]): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + var prototype: any = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + if (typeof Object.getOwnPropertySymbols === 'function' && Object.getOwnPropertySymbols(value).length !== 0) return false; + var actual: string[] = Object.getOwnPropertyNames(value).sort(); + if (actual.length !== expectedKeys.length) return false; + for (var index = 0; index < actual.length; index += 1) { + var propertyName: string | undefined = actual[index]; + if (propertyName === undefined || propertyName !== expectedKeys[index]) return false; + var property: any = Object.getOwnPropertyDescriptor(value, propertyName); + if (!property || !Object.prototype.hasOwnProperty.call(property, 'value')) return false; + } + return true; +} + +function apsUtf8Length(value: string): number { + return (new TextEncoder()).encode(value).length; +} + +function apsHasAsciiControl(value: string): boolean { + return /[\x00-\x1f\x7f]/.test(value); +} + +function apsDimensionResult(value: any): ApsRendererValidationResult { + if (typeof value !== 'number' || !isFinite(value) || Math.floor(value) !== value || value <= 0) { + return 'invalid_dimensions'; + } + if (value < RENDER_DIMENSION_MIN || value > RENDER_DIMENSION_MAX) { + return 'dimensions_out_of_range'; + } + return 'accepted'; +} + +function apsValidCreativeUrl(value: string, publisherOrigin: string): boolean { + try { + var url: URL = new URL(value); + return url.protocol === 'https:' && url.hostname !== '' && url.username === '' && + url.password === '' && url.origin !== publisherOrigin; + } catch (_error) { + return false; + } +} + +function apsDecodeEnvelope(value: string): any | undefined { + if (value.length === 0 || value.length > MAX_RENDER_ENVELOPE_BASE64_BYTES || + value.length % 4 !== 0 || !(new RegExp(STANDARD_BASE64_PATTERN)).test(value)) { + return undefined; + } + try { + var binary: string = atob(value); + if (binary.length > MAX_RENDER_ENVELOPE_BYTES || btoa(binary) !== value) return undefined; + var bytes: Uint8Array = new Uint8Array(binary.length); + for (var index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch (_error) { + return undefined; + } +} + +export function classifyApsRendererDescriptorV1( + value: unknown +): ApsRendererValidationResult { + var renderer: any = value; + if (!apsExactRecord(renderer, DESCRIPTOR_KEYS) && + !apsExactRecord(renderer, DESCRIPTOR_KEYS_WITH_CREATIVE_ID)) return 'descriptor_invalid'; + if (renderer.type !== 'aps' || renderer.version !== 1 || + typeof renderer.accountId !== 'string' || renderer.accountId.length === 0 || + apsUtf8Length(renderer.accountId) > MAX_ACCOUNT_ID_BYTES || + typeof renderer.bidId !== 'string' || renderer.bidId.length === 0 || + apsUtf8Length(renderer.bidId) > MAX_BID_ID_BYTES || + apsHasAsciiControl(renderer.bidId)) return 'descriptor_invalid'; + if (Object.prototype.hasOwnProperty.call(renderer, 'creativeId') && + (typeof renderer.creativeId !== 'string' || renderer.creativeId.length === 0 || + apsUtf8Length(renderer.creativeId) > MAX_CREATIVE_ID_BYTES)) return 'descriptor_invalid'; + if (renderer.tagType !== 'iframe' && renderer.tagType !== 'script') return 'descriptor_invalid'; + + var widthResult: ApsRendererValidationResult = apsDimensionResult(renderer.width); + if (widthResult !== 'accepted') return widthResult; + var heightResult: ApsRendererValidationResult = apsDimensionResult(renderer.height); + if (heightResult !== 'accepted') return heightResult; + + if (typeof renderer.creativeUrl !== 'string' || + apsUtf8Length(renderer.creativeUrl) > MAX_CREATIVE_URL_BYTES || + typeof renderer.aaxResponse !== 'string' || + renderer.aaxResponse.length > MAX_RENDER_ENVELOPE_BASE64_BYTES) return 'descriptor_invalid'; + return 'accepted'; +} + +export function classifyApsRendererV1( + value: unknown, + publisherOrigin: string +): ApsRendererValidationResult { + var renderer: any = value; + var descriptorResult: ApsRendererValidationResult = + classifyApsRendererDescriptorV1(renderer); + if (descriptorResult !== 'accepted') return descriptorResult; + if (!apsValidCreativeUrl(renderer.creativeUrl, publisherOrigin)) return 'descriptor_invalid'; + + var decoded: any = apsDecodeEnvelope(renderer.aaxResponse); + if (!apsExactRecord(decoded, ENVELOPE_ROOT_KEYS) || !Array.isArray(decoded.seatbid) || + decoded.seatbid.length !== 1) return 'descriptor_invalid'; + var seat: any = decoded.seatbid[0]; + if (!apsExactRecord(seat, ENVELOPE_SEAT_KEYS) || !Array.isArray(seat.bid) || + seat.bid.length !== 1) return 'descriptor_invalid'; + var bid: any = seat.bid[0]; + if (!apsExactRecord(bid, ENVELOPE_BID_KEYS) || + !apsExactRecord(bid.ext, ENVELOPE_EXT_KEYS)) return 'descriptor_invalid'; + + var bidWidthResult: ApsRendererValidationResult = apsDimensionResult(bid.w); + if (bidWidthResult !== 'accepted') return bidWidthResult; + var bidHeightResult: ApsRendererValidationResult = apsDimensionResult(bid.h); + if (bidHeightResult !== 'accepted') return bidHeightResult; + if (bid.id !== renderer.bidId || bid.w !== renderer.width || bid.h !== renderer.height || + bid.ext.creativeurl !== renderer.creativeUrl || bid.ext.tagtype !== renderer.tagType || + typeof bid.price !== 'number' || !isFinite(bid.price) || bid.price < 0) { + return 'descriptor_invalid'; + } + return 'accepted'; +} diff --git a/crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts b/crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts new file mode 100644 index 000000000..692e33d27 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts @@ -0,0 +1,175 @@ +import { + INTEGRATION_CONFIG_IDS_V1, + type BootJsonValueV1, + type IntegrationConfigEntryV1, + type IntegrationConfigIdV1, + type IntegrationConfigsV1, +} from '../types'; + +import { sha256HexUtf8V1 } from './sha256'; + +export { sha256HexUtf8V1 } from './sha256'; + +const MAX_DEPTH = 16; +const MAX_VALUES = 4_096; +const MAX_STRING_BYTES = 4_096; +const MAX_ENTRY_BYTES = 65_536; +const MAX_CARRIER_BYTES = 524_288; + +const CONFIG_ORDER = new Map(INTEGRATION_CONFIG_IDS_V1.map((id, index) => [id, index] as const)); + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function ownPlainDataRecord(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; + const result: Record = {}; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + Object.defineProperty(result, key, { + configurable: true, + enumerable: true, + value: descriptor.value, + writable: true, + }); + } + return result; +} + +function exactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === keys.length && actual.every((key) => keys.includes(key)); +} + +function snapshotArray(value: unknown, maximum: number): readonly unknown[] | undefined { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + const length = Object.getOwnPropertyDescriptor(value, 'length'); + if (!length || !('value' in length) || !Number.isSafeInteger(length.value)) return undefined; + if (length.value < 0 || length.value > maximum) return undefined; + const keys = Reflect.ownKeys(value); + if (keys.length !== length.value + 1) return undefined; + const result: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result.push(descriptor.value); + } + return result; +} + +function snapshotJsonValue( + candidate: unknown, + depth: number, + seen: Set, + count: { value: number } +): BootJsonValueV1 | undefined { + if (candidate === null || typeof candidate === 'boolean') { + count.value += 1; + return count.value <= MAX_VALUES ? candidate : undefined; + } + if (typeof candidate === 'number') { + count.value += 1; + return count.value <= MAX_VALUES && Number.isFinite(candidate) ? candidate : undefined; + } + if (typeof candidate === 'string') { + count.value += 1; + return count.value <= MAX_VALUES && utf8Length(candidate) <= MAX_STRING_BYTES + ? candidate + : undefined; + } + if (typeof candidate !== 'object' || depth > MAX_DEPTH || seen.has(candidate)) { + return undefined; + } + seen.add(candidate); + count.value += 1; + if (count.value > MAX_VALUES) return undefined; + + const array = snapshotArray(candidate, MAX_VALUES); + if (array) { + const copy: BootJsonValueV1[] = []; + for (const value of array) { + const accepted = snapshotJsonValue(value, depth + 1, seen, count); + if (accepted === undefined && value !== null) return undefined; + copy.push(accepted as BootJsonValueV1); + } + return Object.freeze(copy); + } + + const record = ownPlainDataRecord(candidate); + if (!record) return undefined; + const copy: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (utf8Length(key) > MAX_STRING_BYTES) return undefined; + const accepted = snapshotJsonValue(value, depth + 1, seen, count); + if (accepted === undefined && value !== null) return undefined; + Object.defineProperty(copy, key, { + configurable: true, + enumerable: true, + value: accepted as BootJsonValueV1, + writable: true, + }); + } + return Object.freeze(copy); +} + +/** Copy and recursively freeze the sole generic browser configuration carrier. */ +export function snapshotIntegrationConfigsV1(candidate: unknown): IntegrationConfigsV1 | undefined { + try { + const root = ownPlainDataRecord(candidate); + if (!root || !exactKeys(root, ['version', 'entries']) || root.version !== 1) return undefined; + const entries = snapshotArray(root.entries, INTEGRATION_CONFIG_IDS_V1.length); + if (!entries) return undefined; + const seen = new Set(); + const count = { value: 0 }; + const accepted = []; + let previous = -1; + for (const candidateEntry of entries) { + const entry = ownPlainDataRecord(candidateEntry); + if (!entry || !exactKeys(entry, ['id', 'config']) || typeof entry.id !== 'string') { + return undefined; + } + const order = CONFIG_ORDER.get(entry.id as IntegrationConfigIdV1); + if (order === undefined || order <= previous) return undefined; + const config = snapshotJsonValue(entry.config, 0, seen, count); + if ( + !config || + Array.isArray(config) || + typeof config !== 'object' || + utf8Length(JSON.stringify({ id: entry.id, config })) > MAX_ENTRY_BYTES + ) { + return undefined; + } + accepted.push( + Object.freeze({ + id: entry.id as IntegrationConfigIdV1, + config: config as Readonly>, + }) + ); + previous = order; + } + const result: IntegrationConfigsV1 = Object.freeze({ + version: 1, + entries: Object.freeze(accepted), + }); + return utf8Length(JSON.stringify(result)) <= MAX_CARRIER_BYTES ? result : undefined; + } catch { + return undefined; + } +} + +/** Return one already-frozen product value without exposing the carrier map. */ +export function integrationConfigValueV1( + carrier: IntegrationConfigsV1, + id: IntegrationConfigIdV1 +): IntegrationConfigEntryV1['config'] | undefined { + return carrier.entries.find((entry) => entry.id === id)?.config; +} + +/** Bind a retained carrier snapshot to first-display takeover metadata. */ +export function canonicalIntegrationConfigDigestV1(carrier: IntegrationConfigsV1): string { + return sha256HexUtf8V1(JSON.stringify(carrier)); +} diff --git a/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts b/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts new file mode 100644 index 000000000..0258dbe14 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts @@ -0,0 +1,182 @@ +const REQUEST_ADS_DEFAULT_TIMEOUT_MS = 10_000; +const REQUEST_ADS_MAX_SLOTS = 256; +const reflectApplyIntrinsic = Reflect.apply; +const objectGetOwnPropertyNamesIntrinsic = Object.getOwnPropertyNames; +const objectKeysIntrinsic = Object.keys; +const textEncoder = new TextEncoder(); +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; +const regExpTestIntrinsic = RegExp.prototype.test; +const loneSurrogatePattern = /[\uD800-\uDFFF]/u; +const abortSignalAbortedGetter = + typeof AbortSignal === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; + +export type RequestAdsInputErrorCode = + | 'invalid_options' + | 'invalid_slots' + | 'empty_slots' + | 'duplicate_slot' + | 'invalid_timeout' + | 'invalid_signal'; + +export class RequestAdsInputError extends Error { + public readonly code: RequestAdsInputErrorCode; + + public constructor(code: RequestAdsInputErrorCode) { + super(code); + this.name = 'RequestAdsInputError'; + this.code = code; + } +} + +export interface ValidatedRequestAdsOptions { + readonly aborted: boolean; + readonly signal: AbortSignal | undefined; + readonly slots: readonly string[] | undefined; + readonly timeoutMs: number; +} + +function ownDataOptions(value: unknown): Record | undefined { + try { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) return undefined; + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const output: Record = Object.create(null) as Record; + const names = reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [ + value, + ]) as string[]; + for (let index = 0; index < names.length; index += 1) { + const key = names[index]; + if (key === undefined) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + output[key] = descriptor.value; + } + return output; + } catch { + return undefined; + } +} + +function ownDataSlots(value: unknown): readonly unknown[] | undefined { + try { + if ( + !Array.isArray(value) || + Object.getPrototypeOf(value) !== Array.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const length = Object.getOwnPropertyDescriptor(value, 'length'); + if ( + !length || + !('value' in length) || + !Number.isSafeInteger(length.value) || + length.value < 0 || + length.value > REQUEST_ADS_MAX_SLOTS || + (reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [value]) as string[]) + .length !== + length.value + 1 + ) { + return undefined; + } + const output: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + output[index] = descriptor.value; + } + return output; + } catch { + return undefined; + } +} + +function readAbortSignal(signal: unknown): boolean | undefined { + try { + return typeof abortSignalAbortedGetter === 'function' + ? (reflectApplyIntrinsic(abortSignalAbortedGetter, signal, []) as boolean) + : undefined; + } catch { + return undefined; + } +} + +function hasAsciiControl(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +/** Validate and detach the complete public request before creating attempts. */ +export function validateRequestAdsOptions(value: unknown): ValidatedRequestAdsOptions { + if (value === undefined) { + return Object.freeze({ + aborted: false, + signal: undefined, + slots: undefined, + timeoutMs: REQUEST_ADS_DEFAULT_TIMEOUT_MS, + }); + } + const options = ownDataOptions(value); + if (!options) throw new RequestAdsInputError('invalid_options'); + const optionKeys = reflectApplyIntrinsic(objectKeysIntrinsic, Object, [options]) as string[]; + for (let index = 0; index < optionKeys.length; index += 1) { + const key = optionKeys[index]; + if (key === 'slots' || key === 'timeoutMs' || key === 'signal') continue; + throw new RequestAdsInputError('invalid_options'); + } + + let slots: readonly string[] | undefined; + if (Object.prototype.hasOwnProperty.call(options, 'slots')) { + const rawSlots = ownDataSlots(options.slots); + if (!rawSlots) throw new RequestAdsInputError('invalid_slots'); + if (rawSlots.length === 0) throw new RequestAdsInputError('empty_slots'); + const seen = new Set(); + const copy: string[] = []; + for (let index = 0; index < rawSlots.length; index += 1) { + const slot = rawSlots[index]; + if ( + typeof slot !== 'string' || + slot.length === 0 || + (reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [slot]) as Uint8Array) + .byteLength > 256 || + hasAsciiControl(slot) || + reflectApplyIntrinsic(regExpTestIntrinsic, loneSurrogatePattern, [slot]) + ) { + throw new RequestAdsInputError('invalid_slots'); + } + if (seen.has(slot)) throw new RequestAdsInputError('duplicate_slot'); + seen.add(slot); + copy.push(slot); + } + slots = Object.freeze(copy); + } + + let timeoutMs = REQUEST_ADS_DEFAULT_TIMEOUT_MS; + if (Object.prototype.hasOwnProperty.call(options, 'timeoutMs')) { + if ( + typeof options.timeoutMs !== 'number' || + !Number.isInteger(options.timeoutMs) || + options.timeoutMs < 100 || + options.timeoutMs > 30_000 + ) { + throw new RequestAdsInputError('invalid_timeout'); + } + timeoutMs = options.timeoutMs; + } + + let signal: AbortSignal | undefined; + let aborted = false; + if (Object.prototype.hasOwnProperty.call(options, 'signal')) { + const observed = readAbortSignal(options.signal); + if (observed === undefined) throw new RequestAdsInputError('invalid_signal'); + signal = options.signal as AbortSignal; + aborted = observed; + } + return Object.freeze({ aborted, signal, slots, timeoutMs }); +} diff --git a/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts b/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts new file mode 100644 index 000000000..0537eda42 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts @@ -0,0 +1,328 @@ +import type { TakeoverOutlineV1 } from '../../shared/first_display_contracts'; +import type { BootManifestV1, TsjsBootV1 } from '../types'; + +const MAX_TRANSPORT_BYTES = 10 * 1024 * 1024; +const MAX_TAKEOVER_INTEGRATIONS = 14; +const RELEASE_ID = /^[0-9a-f]{64}$/; +const HASH = /^[0-9a-f]{64}$/; +const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; +const FIRST_DISPLAY_SRC = + /^\/static\/tsjs=tsjs-first-display\.min\.js\?m=([0-9a-f]{4})&v=[0-9a-f]{64}$/; +const RUNTIME_SRC = /^\/static\/tsjs=tsjs-unified\.min\.js\?v=[0-9a-f]{64}$/; +const DEFERRED_SRC = /^\/static\/tsjs=tsjs-([a-z0-9][a-z0-9_-]{0,63})\.min\.js\?v=[0-9a-f]{64}$/; +const FIRST_DISPLAY_IDS = Object.freeze([ + 'first_display', + 'render_owner_initial', + 'aps_initial', + 'creative_initial', + 'datadome_initial', + 'didomi_initial', + 'google_tag_manager_initial', + 'gpt_initial', + 'lockr_initial', + 'osano_initial', + 'permutive_initial', + 'sourcepoint_initial', + 'prebid_initial', + 'testlight_initial', +] as const); +const CONFIG_IDS = Object.freeze([ + 'aps', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'lockr', + 'osano', + 'permutive', + 'prebid', + 'sourcepoint', + 'testlight', +] as const); +export interface ServerBootIntegrityV1 { + readonly version: 1; + readonly projectionDigest: string; + readonly integrationConfigDigest: string; +} + +export interface ServerBootTransportSnapshotV1 { + readonly version: 1; + readonly boot: Readonly; + readonly integrity: Readonly; + readonly outline: TakeoverOutlineV1 | null; +} + +function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function exact(value: unknown, keys: readonly string[]): Record | undefined { + const candidate = record(value); + if (!candidate || Object.getPrototypeOf(candidate) !== Object.prototype) return undefined; + const actual = Object.keys(candidate); + return actual.length === keys.length && actual.every((key) => keys.includes(key)) + ? candidate + : undefined; +} + +export function deepFreezeTransportV1(value: unknown): void { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return; + for (const key of Object.keys(value)) { + deepFreezeTransportV1((value as Record)[key]); + } + Object.freeze(value); +} + +function validManifest(candidate: unknown, releaseId: string): BootManifestV1 | undefined { + const manifest = exact(candidate, [ + 'version', + 'releaseId', + 'firstDisplay', + 'runtimeSrc', + 'integrations', + ]); + if ( + !manifest || + manifest.version !== 1 || + manifest.releaseId !== releaseId || + typeof manifest.runtimeSrc !== 'string' || + !RUNTIME_SRC.test(manifest.runtimeSrc) || + !Array.isArray(manifest.integrations) || + manifest.integrations.length > 20 + ) { + return undefined; + } + const seen = new Set(); + let deferred = false; + let takeoverCount = 0; + for (const value of manifest.integrations) { + const integration = record(value); + if ( + !integration || + typeof integration.id !== 'string' || + !INTEGRATION_ID.test(integration.id) || + seen.has(integration.id) + ) { + return undefined; + } + seen.add(integration.id); + if (integration.phase === 'takeover') { + takeoverCount += 1; + if ( + deferred || + takeoverCount > MAX_TAKEOVER_INTEGRATIONS || + !exact(integration, ['id', 'phase']) + ) { + return undefined; + } + continue; + } + const deferredSource = + typeof integration.src === 'string' ? DEFERRED_SRC.exec(integration.src) : null; + if ( + integration.phase !== 'deferred' || + !exact(integration, ['id', 'phase', 'trigger', 'src']) || + integration.trigger !== 'first_display_or_idle' || + deferredSource?.[1] !== integration.id + ) { + return undefined; + } + deferred = true; + } + if (manifest.firstDisplay === null) return manifest as unknown as BootManifestV1; + const firstDisplay = exact(manifest.firstDisplay, ['src', 'slices']); + const slices = firstDisplay?.slices; + if ( + !firstDisplay || + typeof firstDisplay.src !== 'string' || + !Array.isArray(slices) || + slices.length === 0 || + slices.length > FIRST_DISPLAY_IDS.length + ) { + return undefined; + } + const match = FIRST_DISPLAY_SRC.exec(firstDisplay.src); + if (!match) return undefined; + const mask = Number.parseInt(match[1]!, 16); + const selected = FIRST_DISPLAY_IDS.filter((_id, index) => (mask & (1 << index)) !== 0); + if ( + (mask & 1) === 0 || + mask >>> FIRST_DISPLAY_IDS.length !== 0 || + ((mask & 4) !== 0 && (mask & 2) === 0) || + ((mask & 2) !== 0 && (mask & 128) === 0) || + selected.length !== slices.length || + selected.some((id, index) => slices[index] !== id) + ) { + return undefined; + } + return manifest as unknown as BootManifestV1; +} + +function validBoot(candidate: unknown, releaseId: string): Readonly | undefined { + const boot = exact(candidate, [ + 'abi', + 'releaseId', + 'manifest', + 'auctionProjection', + 'integrations', + 'creative', + 'diagnostics', + ]); + if (!boot || boot.abi !== 1 || boot.releaseId !== releaseId) return undefined; + if (!validManifest(boot.manifest, releaseId)) return undefined; + + const projection = exact(boot.auctionProjection, ['version', 'auction', 'slots', 'bids']); + const auction = exact(projection?.auction, ['version', 'auctionId', 'results']); + if ( + !projection || + projection.version !== 1 || + !auction || + auction.version !== 1 || + typeof auction.auctionId !== 'string' || + !Array.isArray(auction.results) || + auction.results.length > 256 || + !Array.isArray(projection.slots) || + projection.slots.length > 256 || + !Array.isArray(projection.bids) + ) { + return undefined; + } + + const integrations = exact(boot.integrations, ['version', 'entries']); + if ( + !integrations || + integrations.version !== 1 || + !Array.isArray(integrations.entries) || + integrations.entries.length > CONFIG_IDS.length + ) { + return undefined; + } + let previous = -1; + for (const value of integrations.entries) { + const entry = exact(value, ['id', 'config']); + const order = + entry && typeof entry.id === 'string' + ? CONFIG_IDS.indexOf(entry.id as (typeof CONFIG_IDS)[number]) + : -1; + if (!entry || order <= previous || !record(entry.config)) return undefined; + previous = order; + } + + const creative = exact(boot.creative, ['version', 'enabled', 'clickGuard', 'renderGuard']); + const diagnostics = exact(boot.diagnostics, ['version', 'renderTraceOverlay', 'gpt']); + const gpt = exact(diagnostics?.gpt, ['active']); + if ( + !creative || + creative.version !== 1 || + typeof creative.enabled !== 'boolean' || + typeof creative.clickGuard !== 'boolean' || + typeof creative.renderGuard !== 'boolean' || + (!creative.enabled && (creative.clickGuard || creative.renderGuard)) || + !diagnostics || + diagnostics.version !== 1 || + typeof diagnostics.renderTraceOverlay !== 'boolean' || + !gpt || + typeof gpt.active !== 'boolean' + ) { + return undefined; + } + return boot as unknown as Readonly; +} +function validIntegrity(candidate: unknown): Readonly | undefined { + const integrity = exact(candidate, ['version', 'projectionDigest', 'integrationConfigDigest']); + return integrity && + integrity.version === 1 && + typeof integrity.projectionDigest === 'string' && + HASH.test(integrity.projectionDigest) && + typeof integrity.integrationConfigDigest === 'string' && + HASH.test(integrity.integrationConfigDigest) + ? (integrity as unknown as Readonly) + : undefined; +} + +function validOutline( + candidate: unknown, + releaseId: string, + integrity: Readonly, + boot: Readonly +): TakeoverOutlineV1 | null | undefined { + if (candidate === null) return boot.manifest.firstDisplay === null ? null : undefined; + const outline = exact(candidate, [ + 'version', + 'releaseId', + 'generation', + 'projectionDigest', + 'integrationConfigDigest', + 'slices', + 'slotCount', + 'outcomeCount', + 'capabilities', + 'objectKinds', + ]); + const firstDisplay = boot.manifest.firstDisplay; + const projection = boot.auctionProjection; + if ( + !outline || + !firstDisplay || + outline.version !== 1 || + outline.releaseId !== releaseId || + !Number.isInteger(outline.generation) || + (outline.generation as number) < 1 || + (outline.generation as number) > 4_294_967_295 || + outline.projectionDigest !== integrity.projectionDigest || + outline.integrationConfigDigest !== integrity.integrationConfigDigest || + !Array.isArray(outline.slices) || + outline.slices.length !== firstDisplay.slices.length || + outline.slices.some((id, index) => id !== firstDisplay.slices[index]) || + !Number.isInteger(outline.slotCount) || + outline.slotCount !== projection.slots.length || + (outline.slotCount as number) < 1 || + !Number.isInteger(outline.outcomeCount) || + outline.outcomeCount !== projection.auction.results.length || + outline.outcomeCount !== outline.slotCount || + !Array.isArray(outline.capabilities) || + outline.capabilities.length !== 0 || + !Array.isArray(outline.objectKinds) + ) { + return undefined; + } + const expectedKinds = projection.bids.length === 0 ? [] : ['gpt_slot', 'dom_artifact']; + if ( + outline.objectKinds.length !== expectedKinds.length || + outline.objectKinds.some((kind, index) => kind !== expectedKinds[index]) + ) { + return undefined; + } + return outline as unknown as TakeoverOutlineV1; +} + +/** Parse the one server-sealed lexical boot value without importing domain validators. */ +export function snapshotServerBootTransportV1( + payload: unknown, + releaseId: string +): ServerBootTransportSnapshotV1 | undefined { + try { + if ( + typeof payload !== 'string' || + typeof releaseId !== 'string' || + !RELEASE_ID.test(releaseId) || + payload.length > MAX_TRANSPORT_BYTES || + new TextEncoder().encode(payload).byteLength > MAX_TRANSPORT_BYTES + ) { + return undefined; + } + const root = exact(JSON.parse(payload), ['version', 'boot', 'integrity', 'outline']); + if (!root || root.version !== 1) return undefined; + const boot = validBoot(root.boot, releaseId); + const integrity = validIntegrity(root.integrity); + if (!boot || !integrity) return undefined; + const outline = validOutline(root.outline, releaseId, integrity, boot); + if (outline === undefined) return undefined; + deepFreezeTransportV1(root); + return root as unknown as ServerBootTransportSnapshotV1; + } catch { + return undefined; + } +} diff --git a/crates/trusted-server-js/lib/src/core/contracts/sha256.ts b/crates/trusted-server-js/lib/src/core/contracts/sha256.ts new file mode 100644 index 000000000..49887c590 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/sha256.ts @@ -0,0 +1,75 @@ +const H: number[] = []; +const K: number[] = []; +const ENCODER = new TextEncoder(); +for (let n = 2; K.length < 64; n += 1) { + let prime = true; + for (let d = 2; d * d <= n; d += 1) { + if (n % d === 0) { + prime = false; + break; + } + } + if (!prime) continue; + if (H.length < 8) H.push((Math.sqrt(n) * 0x1_0000_0000) >>> 0); + K.push((Math.cbrt(n) * 0x1_0000_0000) >>> 0); +} + +function rotr(word: number, bits: number): number { + return (word >>> bits) | (word << (32 - bits)); +} + +/** Calculate SHA-256 synchronously so boot validation remains effect-inert. */ +export function sha256HexUtf8V1(text: string): string { + const bytes = ENCODER.encode(text); + const length = Math.ceil((bytes.length + 9) / 64) * 64; + const data = new Uint8Array(length); + data.set(bytes); + data[bytes.length] = 0x80; + const bits = bytes.length * 8; + const view = new DataView(data.buffer); + view.setUint32(length - 8, Math.floor(bits / 0x1_0000_0000)); + view.setUint32(length - 4, bits >>> 0); + const state = [...H]; + const w = new Uint32Array(64); + for (let offset = 0; offset < length; offset += 64) { + for (let i = 0; i < 16; i += 1) w[i] = view.getUint32(offset + i * 4); + for (let i = 16; i < 64; i += 1) { + const left = w[i - 15]!; + const right = w[i - 2]!; + const s0 = rotr(left, 7) ^ rotr(left, 18) ^ (left >>> 3); + const s1 = rotr(right, 17) ^ rotr(right, 19) ^ (right >>> 10); + w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0; + } + let [a, b, c, d, e, f, g, h] = state; + for (let i = 0; i < 64; i += 1) { + const s1 = rotr(e!, 6) ^ rotr(e!, 11) ^ rotr(e!, 25); + const ch = (e! & f!) ^ (~e! & g!); + const t1 = (h! + s1 + ch + K[i]! + w[i]!) >>> 0; + const s0 = rotr(a!, 2) ^ rotr(a!, 13) ^ rotr(a!, 22); + const maj = (a! & b!) ^ (a! & c!) ^ (b! & c!); + const t2 = (s0 + maj) >>> 0; + h = g; + g = f; + f = e; + e = (d! + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + state[0] = (state[0]! + a!) >>> 0; + state[1] = (state[1]! + b!) >>> 0; + state[2] = (state[2]! + c!) >>> 0; + state[3] = (state[3]! + d!) >>> 0; + state[4] = (state[4]! + e!) >>> 0; + state[5] = (state[5]! + f!) >>> 0; + state[6] = (state[6]! + g!) >>> 0; + state[7] = (state[7]! + h!) >>> 0; + } + return state.map((word) => word.toString(16).padStart(8, '0')).join(''); +} + +/** Measure UTF-8 with the same intrinsic used by the synchronous digest. */ +export function utf8LengthV1(text: string): number { + return ENCODER.encode(text).byteLength; +} diff --git a/crates/trusted-server-js/lib/src/core/first_impression.ts b/crates/trusted-server-js/lib/src/core/first_impression.ts deleted file mode 100644 index 05252fcc1..000000000 --- a/crates/trusted-server-js/lib/src/core/first_impression.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { resolveSlotElementByDivId } from './slot_element'; -import type { - FirstImpressionPhase, - FirstImpressionPublisherAuction, - FirstImpressionSlotClaim, - FirstImpressionState, - TsjsApi, -} from './types'; - -/** Time allowed for one navigation's losing first-impression delivery. */ -export const FIRST_IMPRESSION_LEASE_MS = 5000; - -const MAX_FIRST_IMPRESSION_SLOTS = 256; -const MAX_PUBLISHER_AUCTIONS_PER_SLOT = 16; - -function currentGeneration(ts: TsjsApi): number { - return ts.navGeneration ?? 0; -} - -function claimMatchesElement( - claim: FirstImpressionSlotClaim, - element: HTMLElement, - generation: number -): boolean { - return ( - claim.generation === generation && - claim.slotElementId === element.id && - claim.element === element && - element.ownerDocument === document && - element.isConnected && - document.getElementById(element.id) === element - ); -} - -function removePublisherAuction( - state: FirstImpressionState, - claim: FirstImpressionSlotClaim, - token: string, - now: number -): void { - delete claim.publisherAuctions[token]; - if ( - claim.owner === 'publisher' && - (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && - Object.keys(claim.publisherAuctions).length === 0 && - claim.expiresAt <= now - ) { - delete state.slots[claim.slotElementId]; - } -} - -function pruneFirstImpressionState(ts: TsjsApi, now = Date.now()): FirstImpressionState { - const generation = currentGeneration(ts); - if (ts.firstImpression?.generation !== generation) { - ts.firstImpression = { generation, nextToken: 0, slots: {}, fallbackSlots: {} }; - } - - const state = ts.firstImpression; - state.slots ??= {}; - state.fallbackSlots ??= {}; - for (const [elementId, claim] of Object.entries(state.slots)) { - if ( - claim.slotElementId !== elementId || - !claimMatchesElement(claim, claim.element, generation) - ) { - delete state.slots[elementId]; - continue; - } - const hasReservedFallback = - claim.owner === 'publisher' && - (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && - state.fallbackSlots[elementId] === claim.element; - for (const [token, auction] of Object.entries(claim.publisherAuctions)) { - // A TS-owned losing publisher auction remains a fail-closed tombstone for - // this physical element and navigation. Publisher registrations also stay - // intact while an expired claim is waiting to transition to its reserved - // TS fallback, so an overlapping late callback cannot escape suppression. - if ( - auction.expiresAt <= now && - !hasReservedFallback && - !(claim.owner === 'trusted_server' && auction.suppressDelivery) - ) { - removePublisherAuction(state, claim, token, now); - } - } - if ( - claim.owner === 'publisher' && - (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && - Object.keys(claim.publisherAuctions).length === 0 && - claim.expiresAt <= now && - !hasReservedFallback - ) { - delete state.slots[elementId]; - } - } - for (const [elementId, element] of Object.entries(state.fallbackSlots)) { - if ( - !element.isConnected || - element.id !== elementId || - document.getElementById(elementId) !== element - ) { - delete state.fallbackSlots[elementId]; - } - } - return state; -} - -/** Resolve a publisher ad-unit code with the same contract GPT uses. */ -export function resolveFirstImpressionElement(adUnitCode: string): HTMLElement | undefined { - return resolveSlotElementByDivId(adUnitCode).element ?? undefined; -} - -/** Return the live ownership claim for an exact slot element. */ -export function firstImpressionClaim( - ts: TsjsApi, - element: HTMLElement -): FirstImpressionSlotClaim | undefined { - const state = pruneFirstImpressionState(ts); - const claim = state.slots[element.id]; - return claim && claimMatchesElement(claim, element, state.generation) ? claim : undefined; -} - -function storeClaim(state: FirstImpressionState, claim: FirstImpressionSlotClaim): boolean { - if ( - !state.slots[claim.slotElementId] && - Object.keys(state.slots).length >= MAX_FIRST_IMPRESSION_SLOTS - ) { - return false; - } - state.slots[claim.slotElementId] = claim; - return true; -} - -/** Atomically claim an untouched slot for Trusted Server. */ -export function claimFirstImpressionForTrustedServer( - ts: TsjsApi, - element: HTMLElement, - now = Date.now() -): FirstImpressionSlotClaim | undefined { - const state = pruneFirstImpressionState(ts, now); - const existing = state.slots[element.id]; - if (existing && claimMatchesElement(existing, element, state.generation)) { - const canTransitionPublisherFallback = - existing.owner === 'publisher' && - existing.phase !== 'requested' && - existing.phase !== 'rendered' && - existing.expiresAt <= now && - state.fallbackSlots[element.id] === element; - if (!canTransitionPublisherFallback) return undefined; - - existing.owner = 'trusted_server'; - existing.phase = 'delivery_pending'; - existing.expiresAt = now + FIRST_IMPRESSION_LEASE_MS; - for (const auction of Object.values(existing.publisherAuctions)) { - auction.suppressDelivery = true; - } - return existing; - } - - const claim: FirstImpressionSlotClaim = { - generation: state.generation, - slotElementId: element.id, - element, - owner: 'trusted_server', - phase: 'delivery_pending', - expiresAt: now + FIRST_IMPRESSION_LEASE_MS, - publisherAuctions: {}, - }; - return storeClaim(state, claim) ? claim : undefined; -} - -function schedulePublisherAuctionExpiry(ts: TsjsApi, token: string): void { - window.setTimeout(() => { - // Pruning releases ordinary publisher claims. TS-owned suppression tokens - // deliberately survive as bounded tombstones until navigation/element change. - findPublisherAuction(ts, token); - }, FIRST_IMPRESSION_LEASE_MS); -} - -/** Release a TS claim when slot setup failed before any request could start. */ -export function releaseTrustedServerFirstImpressionClaim( - ts: TsjsApi, - element: HTMLElement, - claim: FirstImpressionSlotClaim -): void { - const state = pruneFirstImpressionState(ts); - if ( - state.slots[element.id] === claim && - claim.owner === 'trusted_server' && - claim.phase === 'delivery_pending' - ) { - delete state.slots[element.id]; - if (state.fallbackSlots[element.id] === element) { - delete state.fallbackSlots[element.id]; - } - } -} - -/** Register real publisher auctions before native `requestBids()` starts. */ -export function registerPublisherFirstImpressionAuctions( - ts: TsjsApi, - adUnitCodes: Iterable, - now = Date.now() -): Map { - const state = pruneFirstImpressionState(ts, now); - const registrations = new Map(); - - for (const adUnitCode of adUnitCodes) { - const element = resolveFirstImpressionElement(adUnitCode); - if (!element) continue; - - let claim = state.slots[element.id]; - if (!claim || !claimMatchesElement(claim, element, state.generation)) { - claim = { - generation: state.generation, - slotElementId: element.id, - element, - owner: 'publisher', - phase: 'auctioning', - expiresAt: now + FIRST_IMPRESSION_LEASE_MS, - publisherAuctions: {}, - }; - if (!storeClaim(state, claim)) continue; - } - - if ( - claim.owner === 'publisher' && - (claim.phase === 'requested' || claim.phase === 'rendered') - ) { - continue; - } - if ( - claim.owner === 'trusted_server' && - (claim.publisherRegistrationClosed || claim.expiresAt <= now) - ) { - continue; - } - if (Object.keys(claim.publisherAuctions).length >= MAX_PUBLISHER_AUCTIONS_PER_SLOT) continue; - - const token = `${state.generation}:${++state.nextToken}`; - const auction: FirstImpressionPublisherAuction = { - token, - adUnitCode, - phase: 'auctioning', - expiresAt: now + FIRST_IMPRESSION_LEASE_MS, - adIds: [], - suppressDelivery: claim.owner === 'trusted_server', - }; - claim.publisherAuctions[token] = auction; - if (claim.owner === 'publisher') claim.expiresAt = Math.max(claim.expiresAt, auction.expiresAt); - registrations.set(adUnitCode, token); - schedulePublisherAuctionExpiry(ts, token); - } - - return registrations; -} - -function findPublisherAuction( - ts: TsjsApi, - token: string, - now = Date.now() -): - | { - state: FirstImpressionState; - claim: FirstImpressionSlotClaim; - auction: FirstImpressionPublisherAuction; - } - | undefined { - const state = pruneFirstImpressionState(ts, now); - for (const claim of Object.values(state.slots)) { - const auction = claim.publisherAuctions[token]; - if (auction) return { state, claim, auction }; - } - return undefined; -} - -/** Move one publisher auction to delivery-pending without disturbing overlaps. */ -export function markPublisherFirstImpressionDeliveryPending( - ts: TsjsApi, - token: string, - adIds: string[], - now = Date.now() -): void { - const found = findPublisherAuction(ts, token, now); - if (!found) return; - found.auction.phase = 'delivery_pending'; - found.auction.adIds = [...new Set(adIds)]; - if (found.claim.owner === 'publisher') found.claim.phase = 'delivery_pending'; -} - -/** Release exactly one publisher auction token after failure, timeout, or removal. */ -export function releasePublisherFirstImpressionAuction( - ts: TsjsApi, - token: string, - now = Date.now() -): void { - const found = findPublisherAuction(ts, token, now); - if (!found) return; - if (found.claim.owner === 'trusted_server' && found.auction.suppressDelivery) { - found.claim.publisherRegistrationClosed = true; - return; - } - found.auction.expiresAt = Math.min(found.auction.expiresAt, now); - if ( - found.claim.owner === 'publisher' && - Object.keys(found.claim.publisherAuctions).length === 1 - ) { - found.claim.expiresAt = now; - } - removePublisherAuction(found.state, found.claim, token, now); -} - -/** Consume one correlated publisher delivery and report whether TS owns it. */ -export function consumePublisherFirstImpressionDelivery( - ts: TsjsApi, - token: string | undefined, - now = Date.now() -): boolean { - if (!token) return false; - const found = findPublisherAuction(ts, token, now); - if (!found) return false; - - const suppress = found.claim.owner === 'trusted_server' && found.auction.suppressDelivery; - delete found.claim.publisherAuctions[token]; - if (suppress) found.claim.publisherRegistrationClosed = true; - return suppress; -} - -/** Record a GPT request or render, using publisher ownership when no claimant exists. */ -export function observeFirstImpressionGptLifecycle( - ts: TsjsApi, - element: HTMLElement, - phase: Extract, - now = Date.now() -): void { - const state = pruneFirstImpressionState(ts, now); - let claim = state.slots[element.id]; - if (!claim || !claimMatchesElement(claim, element, state.generation)) { - claim = { - generation: state.generation, - slotElementId: element.id, - element, - owner: 'publisher', - phase, - expiresAt: Number.POSITIVE_INFINITY, - publisherAuctions: {}, - }; - storeClaim(state, claim); - return; - } - - claim.phase = phase; - if (claim.owner === 'publisher') { - claim.expiresAt = Number.POSITIVE_INFINITY; - } else { - // Once TS has committed a GPT request, only publisher auctions that were - // already registered can still represent an overlapping first impression. - // New publisher refreshes are ordinary later impressions and must proceed. - claim.publisherRegistrationClosed = true; - } -} - -/** Reserve the only Trusted Server fallback allowed for this physical slot and generation. */ -export function reservePublisherFirstImpressionFallback( - ts: TsjsApi, - element: HTMLElement -): boolean { - const state = pruneFirstImpressionState(ts); - const reservedElement = state.fallbackSlots[element.id]; - if (reservedElement) return false; - state.fallbackSlots[element.id] = element; - return true; -} - -/** Delay before an abandoned publisher claim can receive one per-slot TS fallback. */ -export function publisherFirstImpressionRetryDelay( - ts: TsjsApi, - element: HTMLElement, - now = Date.now() -): number | undefined { - const claim = firstImpressionClaim(ts, element); - if (!claim) return 0; - if (claim.owner !== 'publisher') return undefined; - if (claim.phase === 'requested' || claim.phase === 'rendered') return undefined; - return Math.max(0, claim.expiresAt - now); -} 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..66a892730 100644 --- a/crates/trusted-server-js/lib/src/core/global.d.ts +++ b/crates/trusted-server-js/lib/src/core/global.d.ts @@ -1,9 +1,8 @@ -import type { TsjsApi } from './types'; - declare global { interface Window { - tsjs?: TsjsApi; - pbjs?: TsjsApi; + /** Bootstrap input before the runtime atomically publishes its exact API. */ + tsjs?: unknown; + pbjs?: unknown; } } diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index b2c4e41e1..9dacfee04 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -1,72 +1,275 @@ -// Public tsjs core bundle: sets up the global API, queue, and default methods. +// Sole production bootstrap for the resilient TSJS runtime. +declare const __TSJS_RUNTIME_CLAIMED_V1__: unknown; + export type { - AdUnit, + AddAdUnitsResult, GptDiagnosticsApi, GptDiagnosticsExportV1, GptDiagnosticsRequestCycle, + ProgrammaticAdUnit, + RequestAdsOptions, + RequestAdsResult, TsjsApi, + TsjsBootV1, + TsjsDiagnostics, } from './types'; -import type { TsjsApi } from './types'; -import { addAdUnits } from './registry'; -import { renderAdUnit, renderAllAdUnits } from './render'; -import { log } from './log'; -import { setConfig, getConfig } from './config'; -import { requestAds } from './request'; -import { installQueue } from './queue'; +export type { Runtime, RuntimeOptions, RuntimeState } from '../kernel/runtime'; + +import type { Runtime, RuntimeOptions } from '../kernel/runtime'; +import { validateRuntimeManifestV1 } from '../kernel/integration_registry'; +import { + coordinatePreparedFirstDisplayTakeoverV1, + finalizeFirstDisplayAgentCaptureV1, +} from '../shared/first_display_handoff'; +import type { ClaimedFirstDisplayTakeoverV1 } from '../shared/takeover'; + +import { ownDataObject } from './contracts/auction_projection'; +import { snapshotFrozenTsjsBootV1 } from './contracts/boot'; +import type { ServerBootIntegrityV1 } from './contracts/server_boot_transport'; +import { sha256HexUtf8V1 } from './contracts/sha256'; +import { EMBEDDED_INTEGRATION_IDS, EMBEDDED_RELEASE_ID, EMBEDDED_RUNTIME_CATALOG } from './release'; +import type { TsjsBootV1 } from './types'; + +type BootstrapTarget = object & { + boot?: unknown; + que?: unknown; +}; -const VERSION = '0.1.0'; +type DirectRuntimeCompletion = (outcome: 'kernel' | 'runtime_fallback' | 'failed_start') => void; -const w: Window & { tsjs?: TsjsApi } = - ((globalThis as unknown as { window?: Window }).window as Window & { - tsjs?: TsjsApi; - }) || ({} as Window & { tsjs?: TsjsApi }); +interface ClaimedServerBootV1 { + readonly source: object; + readonly target: BootstrapTarget; + readonly boot: Readonly; + readonly integrity: Readonly; + readonly complete: (outcome: 'kernel' | 'abi_mismatch' | 'bundle_partial') => void; + readonly currentScript: () => HTMLScriptElement | null; + readonly mode: 'direct' | 'takeover'; + readonly bind: (input: unknown) => unknown; +} -// Collect existing tsjs queued fns before we overwrite -const pending: Array<() => void> = Array.isArray(w.tsjs?.que) ? [...w.tsjs.que] : []; +function retainServerBoot(candidate: unknown, releaseId: string): Readonly | undefined { + try { + const boot = snapshotFrozenTsjsBootV1(candidate, releaseId); + if (!boot) return undefined; + const manifest = validateRuntimeManifestV1( + boot.manifest, + releaseId, + EMBEDDED_RUNTIME_CATALOG, + true + ); + return manifest && JSON.stringify(boot.manifest) === JSON.stringify(manifest) + ? boot + : undefined; + } catch { + return undefined; + } +} -// Create API and attach methods -const api: TsjsApi = (w.tsjs ??= {} as TsjsApi); -api.version = VERSION; -api.addAdUnits = addAdUnits; -api.renderAdUnit = renderAdUnit; -api.renderAllAdUnits = () => renderAllAdUnits(); -api.log = log; -api.setConfig = setConfig; -api.getConfig = getConfig; -// Provide core requestAds API -api.requestAds = requestAds; -// Defensive defaults: the edge injects adSlots (head-open) and bids (before -// ) only when server-side ad templates run for the request. When template -// delivery is disabled or gated off (auction/consent, bots, prefetch), page code -// reading window.tsjs.bids / window.tsjs.adSlots must still see defined values -// instead of throwing. Injected scripts overwrite these wholesale. -api.adSlots ??= []; -api.bids ??= {}; -// Point global tsjs -w.tsjs = api; +export type BrowserRuntimeCompositionFactory = ( + runtimeOptions: RuntimeOptions, + compositionOptions: Readonly> +) => Readonly<{ runtime: Runtime }>; -// Single shared queue -installQueue(api, w); +function claimedRuntimeCandidate(): unknown { + if (typeof __TSJS_RUNTIME_CLAIMED_V1__ !== 'undefined') { + return __TSJS_RUNTIME_CLAIMED_V1__; + } + try { + const source = (window as unknown as { tsjs?: unknown }).tsjs; + if ((typeof source !== 'object' && typeof source !== 'function') || source === null) return; + const claim = (source as { _claimRuntimeV1?: unknown })._claimRuntimeV1; + return typeof claim === 'function' ? claim(source) : undefined; + } catch { + return; + } +} -// Flush prior queued callbacks -for (const fn of pending) { +function retainClaimedServerBootV1(candidate: unknown): ClaimedServerBootV1 | undefined { try { - if (typeof fn === 'function') { - fn.call(api); - log.debug('queue: flushed callback'); + const fields = ownDataObject(candidate, [ + 'boot', + 'integrity', + 'complete', + 'currentScript', + 'source', + 'target', + 'mode', + 'bind', + ]); + if (!fields || !Object.isFrozen(candidate)) return undefined; + const boot = retainServerBoot(fields['boot'], EMBEDDED_RELEASE_ID); + const integrity = fields['integrity']; + const acceptedIntegrity = ownDataObject(integrity, [ + 'version', + 'projectionDigest', + 'integrationConfigDigest', + ]); + if ( + !boot || + !acceptedIntegrity || + !Object.isFrozen(integrity) || + typeof fields['complete'] !== 'function' || + typeof fields['currentScript'] !== 'function' || + (typeof fields['source'] !== 'object' && typeof fields['source'] !== 'function') || + fields['source'] === null || + (typeof fields['target'] !== 'object' && typeof fields['target'] !== 'function') || + fields['target'] === null || + (fields['mode'] !== 'direct' && fields['mode'] !== 'takeover') || + typeof fields['bind'] !== 'function' + ) { + return undefined; } + if ( + acceptedIntegrity['version'] !== 1 || + acceptedIntegrity['projectionDigest'] !== + sha256HexUtf8V1(JSON.stringify(boot.auctionProjection)) || + acceptedIntegrity['integrationConfigDigest'] !== + sha256HexUtf8V1(JSON.stringify(boot.integrations)) + ) { + return undefined; + } + return candidate as ClaimedServerBootV1; } catch { - /* ignore queued callback error */ + return undefined; } } -log.info('tsjs initialized', { - methods: [ - 'setConfig', - 'getConfig', - 'requestAds', - 'addAdUnits', - 'renderAdUnit', - 'renderAllAdUnits', - ], -}); +function claimedBootCompletion(candidate: unknown): ClaimedServerBootV1['complete'] | undefined { + const fields = ownDataObject(candidate, [ + 'boot', + 'integrity', + 'complete', + 'currentScript', + 'source', + 'target', + 'mode', + 'bind', + ]); + return fields && typeof fields['complete'] === 'function' + ? (fields['complete'] as ClaimedServerBootV1['complete']) + : undefined; +} + +/** Claim the browser namespace and start the injected sole composition root. */ +export function startProductionRuntime(createComposition: BrowserRuntimeCompositionFactory): void { + const candidate = claimedRuntimeCandidate(); + const completeBoot = claimedBootCompletion(candidate); + const claimed = retainClaimedServerBootV1(candidate); + if (!claimed) { + try { + completeBoot?.('abi_mismatch'); + } catch { + // The authenticated bootstrap watchdog remains the terminal fallback owner. + } + return; + } + const source = claimed.source; + const target = claimed.target; + let authenticatedSource: HTMLScriptElement | null; + try { + authenticatedSource = claimed.currentScript(); + } catch { + authenticatedSource = null; + } + if (authenticatedSource !== source) { + try { + claimed.complete('abi_mismatch'); + } catch { + // The authenticated bootstrap watchdog remains the terminal fallback owner. + } + return; + } + const { boot } = claimed; + const directMode = boot.manifest.firstDisplay === null; + if ((directMode && claimed.mode !== 'direct') || (!directMode && claimed.mode !== 'takeover')) { + try { + claimed.complete('abi_mismatch'); + } catch { + // The authenticated bootstrap watchdog remains the terminal fallback owner. + } + return; + } + let trustedScriptUrl: ((value: string) => unknown) | undefined; + let trustedCurrentScript: (() => HTMLScriptElement | null) | undefined = claimed.currentScript; + const composition = createComposition( + { + target, + releaseId: EMBEDDED_RELEASE_ID, + manifest: boot.manifest, + knownIntegrationIds: EMBEDDED_INTEGRATION_IDS, + catalog: EMBEDDED_RUNTIME_CATALOG, + boot, + currentScript: () => trustedCurrentScript?.() ?? null, + ...(!directMode + ? { + trustedScriptUrl: (value: string) => { + if (!trustedScriptUrl) throw new TypeError('tsjs'); + return trustedScriptUrl(value); + }, + coordinateTakeover: (prepared) => { + const leased = claimed.bind(finalizeFirstDisplayAgentCaptureV1) as + ClaimedFirstDisplayTakeoverV1 | undefined; + if (!leased) throw new TypeError('tsjs'); + trustedScriptUrl = leased[9]; + trustedCurrentScript = leased[10]; + if ( + !coordinatePreparedFirstDisplayTakeoverV1({ + prepared, + finalized: leased[0], + outline: leased[1], + boot, + isCurrentGeneration: leased[2], + authenticateRuntimeScript: leased[3], + currentMutationRevision: leased[4], + quiesceAgent: () => undefined, + detachCommittedArtifacts: () => { + if (!leased[5]()) throw new TypeError('tsjs'); + }, + disposeAgent: leased[6], + onFailure: leased[7], + }) + ) { + throw new TypeError('tsjs'); + } + leased[8](); + }, + } + : {}), + autoInstall: true, + onInstallComplete: (result) => { + try { + claimed.complete(result.state === 'kernel' ? 'kernel' : result.reason); + } catch { + // Direct completion still closes its independently authenticated watchdog. + } + completeDirect?.(result.state === 'kernel' ? 'kernel' : 'runtime_fallback'); + }, + kernel: { + addAdUnits: () => Object.freeze({ registered: Object.freeze([]) }), + diagnostics: Object.freeze({}), + requestAds: async () => Object.freeze({ slots: Object.freeze([]) }), + }, + }, + {} + ); + const completeDirect = directMode + ? (claimed.bind(() => composition.runtime.dispose()) as DirectRuntimeCompletion | undefined) + : undefined; + if (directMode && !completeDirect) { + try { + composition.runtime.dispose(); + } catch { + // Completion still commits the independently authenticated terminal fallback. + } + try { + claimed.complete('abi_mismatch'); + } catch { + // The authenticated bootstrap watchdog remains the terminal fallback owner. + } + return; + } + if (!composition.runtime.start()) { + completeDirect?.('failed_start'); + } +} diff --git a/crates/trusted-server-js/lib/src/core/log.ts b/crates/trusted-server-js/lib/src/core/log.ts index b750430c6..fb616292c 100644 --- a/crates/trusted-server-js/lib/src/core/log.ts +++ b/crates/trusted-server-js/lib/src/core/log.ts @@ -37,8 +37,9 @@ function styleFor(method: 'log' | 'info' | 'warn' | 'error'): string { function print(method: 'log' | 'info' | 'warn' | 'error', ...args: unknown[]) { const c: - | Partial void>> - | undefined = (globalThis as unknown as { console?: Console }).console; + Partial void>> | undefined = ( + globalThis as unknown as { console?: Console } + ).console; if (!c || typeof c[method] !== 'function') return; if (supportsCss()) { c[method]('%c[tsjs]%c ' + ts() + ':', styleFor(method), 'color:inherit', ...args); diff --git a/crates/trusted-server-js/lib/src/core/puc_shell.ts b/crates/trusted-server-js/lib/src/core/puc_shell.ts new file mode 100644 index 000000000..51249e8ac --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/puc_shell.ts @@ -0,0 +1,147 @@ +const shellDocument = typeof document === 'undefined' ? undefined : document; +const shellWindow = shellDocument?.defaultView ?? undefined; +const queryAll = typeof Document === 'undefined' ? undefined : Document.prototype.querySelectorAll; +const connected = + typeof Node === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Node.prototype, 'isConnected')?.get; +const parentElement = + typeof Node === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Node.prototype, 'parentElement')?.get; +const frameWindow = + typeof HTMLIFrameElement === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow')?.get; +const getAttribute = typeof Element === 'undefined' ? undefined : Element.prototype.getAttribute; +const closest = typeof Element === 'undefined' ? undefined : Element.prototype.closest; +const style = + typeof HTMLElement === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'style')?.get; +const setProperty = + typeof CSSStyleDeclaration === 'undefined' + ? undefined + : CSSStyleDeclaration.prototype.setProperty; +const getComputedStyle = shellWindow?.getComputedStyle; + +export interface CollapsedPucShellResizeInput { + readonly source: object; + readonly width: number; + readonly height: number; +} + +function attribute(element: Element, name: string): string | null | undefined { + if (!getAttribute) return undefined; + try { + return Reflect.apply(getAttribute, element, [name]) as string | null; + } catch { + return undefined; + } +} + +function onePixelAttribute(element: Element, name: 'width' | 'height'): boolean { + const value = attribute(element, name); + if (value === undefined || value === null || !/^\d+(?:\.\d+)?$/.test(value)) return false; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed <= 1; +} + +function computedStyle(element: Element): CSSStyleDeclaration | undefined { + if (!shellWindow || !getComputedStyle) return undefined; + try { + return Reflect.apply(getComputedStyle, shellWindow, [element]) as CSSStyleDeclaration; + } catch { + return undefined; + } +} + +function onePixelComputed(value: CSSStyleDeclaration, dimension: 'width' | 'height'): boolean { + const match = /^(\d+(?:\.\d+)?)px$/.exec(dimension === 'width' ? value.width : value.height); + return match !== null && Number(match[1]) <= 1; +} + +function ordinaryCollapsedElement(element: HTMLElement): boolean { + const value = computedStyle(element); + return ( + value !== undefined && + value.position !== 'fixed' && + value.position !== 'sticky' && + onePixelComputed(value, 'width') && + onePixelComputed(value, 'height') + ); +} + +/** Resize only the authenticated, ordinary collapsed Universal Creative shell. */ +export function resizeCollapsedPucShell(input: CollapsedPucShellResizeInput): boolean { + if ( + !shellDocument || + !queryAll || + !connected || + !parentElement || + !frameWindow || + !closest || + !style || + !setProperty || + typeof input !== 'object' || + input === null || + typeof input.source !== 'object' || + input.source === null || + !Number.isFinite(input.width) || + !Number.isFinite(input.height) || + input.width <= 0 || + input.height <= 0 + ) { + return false; + } + + try { + const candidates = Reflect.apply(queryAll, shellDocument, [ + 'iframe', + ]) as NodeListOf; + let frame: HTMLIFrameElement | undefined; + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates.item(index); + if ( + candidate && + Reflect.apply(connected, candidate, []) === true && + Reflect.apply(frameWindow, candidate, []) === input.source + ) { + if (frame) return false; + frame = candidate; + } + } + if ( + !frame || + !onePixelAttribute(frame, 'width') || + !onePixelAttribute(frame, 'height') || + !ordinaryCollapsedElement(frame) || + Reflect.apply(closest, frame, ['a,[data-anchor-status]']) !== null + ) { + return false; + } + + const wrapper = Reflect.apply(parentElement, frame, []) as HTMLElement | null; + if ( + !wrapper || + wrapper === shellDocument.body || + wrapper === shellDocument.documentElement || + wrapper instanceof HTMLAnchorElement || + Reflect.apply(connected, wrapper, []) !== true || + !ordinaryCollapsedElement(wrapper) || + Reflect.apply(closest, wrapper, ['a,[data-anchor-status]']) !== null + ) { + return false; + } + + const frameStyle = Reflect.apply(style, frame, []) as CSSStyleDeclaration; + const wrapperStyle = Reflect.apply(style, wrapper, []) as CSSStyleDeclaration; + Reflect.apply(setProperty, frameStyle, ['width', `${input.width}px`]); + Reflect.apply(setProperty, frameStyle, ['height', `${input.height}px`]); + Reflect.apply(setProperty, wrapperStyle, ['width', `${input.width}px`]); + Reflect.apply(setProperty, wrapperStyle, ['height', `${input.height}px`]); + return true; + } catch { + return false; + } +} diff --git a/crates/trusted-server-js/lib/src/core/queue.ts b/crates/trusted-server-js/lib/src/core/queue.ts index 73c2741be..414af4f5b 100644 --- a/crates/trusted-server-js/lib/src/core/queue.ts +++ b/crates/trusted-server-js/lib/src/core/queue.ts @@ -1,6 +1,240 @@ -// Minimal Prebid-style queue shim that executes callbacks immediately. import { log } from './log'; +export type QueueCallback = (this: object) => void; + +export interface PublishedQueue { + readonly queue: unknown[]; + readonly drain: () => void; +} + +type QueueOwner = object & { que?: unknown }; + +function immediatePush(owner: object): unknown[]['push'] { + return function (item: unknown): number { + if (typeof item !== 'function') return 0; + try { + (item as QueueCallback).call(owner); + } catch (error) { + try { + log.warn('queue: callback failed', error); + } catch { + // Callback isolation cannot depend on an observer. + } + return 0; + } + try { + log.debug('queue: push executed immediately'); + } catch { + // Queue behavior cannot depend on an observer. + } + return 0; + } as unknown[]['push']; +} + +function ownArrayEntries(value: unknown[]): readonly [number, unknown][] { + const entries: [number, unknown][] = []; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !/^(0|[1-9][0-9]*)$/.test(key)) continue; + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || index >= 4_294_967_295) continue; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor && 'value' in descriptor) entries.push([index, descriptor.value]); + } + entries.sort(([left], [right]) => left - right); + return entries; +} + +function canReuseIngress(value: unknown[]): boolean { + if (!Object.isExtensible(value)) return false; + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (!lengthDescriptor?.writable) return false; + const pushDescriptor = Object.getOwnPropertyDescriptor(value, 'push'); + if (pushDescriptor && !pushDescriptor.configurable) return false; + return ownArrayEntries(value).every(([index]) => { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + return descriptor?.configurable === true; + }); +} + +function preflightTerminalFields( + target: QueueOwner, + committedFields: Readonly>, + removedFields: readonly string[] +): Readonly<{ committed: readonly string[]; removed: readonly string[] }> { + const keys: string[] = []; + const seen = new Set(); + for (const key of Reflect.ownKeys(committedFields)) { + if (typeof key !== 'string') continue; + const field = Object.getOwnPropertyDescriptor(committedFields, key); + if (!field || !('value' in field)) continue; + const existing = Object.getOwnPropertyDescriptor(target, key); + if (existing && !existing.configurable) { + throw new TypeError(`TSJS terminal field is not configurable: ${key}`); + } + keys.push(key); + seen.add(key); + } + const removed: string[] = []; + for (const key of removedFields) { + if (seen.has(key) || removed.includes(key)) { + throw new TypeError(`TSJS terminal field inventory overlaps: ${key}`); + } + const existing = Object.getOwnPropertyDescriptor(target, key); + if (existing && !existing.configurable) { + throw new TypeError(`TSJS removed field is not configurable: ${key}`); + } + removed.push(key); + } + // A terminal publication is an exact replacement, not a compatibility + // merge. Remove every other own string field without carrying an inventory + // of retired public names into the shipped bundle. + for (const key of Object.getOwnPropertyNames(target)) { + if (key === 'que' || seen.has(key) || removed.includes(key)) continue; + const existing = Object.getOwnPropertyDescriptor(target, key); + if (existing && !existing.configurable) { + throw new TypeError(`TSJS unpublished field is not configurable: ${key}`); + } + removed.push(key); + } + const queueDescriptor = Object.getOwnPropertyDescriptor(target, 'que'); + if (queueDescriptor && !queueDescriptor.configurable) { + throw new TypeError('TSJS terminal field is not configurable: que'); + } + return Object.freeze({ committed: Object.freeze(keys), removed: Object.freeze(removed) }); +} + +/** Side-effect-free ordinary-object preflight used before fallback queue normalization. */ +export function canPublishTerminalFields( + target: QueueOwner, + committedFields: Readonly>, + removedFields: readonly string[] = Object.freeze([]) +): boolean { + try { + preflightTerminalFields(target, committedFields, removedFields); + return true; + } catch { + return false; + } +} + +function preflightPublication( + target: QueueOwner, + ingress: unknown[], + committedFields: Readonly>, + removedFields: readonly string[] +): Readonly<{ committed: readonly string[]; removed: readonly string[] }> { + if (!canReuseIngress(ingress)) { + throw new TypeError('TSJS ingress queue cannot be committed'); + } + return preflightTerminalFields(target, committedFields, removedFields); +} + +/** Establishes the mutable preload queue used only during bootstrap preparation. */ +export function prepareQueue(target: T): unknown[] { + const existing = Object.getOwnPropertyDescriptor(target, 'que'); + const publisherQueue = + existing && 'value' in existing && Array.isArray(existing.value) ? existing.value : undefined; + const ingress = publisherQueue && canReuseIngress(publisherQueue) ? publisherQueue : []; + if (publisherQueue && ingress !== publisherQueue) { + for (const [index, value] of ownArrayEntries(publisherQueue)) ingress[index] = value; + } + Object.defineProperty(ingress, 'push', { + configurable: true, + enumerable: false, + value: Array.prototype.push, + writable: true, + }); + Object.defineProperty(target, 'que', { + configurable: true, + enumerable: true, + value: ingress, + writable: false, + }); + return ingress; +} + +/** + * Performs the terminal, synchronous queue and public-field handoff. + * + * The returned queue is a frozen real Array whose own `push` executes callable + * entries immediately without ever retaining them. + */ +export function publishQueue( + target: T, + ingress: unknown[], + committedFields: Readonly> = {}, + removedFields: readonly string[] = Object.freeze([]) +): PublishedQueue { + const inventory = preflightPublication(target, ingress, committedFields, removedFields); + const queue: unknown[] = []; + Object.defineProperty(queue, 'push', { + configurable: false, + enumerable: false, + value: immediatePush(target), + writable: false, + }); + Object.freeze(queue); + + const snapshot: QueueCallback[] = []; + for (const [, value] of ownArrayEntries(ingress)) { + if (typeof value === 'function') snapshot.push(value as QueueCallback); + } + + ingress.length = 0; + Object.defineProperty(ingress, 'push', { + configurable: false, + enumerable: false, + value: immediatePush(target), + writable: false, + }); + Object.freeze(ingress); + + for (const key of inventory.removed) { + if (!Reflect.deleteProperty(target, key)) { + throw new TypeError(`TSJS removed field could not be deleted: ${key}`); + } + } + for (const key of inventory.committed) { + const descriptor = Object.getOwnPropertyDescriptor(committedFields, key); + if (!descriptor || !('value' in descriptor)) { + throw new TypeError(`TSJS terminal field changed during publication: ${key}`); + } + Object.defineProperty(target, key, { + configurable: false, + enumerable: descriptor.enumerable ?? true, + value: descriptor.value, + writable: false, + }); + } + Object.defineProperty(target, 'que', { + configurable: false, + enumerable: true, + value: queue, + writable: false, + }); + + let drained = false; + return Object.freeze({ + queue, + drain: () => { + if (drained) return; + drained = true; + for (const callback of snapshot) queue.push(callback); + }, + }); +} + +/** Publish and immediately drain a queue outside the transactional registry. */ +export function commitQueue( + target: T, + ingress: unknown[], + committedFields: Readonly> = {} +): unknown[] { + const published = publishQueue(target, ingress, committedFields); + published.drain(); + return published.queue; +} + // Replace the legacy Prebid-style queue with an immediate executor so queued work runs in order. export function installQueue void> }>( target: T, diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 9af4a0a34..8250628bb 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -1,31 +1,686 @@ -// In-memory registry for ad units registered via tsjs (used by core + extensions). -import type { AdUnit, Size } from './types'; -import { toArray } from './util'; -import { log } from './log'; +// Programmatic ad-unit validation for the hard-cutover runtime. +import type { AddAdUnitsResult, ProgrammaticAdUnit } from './types'; +import { validBoundedString } from './contracts/bounded_string'; -const registry = new Map(); +const MAX_AUCTION_BODY_BYTES = 256 * 1024; +const MAX_PROGRAMMATIC_UNITS = 256; +const MAX_ACTIVE_SLOT_RECORDS = 256; +const MAX_JSON_STRUCTURE_ENTRIES = Math.floor((MAX_AUCTION_BODY_BYTES - 1) / 2); +const textEncoder = new TextEncoder(); +const reflectApplyIntrinsic = Reflect.apply; +const jsonStringifyIntrinsic = JSON.stringify; +const objectCreateIntrinsic = Object.create; +const objectGetOwnPropertyNamesIntrinsic = Object.getOwnPropertyNames; +const objectKeysIntrinsic = Object.keys; +const objectSetPrototypeOfIntrinsic = Object.setPrototypeOf; +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; -// Merge ad unit definitions into the in-memory registry (supports array or single unit). -export function addAdUnits(units: AdUnit | AdUnit[]): void { - for (const u of toArray(units)) { - if (!u || !u.code) continue; - registry.set(u.code, { ...registry.get(u.code), ...u }); +export type AdUnitRegistrationErrorCode = + | 'invalid_units' + | 'invalid_unit' + | 'invalid_code' + | 'duplicate_code' + | 'slot_collision' + | 'invalid_media_types' + | 'invalid_dimensions' + | 'dimensions_out_of_range' + | 'invalid_bids' + | 'invalid_bidder' + | 'invalid_params' + | 'request_body_too_large' + | 'registry_capacity'; + +export class AdUnitRegistrationError extends Error { + public readonly code: AdUnitRegistrationErrorCode; + public readonly unitIndex?: number; + + public constructor(code: AdUnitRegistrationErrorCode, unitIndex?: number) { + super(code); + this.name = 'AdUnitRegistrationError'; + this.code = code; + if (unitIndex !== undefined) this.unitIndex = unitIndex; + } +} + +interface JsonContainerSnapshot { + readonly array: boolean; + readonly entries: readonly Readonly<{ key: string; value: unknown }>[]; +} + +interface JsonCloneFrame { + readonly output: Record | unknown[]; + readonly snapshot: JsonContainerSnapshot; + readonly source: object; + index: number; +} + +interface JsonMeasureFrame { + readonly array: boolean; + readonly entries: readonly Readonly<{ key: string; value: unknown }>[]; + readonly source: object; + bytes: number; + structureEntries: number; + index: number; +} + +interface JsonMeasurement { + readonly bytes: number; + readonly snapshot?: JsonContainerSnapshot; + readonly structureEntries: number; +} + +interface PendingProgrammaticBid { + readonly bidder: string; + readonly params?: object; +} + +interface PendingProgrammaticAdUnit { + readonly code: string; + readonly mediaTypes: ProgrammaticAdUnit['mediaTypes']; + readonly bids?: readonly PendingProgrammaticBid[]; +} + +function ownDataRecord(value: unknown): Record | undefined { + try { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) return undefined; + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const output: Record = Object.create(null) as Record; + const names = reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [ + value, + ]) as string[]; + for (let index = 0; index < names.length; index += 1) { + const key = names[index]; + if (key === undefined) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + Object.defineProperty(output, key, { + configurable: true, + enumerable: true, + value: descriptor.value, + writable: true, + }); + } + return output; + } catch { + return undefined; + } +} + +function ownDataArray(value: unknown, maximum: number): readonly unknown[] | undefined { + try { + if ( + !Array.isArray(value) || + Object.getPrototypeOf(value) !== Array.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const length = Object.getOwnPropertyDescriptor(value, 'length'); + if ( + !length || + !('value' in length) || + !Number.isSafeInteger(length.value) || + length.value < 0 || + length.value > maximum || + (reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [value]) as string[]) + .length !== + length.value + 1 + ) { + return undefined; + } + const output: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + output[index] = descriptor.value; + } + return output; + } catch { + return undefined; + } +} + +function exactKeys(record: Record, keys: readonly string[]): boolean { + const actual = reflectApplyIntrinsic(objectKeysIntrinsic, Object, [record]) as string[]; + if (actual.length !== keys.length) return false; + for (let actualIndex = 0; actualIndex < actual.length; actualIndex += 1) { + const actualKey = actual[actualIndex]; + let found = false; + for (let expectedIndex = 0; expectedIndex < keys.length; expectedIndex += 1) { + if (keys[expectedIndex] === actualKey) { + found = true; + break; + } + } + if (!found) return false; + } + return true; +} + +function jsonPrimitive(value: unknown): null | boolean | number | string | undefined { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function validPositiveInteger(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0 + ); +} + +function snapshotJsonContainer(value: object): JsonContainerSnapshot | undefined { + const array = Array.isArray(value); + const values = array ? ownDataArray(value, MAX_JSON_STRUCTURE_ENTRIES) : undefined; + if (array && !values) return undefined; + const record = array ? undefined : ownDataRecord(value); + if (!array && !record) return undefined; + const entries = array + ? values!.map((entry, index) => Object.freeze({ key: String(index), value: entry })) + : (reflectApplyIntrinsic(objectKeysIntrinsic, Object, [record]) as string[]).map((key) => + Object.freeze({ key, value: record![key] }) + ); + return Object.freeze({ array, entries: Object.freeze(entries) }); +} + +/** Copy JSON data without invoking accessors or retaining publisher-owned objects. */ +function copyJsonRecord( + value: unknown, + completed = new WeakMap | unknown[]>(), + measurements?: WeakMap +): Readonly> | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const completedRoot = completed.get(value); + if (completedRoot) { + return Array.isArray(completedRoot) ? undefined : completedRoot; + } + const rootSnapshot = measurements?.get(value)?.snapshot ?? snapshotJsonContainer(value); + if (!rootSnapshot || rootSnapshot.array) return undefined; + const root: Record = {}; + const active = new Set(); + active.add(value); + const stack: JsonCloneFrame[] = [ + { index: 0, output: root, snapshot: rootSnapshot, source: value }, + ]; + let structureEntries = 1; + try { + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (!frame) return undefined; + if (frame.index >= frame.snapshot.entries.length) { + Object.freeze(frame.output); + completed.set(frame.source, frame.output); + active.delete(frame.source); + stack.pop(); + continue; + } + const entry = frame.snapshot.entries[frame.index]; + frame.index += 1; + if (!entry || ++structureEntries > MAX_JSON_STRUCTURE_ENTRIES) return undefined; + const primitive = jsonPrimitive(entry.value); + if (primitive !== undefined || entry.value === null) { + Object.defineProperty(frame.output, entry.key, { + configurable: true, + enumerable: true, + value: primitive, + writable: true, + }); + continue; + } + if (typeof entry.value !== 'object' || entry.value === null || active.has(entry.value)) { + return undefined; + } + const completedChild = completed.get(entry.value); + if (completedChild) { + Object.defineProperty(frame.output, entry.key, { + configurable: true, + enumerable: true, + value: completedChild, + writable: true, + }); + continue; + } + const childSnapshot = + measurements?.get(entry.value)?.snapshot ?? snapshotJsonContainer(entry.value); + if (!childSnapshot) return undefined; + const child: Record | unknown[] = childSnapshot.array ? [] : {}; + Object.defineProperty(frame.output, entry.key, { + configurable: true, + enumerable: true, + value: child, + writable: true, + }); + active.add(entry.value); + stack.push({ index: 0, output: child, snapshot: childSnapshot, source: entry.value }); + } + return Object.freeze(root); + } catch { + return undefined; + } +} + +function safeSerializationContainer(array: boolean): Record | unknown[] { + if (!array) { + return reflectApplyIntrinsic(objectCreateIntrinsic, Object, [null]) as Record; + } + const output: unknown[] = []; + reflectApplyIntrinsic(objectSetPrototypeOfIntrinsic, Object, [output, null]); + return output; +} + +/** Copy accepted JSON data onto containers that inherit no publisher hooks. */ +function copyJsonForSerialization(value: object): object | undefined { + const rootSnapshot = snapshotJsonContainer(value); + if (!rootSnapshot) return undefined; + const root = safeSerializationContainer(rootSnapshot.array); + const active = new Set(); + active.add(value); + const completed = new WeakMap | unknown[]>(); + const stack: JsonCloneFrame[] = [ + { index: 0, output: root, snapshot: rootSnapshot, source: value }, + ]; + let structureEntries = 1; + try { + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (!frame) return undefined; + if (frame.index >= frame.snapshot.entries.length) { + completed.set(frame.source, frame.output); + active.delete(frame.source); + stack.pop(); + continue; + } + const entry = frame.snapshot.entries[frame.index]; + frame.index += 1; + if (!entry || ++structureEntries > MAX_JSON_STRUCTURE_ENTRIES) return undefined; + const primitive = jsonPrimitive(entry.value); + if (primitive !== undefined || entry.value === null) { + Object.defineProperty(frame.output, entry.key, { + configurable: true, + enumerable: true, + value: primitive, + writable: true, + }); + continue; + } + if (typeof entry.value !== 'object' || entry.value === null || active.has(entry.value)) { + return undefined; + } + const completedChild = completed.get(entry.value); + if (completedChild) { + Object.defineProperty(frame.output, entry.key, { + configurable: true, + enumerable: true, + value: completedChild, + writable: true, + }); + continue; + } + const childSnapshot = snapshotJsonContainer(entry.value); + if (!childSnapshot) return undefined; + const child = safeSerializationContainer(childSnapshot.array); + Object.defineProperty(frame.output, entry.key, { + configurable: true, + enumerable: true, + value: child, + writable: true, + }); + active.add(entry.value); + stack.push({ index: 0, output: child, snapshot: childSnapshot, source: entry.value }); + } + return root; + } catch { + return undefined; + } +} + +function encodedJsonStringBytes(value: string): number { + let bytes = 2; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code === 0x22 || code === 0x5c) bytes += 2; + else if (code <= 0x1f) { + bytes += + code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6; + } else if (code <= 0x7f) bytes += 1; + else if (code <= 0x7ff) bytes += 2; + else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index += 1; + } else bytes += 6; + } else if (code >= 0xdc00 && code <= 0xdfff) bytes += 6; + else bytes += 3; + if (bytes > MAX_AUCTION_BODY_BYTES) return bytes; } - log.info('addAdUnits:', { count: toArray(units).length }); + return bytes; } -// Convenience helper to grab the first banner size off an ad unit. -export function firstSize(unit: AdUnit): Size | null { - const sizes = unit.mediaTypes?.banner?.sizes; - return sizes && sizes.length ? sizes[0] : null; +function primitiveJsonBytes(value: unknown): number | undefined { + if (value === null) return 4; + if (typeof value === 'boolean') return value ? 4 : 5; + if (typeof value === 'string') return encodedJsonStringBytes(value); + if (typeof value === 'number' && Number.isFinite(value)) return String(value).length; + return undefined; } -// Return a snapshot array of all registered ad units. -export function getAllUnits(): AdUnit[] { - return Array.from(registry.values()); +function boundedBytes(left: number, right: number): number { + return left > MAX_AUCTION_BODY_BYTES - right ? MAX_AUCTION_BODY_BYTES + 1 : left + right; +} + +function boundedStructureEntries(left: number, right: number): number { + return left > MAX_JSON_STRUCTURE_ENTRIES - right ? MAX_JSON_STRUCTURE_ENTRIES + 1 : left + right; +} + +/** Exact JSON byte measurement that never consults `toJSON` or publisher prototypes. */ +function measureJson( + value: unknown, + memo = new WeakMap() +): JsonMeasurement | undefined { + const primitive = primitiveJsonBytes(value); + if (primitive !== undefined) return Object.freeze({ bytes: primitive, structureEntries: 0 }); + if (typeof value !== 'object' || value === null) return undefined; + const completedRoot = memo.get(value); + if (completedRoot) return completedRoot; + const root = snapshotJsonContainer(value); + if (!root) return undefined; + const active = new Set(); + active.add(value); + const stack: JsonMeasureFrame[] = [ + { + array: root.array, + bytes: 2, + entries: root.entries, + index: 0, + source: value, + structureEntries: 1, + }, + ]; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (!frame) return undefined; + if (frame.index >= frame.entries.length) { + const measurement = Object.freeze({ + bytes: frame.bytes, + snapshot: Object.freeze({ array: frame.array, entries: frame.entries }), + structureEntries: frame.structureEntries, + }); + memo.set(frame.source, measurement); + active.delete(frame.source); + stack.pop(); + const parent = stack[stack.length - 1]; + if (!parent) return measurement; + parent.bytes = boundedBytes(parent.bytes, measurement.bytes); + parent.structureEntries = boundedStructureEntries( + parent.structureEntries, + measurement.structureEntries - 1 + ); + continue; + } + const entry = frame.entries[frame.index]; + const entryIndex = frame.index; + frame.index += 1; + if (!entry) return undefined; + const prefix = + (entryIndex === 0 ? 0 : 1) + (frame.array ? 0 : encodedJsonStringBytes(entry.key) + 1); + frame.bytes = boundedBytes(frame.bytes, prefix); + frame.structureEntries = boundedStructureEntries(frame.structureEntries, 1); + const childPrimitive = primitiveJsonBytes(entry.value); + if (childPrimitive !== undefined) { + frame.bytes = boundedBytes(frame.bytes, childPrimitive); + continue; + } + if (typeof entry.value !== 'object' || entry.value === null || active.has(entry.value)) { + return undefined; + } + const completed = memo.get(entry.value); + if (completed !== undefined) { + frame.bytes = boundedBytes(frame.bytes, completed.bytes); + frame.structureEntries = boundedStructureEntries( + frame.structureEntries, + completed.structureEntries - 1 + ); + continue; + } + const child = snapshotJsonContainer(entry.value); + if (!child) return undefined; + active.add(entry.value); + stack.push({ + array: child.array, + bytes: 2, + entries: child.entries, + index: 0, + source: entry.value, + structureEntries: 1, + }); + } + return undefined; } -// Look up a unit by its code. -export function getUnit(code: string): AdUnit | undefined { - return registry.get(code); +function snapshotKnownSlots(knownSlots: ReadonlySet): ReadonlySet { + try { + return new Set(knownSlots); + } catch { + throw new AdUnitRegistrationError('slot_collision'); + } +} + +interface ValidatedProgrammaticAdUnits { + readonly measurementMemo: WeakMap; + readonly pending: readonly PendingProgrammaticAdUnit[]; +} + +/** + * Validate and detach one complete public registration call before slot mutation. + * + * The returned graph is recursively frozen and safe to serialize later without + * reading publisher accessors again. + */ +function validateProgrammaticAdUnitsInput( + value: unknown, + knownSlots: ReadonlySet +): ValidatedProgrammaticAdUnits { + let units: readonly unknown[] | undefined; + try { + units = Array.isArray(value) ? ownDataArray(value, MAX_PROGRAMMATIC_UNITS) : [value]; + } catch { + units = undefined; + } + if (!units || units.length === 0 || units.length > MAX_PROGRAMMATIC_UNITS) { + throw new AdUnitRegistrationError('invalid_units'); + } + + const occupied = snapshotKnownSlots(knownSlots); + const seen = new Set(); + const pending: PendingProgrammaticAdUnit[] = []; + const measurementMemo = new WeakMap(); + for (let index = 0; index < units.length; index += 1) { + const unit = ownDataRecord(units[index]); + if ( + !unit || + (!exactKeys(unit, ['code', 'mediaTypes']) && !exactKeys(unit, ['code', 'mediaTypes', 'bids'])) + ) { + throw new AdUnitRegistrationError('invalid_unit', index); + } + if (!validBoundedString(unit.code, 256)) { + throw new AdUnitRegistrationError('invalid_code', index); + } + if (seen.has(unit.code)) throw new AdUnitRegistrationError('duplicate_code', index); + if (occupied.has(unit.code)) throw new AdUnitRegistrationError('slot_collision', index); + seen.add(unit.code); + + const mediaTypes = ownDataRecord(unit.mediaTypes); + const banner = ownDataRecord(mediaTypes?.banner); + if ( + !mediaTypes || + !exactKeys(mediaTypes, ['banner']) || + !banner || + !exactKeys(banner, ['sizes']) + ) { + throw new AdUnitRegistrationError('invalid_media_types', index); + } + const rawSizes = ownDataArray(banner.sizes, MAX_JSON_STRUCTURE_ENTRIES); + if (!rawSizes || rawSizes.length === 0) { + throw new AdUnitRegistrationError('invalid_media_types', index); + } + const sizes: Array = []; + for (let sizeIndex = 0; sizeIndex < rawSizes.length; sizeIndex += 1) { + const rawSize = rawSizes[sizeIndex]; + const dimensions = ownDataArray(rawSize, 2); + const width = dimensions?.[0]; + const height = dimensions?.[1]; + if ( + !dimensions || + dimensions.length !== 2 || + !validPositiveInteger(width) || + !validPositiveInteger(height) + ) { + throw new AdUnitRegistrationError('invalid_dimensions', index); + } + if (width > 4_096 || height > 4_096) { + throw new AdUnitRegistrationError('dimensions_out_of_range', index); + } + sizes.push(Object.freeze([width, height])); + } + + let bids: readonly PendingProgrammaticBid[] | undefined; + if (unit.bids !== undefined) { + const rawBids = ownDataArray(unit.bids, MAX_JSON_STRUCTURE_ENTRIES); + if (!rawBids) throw new AdUnitRegistrationError('invalid_bids', index); + const pendingBids: PendingProgrammaticBid[] = []; + for (let bidIndex = 0; bidIndex < rawBids.length; bidIndex += 1) { + const rawBid = rawBids[bidIndex]; + const bid = ownDataRecord(rawBid); + if (!bid || (!exactKeys(bid, ['bidder']) && !exactKeys(bid, ['bidder', 'params']))) { + throw new AdUnitRegistrationError('invalid_bids', index); + } + if ( + typeof bid.bidder !== 'string' || + bid.bidder.length === 0 || + ( + reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [ + bid.bidder, + ]) as Uint8Array + ).byteLength > 64 + ) { + throw new AdUnitRegistrationError('invalid_bidder', index); + } + let params: object | undefined; + if (bid.params !== undefined) { + if (typeof bid.params !== 'object' || bid.params === null || Array.isArray(bid.params)) { + throw new AdUnitRegistrationError('invalid_params', index); + } + const measurement = measureJson(bid.params, measurementMemo); + if (!measurement) throw new AdUnitRegistrationError('invalid_params', index); + params = bid.params; + } + pendingBids.push( + Object.freeze({ bidder: bid.bidder, ...(params === undefined ? {} : { params }) }) + ); + } + bids = Object.freeze(pendingBids); + } + + pending.push( + Object.freeze({ + code: unit.code, + mediaTypes: Object.freeze({ + banner: Object.freeze({ sizes: Object.freeze(sizes) }), + }), + ...(bids === undefined ? {} : { bids }), + }) + ); + } + + const bodyMeasurement = measureJson({ adUnits: pending, config: {} }, measurementMemo); + if (!bodyMeasurement) throw new AdUnitRegistrationError('invalid_params'); + if ( + bodyMeasurement.bytes > MAX_AUCTION_BODY_BYTES || + bodyMeasurement.structureEntries > MAX_JSON_STRUCTURE_ENTRIES + ) { + throw new AdUnitRegistrationError('request_body_too_large'); + } + if (occupied.size + pending.length > MAX_ACTIVE_SLOT_RECORDS) { + throw new AdUnitRegistrationError('registry_capacity'); + } + return Object.freeze({ measurementMemo, pending: Object.freeze(pending) }); +} + +/** Validate one complete registration without retaining publisher-owned input. */ +export function validateProgrammaticAdUnits(value: unknown, knownSlots: ReadonlySet): void { + validateProgrammaticAdUnitsInput(value, knownSlots); +} + +/** Validate and detach one complete public registration call before slot mutation. */ +export function prepareProgrammaticAdUnits( + value: unknown, + knownSlots: ReadonlySet +): readonly ProgrammaticAdUnit[] { + const { measurementMemo, pending } = validateProgrammaticAdUnitsInput(value, knownSlots); + + const completedCopies = new WeakMap | unknown[]>(); + const prepared: ProgrammaticAdUnit[] = []; + for (let index = 0; index < pending.length; index += 1) { + const unit = pending[index]; + if (!unit) throw new AdUnitRegistrationError('invalid_unit', index); + let bids: ProgrammaticAdUnit['bids']; + if (unit.bids !== undefined) { + const copiedBids: Array[number]> = []; + for (let bidIndex = 0; bidIndex < unit.bids.length; bidIndex += 1) { + const bid = unit.bids[bidIndex]; + if (!bid) throw new AdUnitRegistrationError('invalid_bids', index); + let params: Readonly> | undefined; + if (bid.params !== undefined) { + params = copyJsonRecord(bid.params, completedCopies, measurementMemo); + if (!params) throw new AdUnitRegistrationError('invalid_params', index); + } + copiedBids.push( + Object.freeze({ bidder: bid.bidder, ...(params === undefined ? {} : { params }) }) + ); + } + bids = Object.freeze(copiedBids); + } + prepared.push( + Object.freeze({ + code: unit.code, + mediaTypes: unit.mediaTypes, + ...(bids === undefined ? {} : { bids }), + }) + ); + } + const frozenPrepared = Object.freeze(prepared); + const finalMeasurement = measureJson({ adUnits: frozenPrepared, config: {} }); + if (!finalMeasurement) throw new AdUnitRegistrationError('invalid_params'); + if ( + finalMeasurement.bytes > MAX_AUCTION_BODY_BYTES || + finalMeasurement.structureEntries > MAX_JSON_STRUCTURE_ENTRIES + ) { + throw new AdUnitRegistrationError('request_body_too_large'); + } + return frozenPrepared; +} + +export function addAdUnitsResult(units: readonly ProgrammaticAdUnit[]): AddAdUnitsResult { + return Object.freeze({ registered: Object.freeze(units.map(({ code }) => code)) }); +} + +/** Serialize one bounded `/auction` body without consulting inherited `toJSON` hooks. */ +export function serializeAuctionRequestBody( + adUnits: readonly Readonly[], + config: Readonly> +): string | undefined { + try { + const detached = copyJsonForSerialization({ adUnits, config }); + if (!detached) return undefined; + const serialized = reflectApplyIntrinsic(jsonStringifyIntrinsic, JSON, [detached]) as unknown; + if (typeof serialized !== 'string') return undefined; + const bytes = reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [ + serialized, + ]) as Uint8Array; + return bytes.byteLength <= MAX_AUCTION_BODY_BYTES ? serialized : undefined; + } catch { + return undefined; + } } diff --git a/crates/trusted-server-js/lib/src/core/release.ts b/crates/trusted-server-js/lib/src/core/release.ts new file mode 100644 index 000000000..aaf201c27 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/release.ts @@ -0,0 +1,28 @@ +declare const __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: readonly string[]; +declare const __TSJS_EMBEDDED_RUNTIME_CATALOG_V1__: readonly Readonly<{ + id: string; + phase: 'takeover' | 'deferred'; + trigger: 'first_display_or_idle' | null; + config: import('../kernel/release_catalog').ReleaseConfigSourceV1; + consumes: readonly string[]; + provides: readonly string[]; +}>[]; + +export { EMBEDDED_RELEASE_ID } from './release_id'; + +/** Build-generated inventory of every integration bundle admitted by this release. */ +export const EMBEDDED_INTEGRATION_IDS = Object.freeze([...__TSJS_EMBEDDED_INTEGRATION_IDS_V1__]); + +/** Build-generated capability/order authority without build-only product prose. */ +export const EMBEDDED_RUNTIME_CATALOG = Object.freeze( + __TSJS_EMBEDDED_RUNTIME_CATALOG_V1__.map((entry) => + Object.freeze({ + id: entry.id, + phase: entry.phase, + trigger: entry.trigger, + config: entry.config, + consumes: Object.freeze([...entry.consumes]), + provides: Object.freeze([...entry.provides]), + }) + ) +); diff --git a/crates/trusted-server-js/lib/src/core/release_id.ts b/crates/trusted-server-js/lib/src/core/release_id.ts new file mode 100644 index 000000000..303574bce --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/release_id.ts @@ -0,0 +1,4 @@ +declare const __TSJS_EMBEDDED_RELEASE_ID_V1__: string; + +/** Build-stamped identity shared by every artifact in one generated release. */ +export const EMBEDDED_RELEASE_ID = __TSJS_EMBEDDED_RELEASE_ID_V1__; diff --git a/crates/trusted-server-js/lib/src/core/render.ts b/crates/trusted-server-js/lib/src/core/render.ts index e0f25a807..b16f94376 100644 --- a/crates/trusted-server-js/lib/src/core/render.ts +++ b/crates/trusted-server-js/lib/src/core/render.ts @@ -1,10 +1,4 @@ -// Rendering utilities for Trusted Server demo placements: find slots, seed placeholders, -// and inject creatives into sandboxed iframes. -import { normalizeTrustedOrigin } from '../shared/origin'; - -import { log } from './log'; -import type { AdUnit } from './types'; -import { getUnit, getAllUnits, firstSize } from './registry'; +// Rendering utilities for injecting creatives into sandboxed iframes. import NORMALIZE_CSS from './styles/normalize.css?inline'; import IFRAME_TEMPLATE from './templates/iframe.html?raw'; @@ -29,6 +23,97 @@ const CREATIVE_SANDBOX_TOKENS = [ 'allow-top-navigation-by-user-activation', ] as const; +/** Exact sandbox granted to TS-owned ADM documents. */ +export const ADM_IFRAME_SANDBOX = CREATIVE_SANDBOX_TOKENS.join(' '); + +const ADM_MAX_UTF8_BYTES = 512 * 1024; +const RENDER_DIMENSION_MIN = 1; +const RENDER_DIMENSION_MAX = 4096; +const nativeDocument = typeof document === 'undefined' ? undefined : document; +const nativeUrl = typeof URL === 'undefined' ? undefined : URL; +const nativeTextEncoder = typeof TextEncoder === 'undefined' ? undefined : TextEncoder; +const nativeTextEncoderEncode = nativeTextEncoder?.prototype.encode; +const nativePublisherOrigin = + typeof location === 'undefined' ? undefined : exactHttpOrigin(location.origin); +const documentCreateElement = + typeof Document === 'undefined' ? undefined : Document.prototype.createElement; +const nodeAppendChild = typeof Node === 'undefined' ? undefined : Node.prototype.appendChild; +const nodeRemoveChild = typeof Node === 'undefined' ? undefined : Node.prototype.removeChild; +const nodeParentGetter = + typeof Node === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Node.prototype, 'parentNode')?.get; +const nodeOwnerDocumentGetter = + typeof Node === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Node.prototype, 'ownerDocument')?.get; +const nodeConnectedGetter = + typeof Node === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Node.prototype, 'isConnected')?.get; +const elementChildrenGetter = + typeof Element === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Element.prototype, 'children')?.get; +const elementGetAttribute = + typeof Element === 'undefined' ? undefined : Element.prototype.getAttribute; +const elementHasAttribute = + typeof Element === 'undefined' ? undefined : Element.prototype.hasAttribute; +const elementSetAttribute = + typeof Element === 'undefined' ? undefined : Element.prototype.setAttribute; +const eventTargetAddEventListener = + typeof EventTarget === 'undefined' ? undefined : EventTarget.prototype.addEventListener; +const eventTargetRemoveEventListener = + typeof EventTarget === 'undefined' ? undefined : EventTarget.prototype.removeEventListener; +const htmlCollectionLengthGetter = + typeof HTMLCollection === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(HTMLCollection.prototype, 'length')?.get; +const htmlCollectionItem = + typeof HTMLCollection === 'undefined' ? undefined : HTMLCollection.prototype.item; +const iframeSrcdocDescriptor = + typeof HTMLIFrameElement === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'srcdoc'); +const iframeReferrerPolicyDescriptor = + typeof HTMLIFrameElement === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'referrerPolicy'); +const objectDefineProperty = Object.defineProperty; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const objectFreezeIntrinsic = Object.freeze; +const numberIsIntegerIntrinsic = Number.isInteger; +const reflectApplyIntrinsic = Reflect.apply; +const stringReplaceIntrinsic = String.prototype.replace; +const stringTrimIntrinsic = String.prototype.trim; +const stringIntrinsic = String; + +export interface PrepareAdmIframeOptions { + readonly adm: string; + readonly container: HTMLElement; + readonly height: number; + readonly onError: () => void; + readonly onLoad: () => void; + readonly width: number; +} + +export interface AdmIframeHandle { + readonly frame: HTMLIFrameElement; + append(): boolean; + activate(): boolean; + commit(): boolean; + current(): boolean; + dispose(): void; +} + +function applyIntrinsic( + method: (...arguments_: never[]) => unknown, + receiver: unknown, + arguments_: unknown[] +): Result { + return reflectApplyIntrinsic(method, receiver, arguments_) as Result; +} + export type CreativeSanitizationRejectionReason = 'empty-after-sanitize' | 'invalid-creative-html'; export type AcceptedCreativeHtml = { @@ -58,11 +143,6 @@ export type RejectedCreativeHtml = { export type SanitizeCreativeHtmlResult = AcceptedCreativeHtml | RejectedCreativeHtml; -function normalizeId(raw: string): string { - const s = String(raw ?? '').trim(); - return s.startsWith('#') ? s.slice(1) : s; -} - // Validate the untrusted creative fragment before embedding it in the sandboxed iframe. // This is validation-only, not sanitization: it guards against type errors and empty // payloads and never removes content. Server-side stripping of executable markup is @@ -103,78 +183,6 @@ export function sanitizeCreativeHtml(creativeHtml: unknown): SanitizeCreativeHtm }; } -// Locate an ad slot element by id, tolerating funky selectors provided by tag managers. -export function findSlot(id: string): HTMLElement | null { - const nid = normalizeId(id); - // Fast path - const byId = document.getElementById(nid) as HTMLElement | null; - if (byId) return byId; - // Fallback for odd IDs (special chars) or if provided with quotes/etc. - try { - const selector = `[id="${nid.replace(/"/g, '\\"')}"]`; - const byAttr = document.querySelector(selector) as HTMLElement | null; - if (byAttr) return byAttr; - } catch { - // Ignore selector errors (e.g., invalid characters) - } - return null; -} - -function ensureSlot(id: string): HTMLElement { - const nid = normalizeId(id); - let el = document.getElementById(nid) as HTMLElement | null; - if (el) return el; - el = document.createElement('div'); - el.id = nid; - const body: HTMLElement | null = typeof document !== 'undefined' ? document.body : null; - if (body && typeof body.appendChild === 'function') { - body.appendChild(el); - } else { - // DOM not ready — attach once available - const element = el; - const onReady = () => { - const readyBody = document.body; - if (readyBody && !document.getElementById(nid) && element) readyBody.appendChild(element); - }; - document.addEventListener('DOMContentLoaded', onReady, { once: true }); - } - return el; -} - -// Drop a placeholder message into the slot so pages don't sit empty pre-render. -export function renderAdUnit(codeOrUnit: string | AdUnit): void { - const code = typeof codeOrUnit === 'string' ? codeOrUnit : codeOrUnit?.code; - if (!code) return; - const unit = typeof codeOrUnit === 'string' ? getUnit(code) : codeOrUnit; - const size = (unit && firstSize(unit)) || [300, 250]; - const el = ensureSlot(code); - try { - el.textContent = `Trusted Server — ${size[0]}x${size[1]}`; - log.info('renderAdUnit: rendered placeholder', { code, size }); - } catch { - log.warn('renderAdUnit: failed', { code }); - } -} - -// Render placeholders for every registered ad unit (used in simple publisher demos). -export function renderAllAdUnits(): void { - try { - const parentReady = - typeof document !== 'undefined' && (document.body || document.documentElement); - if (!parentReady) { - log.warn('renderAllAdUnits: DOM not ready; skipping'); - return; - } - const units = getAllUnits(); - for (const u of units) { - renderAdUnit(u); - } - log.info('renderAllAdUnits: rendered all placeholders', { count: units.length }); - } catch (e) { - log.warn('renderAllAdUnits: failed', e as unknown); - } -} - type IframeOptions = { name?: string; title?: string; width?: number; height?: number }; // Construct a sandboxed iframe for creative HTML. The markup may be raw bidder @@ -200,8 +208,7 @@ export function createAdIframe( } else { iframe.setAttribute('sandbox', CREATIVE_SANDBOX_TOKENS.join(' ')); } - } catch (err) { - log.debug('createAdIframe: sandbox add failed', err); + } catch { iframe.setAttribute('sandbox', CREATIVE_SANDBOX_TOKENS.join(' ')); } // Sizing + style @@ -231,9 +238,22 @@ export function createAdIframe( // // Only an exact `scheme://host[:port]` shape is emitted, so the value cannot // break out of the quoted string it is written into. +function exactHttpOrigin(candidate: unknown): string | undefined { + if (typeof candidate !== 'string' || !nativeUrl) return undefined; + try { + const parsed = new nativeUrl(candidate); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined; + if (parsed.username !== '' || parsed.password !== '') return undefined; + if (parsed.origin !== candidate) return undefined; + return parsed.origin; + } catch { + return undefined; + } +} + function trustedCreativeOrigin(): string { try { - return normalizeTrustedOrigin(location.origin); + return exactHttpOrigin(location.origin) ?? ''; } catch { // fall through to an empty stamp; the runtime degrades to document.baseURI } @@ -241,8 +261,370 @@ function trustedCreativeOrigin(): string { } // Build a complete HTML document for a creative fragment, suitable for iframe.srcdoc. -export function buildCreativeDocument(creativeHtml: string): string { - return IFRAME_TEMPLATE.replace('%NORMALIZE_CSS%', () => NORMALIZE_CSS) - .replace('%TRUSTED_ORIGIN%', () => trustedCreativeOrigin()) - .replace('%CREATIVE_HTML%', () => creativeHtml); +export function buildCreativeDocument( + creativeHtml: string, + publisherOrigin: string = trustedCreativeOrigin() +): string { + const normalized = applyIntrinsic(stringReplaceIntrinsic, IFRAME_TEMPLATE, [ + '%NORMALIZE_CSS%', + () => NORMALIZE_CSS, + ]); + const trusted = applyIntrinsic(stringReplaceIntrinsic, normalized, [ + '%TRUSTED_ORIGIN%', + () => exactHttpOrigin(publisherOrigin) ?? '', + ]); + return applyIntrinsic(stringReplaceIntrinsic, trusted, [ + '%CREATIVE_HTML%', + () => creativeHtml, + ]); +} + +function nativeParent(node: Node): Node | null | undefined { + try { + return nodeParentGetter ? applyIntrinsic(nodeParentGetter, node, []) : undefined; + } catch { + return undefined; + } +} + +function nativeOwnerDocument(node: Node): Document | null | undefined { + try { + return nodeOwnerDocumentGetter + ? applyIntrinsic(nodeOwnerDocumentGetter, node, []) + : undefined; + } catch { + return undefined; + } +} + +function nativeConnected(node: Node): boolean { + try { + return !!nodeConnectedGetter && applyIntrinsic(nodeConnectedGetter, node, []) === true; + } catch { + return false; + } +} + +function nativeAttribute(element: Element, name: string): string | null | undefined { + try { + return elementGetAttribute + ? applyIntrinsic(elementGetAttribute, element, [name]) + : undefined; + } catch { + return undefined; + } +} + +function hasNativeAttribute(element: Element, name: string): boolean { + try { + return ( + !!elementHasAttribute && + applyIntrinsic(elementHasAttribute, element, [name]) === true + ); + } catch { + return true; + } +} + +function setNativeAttribute(element: Element, name: string, value: string): boolean { + try { + if (!elementSetAttribute) return false; + applyIntrinsic(elementSetAttribute, element, [name, value]); + return nativeAttribute(element, name) === value; + } catch { + return false; + } +} + +function nativeSrcdoc(frame: HTMLIFrameElement): string | undefined { + try { + return iframeSrcdocDescriptor?.get + ? applyIntrinsic(iframeSrcdocDescriptor.get, frame, []) + : undefined; + } catch { + return undefined; + } +} + +function nativeReferrerPolicy(frame: HTMLIFrameElement): string | undefined { + try { + if (iframeReferrerPolicyDescriptor?.get) { + return applyIntrinsic(iframeReferrerPolicyDescriptor.get, frame, []); + } + const own = objectGetOwnPropertyDescriptor(frame, 'referrerPolicy'); + return own && 'value' in own && typeof own.value === 'string' ? own.value : undefined; + } catch { + return undefined; + } +} + +function removeNativeNode(node: Node): void { + const parent = nativeParent(node); + if (!parent || !nodeRemoveChild) return; + try { + applyIntrinsic(nodeRemoveChild, parent, [node]); + } catch { + // Best-effort disposal is intentionally exact to this owned node. + } +} + +function snapshotChildren(container: Element): Element[] | undefined { + try { + const children = elementChildrenGetter + ? applyIntrinsic(elementChildrenGetter, container, []) + : undefined; + if (!children || !htmlCollectionLengthGetter || !htmlCollectionItem) return undefined; + const length = applyIntrinsic(htmlCollectionLengthGetter, children, []); + const snapshot: Element[] = []; + for (let index = 0; index < length; index += 1) { + const child = applyIntrinsic(htmlCollectionItem, children, [index]); + if (!child) return undefined; + snapshot[snapshot.length] = child; + } + return snapshot; + } catch { + return undefined; + } +} + +/** + * Prepare one detached, fully configured ADM iframe. + * + * The returned handle owns insertion, event delivery, predecessor cleanup, and + * disposal. No publisher-overridable instance methods are used for those actions. + */ +export function prepareAdmIframe(options: PrepareAdmIframeOptions): AdmIframeHandle | undefined { + const { adm, container, height, onError, onLoad, width } = options; + if ( + !nativeDocument || + !documentCreateElement || + !nodeAppendChild || + !nodeRemoveChild || + !eventTargetAddEventListener || + !eventTargetRemoveEventListener || + !iframeSrcdocDescriptor?.get || + !iframeSrcdocDescriptor.set || + nativeOwnerDocument(container) !== nativeDocument || + !nativeConnected(container) || + typeof adm !== 'string' || + applyIntrinsic(stringTrimIntrinsic, adm, []).length === 0 || + !nativeTextEncoder || + !nativeTextEncoderEncode || + !applyIntrinsic(numberIsIntegerIntrinsic, Number, [width]) || + width < RENDER_DIMENSION_MIN || + width > RENDER_DIMENSION_MAX || + !applyIntrinsic(numberIsIntegerIntrinsic, Number, [height]) || + height < RENDER_DIMENSION_MIN || + height > RENDER_DIMENSION_MAX || + typeof onLoad !== 'function' || + typeof onError !== 'function' + ) { + return undefined; + } + + try { + const encoder = new nativeTextEncoder(); + const bytes = applyIntrinsic(nativeTextEncoderEncode, encoder, [adm]); + if (bytes.byteLength > ADM_MAX_UTF8_BYTES) return undefined; + } catch { + return undefined; + } + + let frame: HTMLIFrameElement; + try { + frame = applyIntrinsic(documentCreateElement, nativeDocument, ['iframe']); + } catch { + return undefined; + } + if (nativeOwnerDocument(frame) !== nativeDocument || nativeParent(frame) !== null) + return undefined; + + const intendedSrcdoc = buildCreativeDocument(adm, nativePublisherOrigin ?? ''); + const attributes = [ + ['sandbox', ADM_IFRAME_SANDBOX], + ['referrerpolicy', 'no-referrer'], + ['width', applyIntrinsic(stringIntrinsic, undefined, [width])], + ['height', applyIntrinsic(stringIntrinsic, undefined, [height])], + ['scrolling', 'no'], + ['frameborder', '0'], + ['marginwidth', '0'], + ['marginheight', '0'], + ['title', 'Ad content'], + ['aria-label', 'Advertisement'], + [ + 'style', + `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;`, + ], + ] as const; + for (let index = 0; index < attributes.length; index += 1) { + const attribute = attributes[index]; + if (!attribute) return undefined; + const name = attribute[0]; + const value = attribute[1]; + if (!setNativeAttribute(frame, name, value)) return undefined; + } + try { + if (iframeReferrerPolicyDescriptor?.set) { + applyIntrinsic(iframeReferrerPolicyDescriptor.set, frame, ['no-referrer']); + } else { + objectDefineProperty(frame, 'referrerPolicy', { + configurable: false, + enumerable: true, + value: 'no-referrer', + writable: false, + }); + } + } catch { + return undefined; + } + + let active = false; + let appended = false; + let committed = false; + let disposed = false; + let terminal = false; + let pending: 'error' | 'load' | undefined; + let predecessors: Element[] = []; + + const exactAttributes = (): boolean => { + for (let index = 0; index < attributes.length; index += 1) { + const attribute = attributes[index]; + if (!attribute) return false; + const name = attribute[0]; + const value = attribute[1]; + if (nativeAttribute(frame, name) !== value) return false; + } + return nativeReferrerPolicy(frame) === 'no-referrer'; + }; + + const current = (): boolean => { + if ( + disposed || + !appended || + nativeParent(frame) !== container || + nativeOwnerDocument(frame) !== nativeDocument || + !nativeConnected(frame) || + nativeSrcdoc(frame) !== intendedSrcdoc || + hasNativeAttribute(frame, 'src') + ) { + return false; + } + return exactAttributes(); + }; + + const removeListeners = (): void => { + try { + applyIntrinsic(eventTargetRemoveEventListener, frame, ['load', onFrameLoad]); + applyIntrinsic(eventTargetRemoveEventListener, frame, ['error', onFrameError]); + } catch { + // Listener disposal remains best-effort after a hostile realm mutation. + } + }; + + const settle = (outcome: 'error' | 'load'): void => { + if (disposed || terminal) return; + terminal = true; + pending = undefined; + removeListeners(); + if (outcome === 'load' && current()) onLoad(); + else onError(); + }; + + function onFrameLoad(): void { + if (disposed || terminal || !appended) return; + if (!current()) { + if (active) settle('error'); + else pending = 'error'; + return; + } + if (active) settle('load'); + else pending = 'load'; + } + + function onFrameError(): void { + if (disposed || terminal || !appended) return; + if (active) settle('error'); + else pending = 'error'; + } + + try { + applyIntrinsic(eventTargetAddEventListener, frame, ['load', onFrameLoad]); + applyIntrinsic(eventTargetAddEventListener, frame, ['error', onFrameError]); + applyIntrinsic(iframeSrcdocDescriptor.set, frame, [intendedSrcdoc]); + } catch { + removeListeners(); + return undefined; + } + if (nativeSrcdoc(frame) !== intendedSrcdoc || hasNativeAttribute(frame, 'src')) { + removeListeners(); + return undefined; + } + + const dispose = (): void => { + if (disposed) return; + disposed = true; + pending = undefined; + removeListeners(); + removeNativeNode(frame); + }; + + return applyIntrinsic>(objectFreezeIntrinsic, Object, [ + { + frame, + append: (): boolean => { + if ( + disposed || + committed || + appended || + nativeParent(frame) !== null || + nativeOwnerDocument(container) !== nativeDocument || + !nativeConnected(container) || + nativeSrcdoc(frame) !== intendedSrcdoc || + hasNativeAttribute(frame, 'src') + ) { + return false; + } + const before = snapshotChildren(container); + if (!before) return false; + predecessors = before; + appended = true; + try { + applyIntrinsic(nodeAppendChild, container, [frame]); + } catch { + dispose(); + return false; + } + if (!current()) { + dispose(); + return false; + } + return true; + }, + activate: (): boolean => { + if (disposed || committed || terminal || active || !appended) return false; + active = true; + if (!current()) settle('error'); + else if (pending) settle(pending); + return true; + }, + commit: (): boolean => { + if (disposed || committed || !terminal || !current()) return false; + removeListeners(); + for (let index = 0; index < predecessors.length; index += 1) { + const predecessor = predecessors[index]; + if (!predecessor || !current()) return false; + if (predecessor !== frame && nativeParent(predecessor) === container) { + removeNativeNode(predecessor); + if (nativeParent(predecessor) === container) return false; + } + } + if (!current()) return false; + predecessors = []; + committed = true; + return true; + }, + current, + dispose, + }, + ]); } diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts deleted file mode 100644 index c05070a9d..000000000 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ /dev/null @@ -1,163 +0,0 @@ -// Request orchestration for tsjs: unified auction endpoint with iframe-based creative rendering. -import { dispatchApsRendering, renderApsCreative } from '../integrations/aps/render'; - -import { buildAdRequest, sendAuction } from './auction'; -import { collectContext } from './context'; -import { log } from './log'; -import { getAllUnits, firstSize } from './registry'; -import { createAdIframe, findSlot, buildCreativeDocument, sanitizeCreativeHtml } from './render'; - -export type RequestAdsCallback = () => void; -export interface RequestAdsOptions { - bidsBackHandler?: RequestAdsCallback; - timeout?: number; -} - -type RenderCreativeInlineOptions = { - slotId: string; - // Accept unknown input here because bidder JSON is untrusted at runtime. - creativeHtml: unknown; - creativeWidth?: number; - creativeHeight?: number; - seat: string; - creativeId: string; -}; - -// Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. -export function requestAds( - callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, - maybeOpts?: RequestAdsOptions -): void { - let callback: RequestAdsCallback | undefined; - let opts: RequestAdsOptions | undefined; - if (typeof callbackOrOpts === 'function') { - callback = callbackOrOpts as RequestAdsCallback; - opts = maybeOpts; - } else { - opts = callbackOrOpts as RequestAdsOptions | undefined; - callback = opts?.bidsBackHandler; - } - - log.info('requestAds: called', { hasCallback: typeof callback === 'function' }); - try { - const adUnits = getAllUnits(); - const config = collectContext(); - const payload = { ...buildAdRequest(adUnits), config }; - log.debug('requestAds: payload', { units: adUnits.length, contextKeys: Object.keys(config) }); - - // Use unified auction endpoint - void sendAuction('/auction', payload) - .then((bids) => { - log.info('requestAds: got bids', { count: bids.length }); - for (const bid of bids) { - if (!bid.impid) continue; - if (bid.renderer) { - void Promise.resolve( - dispatchApsRendering({ - slotId: bid.impid, - renderer: bid.renderer, - trustedServer: (renderer) => renderApsCreative({ slotId: bid.impid, renderer }), - }) - ); - continue; - } - if (!bid.adm) { - log.debug('requestAds: bid has no adm, skipping', { slotId: bid.impid }); - continue; - } - renderCreativeInline({ - slotId: bid.impid, - creativeHtml: bid.adm, - creativeWidth: bid.width, - creativeHeight: bid.height, - seat: bid.seat, - creativeId: bid.creativeId, - }); - } - log.info('requestAds: rendered creatives from response'); - }) - .catch((err) => { - log.warn('requestAds: auction failed', err); - }); - - // Synchronously invoke callback to match test expectations - try { - if (callback) callback(); - } catch { - /* ignore callback errors */ - } - } catch { - log.warn('requestAds: failed to initiate'); - } -} - -// Render a creative by writing its HTML into a sandboxed iframe. The markup may -// be raw bidder output (server-side sanitization is opt-in); the sandbox's -// origin isolation is the security boundary. -function renderCreativeInline({ - slotId, - creativeHtml, - creativeWidth, - creativeHeight, - seat, - creativeId, -}: RenderCreativeInlineOptions): void { - const container = findSlot(slotId) as HTMLElement | null; - if (!container) { - log.warn('renderCreativeInline: slot not found; skipping render', { slotId, seat, creativeId }); - return; - } - - try { - const sanitization = sanitizeCreativeHtml(creativeHtml); - if (sanitization.kind === 'rejected') { - log.warn('renderCreativeInline: rejected creative', { - slotId, - seat, - creativeId, - originalLength: sanitization.originalLength, - rejectionReason: sanitization.rejectionReason, - }); - return; - } - - // Clear the slot only after sanitization succeeds so rejected creatives never blank existing content. - container.innerHTML = ''; - - // Determine size with fallback chain: creative size → ad unit size → 300x250 - let width: number; - let height: number; - - if (creativeWidth && creativeHeight && creativeWidth > 0 && creativeHeight > 0) { - width = creativeWidth; - height = creativeHeight; - log.debug('renderCreativeInline: using creative dimensions', { width, height }); - } else { - const unit = getAllUnits().find((u) => u.code === slotId); - const size = (unit && firstSize(unit)) || [300, 250]; - width = size[0]; - height = size[1]; - log.debug('renderCreativeInline: using ad unit dimensions', { width, height }); - } - - const iframe = createAdIframe(container, { - name: `tsjs_iframe_${slotId}`, - title: 'Ad content', - width, - height, - }); - - iframe.srcdoc = buildCreativeDocument(sanitization.sanitizedHtml); - - log.info('renderCreativeInline: rendered', { - slotId, - seat, - creativeId, - width, - height, - originalLength: sanitization.originalLength, - }); - } catch (err) { - log.warn('renderCreativeInline: failed', { slotId, seat, creativeId, err }); - } -} diff --git a/crates/trusted-server-js/lib/src/core/slot_element.ts b/crates/trusted-server-js/lib/src/core/slot_element.ts deleted file mode 100644 index b7cf47d88..000000000 --- a/crates/trusted-server-js/lib/src/core/slot_element.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** Result of resolving one configured slot div ID against the live DOM. */ -export interface SlotElementResolution { - element: HTMLElement | null; - prefixMatchCount: number; - activeMatchCount: number; -} - -function isElementVisible(element: HTMLElement): boolean { - const elementWithVisibilityCheck = element as HTMLElement & { - checkVisibility?: (options?: { - checkVisibilityCSS?: boolean; - visibilityProperty?: boolean; - }) => boolean; - }; - if (typeof elementWithVisibilityCheck.checkVisibility === 'function') { - return elementWithVisibilityCheck.checkVisibility({ - checkVisibilityCSS: true, - visibilityProperty: true, - }); - } - - for (let current: HTMLElement | null = element; current; current = current.parentElement) { - const style = window.getComputedStyle(current); - if ( - style.display === 'none' || - style.visibility === 'hidden' || - style.visibility === 'collapse' - ) { - return false; - } - } - return true; -} - -function slotElementHasLayout(element: HTMLElement): boolean { - if (!isElementVisible(element)) return false; - const elementRect = element.getBoundingClientRect(); - if (elementRect.width > 0 && elementRect.height > 0) return true; - - const container = document.getElementById(`${element.id}-container`); - if (!container || !isElementVisible(container)) return false; - const containerRect = container.getBoundingClientRect(); - return containerRect.width > 0; -} - -/** Resolve an exact ID or one unambiguous visible/layout prefix match. */ -export function resolveSlotElementByDivId(divId: string): SlotElementResolution { - if (!divId) { - return { element: null, prefixMatchCount: 0, activeMatchCount: 0 }; - } - - const exact = document.getElementById(divId); - if (exact) { - return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 }; - } - - const prefixMatches = Array.from(document.querySelectorAll('[id]')).filter( - (element) => element.id.startsWith(divId) && !element.id.endsWith('-container') - ); - if (prefixMatches.length === 1 && isElementVisible(prefixMatches[0]!)) { - return { - element: prefixMatches[0]!, - prefixMatchCount: 1, - activeMatchCount: 1, - }; - } - - const visibleMatches = prefixMatches.filter(isElementVisible); - if (visibleMatches.length === 1) { - return { - element: visibleMatches[0]!, - prefixMatchCount: prefixMatches.length, - activeMatchCount: 1, - }; - } - - const activeMatches = visibleMatches.filter(slotElementHasLayout); - return { - element: activeMatches.length === 1 ? activeMatches[0]! : null, - prefixMatchCount: prefixMatches.length, - activeMatchCount: activeMatches.length, - }; -} diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts new file mode 100644 index 000000000..5601c7ab5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -0,0 +1,1027 @@ +// Closure-private render diagnostics data for the hard-cutover runtime. +import type { RenderTraceDiagnostics, RenderTraceRecord } from './types'; + +const MAX_RENDER_LOG_ENTRIES = 200; + +const MAX_RENDER_TRACE_SLOTS = 256; +const MAX_RENDER_TRACE_COUNTERS = 768; +const MAX_RENDER_TRACE_SUBSCRIBERS = 32; +const MAX_RENDER_TRACE_NOTIFICATIONS = 200; +const EMPTY_RENDER_TRACE_CURRENT = Object.freeze( + Object.create(null) as Record> +); +const EMPTY_RENDER_TRACE_HISTORY = Object.freeze([]) as readonly Readonly[]; + +type RenderTraceInputV1 = Omit; +type RenderTraceUpdateV1 = Partial>; + +/** Safe GPT fact shape admitted by the closure-private diagnostics bus. */ +export interface RenderTraceGptFactV1 extends Readonly> { + readonly kind: + | 'slotRequested' + | 'slotResponseReceived' + | 'slotRenderEnded' + | 'slotOnload' + | 'impressionViewable' + | 'slotVisibilityChanged'; + readonly slot: Readonly<{ + readonly token: string; + readonly cycleOrdinal: number; + readonly elementId?: string; + }>; + readonly isEmpty?: boolean; + readonly inViewPercentage?: number; +} + +/** Current registered-slot identity and presentation state for one safe GPT fact. */ +export interface RenderTraceGptResolutionV1 { + readonly slotId: string; + readonly navigationGeneration: object; + readonly traceToken: string; + readonly elementId?: string; + readonly visible?: boolean; +} + +export interface RenderTraceRuntimeScheduler { + readonly set: (callback: () => void, milliseconds: number) => unknown; + readonly clear: (handle: unknown) => void; +} + +export interface RenderTraceRuntimeOptions { + readonly now?: () => number; + readonly onOverflow?: (droppedNotifications: number) => void; + readonly onPresentationError?: (error: unknown) => void; + readonly onSubscriberError?: (error: unknown) => void; + readonly schedule?: (callback: () => void) => () => void; + readonly scheduler?: RenderTraceRuntimeScheduler; +} + +export interface FirstDisplayTraceAdoptionV1 { + readonly navigationGeneration: object; + readonly nextSequence: number; + readonly slots: readonly Readonly<{ + readonly bindings: readonly Readonly<{ + readonly cycleOrdinal: number; + readonly historySequence: number; + readonly state: 'completed' | 'retired'; + readonly token: string; + }>[]; + readonly impressions: number; + readonly records: readonly Readonly[]; + readonly slotId: string; + }>[]; +} + +/** Closure-private data channel made available only to the deferred presentation owner. */ +export interface RenderTracePresentationSource { + readonly current: RenderTraceDiagnostics['current']; + readonly history: RenderTraceDiagnostics['history']; + readonly subscribe: (listener: () => void) => () => void; +} + +export interface RenderTracePresentationControls { + readonly dispose: () => void; +} + +export type RenderTracePresentationFactory = ( + source: RenderTracePresentationSource +) => RenderTracePresentationControls; + +export interface RenderTraceRuntimeOwner { + readonly api: RenderTraceDiagnostics; + readonly diagnostics: RenderTraceDiagnostics; + readonly record: (input: RenderTraceInputV1) => Readonly | undefined; + readonly enrich: ( + recordOrSequence: Readonly | number, + patch: RenderTraceUpdateV1 + ) => Readonly | undefined; + readonly prune: (slotId: string, sequence?: number) => boolean; + readonly pruneNavigation: (navigationGeneration: object) => number; + readonly observeGptFact: ( + fact: Readonly, + resolve: (elementId: string | undefined) => RenderTraceGptResolutionV1 | undefined + ) => void; + readonly attachPresentation: (factory: RenderTracePresentationFactory) => () => void; + readonly adoptFirstDisplay: (candidate: FirstDisplayTraceAdoptionV1) => boolean; + readonly dispose: () => void; +} + +export class DiagnosticsSubscriberLimitError extends Error { + public readonly code = 'subscriber_capacity' as const; + public readonly surface: 'renderTrace' | 'gpt'; + + public constructor(surface: 'renderTrace' | 'gpt') { + super('subscriber_capacity'); + this.name = 'DiagnosticsSubscriberLimitError'; + this.surface = surface; + } +} + +interface RenderTraceSubscription { + readonly id: number; + readonly listener: (record: Readonly) => void; +} + +interface PendingRenderTraceNotification { + readonly record: Readonly; + readonly subscriberIds: readonly number[]; +} + +function copyRenderTraceRecord(record: Readonly): Readonly { + const copy: Record = { + slotId: record.slotId, + path: record.path, + rendered: record.rendered, + }; + const optional = [ + 'elementId', + 'auctionId', + 'bidder', + 'adId', + 'bidId', + 'creativeId', + 'admHash', + 'servedFrom', + 'gamEmpty', + 'injected', + 'visible', + ] as const; + for (const key of optional) { + const value = record[key]; + if (value !== undefined) copy[key] = value; + } + copy.count = record.count; + copy.seq = record.seq; + copy.at = record.at; + return Object.freeze(copy) as unknown as Readonly; +} + +function scheduleRenderTraceTask(callback: () => void): () => void { + const handle = globalThis.setTimeout(callback, 0); + return (): void => globalThis.clearTimeout(handle); +} + +function createRenderTraceOwner(options: RenderTraceRuntimeOptions): RenderTraceRuntimeOwner { + const current = new Map>(); + const counts = new Map(); + const history: Array> = []; + const recordsBySequence = new Map>(); + const gptImpressions = new Map< + string, + { + readonly baselineSequence: number | undefined; + historySequence?: number; + readonly navigationGeneration: object; + reconciled?: boolean; + readonly slotId: string; + state: 'open' | 'completed' | 'retired'; + readonly token: string; + } + >(); + const subscribers = new Map(); + const pendingOrder: number[] = []; + const pendingBySequence = new Map(); + let sequence = 0; + let subscriberSequence = 0; + let droppedNotifications = 0; + let reportedDroppedNotifications = 0; + let cancelScheduled: (() => void) | undefined; + let presentationSubscriber: Readonly<{ generation: number; listener: () => void }> | undefined; + let presentationGeneration = 0; + let presentationPending: + | Readonly<{ + subscriber: Readonly<{ generation: number; listener: () => void }>; + }> + | undefined; + let cancelPresentationScheduled: (() => void) | undefined; + let presentationControls: RenderTracePresentationControls | undefined; + let invalidatePresentationSource: (() => void) | undefined; + let presentationAttaching = false; + let disposed = false; + + const adoptFirstDisplay = (candidate: FirstDisplayTraceAdoptionV1): boolean => { + try { + if ( + disposed || + sequence !== 0 || + current.size !== 0 || + history.length !== 0 || + recordsBySequence.size !== 0 || + counts.size !== 0 || + gptImpressions.size !== 0 || + typeof candidate !== 'object' || + candidate === null || + typeof candidate.navigationGeneration !== 'object' || + candidate.navigationGeneration === null || + !Number.isInteger(candidate.nextSequence) || + candidate.nextSequence < 1 || + candidate.nextSequence > 4_294_967_295 || + !Array.isArray(candidate.slots) || + candidate.slots.length > MAX_RENDER_TRACE_SLOTS + ) { + return false; + } + const adopted = new Map< + string, + Readonly<{ + bindings: readonly Readonly<{ + cycleOrdinal: number; + historySequence: number; + state: 'completed' | 'retired'; + token: string; + }>[]; + impressions: number; + records: readonly Readonly[]; + }> + >(); + const recordSequences = new Set(); + const bindingKeys = new Set(); + let bindingCount = 0; + for (const slot of candidate.slots) { + if ( + typeof slot !== 'object' || + slot === null || + typeof slot.slotId !== 'string' || + slot.slotId.length === 0 || + adopted.has(slot.slotId) || + !Number.isInteger(slot.impressions) || + slot.impressions < 0 || + slot.impressions > 4_294_967_295 || + !Array.isArray(slot.bindings) || + slot.bindings.length > 10 || + !Array.isArray(slot.records) || + slot.records.length > 10 + ) { + return false; + } + const adoptedRecords: Readonly[] = []; + for (const record of slot.records) { + if ( + typeof record !== 'object' || + record === null || + record.slotId !== slot.slotId || + !['auction', 'ssat', 'gam-refresh'].includes(record.path) || + typeof record.rendered !== 'boolean' || + !Number.isInteger(record.count) || + record.count < 1 || + record.count > slot.impressions || + !Number.isInteger(record.seq) || + record.seq < 1 || + record.seq >= candidate.nextSequence || + recordSequences.has(record.seq) || + typeof record.at !== 'number' || + !Number.isFinite(record.at) || + record.at < 0 + ) { + return false; + } + recordSequences.add(record.seq); + adoptedRecords.push(copyRenderTraceRecord(record)); + } + const adoptedBindings: Array< + Readonly<{ + cycleOrdinal: number; + historySequence: number; + state: 'completed' | 'retired'; + token: string; + }> + > = []; + for (const binding of slot.bindings) { + if ( + typeof binding !== 'object' || + binding === null || + typeof binding.token !== 'string' || + !/^gt1_[1-9a-z][0-9a-z]{0,6}$/.test(binding.token) || + !Number.isInteger(binding.cycleOrdinal) || + binding.cycleOrdinal < 1 || + binding.cycleOrdinal > 4_294_967_295 || + !Number.isInteger(binding.historySequence) || + !recordSequences.has(binding.historySequence) || + (binding.state !== 'completed' && binding.state !== 'retired') + ) { + return false; + } + const key = `${binding.token}:${binding.cycleOrdinal}`; + if (bindingKeys.has(key)) return false; + bindingKeys.add(key); + bindingCount += 1; + if (bindingCount > MAX_RENDER_TRACE_SLOTS) return false; + adoptedBindings.push( + Object.freeze({ + cycleOrdinal: binding.cycleOrdinal, + historySequence: binding.historySequence, + state: binding.state, + token: binding.token, + }) + ); + } + if ( + adoptedRecords.length > 0 && + !adoptedRecords.some((record) => record.count === slot.impressions) + ) { + return false; + } + adopted.set( + slot.slotId, + Object.freeze({ + bindings: Object.freeze(adoptedBindings), + impressions: slot.impressions, + records: Object.freeze(adoptedRecords), + }) + ); + } + const adoptedHistory = [...adopted.entries()] + .flatMap(([, value]) => value.records) + .sort((left, right) => left.seq - right.seq); + const adoptedCurrent = new Map>(); + for (const [slotId, value] of adopted) { + const latest = value.records.reduce | undefined>( + (candidateRecord, record) => + !candidateRecord || record.seq > candidateRecord.seq ? record : candidateRecord, + undefined + ); + if (latest) adoptedCurrent.set(slotId, latest); + } + const retainedSequences = new Set( + [...adoptedHistory.slice(-MAX_RENDER_LOG_ENTRIES), ...adoptedCurrent.values()].map( + (record) => record.seq + ) + ); + if ( + [...adopted.values()].some((value) => + value.bindings.some((binding) => !retainedSequences.has(binding.historySequence)) + ) + ) { + return false; + } + + sequence = candidate.nextSequence - 1; + for (const [slotId, value] of adopted) { + if (value.impressions > 0) counts.set(slotId, value.impressions); + const latest = adoptedCurrent.get(slotId); + if (latest) current.set(slotId, latest); + for (const binding of value.bindings) { + gptImpressions.set(`${binding.token}:${binding.cycleOrdinal}`, { + baselineSequence: undefined, + historySequence: binding.historySequence, + navigationGeneration: candidate.navigationGeneration, + slotId, + state: binding.state, + token: binding.token, + }); + } + } + for (const record of adoptedHistory.slice(-MAX_RENDER_LOG_ENTRIES)) { + history.push(record); + } + for (const record of current.values()) recordsBySequence.set(record.seq, record); + for (const record of history) recordsBySequence.set(record.seq, record); + return true; + } catch { + return false; + } + }; + + const schedule = (callback: () => void): (() => void) => { + if (options.schedule) return options.schedule(callback); + if (options.scheduler) { + const handle = options.scheduler.set(callback, 0); + return (): void => options.scheduler?.clear(handle); + } + return scheduleRenderTraceTask(callback); + }; + + const reportSubscriberError = (error: unknown): void => { + try { + options.onSubscriberError?.(error); + } catch { + // Diagnostics error reporting cannot affect correctness work. + } + }; + + const reportPresentationError = (error: unknown): void => { + try { + options.onPresentationError?.(error); + } catch { + // Deferred presentation reporting cannot affect trace data ownership. + } + }; + + const cancelPresentationTask = (): void => { + const cancel = cancelPresentationScheduled; + cancelPresentationScheduled = undefined; + presentationPending = undefined; + if (!cancel) return; + try { + cancel(); + } catch (error) { + reportPresentationError(error); + } + }; + + const notifyPresentation = (): void => { + const subscriber = presentationSubscriber; + if (disposed || !subscriber || presentationPending) return; + const pending = Object.freeze({ subscriber }); + presentationPending = pending; + try { + const cancel = schedule(() => { + if (presentationPending !== pending) return; + cancelPresentationScheduled = undefined; + presentationPending = undefined; + if (disposed || presentationSubscriber !== subscriber) return; + try { + subscriber.listener(); + } catch (error) { + reportPresentationError(error); + } + }); + if (typeof cancel !== 'function') throw new TypeError('invalid presentation scheduler'); + if (presentationPending === pending && presentationSubscriber === subscriber) { + cancelPresentationScheduled = cancel; + } + } catch (error) { + if (presentationPending === pending) presentationPending = undefined; + cancelPresentationScheduled = undefined; + reportPresentationError(error); + } + }; + + const drain = (): void => { + cancelScheduled = undefined; + if (droppedNotifications !== reportedDroppedNotifications) { + reportedDroppedNotifications = droppedNotifications; + try { + options.onOverflow?.(droppedNotifications); + } catch { + // Diagnostics-only overflow reporting stays inside the diagnostics task. + } + } + while (!disposed && pendingOrder.length > 0) { + const next = pendingOrder.shift(); + if (next === undefined) continue; + const pending = pendingBySequence.get(next); + pendingBySequence.delete(next); + if (!pending) continue; + for (const id of pending.subscriberIds) { + const subscription = subscribers.get(id); + if (!subscription) continue; + try { + subscription.listener(pending.record); + } catch (error) { + reportSubscriberError(error); + } + } + } + }; + + const ensureDrain = (): boolean => { + if (cancelScheduled) return true; + try { + const cancel = schedule(drain); + if (typeof cancel !== 'function') throw new TypeError('invalid diagnostics scheduler'); + if (!disposed && pendingOrder.length > 0) cancelScheduled = cancel; + return true; + } catch { + pendingOrder.length = 0; + pendingBySequence.clear(); + cancelScheduled = undefined; + return false; + } + }; + + const enqueue = (record: Readonly): void => { + if (disposed || subscribers.size === 0) return; + const pending = Object.freeze({ + record: copyRenderTraceRecord(record), + subscriberIds: Object.freeze([...subscribers.keys()]), + }); + if (pendingBySequence.has(record.seq)) { + pendingBySequence.set(record.seq, pending); + return; + } + if (pendingOrder.length >= MAX_RENDER_TRACE_NOTIFICATIONS) { + const dropped = pendingOrder.shift(); + if (dropped !== undefined) pendingBySequence.delete(dropped); + droppedNotifications += 1; + } + pendingOrder.push(record.seq); + pendingBySequence.set(record.seq, pending); + ensureDrain(); + }; + + const retained = (record: Readonly): boolean => + current.get(record.slotId)?.seq === record.seq || + history.some((candidate) => candidate.seq === record.seq); + + const trimCounters = (): void => { + if (counts.size <= MAX_RENDER_TRACE_COUNTERS) return; + const protectedSlotIds = new Set(); + for (const slotId of current.keys()) protectedSlotIds.add(slotId); + for (const traceRecord of history) protectedSlotIds.add(traceRecord.slotId); + for (const impression of gptImpressions.values()) protectedSlotIds.add(impression.slotId); + while (counts.size > MAX_RENDER_TRACE_COUNTERS) { + let evicted = false; + for (const slotId of counts.keys()) { + if (protectedSlotIds.has(slotId)) continue; + counts.delete(slotId); + evicted = true; + break; + } + if (!evicted) break; + } + }; + + const record = (input: RenderTraceInputV1): Readonly | undefined => { + if (disposed) return undefined; + if (input.path !== 'gam-refresh') { + for (const impression of gptImpressions.values()) { + if ( + impression.slotId !== input.slotId || + impression.state !== 'completed' || + impression.reconciled === true || + impression.historySequence === undefined || + current.get(input.slotId)?.seq !== impression.historySequence + ) { + continue; + } + const reconciled = enrich(impression.historySequence, input); + if (reconciled) { + impression.reconciled = true; + return reconciled; + } + } + } + const previous = current.get(input.slotId); + const evictedCurrentSlot = + !previous && current.size >= MAX_RENDER_TRACE_SLOTS + ? (current.keys().next().value as string | undefined) + : undefined; + let at: number; + try { + at = (options.now ?? Date.now)(); + } catch { + at = Date.now(); + } + const previousCount = counts.get(input.slotId) ?? 0; + if (previousCount > 0) counts.delete(input.slotId); + counts.set(input.slotId, previousCount + 1); + const committed = copyRenderTraceRecord({ + ...input, + count: previousCount + 1, + seq: (sequence += 1), + at, + }); + if (evictedCurrentSlot !== undefined) { + current.delete(evictedCurrentSlot); + } + current.set(committed.slotId, committed); + recordsBySequence.set(committed.seq, committed); + history.push(committed); + if (history.length > MAX_RENDER_LOG_ENTRIES) { + const evicted = history.shift(); + if (evicted && !retained(evicted)) recordsBySequence.delete(evicted.seq); + } + if (previous && !retained(previous)) recordsBySequence.delete(previous.seq); + trimCounters(); + enqueue(committed); + notifyPresentation(); + return committed; + }; + + const enrich = ( + recordOrSequence: Readonly | number, + patch: RenderTraceUpdateV1 + ): Readonly | undefined => { + if (disposed) return undefined; + const targetSequence = + typeof recordOrSequence === 'number' ? recordOrSequence : recordOrSequence?.seq; + if (!Number.isSafeInteger(targetSequence) || targetSequence <= 0) return undefined; + const existing = recordsBySequence.get(targetSequence); + if (!existing) return undefined; + const injected = + existing.injected === true || patch.injected === true + ? { injected: true as const } + : existing.injected === false || patch.injected === false + ? { injected: false as const } + : {}; + const merged = { + ...existing, + ...patch, + rendered: + existing.rendered === true && patch.rendered === false + ? true + : (patch.rendered ?? existing.rendered), + ...injected, + slotId: existing.slotId, + count: existing.count, + seq: existing.seq, + at: existing.at, + } as RenderTraceRecord; + const committed = copyRenderTraceRecord(merged); + recordsBySequence.set(targetSequence, committed); + if (current.get(existing.slotId)?.seq === targetSequence) { + current.set(existing.slotId, committed); + } + const historyIndex = history.findIndex(({ seq }) => seq === targetSequence); + if (historyIndex >= 0) history[historyIndex] = committed; + enqueue(committed); + notifyPresentation(); + return committed; + }; + + const prune = (slotId: string, expectedSequence?: number): boolean => { + if (disposed || typeof slotId !== 'string') return false; + let retired = false; + for (const impression of gptImpressions.values()) { + if ( + impression.slotId === slotId && + (expectedSequence === undefined || + impression.historySequence === expectedSequence || + impression.baselineSequence === expectedSequence) + ) { + impression.state = 'retired'; + retired = true; + } + } + const existing = current.get(slotId); + if (!existing || (expectedSequence !== undefined && existing.seq !== expectedSequence)) { + return retired; + } + current.delete(slotId); + if (!retained(existing)) recordsBySequence.delete(existing.seq); + notifyPresentation(); + return true; + }; + + const pruneNavigation = (navigationGeneration: object): number => { + if (disposed || typeof navigationGeneration !== 'object' || navigationGeneration === null) { + return 0; + } + let retired = 0; + let currentChanged = false; + for (const impression of gptImpressions.values()) { + if ( + impression.navigationGeneration !== navigationGeneration || + impression.state === 'retired' + ) { + continue; + } + impression.state = 'retired'; + retired += 1; + const sequence = impression.historySequence ?? impression.baselineSequence; + const existing = current.get(impression.slotId); + if (sequence === undefined || existing?.seq !== sequence) continue; + current.delete(impression.slotId); + if (!retained(existing)) recordsBySequence.delete(existing.seq); + currentChanged = true; + } + if (currentChanged) notifyPresentation(); + return retired; + }; + + const observeGptFact = ( + fact: Readonly, + resolve: (elementId: string | undefined) => RenderTraceGptResolutionV1 | undefined + ): void => { + if (disposed || typeof resolve !== 'function') return; + try { + const token = fact.slot.token; + const cycleOrdinal = fact.slot.cycleOrdinal; + if ( + typeof token !== 'string' || + !/^gt1_[1-9a-z][0-9a-z]{0,6}$/.test(token) || + token.length > 11 || + Number.parseInt(token.slice(4), 36) > 4_294_967_295 || + !Number.isInteger(cycleOrdinal) || + cycleOrdinal < 1 || + cycleOrdinal > 4_294_967_295 + ) { + return; + } + const key = `${token}:${cycleOrdinal}`; + + if (fact.kind === 'slotRequested') { + if (gptImpressions.has(key)) return; + const resolution = resolve(fact.slot.elementId); + if ( + !resolution || + typeof resolution.slotId !== 'string' || + resolution.slotId === '' || + typeof resolution.navigationGeneration !== 'object' || + resolution.navigationGeneration === null || + resolution.traceToken !== token + ) { + return; + } + for (const impression of gptImpressions.values()) { + if (impression.token === token && impression.state === 'open') return; + } + if (gptImpressions.size >= MAX_RENDER_TRACE_SLOTS) { + let prunable: string | undefined; + for (const [candidateKey, impression] of gptImpressions) { + if (impression.state !== 'open') { + prunable = candidateKey; + break; + } + } + if (prunable === undefined) return; + gptImpressions.delete(prunable); + } + for (const impression of gptImpressions.values()) { + if ( + impression.slotId === resolution.slotId && + (impression.state === 'completed' || + impression.navigationGeneration !== resolution.navigationGeneration || + impression.token !== token) + ) { + impression.state = 'retired'; + } + } + gptImpressions.set(key, { + baselineSequence: current.get(resolution.slotId)?.seq, + navigationGeneration: resolution.navigationGeneration, + slotId: resolution.slotId, + state: 'open', + token, + }); + return; + } + + const impression = gptImpressions.get(key); + if (!impression) return; + if (fact.kind === 'slotResponseReceived') return; + if (fact.kind === 'slotRenderEnded') { + if (typeof fact.isEmpty !== 'boolean' || impression.state !== 'open') return; + const resolution = resolve(fact.slot.elementId); + if ( + !resolution || + resolution.slotId !== impression.slotId || + resolution.navigationGeneration !== impression.navigationGeneration || + resolution.traceToken !== token + ) { + impression.state = 'retired'; + return; + } + const latest = current.get(impression.slotId); + const target = + latest && latest.seq !== impression.baselineSequence + ? latest + : record({ + slotId: impression.slotId, + path: 'gam-refresh', + rendered: !fact.isEmpty, + gamEmpty: fact.isEmpty, + injected: false, + ...(resolution?.elementId === undefined ? {} : { elementId: resolution.elementId }), + ...(resolution?.visible === undefined + ? {} + : { visible: !fact.isEmpty && resolution.visible }), + servedFrom: 'gam', + }); + if (!target) return; + const enriched = enrich(target, { + rendered: !fact.isEmpty, + gamEmpty: fact.isEmpty, + injected: false, + ...(target.servedFrom === undefined ? { servedFrom: 'gam' as const } : {}), + ...(resolution?.elementId === undefined ? {} : { elementId: resolution.elementId }), + ...(resolution?.visible === undefined + ? {} + : { visible: !fact.isEmpty && resolution.visible }), + }); + impression.historySequence = enriched?.seq ?? target.seq; + impression.state = 'completed'; + return; + } + + const targetSequence = impression.historySequence; + if (targetSequence === undefined) return; + const update = (patch: RenderTraceUpdateV1): void => { + if (impression.state !== 'retired') { + enrich(targetSequence, patch); + return; + } + const active = current.get(impression.slotId); + const enriched = enrich(targetSequence, patch); + if (active?.seq === targetSequence && enriched) current.set(impression.slotId, active); + }; + if (fact.kind === 'impressionViewable') { + update({ visible: true }); + } else if ( + fact.kind === 'slotVisibilityChanged' && + typeof fact.inViewPercentage === 'number' && + Number.isFinite(fact.inViewPercentage) + ) { + update({ visible: fact.inViewPercentage > 0 }); + } else if (fact.kind === 'slotOnload') { + const resolution = resolve(fact.slot.elementId); + if ( + resolution && + resolution.slotId === impression.slotId && + resolution.navigationGeneration === impression.navigationGeneration && + resolution.traceToken === impression.token && + resolution.visible !== undefined + ) { + update({ visible: resolution.visible }); + } + } + } catch { + // GPT diagnostics cannot affect the committed render or adapter callback. + } + }; + + const api: RenderTraceDiagnostics = Object.freeze({ + current: (): Readonly>> => { + const snapshot = Object.create(null) as Record>; + for (const [slotId, traceRecord] of current) { + Object.defineProperty(snapshot, slotId, { + configurable: false, + enumerable: true, + value: copyRenderTraceRecord(traceRecord), + writable: false, + }); + } + return Object.freeze(snapshot); + }, + history: (): readonly Readonly[] => + Object.freeze(history.map((traceRecord) => copyRenderTraceRecord(traceRecord))), + subscribe: (listener: (record: Readonly) => void): (() => void) => { + if (typeof listener !== 'function') + throw new TypeError('diagnostics listener must be callable'); + if (disposed) return () => undefined; + if (subscribers.size >= MAX_RENDER_TRACE_SUBSCRIBERS) { + throw new DiagnosticsSubscriberLimitError('renderTrace'); + } + const id = (subscriberSequence += 1); + const subscription = Object.freeze({ id, listener }); + subscribers.set(id, subscription); + let active = true; + return (): void => { + if (!active) return; + active = false; + if (subscribers.get(id) === subscription) subscribers.delete(id); + }; + }, + }); + + const clearPresentationSubscriber = (): void => { + presentationSubscriber = undefined; + cancelPresentationTask(); + }; + + const disposePresentationCandidate = (candidate: unknown): void => { + try { + if (typeof candidate !== 'object' || candidate === null) return; + const descriptor = Object.getOwnPropertyDescriptor(candidate, 'dispose'); + if (descriptor && 'value' in descriptor && typeof descriptor.value === 'function') { + Reflect.apply(descriptor.value, candidate, []); + } + } catch (error) { + reportPresentationError(error); + } + }; + + const validPresentationControls = ( + candidate: unknown + ): candidate is RenderTracePresentationControls => { + try { + if (typeof candidate !== 'object' || candidate === null || !Object.isFrozen(candidate)) { + return false; + } + const keys = Reflect.ownKeys(candidate); + if (keys.length !== 1 || keys[0] !== 'dispose') return false; + const descriptor = Object.getOwnPropertyDescriptor(candidate, 'dispose'); + return Boolean( + descriptor?.enumerable && 'value' in descriptor && typeof descriptor.value === 'function' + ); + } catch { + return false; + } + }; + + const attachPresentation = (factory: RenderTracePresentationFactory): (() => void) => { + if (typeof factory !== 'function') { + throw new TypeError('render trace presentation factory must be callable'); + } + if (disposed || presentationControls || presentationAttaching) { + throw new TypeError('render trace presentation is unavailable'); + } + presentationAttaching = true; + let sourceLive = true; + const invalidateSource = (): void => { + sourceLive = false; + }; + invalidatePresentationSource = invalidateSource; + const source = Object.freeze({ + current: (): Readonly>> => + sourceLive && !disposed ? api.current() : EMPTY_RENDER_TRACE_CURRENT, + history: (): readonly Readonly[] => + sourceLive && !disposed ? api.history() : EMPTY_RENDER_TRACE_HISTORY, + subscribe: (listener: () => void): (() => void) => { + if (typeof listener !== 'function') { + throw new TypeError('render trace presentation listener must be callable'); + } + if ( + disposed || + !sourceLive || + presentationSubscriber || + (!presentationAttaching && !presentationControls) + ) { + throw new TypeError('render trace presentation subscription is unavailable'); + } + const subscription = Object.freeze({ + generation: (presentationGeneration += 1), + listener, + }); + presentationSubscriber = subscription; + let active = true; + return (): void => { + if (!active) return; + active = false; + if (presentationSubscriber === subscription) clearPresentationSubscriber(); + }; + }, + }) satisfies RenderTracePresentationSource; + let candidate: unknown; + try { + candidate = factory(source); + if (!validPresentationControls(candidate)) { + throw new TypeError('render trace presentation controls are malformed'); + } + if (!presentationSubscriber) { + throw new TypeError('render trace presentation subscription is unavailable'); + } + const controls = candidate; + presentationControls = controls; + let active = true; + return (): void => { + if (!active) return; + active = false; + if (presentationControls !== controls) return; + presentationControls = undefined; + if (invalidatePresentationSource === invalidateSource) { + invalidatePresentationSource = undefined; + } + invalidateSource(); + clearPresentationSubscriber(); + disposePresentationCandidate(controls); + }; + } catch (error) { + if (invalidatePresentationSource === invalidateSource) { + invalidatePresentationSource = undefined; + } + invalidateSource(); + clearPresentationSubscriber(); + disposePresentationCandidate(candidate); + throw error; + } finally { + presentationAttaching = false; + } + }; + + const dispose = (): void => { + if (disposed) return; + disposed = true; + const controls = presentationControls; + presentationControls = undefined; + invalidatePresentationSource?.(); + invalidatePresentationSource = undefined; + clearPresentationSubscriber(); + if (controls) disposePresentationCandidate(controls); + try { + cancelScheduled?.(); + } catch { + // The disposed latch suppresses a hostile late callback. + } + cancelScheduled = undefined; + subscribers.clear(); + pendingOrder.length = 0; + pendingBySequence.clear(); + current.clear(); + history.length = 0; + recordsBySequence.clear(); + counts.clear(); + gptImpressions.clear(); + }; + + return Object.freeze({ + api, + diagnostics: api, + record, + enrich, + prune, + pruneNavigation, + observeGptFact, + attachPresentation, + adoptFirstDisplay, + dispose, + }); +} + +/** Data-only takeover trace owner; contains no DOM presentation behavior. */ +export function createRenderTraceStore( + options: RenderTraceRuntimeOptions = {} +): RenderTraceRuntimeOwner { + return createRenderTraceOwner(options); +} diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index e49b66146..f88010c51 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -1,55 +1,6 @@ // Shared TypeScript types for the tsjs core API and extensions. export type Size = readonly [number, number]; -export interface Banner { - sizes: ReadonlyArray; -} - -export interface MediaTypes { - banner?: Banner; -} - -export interface Bid { - bidder: string; - params?: Record; -} - -export interface AdUnit { - code: string; - mediaTypes?: MediaTypes; - bids?: Bid[]; -} - -/** Minimal shape of a server-side auction slot injected into `window.tsjs.adSlots`. */ -export interface AuctionSlot { - id: string; - gam_unit_path: string; - div_id: string; - formats: Array<[number, number]>; - targeting?: Record; -} - -/** Debug-only copy of server-side bid fields exposed for pipeline inspection. */ -export interface AuctionDebugBidData { - slot_id?: string; - price?: number | null; - currency?: string; - creative?: string | null; - adomain?: string[] | null; - bidder?: string; - width?: number; - height?: number; - nurl?: string | null; - burl?: string | null; - bid_id?: string | null; - ad_id?: string | null; - creative_id?: string | null; - cache_id?: string | null; - cache_host?: string | null; - cache_path?: string | null; - metadata?: Record; -} - export type ApsTagType = 'iframe' | 'script'; /** Version 1 Trusted Server APS renderer descriptor. */ @@ -58,7 +9,7 @@ export interface ApsRendererV1 { version: 1; accountId: string; bidId: string; - creativeId?: string; + creativeId?: string | undefined; tagType: ApsTagType; creativeUrl: string; aaxResponse: string; @@ -66,46 +17,82 @@ export interface ApsRendererV1 { height: number; } -export type AuctionBidRenderer = ApsRendererV1; - -/** A client-side Prebid bid's generated ad ID bound to its APS render capability. */ -export interface ApsPrebidRendererEntry { - adUnitCode: string; - renderer: ApsRendererV1; - registeredAt: number; - expiresAt: number; - /** Mark the bid as won and rendered after replying to Universal Creative. */ - markUsed(): void; -} - -/** Bid targeting data from the server-side auction, injected into `window.tsjs.bids`. */ -export interface AuctionBidData { - hb_pb?: string; - hb_bidder?: string; - hb_adid?: string; - hb_cache_host?: string; - 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. */ - renderer?: AuctionBidRenderer; - /** Winning creative width used by the inline render bridge. */ - w?: number; - /** Winning creative height used by the inline render bridge. */ - h?: number; - /** - * Sanitized winning creative markup for the inline render bridge. Present - * when the server retained a non-empty creative; not gated by debug mode. - */ - adm?: string; - /** Debug-only bid field mirror. Only present when `[debug] inject_adm_for_testing = true`. */ - debug_bid?: AuctionDebugBidData; +export interface AdmRenderSourceV1 { + type: 'adm'; + version: 1; + adm: string; + width: number; + height: number; +} + +export interface BaselinePbsCacheSourceV1 { + type: 'pbs_cache'; + version: 1; + cacheId: string; + cacheHost: string; + cachePath: string; + width: number; + height: number; +} + +export type BidRenderSourceV1 = ApsRendererV1 | AdmRenderSourceV1 | BaselinePbsCacheSourceV1; + +export type AuctionSlotFailureReason = + | 'auction_disabled' + | 'consent_denied' + | 'slot_not_eligible' + | 'provider_timeout' + | 'provider_error' + | 'invalid_provider_response' + | 'mediation_failed' + | 'winner_not_renderable' + | 'identity_generation_failed' + | 'internal_error'; + +export type SlotAuctionDecisionV1 = + | { slot: string; outcome: 'winner'; candidateId: string } + | { slot: string; outcome: 'no_bid' } + | { slot: string; outcome: 'failed'; reason: AuctionSlotFailureReason }; + +export interface AuctionDecisionSetV1 { + version: 1; + auctionId: string; + results: SlotAuctionDecisionV1[]; +} + +interface BrowserAuctionBidBaseV1 { + candidateId: string; + slot: string; + provider: string; + upstreamBidId: string; + cpm: number; + currency: 'USD'; + targeting: Record; +} + +export type BrowserAuctionBidV1 = + | (BrowserAuctionBidBaseV1 & { + rendererReservationId: string; + renderSource: ApsRendererV1 | AdmRenderSourceV1; + }) + | (BrowserAuctionBidBaseV1 & { + renderSource: BaselinePbsCacheSourceV1; + }); + +/** Exact GAM placement metadata required to publish one server-projected slot. */ +export interface BrowserAuctionSlotV1 { + slot: string; + gamUnitPath: string; + divId: string; + formats: ReadonlyArray; + targeting: Record; +} + +export interface BrowserAuctionProjectionV1 { + version: 1; + auction: AuctionDecisionSetV1; + slots: BrowserAuctionSlotV1[]; + bids: BrowserAuctionBidV1[]; } export type GptDiagnosticsCallbackKind = @@ -127,15 +114,15 @@ export type GptDiagnosticsBindingReason = export interface GptDiagnosticsBinding { status: 'bound' | 'unbound' | 'ambiguous'; - reason?: GptDiagnosticsBindingReason; + reason?: GptDiagnosticsBindingReason | undefined; } export interface GptDiagnosticsDurations { - requestToResponseMs?: number; - responseToRenderMs?: number; - requestToRenderMs?: number; - renderToLoadMs?: number; - renderToViewableMs?: number; + requestToResponseMs?: number | undefined; + responseToRenderMs?: number | undefined; + requestToRenderMs?: number | undefined; + renderToLoadMs?: number | undefined; + renderToViewableMs?: number | undefined; } /** @@ -147,14 +134,14 @@ export interface GptDiagnosticsDurations { * Manager delivered; they claim nothing about which demand source supplied it. */ export interface GptDiagnosticsAdManagerIdentity { - lineItemId?: number; - creativeId?: number; - campaignId?: number; - advertiserId?: number; - sourceAgnosticLineItemId?: number; - sourceAgnosticCreativeId?: number; - yieldGroupIds?: number[]; - companyIds?: number[]; + lineItemId?: number | undefined; + creativeId?: number | undefined; + campaignId?: number | undefined; + advertiserId?: number | undefined; + sourceAgnosticLineItemId?: number | undefined; + sourceAgnosticCreativeId?: number | undefined; + yieldGroupIds?: number[] | undefined; + companyIds?: number[] | undefined; } /** @@ -162,31 +149,19 @@ export interface GptDiagnosticsAdManagerIdentity { * facts GPT reported. */ export type GptDiagnosticsResponseClass = - | 'empty' - | 'backfill' - | 'reservation' - | 'unclassified_non_empty'; + 'empty' | 'backfill' | 'reservation' | 'unclassified_non_empty'; /** The request path observed for a GPT request cycle. */ export type GptDiagnosticsRequestPath = - | 'trusted_server_direct' - | 'prebid_refresh' - | 'publisher_refresh' - | 'competing' - | 'unattributed'; + 'trusted_server_direct' | 'prebid_refresh' | 'publisher_refresh' | 'competing' | 'unattributed'; /** The Trusted Server creative opportunity observed for a request. */ export type GptDiagnosticsTrustedServerOpportunity = - | 'renderable_candidate' - | 'unrenderable_candidate' - | 'no_candidate'; + 'renderable_candidate' | 'unrenderable_candidate' | 'no_candidate'; /** 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'; + 'missing_render_source' | 'cache_fetch_failed' | 'invalid_cache_payload' | 'response_post_failed'; /** Delivery evidence derived for a GPT request cycle. */ export type GptDiagnosticsDelivery = @@ -200,58 +175,58 @@ export type GptDiagnosticsDelivery = export interface GptDiagnosticsRequestCycle { requestNumber: number; - requestedAtMs?: number; - responseAtMs?: number; - renderAtMs?: number; - loadAtMs?: number; - viewableAtMs?: number; + requestedAtMs?: number | undefined; + responseAtMs?: number | undefined; + renderAtMs?: number | undefined; + loadAtMs?: number | undefined; + viewableAtMs?: number | undefined; durations: GptDiagnosticsDurations; - isEmpty?: boolean; + isEmpty?: boolean | undefined; /** Configured sizes Trusted Server supplied to GPT for this request. */ - requestedSlotSizes?: ReadonlyArray; - /** Exact fill size fact GPT reported in its `slotRenderEnded` callback. */ - size?: Size; + requestedSlotSizes?: ReadonlyArray | undefined; + /** Exact fill size fact GPT reported in `slotRenderEnded`. */ + size?: Size | undefined; /** * Outer CSS box observed on the uniquely bound, connected slot element after - * a filled GPT render. This is not an assertion about internal creative pixels. + * a filled GPT render. This does not assert the internal creative pixel size. */ - observedSlotSize?: Size; - isBackfill?: boolean; - slotContentChanged?: boolean; + observedSlotSize?: Size | undefined; + isBackfill?: boolean | undefined; + slotContentChanged?: boolean | undefined; incompleteSequence: boolean; - adManager?: GptDiagnosticsAdManagerIdentity; - responseClass?: GptDiagnosticsResponseClass; - requestPath?: GptDiagnosticsRequestPath; - requestIntentId?: number; - trustedServerAuctionId?: string; - opportunityToRequestMs?: number; - replacedRequestNumber?: number; - previousRenderToRequestMs?: number; - creativeChanged?: boolean; - previousCreativeId?: GptDiagnosticsAdManagerIdentity['creativeId']; - loadObservedBeforeRender?: boolean; - trustedServerOpportunity?: GptDiagnosticsTrustedServerOpportunity; - trustedServerCreativeRequestAtMs?: number; - trustedServerCreativeResponseAtMs?: number; - trustedServerCreativeFailures?: GptDiagnosticsCreativeFailure[]; + adManager?: GptDiagnosticsAdManagerIdentity | undefined; + responseClass?: GptDiagnosticsResponseClass | undefined; + requestPath?: GptDiagnosticsRequestPath | undefined; + requestIntentId?: number | undefined; + trustedServerAuctionId?: string | undefined; + opportunityToRequestMs?: number | undefined; + replacedRequestNumber?: number | undefined; + previousRenderToRequestMs?: number | undefined; + creativeChanged?: boolean | undefined; + previousCreativeId?: GptDiagnosticsAdManagerIdentity['creativeId'] | undefined; + loadObservedBeforeRender?: boolean | undefined; + trustedServerOpportunity?: GptDiagnosticsTrustedServerOpportunity | undefined; + trustedServerCreativeRequestAtMs?: number | undefined; + trustedServerCreativeResponseAtMs?: number | undefined; + trustedServerCreativeFailures?: GptDiagnosticsCreativeFailure[] | undefined; /** Derived on every snapshot; absent only on a cycle read before derivation. */ - delivery?: GptDiagnosticsDelivery; + delivery?: GptDiagnosticsDelivery | undefined; } export interface GptDiagnosticsSlotExport { runtimeSlotNumber: number; - slotElementId?: string; - adUnitPath?: string; + slotElementId?: string | undefined; + adUnitPath?: string | undefined; binding: GptDiagnosticsBinding; - currentVisibilityPercentage?: number; - maximumVisibilityPercentage?: number; - requests: GptDiagnosticsRequestCycle[]; + currentVisibilityPercentage?: number | undefined; + maximumVisibilityPercentage?: number | undefined; + requests: readonly Readonly[]; } export interface GptDiagnosticsCallbackIssue { kind: GptDiagnosticsCallbackKind; runtimeSlotNumber: number; - slotElementId?: string; + slotElementId?: string | undefined; timestampMs: number; disposition: GptDiagnosticsCallbackDisposition; reason: string; @@ -284,22 +259,24 @@ export interface GptDiagnosticsCoverageCounters { } export interface GptDiagnosticsExportV1 { - version: 1; - capturedAt: string; - page: { + readonly version: 1; + readonly capturedAt: string; + readonly page: Readonly<{ origin: string; pathname: string; - }; - slots: GptDiagnosticsSlotExport[]; - callbackIssues: GptDiagnosticsCallbackIssue[]; - attributionIssues?: GptDiagnosticsAttributionIssue[]; - coverage: Record; - metadata: { + }>; + readonly slots: readonly Readonly[]; + readonly callbackIssues: readonly Readonly[]; + readonly attributionIssues: readonly Readonly[]; + readonly coverage: Readonly< + Record> + >; + readonly metadata: Readonly<{ droppedCallbacks: number; - droppedAttributionIssues?: number; + droppedAttributionIssues: number; evictedSlots: number; evictedRequestCycles: number; - }; + }>; } /** GPT slot object identity, the only key diagnostics correlates slots by. */ @@ -310,23 +287,15 @@ export interface GptDiagnosticsSlotHandle { /** The documented, read-only operator API. It records no evidence. */ export interface GptDiagnosticsApi { - snapshot(): GptDiagnosticsExportV1; + snapshot(): Readonly; export(): void; - subscribe(listener: (snapshot: GptDiagnosticsExportV1) => void): () => void; + subscribe(listener: (snapshot: Readonly) => void): () => void; show(): void; hide(): void; } -/** - * Evidence writers used by Trusted Server's own integration modules. - * - * Separate bundles can only reach each other through `window.tsjs`, so this - * channel is reachable from the page like anything else there. Keeping it off - * [`GptDiagnosticsApi`] is what makes the documented operator surface read-only - * and stops the writers from becoming part of the public contract. - */ +/** Closure-private evidence channel shared only by release-bound TSJS modules. */ export interface GptDiagnosticsRecorder { - /** Record Trusted Server's creative opportunity and configured sizes for an associated GPT slot. */ recordTrustedServerOpportunity( slot: GptDiagnosticsSlotHandle, auctionSlotId: string, @@ -334,191 +303,258 @@ export interface GptDiagnosticsRecorder { trustedServerAuctionId?: string, requestedSlotSizes?: ReadonlyArray ): void; - /** Mark slots whose next observed GPT request follows the Prebid refresh path. */ recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): 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. */ recordTrustedServerCreativeResponse(attemptId: number): void; - /** Record a safe failure category for a creative attempt. */ recordTrustedServerCreativeFailure( attemptId: number, reason: GptDiagnosticsCreativeFailure ): void; } -/** - * Lifecycle state for a GPT slot TS created before its publisher declares it. - * - * Stored on `window.tsjs` so the head bootstrap and the full TSJS bundle share - * one handoff protocol. - */ -export interface GptSlotHandoff { - gamUnitPath: string; - formats: Array<[number, number]>; - /** Stable configured prefix used to safely bridge framework-generated IDs. */ - divIdPrefix: string; - /** Element ID GPT received when TS created the fallback slot. */ - slotElementId: string; - publisherClaimed: boolean; - suppressPublisherDisplay: boolean; - suppressPublisherRefresh: boolean; -} - -export type FirstImpressionOwner = 'publisher' | 'trusted_server'; -export type FirstImpressionPhase = 'auctioning' | 'delivery_pending' | 'requested' | 'rendered'; - -/** One publisher auction participating in the current navigation's first impression. */ -export interface FirstImpressionPublisherAuction { - token: string; - adUnitCode: string; - phase: 'auctioning' | 'delivery_pending'; - expiresAt: number; - adIds: string[]; - suppressDelivery: boolean; -} - -/** First-impression ownership for one exact physical slot element. */ -export interface FirstImpressionSlotClaim { - generation: number; - slotElementId: string; - element: HTMLElement; - owner: FirstImpressionOwner; - phase: FirstImpressionPhase; - expiresAt: number; - publisherAuctions: Record; - /** No later publisher auction may join this TS-owned first impression. */ - publisherRegistrationClosed?: boolean; - targeting?: Record; -} - -/** Bounded first-impression state shared by the GPT bootstrap, GPT, and Prebid bundles. */ -export interface FirstImpressionState { - generation: number; - nextToken: number; - slots: Record; - fallbackSlots: Record; -} - -export interface TsjsApi { - version: string; - que: Array<() => void>; - addAdUnits(units: AdUnit | AdUnit[]): void; - renderAdUnit(codeOrUnit: string | AdUnit): void; - renderAllAdUnits(): void; - setConfig?(cfg: Record): void; - getConfig?(): Record; - requestAds?(opts?: { bidsBackHandler?: () => void; timeout?: number }): void; - requestAds?( - callback: () => void, - opts?: { bidsBackHandler?: () => void; timeout?: number } - ): void; - log?: { - setLevel(l: 'silent' | 'error' | 'warn' | 'info' | 'debug'): void; - getLevel(): 'silent' | 'error' | 'warn' | 'info' | 'debug'; - info(...args: unknown[]): void; - warn(...args: unknown[]): void; - error(...args: unknown[]): void; - debug(...args: unknown[]): void; - }; - - // ── Server-side auction runtime (populated by TS edge injection) ────────── - /** Ad slot definitions injected at open. */ - adSlots?: AuctionSlot[]; - /** Winning bid targeting data injected before . */ - bids?: Record; - /** - * Bounded client-side Prebid APS renderer capabilities keyed by Prebid's generated - * `hb_adid`. The Universal Creative bridge consumes each entry at most once. - */ - apsPrebidRenderers?: Record; - /** Initialises GPT slots with server-side bid targeting and calls refresh(). */ - adInit?: () => void; - /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ - prevGptSlots?: unknown[]; - /** Guards one-time-per-page enableSingleRequest/enableServices calls. */ - servicesEnabled?: boolean; - /** Maps actualDivId → slotId for slotRenderEnded billing lookup. */ - divToSlotId?: Record; - /** - * Win/billing beacons already fired, keyed by `slotId|bidIdentity|kind|url`. - * Used by the GPT render bridge so a bid's nurl/burl fire at most once even - * across repeated Prebid Universal Creative requests for the same adId. - */ - firedBeacons?: Record; - /** Slot-level GPT targeting keys TS applied on the previous route. */ - prevSlotTargetingKeys?: Record; - /** - * One-shot bypass for the slim-Prebid refresh wrapper: true only while - * adInit() runs its internal refresh of server-side-targeted slots, so the - * wrapper passes that refresh straight to GPT instead of starting a - * client-side auction that would clear the just-applied TS targeting. - */ - adInitRefreshInProgress?: boolean; - /** Scoped context marking an active Prebid-controlled GPT refresh delegation. */ - prebidRefreshDispatchInProgress?: boolean; - /** - * Whether the publisher disabled GPT initial load through - * `googletag.setConfig()` or `googletag.pubads().disableInitialLoad()`. - * TS synchronizes this from GPT's getter and wraps both configuration APIs as - * a fallback when the getter is unavailable. - * When set, `display()` only registers a slot and the ad request must come - * from a `refresh()`; adInit() uses this to refresh its own freshly defined - * slots so they are not left blank. - */ - gptInitialLoadDisabled?: boolean; - /** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */ - gptSlotHandoffs?: Record; - /** True only while TS calls a GPT function that the handoff wrappers observe. */ - gptSlotHandoffInternal?: boolean; - /** Per-navigation first-impression ownership shared by GPT and Prebid. */ - firstImpression?: FirstImpressionState; - /** Guards the shared production GPT lifecycle listener installation. */ - firstImpressionListenersInstalled?: boolean; - /** Guards SPA pushState hook installation. */ - spaHookInstalled?: boolean; - /** Internal one-shot state shared by bootstrap and bundle scheduler installs. */ - initialAdInitScheduled?: boolean; - /** - * Monotonic count of committed SPA navigations, incremented synchronously by - * the SPA auction hook the moment it accepts a route change. The deferred - * initial-adInit bootstrap ([`scheduleInitialAdInit`]) is pinned to - * generation 0 (the SSR document) and no-ops when a navigation has - * committed — before it was called, or while it was pending. A counter is - * used instead of a URL comparison so the guard cannot diverge from the - * auction path: a query-only history change (which the hook deliberately - * ignores) leaves the counter unchanged, and an `/a → /b → /a` round trip - * (where the URL compares equal again) advances it. - */ - navGeneration?: number; - /** - * Defers the initial `adInit()` until after React hydration: window `load`, - * then a double `requestAnimationFrame`. Called by the server-injected - * `` bids script with the SSR bids payload. The whole initial pass - * is pinned to navigation generation 0 (the SSR document): if an SPA - * navigation has already committed — or commits while the deferred callback - * is pending — the payload is dropped and `adInit()` is not run, so a stale - * SSR bootstrap can neither clobber the live route's bids nor re-run it. - * Lives in the bundle so the lifecycle is executable under test and shares - * [`navGeneration`] with the SPA auction hook; `gpt_bootstrap.js` installs - * a minimal fallback for pages where the bundle fails to load. - * - * `initialSlots` exists for the shared-template `` seam, which is the - * only place slot definitions arrive with the bids rather than from the head - * script. Passing them here rather than assigning `tsjs.adSlots` before the - * call puts them behind the same generation guard: an assignment made ahead - * of the guard would clobber a committed SPA navigation's slots with the SSR - * document's, and then be read by that route's `adInit()`. - */ - scheduleInitialAdInit?: ( - initialBids?: Record, - initialSlots?: AuctionSlot[] - ) => void; - /** Read-only GPT lifecycle diagnostics API, present only in an activated tab. */ - gptDiagnostics?: GptDiagnosticsApi; - /** - * Internal evidence channel for Trusted Server integration modules. Not part - * of the operator API; present only in an activated tab. - */ - gptDiagnosticsRecorder?: GptDiagnosticsRecorder; +/** Release-internal takeover module emitted inside the persistent artifact. */ +export interface BootManifestTakeoverIntegrationV1 { + readonly id: string; + readonly phase: 'takeover'; +} + +/** Release-internal later module authenticated and loaded by core. */ +export interface BootManifestDeferredIntegrationV1 { + readonly id: string; + readonly phase: 'deferred'; + readonly trigger: 'first_display_or_idle'; + readonly src: string; +} + +export type BootManifestIntegrationV1 = + BootManifestTakeoverIntegrationV1 | BootManifestDeferredIntegrationV1; + +export interface BootManifestFirstDisplayV1 { + readonly src: string; + readonly slices: readonly string[]; +} + +/** Exact phase-aware bundle set and injection order required by one TSJS release. */ +export interface BootManifestV1 { + readonly version: 1; + readonly releaseId: string; + readonly firstDisplay: Readonly | null; + readonly runtimeSrc: string; + readonly integrations: readonly BootManifestIntegrationV1[]; +} + +/** One direct-auction ad unit admitted into the current navigation. */ +export interface ProgrammaticAdUnit { + readonly code: string; + readonly mediaTypes: Readonly<{ + banner: Readonly<{ sizes: readonly (readonly [number, number])[] }>; + }>; + readonly bids?: readonly Readonly<{ + bidder: string; + params?: Readonly>; + }>[]; +} + +export interface AddAdUnitsResult { + readonly registered: readonly string[]; +} + +export interface RequestAdsOptions { + readonly slots?: readonly string[]; + readonly timeoutMs?: number; + readonly signal?: AbortSignal; +} + +export type RenderFailureReason = + | 'auction_timeout' + | AuctionSlotFailureReason + | 'network_error' + | 'http_error' + | 'invalid_response' + | 'slot_unresolved' + | 'descriptor_invalid' + | 'invalid_dimensions' + | 'dimensions_out_of_range' + | 'no_render_source' + | 'registry_full' + | 'capability_registry_full' + | 'external_queue_full' + | 'external_ready_timeout' + | 'external_artifact_incompatible' + | 'prebid_admission_failed' + | 'prebid_contract_violation' + | 'prebid_selection_timeout' + | 'reservation_collision' + | 'identity_generation_failed' + | 'cycle_unattributable' + | 'slot_quarantined' + | 'gpt_request_failed' + | 'gpt_request_timeout' + | 'gpt_completion_timeout' + | 'reconciliation_capacity' + | 'gam_empty' + | 'bridge_claim_timeout' + | 'bridge_id_mismatch' + | 'owner_registration_timeout' + | 'owner_insertion_timeout' + | 'renderer_document_no_load' + | 'runner_no_load' + | 'runner_failed' + | 'cache_fetch_failed' + | 'invalid_cache_payload' + | 'response_post_failed' + | 'adm_document_no_load' + | 'abi_mismatch' + | 'bundle_partial'; + +export type RequestAdsSlotResult = + | Readonly<{ slot: string; path: 'primary' | 'fallback'; outcome: 'accepted' }> + | Readonly<{ slot: string; path: 'primary' | 'fallback'; outcome: 'no_bid' }> + | Readonly<{ + slot: string; + path: 'primary' | 'fallback'; + outcome: 'failed'; + reason: RenderFailureReason; + }> + | Readonly<{ + slot: string; + path: 'primary' | 'fallback'; + outcome: 'cancelled'; + reason: 'caller_aborted' | 'superseded' | 'navigation_disposed'; + }>; + +export interface RequestAdsResult { + readonly slots: readonly RequestAdsSlotResult[]; +} + +export type TsjsLogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; + +export interface TsjsLog { + setLevel(level: TsjsLogLevel): void; + getLevel(): TsjsLogLevel; + error(...values: readonly unknown[]): void; + warn(...values: readonly unknown[]): void; + info(...values: readonly unknown[]): void; + debug(...values: readonly unknown[]): void; +} + +export interface TsjsCommandQueue { + readonly length: 0; + push(callback: unknown): 0; +} + +export interface CreativeBootV1 { + readonly version: 1; + readonly enabled: boolean; + readonly clickGuard: boolean; + readonly renderGuard: boolean; +} + +export interface DiagnosticsBootV1 { + readonly version: 1; + readonly renderTraceOverlay: boolean; + readonly gpt: Readonly<{ readonly active: boolean }>; +} + +export const INTEGRATION_CONFIG_IDS_V1 = Object.freeze([ + 'aps', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'lockr', + 'osano', + 'permutive', + 'prebid', + 'sourcepoint', + 'testlight', +] as const); + +export type IntegrationConfigIdV1 = (typeof INTEGRATION_CONFIG_IDS_V1)[number]; +export type BootJsonPrimitiveV1 = null | boolean | number | string; +export type BootJsonValueV1 = + | BootJsonPrimitiveV1 + | readonly BootJsonValueV1[] + | Readonly<{ readonly [key: string]: BootJsonValueV1 }>; + +export interface IntegrationConfigEntryV1 { + readonly id: IntegrationConfigIdV1; + readonly config: Readonly<{ readonly [key: string]: BootJsonValueV1 }>; +} + +export interface IntegrationConfigsV1 { + readonly version: 1; + readonly entries: readonly Readonly[]; +} + +export interface TsjsBootV1 { + readonly abi: 1; + readonly releaseId: string; + readonly manifest: Readonly; + readonly auctionProjection: Readonly; + readonly integrations: Readonly; + readonly creative: Readonly; + readonly diagnostics: Readonly; } + +export type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh'; +export type RenderTraceServedFromV1 = 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid'; + +export interface RenderTraceRecord { + readonly slotId: string; + readonly path: RenderTracePathV1; + readonly rendered: boolean; + readonly elementId?: string; + readonly auctionId?: string; + readonly bidder?: string; + readonly adId?: string; + readonly bidId?: string; + readonly creativeId?: string; + readonly admHash?: string; + readonly servedFrom?: RenderTraceServedFromV1; + readonly gamEmpty?: boolean; + readonly injected?: boolean; + readonly visible?: boolean; + readonly count: number; + readonly seq: number; + readonly at: number; +} + +export interface RenderTraceDiagnostics { + current(): Readonly>>; + history(): readonly Readonly[]; + subscribe(listener: (record: Readonly) => void): () => void; +} + +export interface TsjsDiagnostics { + readonly renderTrace: RenderTraceDiagnostics; + readonly gpt?: GptDiagnosticsApi; +} + +export interface TsjsApiBase { + readonly version: '1.0.0'; + readonly releaseId: string; + readonly boot: Readonly; + readonly que: TsjsCommandQueue; + readonly log: TsjsLog; + readonly _registerIntegration: (registration: unknown) => false; + addAdUnits(units: ProgrammaticAdUnit | readonly ProgrammaticAdUnit[]): AddAdUnitsResult; + requestAds(options?: RequestAdsOptions): Promise; +} + +export interface TsjsKernelApi extends TsjsApiBase { + readonly diagnostics: Readonly; + readonly _internal: Readonly<{ state: 'kernel'; releaseId: string }>; +} + +export interface TsjsFallbackApi extends TsjsApiBase { + readonly diagnostics?: never; + readonly _internal: Readonly<{ + state: 'fallback'; + releaseId: string; + reason: 'abi_mismatch' | 'bundle_partial'; + initialDisplayCommitted: boolean; + }>; +} + +export type TsjsApi = TsjsKernelApi | TsjsFallbackApi; diff --git a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts new file mode 100644 index 000000000..d04ff1728 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts @@ -0,0 +1,1756 @@ +import type { + FirstDisplayProjectionBidV1, + FirstDisplayProjectionSlotV1, + FirstDisplayProjectionV1, +} from '../leaf/projection'; +import type { FirstDisplayGptBatchPolicyV1 } from '../leaf/gpt_protocol'; +import type { + FirstDisplayGptDiagnosticEventV1, + FirstDisplayGptDiagnosticsV1, + FirstDisplayGptFactV1, +} from '../../shared/takeover'; + +const MAX_DIAGNOSTIC_FACTS = 512; +const MAX_DIAGNOSTIC_FACT_BYTES = 1_000; +const MAX_DIAGNOSTIC_SECTION_BYTES = 512 * 1024; +const MAX_U32 = 4_294_967_295; +const SLOT_REQUESTED = 'slotRequested'; +const SLOT_RESPONSE_RECEIVED = 'slotResponseReceived'; +const SLOT_RENDER_ENDED = 'slotRenderEnded'; +const SLOT_ONLOAD = 'slotOnload'; +const IMPRESSION_VIEWABLE = 'impressionViewable'; +const SLOT_VISIBILITY_CHANGED = 'slotVisibilityChanged'; +const OVERLAPPING_REQUEST_CYCLES = 'overlapping_request_cycles'; +const INVALID_EVENT_ORDER = 'invalid_event_order'; +const GPT_REQUEST_FAILED = 'gpt_request_failed'; +const SLOT_UNRESOLVED = 'slot_unresolved'; +const DIAGNOSTIC_ONLY_EVENTS = Object.freeze([ + SLOT_RESPONSE_RECEIVED, + SLOT_ONLOAD, + IMPRESSION_VIEWABLE, + SLOT_VISIBILITY_CHANGED, +] as const); +const DIAGNOSTIC_EVENT_ORDER: readonly FirstDisplayGptDiagnosticEventV1[] = Object.freeze([ + SLOT_REQUESTED, + SLOT_RESPONSE_RECEIVED, + SLOT_RENDER_ENDED, + SLOT_ONLOAD, + IMPRESSION_VIEWABLE, + SLOT_VISIBILITY_CHANGED, +]); + +export type FirstDisplayGptRenderResult = 'gam_empty' | 'nonempty_gam'; +export type FirstDisplayGptFailureReason = + | 'cycle_unattributable' + | 'external_artifact_incompatible' + | 'external_ready_timeout' + | 'gpt_completion_timeout' + | typeof GPT_REQUEST_FAILED + | 'gpt_request_timeout' + | typeof SLOT_UNRESOLVED; + +/** Compact live physical-cycle authority crossing GPT, bootstrap, and render owner. */ +export type FirstDisplayGptBoundCycleV1 = readonly [ + bid: FirstDisplayProjectionBidV1, + element: HTMLElement, + /** Closure-private physical-cycle validity; false after a later GPT request takes the slot. */ + isCurrent: () => boolean, + ownership: 'publisher' | 'trusted_server', + physicalSlot: object, + placement: FirstDisplayProjectionSlotV1, + slotId: string, + traceToken: string, +]; + +export interface FirstDisplayTargetingOwnershipV1 { + readonly installed: string; + readonly key: string; + readonly prior: readonly string[]; +} + +export type FirstDisplayGptHandoffCycleV1 = readonly [ + ...FirstDisplayGptBoundCycleV1, + targetingOwnership: readonly FirstDisplayTargetingOwnershipV1[], +]; + +/** Compact physical GPT state crossing from the selected slice to the inline owner. */ +export type FirstDisplayGptCaptureCycleV1 = readonly [ + slotId: string, + elementId: string, + ownership: 'trusted_server' | 'publisher', + targetingOwnership: readonly FirstDisplayTargetingOwnershipV1[], + traceToken: string, + physicalSlot: object, +]; + +export type FirstDisplayGptDiagnosticCycleV1 = readonly [ + slotId: string, + token: string, + nextCycleOrdinal: number, + unknownPriorCycle: boolean, + quarantines: readonly string[], + records: readonly (readonly [ + ordinal: number, + responseIdentifier: string | null, + seen: readonly FirstDisplayGptDiagnosticEventV1[], + state: 'open' | 'completed' | 'retired', + ])[], +]; + +export type FirstDisplayGptDiagnosticsHandoffV1 = readonly [ + cycles: readonly FirstDisplayGptDiagnosticCycleV1[], + facts: FirstDisplayGptDiagnosticsV1['facts'], + nextTraceTokenOrdinal: number, + overflowCount: number, + dropCount: number, +]; + +/** Compact authenticated callback capability crossing from the inline owner. */ +export type FirstDisplayGoogletagBatchCallbacks = readonly [ + onBound: (cycle: FirstDisplayGptBoundCycleV1) => void, + onFailure: (slotId: string, reason: FirstDisplayGptFailureReason) => void, + onFirstAction: () => boolean, + onRenderEnded: (cycle: FirstDisplayGptBoundCycleV1, result: FirstDisplayGptRenderResult) => void, + /** Retire an accepted TS artifact when its exact parser-time physical binding is lost. */ + onRetire: ((cycle: FirstDisplayGptBoundCycleV1) => void) | undefined, +]; + +export interface FirstDisplayGoogletagBatch { + readonly start: (callbacks: FirstDisplayGoogletagBatchCallbacks) => boolean; + /** Stop provisional GPT ingress after every physical cycle is terminal. */ + /** Destroy noncommitted TS slots before closing publisher-mutation observation. */ + readonly closeIngress: (committedSlotIds: readonly string[]) => boolean; + /** Capture exact terminal physical identities without changing disposal ownership. */ + readonly captureHandoff: () => readonly FirstDisplayGptCaptureCycleV1[] | undefined; + readonly captureDiagnosticsHandoff: () => FirstDisplayGptDiagnosticsHandoffV1 | undefined; + /** Exempt exactly the accepted slot identities from provisional destruction/restoration. */ + readonly detachCommittedSlots: (slotIds: readonly string[]) => boolean; + readonly dispose: () => void; +} + +/** Compact authenticated capability passed from the GPT slice to the inline owner. */ +export type FirstDisplayGoogletagBatchCapabilityV1 = readonly [ + start: FirstDisplayGoogletagBatch['start'], + closeIngress: FirstDisplayGoogletagBatch['closeIngress'], + captureHandoff: FirstDisplayGoogletagBatch['captureHandoff'], + captureDiagnosticsHandoff: FirstDisplayGoogletagBatch['captureDiagnosticsHandoff'], + detachCommittedSlots: FirstDisplayGoogletagBatch['detachCommittedSlots'], + dispose: FirstDisplayGoogletagBatch['dispose'], +]; + +export interface FirstDisplayGoogletagBatchOptions { + readonly browser: Window & { googletag?: unknown }; + readonly clearTimer: (handle: unknown) => void; + readonly document: Document; + readonly diagnosticsActive?: boolean; + readonly onNativeMutation?: () => boolean; + readonly projection: FirstDisplayProjectionV1; + readonly protocol: FirstDisplayGptBatchPolicyV1; + readonly setTimer: (callback: () => void, delayMs: number) => unknown; +} + +/** Compact authenticated input crossing from the inline owner to the GPT slice. */ +export type FirstDisplayGoogletagBatchInput = readonly [ + browser: FirstDisplayGoogletagBatchOptions['browser'], + clearTimer: FirstDisplayGoogletagBatchOptions['clearTimer'], + document: FirstDisplayGoogletagBatchOptions['document'], + setTimer: FirstDisplayGoogletagBatchOptions['setTimer'], + projection: FirstDisplayGoogletagBatchOptions['projection'], + diagnosticsActive?: FirstDisplayGoogletagBatchOptions['diagnosticsActive'], + onNativeMutation?: FirstDisplayGoogletagBatchOptions['onNativeMutation'], +]; + +type ExternalObject = Record; + +interface FirstDisplayDiagnosticCycleRecord { + readonly ordinal: number; + readonly seen: Set; + responseIdentifier?: string; + state: 'open' | 'completed' | 'retired'; +} + +interface ActiveCycle { + readonly bindingState: { current: boolean }; + readonly diagnosticRecords: FirstDisplayDiagnosticCycleRecord[]; + elementId: string; + readonly initialLoadDisabled: boolean; + readonly operations: readonly ('display' | 'refresh')[]; + ownership: 'publisher' | 'trusted_server'; + readonly publicCycle: FirstDisplayGptBoundCycleV1; + readonly requestOperation: 0 | 1; + readonly runtimeSlotNumber: number; + completionTimer?: unknown; + nextDiagnosticCycleOrdinal: number; + requestInvoked: boolean; + requested: boolean; + requestTimer?: unknown; + settled: boolean; + suppressPublisherDisplay: boolean; + suppressPublisherRefresh: boolean; + unknownPriorCycle: boolean; +} + +interface TargetingRestorer { + readonly installed: string; + readonly key: string; + readonly prior: readonly string[]; + readonly slot: object; + valid: boolean; +} + +interface TargetingWrite { + consumed: boolean; + readonly operation: 'clearTargeting' | 'setTargeting'; + readonly slot: object; +} + +interface TargetingObserverRestorer { + readonly isCurrent: () => boolean; + readonly restore: () => boolean; +} + +interface TargetingObserver { + readonly isCurrent: () => boolean; + readonly restore: () => boolean; +} + +function externalObject(value: unknown): ExternalObject | undefined { + return (typeof value === 'object' && value !== null) || typeof value === 'function' + ? (value as ExternalObject) + : undefined; +} + +/** Enqueue the document-local GAM attribution command at the parser-time boundary. */ +export function enqueueFirstDisplayGamAttribution( + target: Window & { googletag?: unknown } +): boolean { + try { + let binding = externalObject(target.googletag); + if (!binding) { + binding = { cmd: [] }; + if ( + !Reflect.defineProperty(target, 'googletag', { + configurable: true, + enumerable: true, + value: binding, + writable: true, + }) || + target.googletag !== binding + ) { + return false; + } + } + const queue = externalObject(Reflect.get(binding, 'cmd')); + const push = queue && Reflect.get(queue, 'push'); + if (!queue || typeof push !== 'function') return false; + Reflect.apply(push, queue, [ + () => { + try { + const root = externalObject(target.googletag) ?? binding; + const setConfig = Reflect.get(root, 'setConfig'); + if (typeof setConfig === 'function') { + Reflect.apply(setConfig, root, [{ targeting: { ts: 'true' } }]); + } + } catch { + // Missing or hostile GPT targeting cannot block later queue work. + } + }, + ]); + return true; + } catch { + return false; + } +} + +function member(value: unknown, key: PropertyKey): unknown { + const object = externalObject(value); + if (!object) return undefined; + try { + return Reflect.get(object, key); + } catch { + return undefined; + } +} + +function call(receiver: unknown, key: PropertyKey, arguments_: readonly unknown[]): unknown { + const callable = member(receiver, key); + if (typeof callable !== 'function') throw new TypeError('tsjs'); + return Reflect.apply(callable, receiver, arguments_); +} + +function physicalSlot(value: unknown): object | undefined { + return externalObject(value); +} + +function resolveElement( + document: Document, + placement: FirstDisplayProjectionSlotV1 +): HTMLElement | undefined { + try { + const ElementConstructor = document.defaultView?.HTMLElement; + if (!ElementConstructor) return undefined; + const exact = document.getElementById(placement.divId); + if (exact instanceof ElementConstructor) return exact; + const matches = [...document.querySelectorAll('[id]')].filter( + (element) => element.id.startsWith(placement.divId) && !element.id.endsWith('-container') + ); + return matches.length === 1 ? matches[0] : undefined; + } catch { + return undefined; + } +} + +function initialLoadDisabled(binding: ExternalObject): boolean { + try { + const getConfig = member(binding, 'getConfig'); + if (typeof getConfig !== 'function') return false; + const config = Reflect.apply(getConfig, binding, ['disableInitialLoad']); + return member(config, 'disableInitialLoad') === true; + } catch { + return false; + } +} + +function targetingEntries( + bid: FirstDisplayProjectionBidV1, + placement: FirstDisplayProjectionSlotV1 +): readonly (readonly [string, string])[] { + const targeting: Record = {}; + for (const key of Object.keys(placement.targeting)) targeting[key] = placement.targeting[key]!; + for (const key of Object.keys(bid.targeting)) targeting[key] = bid.targeting[key]!; + targeting.hb_adid = bid.rendererReservationId; + return Object.freeze( + Object.keys(targeting) + .sort() + .map((key) => Object.freeze([key, targeting[key]!] as const)) + ); +} + +function winnerRows( + projection: FirstDisplayProjectionV1 +): readonly (readonly [FirstDisplayProjectionBidV1, FirstDisplayProjectionSlotV1])[] { + const rows: Array = []; + let winner = 0; + for (let index = 0; index < projection.auction.results.length; index += 1) { + const decision = projection.auction.results[index]; + const placement = projection.slots[index]; + if (!decision || !placement || decision.outcome !== 'winner') continue; + const bid = projection.bids[winner]; + winner += 1; + if (bid) rows.push(Object.freeze([bid, placement])); + } + return Object.freeze(rows); +} + +class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { + private readonly cycleMap = new Map(); + private readonly createdSlots = new Set(); + private readonly diagnosticFacts: Readonly[] = []; + private readonly diagnosticListeners = new Map void>(); + private readonly targetingObservers = new Map(); + private readonly targetingRestorers: TargetingRestorer[] = []; + private readonly sealedTargetingOwnership = new Map< + object, + readonly FirstDisplayTargetingOwnershipV1[] + >(); + private readonly publisherCallRestorers: Array<() => void> = []; + private readonly timers = new Set(); + private binding: ExternalObject | undefined; + private command: (() => void) | undefined; + private commandQueue: unknown[] | undefined; + private commandQueueIndex = -1; + private createdBinding: ExternalObject | undefined; + private renderListener: ((event: unknown) => void) | undefined; + private requestedListener: ((event: unknown) => void) | undefined; + private service: ExternalObject | undefined; + private started = false; + private disposed = false; + private ingressClosed = false; + private ingressClosing = false; + private committedSlotsDetached = false; + private readonly detachedSlots = new Set(); + private firstAction = false; + private diagnosticFactOverflow = 0; + private diagnosticFactDrops = 0; + private nextTraceTokenOrdinal = 1; + private readonly targetingWrites: TargetingWrite[] = []; + private trustedServerCall: readonly [receiver: ExternalObject, key: string] | undefined; + + public constructor(private readonly options: FirstDisplayGoogletagBatchOptions) {} + + public start(callbacks: FirstDisplayGoogletagBatchCallbacks): boolean { + if (this.started || this.disposed) return false; + this.started = true; + const rows = winnerRows(this.options.projection); + if (rows.length === 0) return true; + + const binding = this.ensureBinding(); + const queue = member(binding, 'cmd'); + const push = member(queue, 'push'); + if (!binding || !queue || typeof push !== 'function') { + this.failRows(rows, callbacks, 'external_artifact_incompatible'); + return false; + } + this.binding = binding; + const readyTimer = this.timer(() => { + this.removePendingCommand(); + this.failRows(rows, callbacks, 'external_ready_timeout'); + }, this.options.protocol.deadlines.externalReadyMs); + const command = (): void => { + if (this.disposed || this.command !== command) return; + this.command = undefined; + this.clearOwnedTimer(readyTimer); + try { + this.activate(binding, rows, callbacks); + } catch { + this.failRows(rows, callbacks, 'external_artifact_incompatible'); + } + }; + this.command = command; + if (Array.isArray(queue)) { + this.commandQueue = queue; + this.commandQueueIndex = queue.length; + } + try { + Reflect.apply(push, queue, [command]); + } catch { + this.command = undefined; + this.clearOwnedTimer(readyTimer); + this.failRows(rows, callbacks, 'external_artifact_incompatible'); + return false; + } + return true; + } + + public closeIngress(committedSlotIds: readonly string[]): boolean { + if ( + this.disposed || + this.ingressClosed || + this.ingressClosing || + !this.started || + [...this.cycleMap.values()].some((cycle) => !cycle.settled) || + !Array.isArray(committedSlotIds) + ) { + return false; + } + const committed = new Set(committedSlotIds); + if (committed.size !== committedSlotIds.length) return false; + const retainedSlots = new Set(); + for (const slotId of committed) { + const matches = [...this.cycleMap.values()].filter( + (cycle) => cycle.publicCycle[6] === slotId + ); + if (matches.length !== 1 || !matches[0]?.settled) return false; + retainedSlots.add(matches[0].publicCycle[4]); + } + const destroyableSlots = [...this.createdSlots].filter((slot) => !retainedSlots.has(slot)); + this.ingressClosing = true; + if (destroyableSlots.length > 0) { + try { + if (!this.binding || call(this.binding, 'destroySlots', [[...destroyableSlots]]) !== true) { + this.ingressClosing = false; + return false; + } + } catch { + this.ingressClosing = false; + return false; + } + for (const slot of destroyableSlots) this.createdSlots.delete(slot); + } + this.invalidateStaleTargetingObservers(); + const retainedTargetingRestorers = this.targetingRestorers.filter((restoration) => + retainedSlots.has(restoration.slot) + ); + for (const restoration of [...this.targetingRestorers].reverse()) { + if (retainedSlots.has(restoration.slot)) continue; + try { + this.restorePublisherTargeting(restoration); + } catch { + // Publisher mutations win while retained-slot observation is still authoritative. + } + } + this.targetingRestorers.splice( + 0, + this.targetingRestorers.length, + ...retainedTargetingRestorers + ); + this.ingressClosed = true; + this.ingressClosing = false; + this.removePendingCommand(); + for (const handle of [...this.timers]) this.clearOwnedTimer(handle); + this.removeListener(); + this.restorePublisherCalls(); + const targetingCandidates = this.captureRetainedTargeting(retainedSlots); + this.restoreTargetingObservers(); + for (const [slot, restorations] of targetingCandidates) { + this.sealedTargetingOwnership.set( + slot, + Object.freeze( + restorations + .filter(({ valid }) => valid) + .map(({ installed, key, prior }) => Object.freeze({ installed, key, prior })) + ) + ); + } + return true; + } + + public captureHandoff(): readonly FirstDisplayGptCaptureCycleV1[] | undefined { + if (this.disposed || !this.ingressClosed) return undefined; + return Object.freeze( + [...this.cycleMap.values()] + .filter((cycle) => this.sealedTargetingOwnership.has(cycle.publicCycle[4])) + .map((cycle) => + Object.freeze([ + cycle.publicCycle[6], + cycle.elementId, + cycle.ownership, + Object.freeze(this.sealedTargetingOwnership.get(cycle.publicCycle[4]) ?? []), + cycle.publicCycle[7], + cycle.publicCycle[4], + ] as const) + ) + ); + } + + public captureDiagnosticsHandoff(): FirstDisplayGptDiagnosticsHandoffV1 | undefined { + if (this.disposed || !this.ingressClosed) return undefined; + return Object.freeze([ + Object.freeze( + [...this.cycleMap.values()] + .filter((cycle) => this.sealedTargetingOwnership.has(cycle.publicCycle[4])) + .map((cycle) => + Object.freeze([ + cycle.publicCycle[6], + cycle.publicCycle[7], + cycle.nextDiagnosticCycleOrdinal, + cycle.unknownPriorCycle, + Object.freeze([]), + Object.freeze( + cycle.diagnosticRecords.map((record) => + Object.freeze([ + record.ordinal, + record.responseIdentifier ?? null, + Object.freeze(DIAGNOSTIC_EVENT_ORDER.filter((event) => record.seen.has(event))), + record.state, + ] as const) + ) + ), + ] as const) + ) + ), + Object.freeze([...this.diagnosticFacts]), + this.nextTraceTokenOrdinal, + this.diagnosticFactOverflow, + this.diagnosticFactDrops, + ] as const); + } + + public detachCommittedSlots(slotIds: readonly string[]): boolean { + if (this.disposed || !this.ingressClosed || this.committedSlotsDetached) return false; + const requested = new Set(slotIds); + if (requested.size !== slotIds.length) return false; + const selected: object[] = []; + for (const slotId of requested) { + const matches = [...this.cycleMap.values()].filter( + (cycle) => cycle.publicCycle[6] === slotId + ); + if (matches.length !== 1 || !matches[0]?.settled) return false; + selected.push(matches[0].publicCycle[4]); + } + this.committedSlotsDetached = true; + for (const slot of selected) this.detachedSlots.add(slot); + return true; + } + + public dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.removePendingCommand(); + for (const handle of [...this.timers]) this.clearOwnedTimer(handle); + this.removeListener(); + this.restorePublisherCalls(); + this.invalidateStaleTargetingObservers(); + for (const restoration of this.targetingRestorers.reverse()) { + if (this.detachedSlots.has(restoration.slot)) continue; + try { + this.restorePublisherTargeting(restoration); + } catch { + // Publisher mutations and hostile GPT objects always win over restoration. + } + } + this.targetingRestorers.length = 0; + this.restoreTargetingObservers(); + const destroyableSlots = [...this.createdSlots].filter((slot) => !this.detachedSlots.has(slot)); + if (this.binding && destroyableSlots.length > 0) { + try { + call(this.binding, 'destroySlots', [destroyableSlots]); + } catch { + // Generation latching keeps failed physical cleanup inert. + } + } + this.createdSlots.clear(); + this.cycleMap.clear(); + this.sealedTargetingOwnership.clear(); + if (this.detachedSlots.size === 0) this.restoreCreatedBinding(); + else this.createdBinding = undefined; + this.detachedSlots.clear(); + this.binding = undefined; + this.service = undefined; + } + + private ensureBinding(): ExternalObject | undefined { + const current = externalObject(this.options.browser.googletag); + if (current) return current; + const binding: ExternalObject = { cmd: [] }; + try { + if ( + !Reflect.defineProperty(this.options.browser, 'googletag', { + configurable: true, + enumerable: true, + value: binding, + writable: true, + }) || + this.options.browser.googletag !== binding + ) { + return undefined; + } + this.createdBinding = binding; + return binding; + } catch { + return undefined; + } + } + + private restoreCreatedBinding(): void { + if (!this.createdBinding) return; + try { + const descriptor = Object.getOwnPropertyDescriptor(this.options.browser, 'googletag'); + if (descriptor && 'value' in descriptor && descriptor.value === this.createdBinding) { + Reflect.deleteProperty(this.options.browser, 'googletag'); + } + } catch { + // Publisher replacement wins over restoration. + } + this.createdBinding = undefined; + } + + private activate( + binding: ExternalObject, + rows: readonly (readonly [FirstDisplayProjectionBidV1, FirstDisplayProjectionSlotV1])[], + callbacks: FirstDisplayGoogletagBatchCallbacks + ): void { + if (this.disposed || this.binding !== binding) return; + const service = externalObject(call(binding, 'pubads', [])); + if (!service) throw new TypeError('tsjs'); + this.service = service; + this.observePublisherCalls(binding, service, callbacks); + this.installListeners(service, callbacks); + const existing = call(service, 'getSlots', []); + if (!Array.isArray(existing)) throw new TypeError('tsjs'); + const disabled = initialLoadDisabled(binding); + + for (const row of rows) { + if (this.disposed) return; + const [bid, placement] = row; + const element = resolveElement(this.options.document, placement); + if (!element) { + callbacks[1](placement.slot, SLOT_UNRESOLVED); + continue; + } + const matches = existing.filter((candidate) => + physicalSlot(candidate) && typeof member(candidate, 'getSlotElementId') === 'function' + ? call(candidate, 'getSlotElementId', []) === element.id + : member(candidate, 'getSlotElementId') === undefined + ? member(candidate, 'elementId') === element.id + : false + ); + if (matches.length > 1) { + callbacks[1](placement.slot, SLOT_UNRESOLVED); + continue; + } + const publisherSlot = physicalSlot(matches[0]); + const slot = + publisherSlot ?? + physicalSlot( + this.callTrustedServer(binding, 'defineSlot', [ + placement.gamUnitPath, + placement.formats, + element.id, + ]) + ); + if (!slot) { + callbacks[1](placement.slot, SLOT_UNRESOLVED); + continue; + } + const ownership = publisherSlot ? 'publisher' : 'trusted_server'; + if (!publisherSlot) { + call(slot, 'addService', [service]); + this.createdSlots.add(slot); + } else { + this.observePublisherTargeting(slot); + } + const plan = this.options.protocol.requestPlan( + Object.freeze({ initialLoadDisabled: disabled, ownership }) + ); + if (!plan) { + callbacks[1](placement.slot, GPT_REQUEST_FAILED); + continue; + } + const traceTokenOrdinal = this.nextTraceTokenOrdinal; + if (traceTokenOrdinal > 4_294_967_295) { + callbacks[1](placement.slot, GPT_REQUEST_FAILED); + continue; + } + const traceToken = `gt1_${traceTokenOrdinal.toString(36)}`; + this.nextTraceTokenOrdinal += 1; + const bindingState = { current: true }; + const publicCycle: FirstDisplayGptBoundCycleV1 = Object.freeze([ + bid, + element, + () => bindingState.current, + ownership, + slot, + placement, + placement.slot, + traceToken, + ]); + const cycle: ActiveCycle = { + bindingState, + diagnosticRecords: [], + elementId: element.id, + initialLoadDisabled: disabled, + operations: plan.operations, + ownership, + publicCycle, + requestOperation: plan.requestOperation, + runtimeSlotNumber: traceTokenOrdinal, + nextDiagnosticCycleOrdinal: 1, + requestInvoked: false, + requested: false, + settled: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + unknownPriorCycle: ownership === 'publisher', + }; + this.cycleMap.set(slot, cycle); + callbacks[0](publicCycle); + for (const [key, value] of targetingEntries(bid, placement)) { + if (publisherSlot) this.journalPublisherTargeting(slot, key, value); + this.writeTargeting(slot, 'setTargeting', [key, value]); + } + } + + const armRequest = (candidate: ActiveCycle): void => { + if (candidate.requestInvoked) return; + candidate.requestInvoked = true; + candidate.requestTimer = this.timer( + () => this.failCycle(candidate, callbacks, 'gpt_request_timeout'), + this.options.protocol.deadlines.requestStartMs + ); + candidate.completionTimer = this.timer( + () => this.failCycle(candidate, callbacks, 'gpt_completion_timeout'), + this.options.protocol.deadlines.completionMs + ); + }; + const synchronousSraCycles = [...this.cycleMap.values()].filter( + (candidate) => + !candidate.settled && + candidate.requestOperation === 0 && + candidate.operations[0] === 'display' + ); + for (const cycle of this.cycleMap.values()) { + if (this.disposed || cycle.settled) continue; + try { + for (let index = 0; index < cycle.operations.length; index += 1) { + const operation = cycle.operations[index]; + if (index === cycle.requestOperation) { + if (operation === 'display' && cycle.requestOperation === 0) { + for (const candidate of synchronousSraCycles) armRequest(candidate); + } else { + armRequest(cycle); + } + if (!this.firstAction) { + this.firstAction = true; + if (!callbacks[2]()) { + for (const active of this.cycleMap.values()) { + this.failCycle(active, callbacks, GPT_REQUEST_FAILED); + } + return; + } + if (member(binding, 'pubadsReady') !== true) { + try { + call(service, 'enableSingleRequest', []); + call(binding, 'enableServices', []); + } catch { + for (const active of this.cycleMap.values()) { + this.failCycle(active, callbacks, GPT_REQUEST_FAILED); + } + return; + } + } + } + if (cycle.settled) break; + } + if (operation === 'display') { + this.callTrustedServer(binding, 'display', [cycle.elementId]); + } else { + this.callTrustedServer(service, 'refresh', [ + [cycle.publicCycle[4]], + { changeCorrelator: false }, + ]); + } + } + } catch { + if (cycle.settled) continue; + this.failCycle(cycle, callbacks, GPT_REQUEST_FAILED); + } + } + } + + private installListeners( + service: ExternalObject, + callbacks: FirstDisplayGoogletagBatchCallbacks + ): void { + const requestedListener = (event: unknown): void => { + if (this.disposed || this.requestedListener !== requestedListener) return; + this.notifyNativeMutation(); + const slot = physicalSlot(member(event, 'slot')); + const cycle = slot ? this.cycleMap.get(slot) : undefined; + if (!cycle) { + if (slot) { + this.invalidateCyclesForElement(this.readPhysicalElementId(slot), slot, callbacks); + } + return; + } + if (cycle.settled) { + this.retireCycle(cycle, callbacks); + this.captureDiagnosticFact(SLOT_REQUESTED, cycle, event); + return; + } + if (!cycle.requestInvoked) return; + if (cycle.requested) { + this.failCycle(cycle, callbacks, 'cycle_unattributable'); + return; + } + cycle.requested = true; + this.captureDiagnosticFact(SLOT_REQUESTED, cycle, event); + if (cycle.requestTimer !== undefined) this.clearOwnedTimer(cycle.requestTimer); + }; + const renderListener = (event: unknown): void => { + if (this.disposed || this.renderListener !== renderListener) return; + this.notifyNativeMutation(); + const slot = physicalSlot(member(event, 'slot')); + const cycle = slot ? this.cycleMap.get(slot) : undefined; + if (!cycle) return; + if (cycle.settled) { + this.captureDiagnosticFact(SLOT_RENDER_ENDED, cycle, event); + return; + } + if (!cycle.requested) return; + const result = this.options.protocol.classifyRenderEnded( + Object.freeze({ isEmpty: member(event, 'isEmpty') }) + ); + if (!result) { + this.failCycle(cycle, callbacks, GPT_REQUEST_FAILED); + return; + } + this.captureDiagnosticFact(SLOT_RENDER_ENDED, cycle, event); + cycle.settled = true; + if (cycle.requestTimer !== undefined) this.clearOwnedTimer(cycle.requestTimer); + if (cycle.completionTimer !== undefined) this.clearOwnedTimer(cycle.completionTimer); + callbacks[3](cycle.publicCycle, result); + }; + this.requestedListener = requestedListener; + this.renderListener = renderListener; + this.diagnosticListeners.set(SLOT_REQUESTED, requestedListener); + this.diagnosticListeners.set(SLOT_RENDER_ENDED, renderListener); + call(service, 'addEventListener', [SLOT_REQUESTED, requestedListener]); + call(service, 'addEventListener', [SLOT_RENDER_ENDED, renderListener]); + if (this.options.diagnosticsActive === true) { + for (const eventType of DIAGNOSTIC_ONLY_EVENTS) { + const listener = (event: unknown): void => { + if (this.disposed || this.diagnosticListeners.get(eventType) !== listener) return; + this.notifyNativeMutation(); + const slot = physicalSlot(member(event, 'slot')); + const cycle = slot ? this.cycleMap.get(slot) : undefined; + if (cycle) this.captureDiagnosticFact(eventType, cycle, event); + }; + this.diagnosticListeners.set(eventType, listener); + call(service, 'addEventListener', [eventType, listener]); + } + } + } + + private removeListener(): void { + if (!this.service) return; + this.requestedListener = undefined; + this.renderListener = undefined; + try { + for (const [eventType, listener] of this.diagnosticListeners) { + call(this.service, 'removeEventListener', [eventType, listener]); + } + } catch { + // The generation latch remains authoritative if GPT cannot detach physically. + } finally { + this.diagnosticListeners.clear(); + } + } + + private failCycle( + cycle: ActiveCycle, + callbacks: FirstDisplayGoogletagBatchCallbacks, + reason: FirstDisplayGptFailureReason + ): void { + if (cycle.settled) return; + cycle.settled = true; + for (const record of cycle.diagnosticRecords) { + if (record.state === 'open') record.state = 'retired'; + } + if (cycle.requestTimer !== undefined) this.clearOwnedTimer(cycle.requestTimer); + if (cycle.completionTimer !== undefined) this.clearOwnedTimer(cycle.completionTimer); + callbacks[1](cycle.publicCycle[6], reason); + } + + private journalPublisherTargeting(slot: object, key: string, installed: string): void { + const original = call(slot, 'getTargeting', [key]); + if (!Array.isArray(original) || !original.every((value) => typeof value === 'string')) { + throw new TypeError('tsjs'); + } + this.targetingRestorers.push({ + installed, + key, + prior: Object.freeze([...original]), + slot, + valid: true, + }); + } + + private observePublisherTargeting(slot: object): void { + if (this.targetingObservers.has(slot)) return; + const restorers: TargetingObserverRestorer[] = []; + try { + for (const key of ['setTargeting', 'clearTargeting'] as const) { + const original = member(slot, key); + if (typeof original !== 'function') throw new TypeError('tsjs'); + const prior = Object.getOwnPropertyDescriptor(slot, key); + const invalidateTargeting = (targetingKey: string | undefined): void => + this.invalidateTargeting(slot, targetingKey); + const consumeTrustedServerWrite = (): boolean => this.consumeTargetingWrite(slot, key); + const ownerMutation = (): void => this.notifyNativeMutation(); + const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { + const trustedServerWrite = this === slot && consumeTrustedServerWrite(); + if (!trustedServerWrite && this === slot) { + const targetingKey = typeof arguments_[0] === 'string' ? arguments_[0] : undefined; + invalidateTargeting(targetingKey); + const result = Reflect.apply(original, this, arguments_); + ownerMutation(); + return result; + } + return Reflect.apply(original, this, arguments_); + }; + if ( + !Reflect.defineProperty(slot, key, { + configurable: true, + enumerable: prior?.enumerable ?? false, + value: wrapper, + writable: true, + }) + ) { + throw new TypeError('tsjs'); + } + const isCurrent = (): boolean => { + try { + const current = Object.getOwnPropertyDescriptor(slot, key); + return !!current && 'value' in current && current.value === wrapper; + } catch { + return false; + } + }; + restorers.push({ + isCurrent, + restore: (): boolean => { + if (!isCurrent()) return false; + try { + return prior + ? Reflect.defineProperty(slot, key, prior) + : Reflect.deleteProperty(slot, key); + } catch { + return false; + } + }, + }); + } + } catch (error) { + for (const restorer of restorers.reverse()) restorer.restore(); + throw error; + } + this.targetingObservers.set(slot, { + isCurrent: (): boolean => restorers.every(({ isCurrent }) => isCurrent()), + restore: (): boolean => { + let exact = restorers.every(({ isCurrent }) => isCurrent()); + for (const restorer of restorers.reverse()) { + if (!restorer.restore()) exact = false; + } + return exact; + }, + }); + } + + private replacementSizesEqual( + candidate: unknown, + expected: readonly (readonly [number, number])[] + ): boolean { + if (!Array.isArray(candidate)) return false; + const values = + candidate.length === 2 && candidate.every((value) => typeof value === 'number') + ? [candidate] + : candidate; + if (values.length !== expected.length) return false; + return values.every( + (value, index) => + Array.isArray(value) && + value.length === 2 && + value[0] === expected[index]?.[0] && + value[1] === expected[index]?.[1] + ); + } + + private claimPublisherSlot(arguments_: readonly unknown[]): ActiveCycle | undefined { + if (arguments_.length !== 3 || typeof arguments_[2] !== 'string') return undefined; + const elementId = arguments_[2]; + const exact: ActiveCycle[] = []; + const hydration: ActiveCycle[] = []; + for (const cycle of this.cycleMap.values()) { + if (cycle.ownership !== 'trusted_server' || !cycle.bindingState.current) continue; + if (cycle.elementId === elementId) { + exact.push(cycle); + continue; + } + const placement = cycle.publicCycle[5]; + const oldElement = this.options.document.getElementById(cycle.elementId); + const replacement = this.options.document.getElementById(elementId); + if ( + elementId.startsWith(placement.divId) && + (!oldElement || !oldElement.isConnected) && + replacement?.isConnected === true && + arguments_[0] === placement.gamUnitPath && + this.replacementSizesEqual(arguments_[1], placement.formats) + ) { + hydration.push(cycle); + } + } + const matches = exact.length > 0 ? exact : hydration; + if (matches.length !== 1) return undefined; + const cycle = matches[0]; + if (!cycle) return undefined; + const slot = cycle.publicCycle[4]; + try { + this.observePublisherTargeting(slot); + } catch { + return undefined; + } + for (const [key, installed] of targetingEntries(cycle.publicCycle[0], cycle.publicCycle[5])) { + this.targetingRestorers.push({ installed, key, prior: Object.freeze([]), slot, valid: true }); + } + if (exact.length === 1) { + const placement = cycle.publicCycle[5]; + const formatsMismatch = !this.replacementSizesEqual(arguments_[1], placement.formats); + const pathMismatch = arguments_[0] !== placement.gamUnitPath; + if (formatsMismatch || pathMismatch) { + try { + this.options.document.defaultView?.console.warn( + 'GPT publisher handoff metadata mismatch', + { + formatsMismatch, + pathMismatch, + } + ); + } catch { + // A bounded local diagnostic cannot block exact publisher ownership transfer. + } + } + } + cycle.ownership = 'publisher'; + cycle.elementId = elementId; + cycle.suppressPublisherDisplay = true; + cycle.suppressPublisherRefresh = this.binding + ? initialLoadDisabled(this.binding) + : cycle.initialLoadDisabled; + this.createdSlots.delete(slot); + return cycle; + } + + private publisherCycleForTarget(target: unknown): ActiveCycle | undefined { + const slot = physicalSlot(target); + if (slot) return this.cycleMap.get(slot); + if (typeof target !== 'string') return undefined; + const matches = [...this.cycleMap.values()].filter((cycle) => cycle.elementId === target); + return matches.length === 1 ? matches[0] : undefined; + } + + private mediatePublisherRequest( + key: string, + arguments_: readonly unknown[] + ): + | Readonly<{ action: 'forward'; arguments: readonly unknown[] }> + | Readonly<{ action: 'suppress' }> { + if (key === 'display' && arguments_.length === 1) { + const cycle = this.publisherCycleForTarget(arguments_[0]); + if (cycle?.suppressPublisherDisplay) { + cycle.suppressPublisherDisplay = false; + return Object.freeze({ action: 'suppress' }); + } + } + if (key !== 'refresh' || arguments_.length > 2) { + return Object.freeze({ action: 'forward', arguments: arguments_ }); + } + let requested: readonly unknown[]; + if (arguments_[0] === undefined) { + const all = this.service ? call(this.service, 'getSlots', []) : undefined; + if (!Array.isArray(all)) return Object.freeze({ action: 'forward', arguments: arguments_ }); + requested = all; + } else if (Array.isArray(arguments_[0])) { + requested = arguments_[0]; + } else { + return Object.freeze({ action: 'forward', arguments: arguments_ }); + } + const forwarded: object[] = []; + let suppressed = false; + for (const candidate of requested) { + const slot = physicalSlot(candidate); + const cycle = slot ? this.cycleMap.get(slot) : undefined; + if (cycle?.suppressPublisherRefresh) { + cycle.suppressPublisherRefresh = false; + suppressed = true; + } else if (slot) { + forwarded.push(slot); + } + } + if (!suppressed) return Object.freeze({ action: 'forward', arguments: arguments_ }); + if (forwarded.length === 0) return Object.freeze({ action: 'suppress' }); + return Object.freeze({ + action: 'forward', + arguments: Object.freeze([ + Object.freeze(forwarded), + ...(arguments_.length === 2 ? [arguments_[1]] : []), + ]), + }); + } + + private observePublisherCalls( + binding: ExternalObject, + service: ExternalObject, + callbacks: FirstDisplayGoogletagBatchCallbacks + ): void { + const installed: Array<() => void> = []; + try { + for (const [receiver, key] of [ + [binding, 'defineSlot'], + [binding, 'destroySlots'], + [binding, 'display'], + [service, 'refresh'], + ] as const) { + const original = member(receiver, key); + if (typeof original !== 'function') continue; + const prior = Object.getOwnPropertyDescriptor(receiver, key); + const notify = (): void => this.notifyNativeMutation(); + const snapshotDestroy = (arguments_: readonly unknown[]): readonly ActiveCycle[] => + key === 'destroySlots' ? this.snapshotDestroyedCycles(arguments_) : Object.freeze([]); + const invalidate = ( + arguments_: readonly unknown[], + result: unknown, + destroyed: readonly ActiveCycle[] + ): void => + this.invalidateCyclesForPublisherCall(key, arguments_, result, destroyed, callbacks); + const invalidateRequest = (arguments_: readonly unknown[]): void => + this.invalidateCyclesForPublisherRequest(key, arguments_, callbacks); + const claimPublisherSlot = (arguments_: readonly unknown[]): ActiveCycle | undefined => + this.claimPublisherSlot(arguments_); + const mediatePublisherRequest = (arguments_: readonly unknown[]) => + this.mediatePublisherRequest(key, arguments_); + const trustedServer = (): boolean => { + const current = this.trustedServerCall; + if (!current || current[0] !== receiver || current[1] !== key) return false; + this.trustedServerCall = undefined; + return true; + }; + const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { + const trusted = this === receiver && trustedServer(); + const destroyed = this === receiver ? snapshotDestroy(arguments_) : Object.freeze([]); + let forwardedArguments: readonly unknown[] = arguments_; + if (this === receiver && !trusted) { + if (key === 'defineSlot') { + const claimed = claimPublisherSlot(arguments_); + if (claimed) { + notify(); + return claimed.publicCycle[4]; + } + } + const mediation = mediatePublisherRequest(arguments_); + if (mediation.action === 'suppress') { + notify(); + return undefined; + } + forwardedArguments = mediation.arguments; + invalidateRequest(forwardedArguments); + } + const result = Reflect.apply(original, this, forwardedArguments); + if (this === receiver && !trusted) { + invalidate(forwardedArguments, result, destroyed); + notify(); + } + return result; + }; + if ( + !Reflect.defineProperty(receiver, key, { + configurable: true, + enumerable: prior?.enumerable ?? false, + value: wrapper, + writable: true, + }) + ) { + throw new TypeError('tsjs'); + } + installed.push(() => { + const current = Object.getOwnPropertyDescriptor(receiver, key); + if (!current || !('value' in current) || current.value !== wrapper) return; + if (prior) Reflect.defineProperty(receiver, key, prior); + else Reflect.deleteProperty(receiver, key); + }); + } + } catch (error) { + for (const restore of installed.reverse()) restore(); + throw error; + } + this.publisherCallRestorers.push(...installed); + } + + private readPhysicalElementId(slot: object): string | undefined { + try { + const read = member(slot, 'getSlotElementId'); + if (typeof read !== 'function') return undefined; + const value = Reflect.apply(read, slot, []); + return typeof value === 'string' && value.length > 0 ? value : undefined; + } catch { + return undefined; + } + } + + private retireCycle(cycle: ActiveCycle, callbacks: FirstDisplayGoogletagBatchCallbacks): boolean { + if (!cycle.bindingState.current) return false; + cycle.bindingState.current = false; + try { + callbacks[4]?.(cycle.publicCycle); + } catch { + // Binding invalidation remains authoritative when retirement observation fails. + } + return true; + } + + private snapshotDestroyedCycles(arguments_: readonly unknown[]): readonly ActiveCycle[] { + try { + const targets = arguments_[0]; + if (targets === undefined) return Object.freeze([...this.cycleMap.values()]); + if (!Array.isArray(targets)) return Object.freeze([]); + const cycles = new Set(); + for (let index = 0; index < targets.length; index += 1) { + const target = physicalSlot(targets[index]); + const cycle = target ? this.cycleMap.get(target) : undefined; + if (cycle) cycles.add(cycle); + } + return Object.freeze([...cycles]); + } catch { + return Object.freeze([]); + } + } + + private invalidateCyclesForElement( + elementId: string | undefined, + replacement: object, + callbacks: FirstDisplayGoogletagBatchCallbacks + ): void { + if (!elementId) return; + for (const cycle of this.cycleMap.values()) { + if (cycle.publicCycle[4] !== replacement && cycle.elementId === elementId) { + this.retireCycle(cycle, callbacks); + } + } + } + + private invalidateCyclesForPublisherCall( + key: string, + arguments_: readonly unknown[], + result: unknown, + destroyed: readonly ActiveCycle[], + callbacks: FirstDisplayGoogletagBatchCallbacks + ): void { + try { + if (key === 'destroySlots' && result === true) { + for (const cycle of destroyed) this.retireCycle(cycle, callbacks); + return; + } + if (key === 'defineSlot') { + const replacement = physicalSlot(result); + const elementId = arguments_[2]; + if (replacement && typeof elementId === 'string') { + this.invalidateCyclesForElement(elementId, replacement, callbacks); + } + } + } catch { + // The publisher call already completed; observation cannot alter its result. + } + } + + private invalidateCyclesForPublisherRequest( + key: string, + arguments_: readonly unknown[], + callbacks: FirstDisplayGoogletagBatchCallbacks + ): void { + try { + const cycles: ActiveCycle[] = []; + if (key === 'display') { + const requested = arguments_[0]; + const slot = physicalSlot(requested); + const exactCycle = slot ? this.cycleMap.get(slot) : undefined; + if (exactCycle) cycles.push(exactCycle); + else if (typeof requested === 'string') { + for (const cycle of this.cycleMap.values()) { + if (cycle.elementId === requested) cycles.push(cycle); + } + } + } else if (key === 'refresh') { + const requested = arguments_[0]; + if (requested === undefined) cycles.push(...this.cycleMap.values()); + else if (Array.isArray(requested)) { + for (const candidate of requested) { + const slot = physicalSlot(candidate); + const cycle = slot ? this.cycleMap.get(slot) : undefined; + if (cycle && !cycles.includes(cycle)) cycles.push(cycle); + } + } + } + for (const cycle of cycles) { + if (cycle.settled) this.retireCycle(cycle, callbacks); + else this.failCycle(cycle, callbacks, 'cycle_unattributable'); + } + } catch { + // Publisher calls remain pass-through even if competition observation fails. + } + } + + private callTrustedServer( + receiver: ExternalObject, + key: string, + arguments_: readonly unknown[] + ): unknown { + if (this.trustedServerCall) throw new TypeError('tsjs'); + const marker = Object.freeze([receiver, key] as const); + this.trustedServerCall = marker; + try { + return call(receiver, key, arguments_); + } finally { + if (this.trustedServerCall === marker) this.trustedServerCall = undefined; + } + } + + private restorePublisherCalls(): void { + for (const restore of this.publisherCallRestorers.reverse()) { + try { + restore(); + } catch { + // Publisher replacement wins while the old wrapper loses all authority. + } + } + this.publisherCallRestorers.length = 0; + } + + private notifyNativeMutation(): void { + try { + this.options.onNativeMutation?.(); + } catch { + // Mutation observation failure cannot alter the publisher's admitted call. + } + } + + private captureDiagnosticFact( + eventType: FirstDisplayGptDiagnosticEventV1, + cycle: ActiveCycle, + event: unknown + ): void { + const responseIdentifier = (() => { + const candidate = member(event, 'responseIdentifier'); + if (typeof candidate !== 'string' || candidate.length === 0) return undefined; + if (new TextEncoder().encode(candidate).byteLength > 256) return undefined; + for (let index = 0; index < candidate.length; index += 1) { + const code = candidate.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return undefined; + } + return candidate; + })(); + let disposition: FirstDisplayGptFactV1['disposition'] = 'matched'; + let issueReason: FirstDisplayGptFactV1['issueReason'] = null; + let diagnosticCycle: FirstDisplayDiagnosticCycleRecord | undefined; + if (eventType === SLOT_REQUESTED) { + if (cycle.diagnosticRecords.some((record) => record.state === 'open')) { + disposition = 'ambiguous'; + issueReason = OVERLAPPING_REQUEST_CYCLES; + } else if (cycle.nextDiagnosticCycleOrdinal > MAX_U32) { + disposition = 'unmatched'; + issueReason = INVALID_EVENT_ORDER; + } else { + if (cycle.diagnosticRecords.length >= 10) { + const pruneIndex = cycle.diagnosticRecords.findIndex((record) => record.state !== 'open'); + if (pruneIndex < 0) { + disposition = 'ambiguous'; + issueReason = OVERLAPPING_REQUEST_CYCLES; + } else { + cycle.diagnosticRecords.splice(pruneIndex, 1); + cycle.unknownPriorCycle = true; + } + } + if (disposition === 'matched') { + diagnosticCycle = { + ordinal: cycle.nextDiagnosticCycleOrdinal, + ...(responseIdentifier === undefined ? {} : { responseIdentifier }), + seen: new Set([SLOT_REQUESTED]), + state: 'open', + }; + cycle.nextDiagnosticCycleOrdinal += 1; + cycle.diagnosticRecords.push(diagnosticCycle); + } + } + } else { + let candidates: FirstDisplayDiagnosticCycleRecord[]; + if (responseIdentifier !== undefined) { + candidates = cycle.diagnosticRecords.filter( + (record) => + record.responseIdentifier === responseIdentifier && !record.seen.has(eventType) + ); + if (candidates.length === 0) { + const unboundOpen = cycle.diagnosticRecords.filter( + (record) => + record.state === 'open' && + record.responseIdentifier === undefined && + !record.seen.has(eventType) + ); + if (unboundOpen.length === 1) candidates = unboundOpen; + } + } else { + candidates = cycle.diagnosticRecords.filter( + (record) => record.state === 'open' && !record.seen.has(eventType) + ); + if (candidates.length === 0 && !cycle.unknownPriorCycle) { + candidates = cycle.diagnosticRecords.filter((record) => !record.seen.has(eventType)); + } + } + if (candidates.length === 1) { + diagnosticCycle = candidates[0]; + } else if (candidates.length > 1) { + disposition = 'ambiguous'; + issueReason = OVERLAPPING_REQUEST_CYCLES; + } else { + const duplicate = cycle.diagnosticRecords.find( + (record) => + record.seen.has(eventType) && + (responseIdentifier === undefined || + record.responseIdentifier === responseIdentifier || + record.responseIdentifier === undefined) + ); + if (duplicate) { + diagnosticCycle = duplicate; + issueReason = INVALID_EVENT_ORDER; + } else { + disposition = 'unmatched'; + issueReason = cycle.unknownPriorCycle ? 'unknown_prior_cycle' : 'no_request_cycle'; + } + } + if (diagnosticCycle && issueReason !== INVALID_EVENT_ORDER) { + diagnosticCycle.seen.add(eventType); + if (diagnosticCycle.responseIdentifier === undefined && responseIdentifier !== undefined) { + diagnosticCycle.responseIdentifier = responseIdentifier; + } + if (eventType === SLOT_RENDER_ENDED) diagnosticCycle.state = 'completed'; + } + } + if (this.options.diagnosticsActive !== true) return; + let observedAtMs: number; + try { + observedAtMs = this.options.browser.performance.now(); + if (!Number.isFinite(observedAtMs) || observedAtMs < 0) { + this.incrementDiagnosticDrops(); + return; + } + } catch { + this.incrementDiagnosticDrops(); + return; + } + const size = member(event, 'size'); + const renderedSize = + eventType === SLOT_RENDER_ENDED && + Array.isArray(size) && + size.length === 2 && + size.every( + (dimension) => + typeof dimension === 'number' && + Number.isInteger(dimension) && + dimension >= 1 && + dimension <= 4096 + ) + ? Object.freeze([size[0] as number, size[1] as number] as const) + : null; + const optionalBoolean = (key: string): boolean | null => { + const value = member(event, key); + return typeof value === 'boolean' ? value : null; + }; + const visibility = member(event, 'inViewPercentage'); + const requestedSlotSizes = + eventType === SLOT_REQUESTED && disposition === 'matched' && !cycle.settled + ? Object.freeze( + cycle.publicCycle[5].formats + .slice(0, 16) + .map((size) => Object.freeze([size[0], size[1]] as const)) + ) + : null; + const fact: Readonly = Object.freeze({ + version: 1, + event: eventType, + token: cycle.publicCycle[7], + runtimeSlotNumber: cycle.runtimeSlotNumber, + cycleOrdinal: disposition === 'matched' ? (diagnosticCycle?.ordinal ?? null) : null, + disposition, + issueReason, + capturedAtMs: observedAtMs, + elementId: cycle.elementId, + adUnitPath: cycle.publicCycle[5].gamUnitPath, + requestedSlotSizes, + isEmpty: eventType === SLOT_RENDER_ENDED ? optionalBoolean('isEmpty') : null, + renderedSize, + isBackfill: eventType === SLOT_RENDER_ENDED ? optionalBoolean('isBackfill') : null, + slotContentChanged: + eventType === SLOT_RENDER_ENDED ? optionalBoolean('slotContentChanged') : null, + visibilityPercent: + eventType === SLOT_VISIBILITY_CHANGED && + typeof visibility === 'number' && + Number.isFinite(visibility) && + visibility >= 0 && + visibility <= 100 + ? visibility + : null, + }); + if (this.encodedBytes(fact) > MAX_DIAGNOSTIC_FACT_BYTES) { + this.incrementDiagnosticDrops(); + return; + } + const overflow = this.diagnosticFacts.length >= MAX_DIAGNOSTIC_FACTS; + const nextFacts = overflow + ? [...this.diagnosticFacts.slice(1), fact] + : [...this.diagnosticFacts, fact]; + const nextOverflow = overflow + ? Math.min(MAX_U32, this.diagnosticFactOverflow + 1) + : this.diagnosticFactOverflow; + if ( + this.encodedBytes({ + facts: nextFacts, + overflowCount: nextOverflow, + dropCount: this.diagnosticFactDrops, + }) > MAX_DIAGNOSTIC_SECTION_BYTES + ) { + this.incrementDiagnosticDrops(); + return; + } + this.diagnosticFacts.splice(0, this.diagnosticFacts.length, ...nextFacts); + this.diagnosticFactOverflow = nextOverflow; + } + + private encodedBytes(value: unknown): number { + try { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; + } catch { + return Number.POSITIVE_INFINITY; + } + } + + private incrementDiagnosticDrops(): void { + this.diagnosticFactDrops = Math.min(MAX_U32, this.diagnosticFactDrops + 1); + } + + private invalidateTargeting(slot: object, key: string | undefined): void { + for (const restoration of this.targetingRestorers) { + if (restoration.slot === slot && (key === undefined || restoration.key === key)) { + restoration.valid = false; + } + } + } + + private restorePublisherTargeting(restoration: TargetingRestorer): void { + if (!restoration.valid) return; + const observer = this.targetingObservers.get(restoration.slot); + if (observer && !observer.isCurrent()) { + this.invalidateTargeting(restoration.slot, undefined); + return; + } + if (!restoration.valid) return; + const current = call(restoration.slot, 'getTargeting', [restoration.key]); + if (!restoration.valid) return; + if (observer && !observer.isCurrent()) { + this.invalidateTargeting(restoration.slot, undefined); + return; + } + if ( + !restoration.valid || + !Array.isArray(current) || + current.length !== 1 || + current[0] !== restoration.installed + ) { + return; + } + if (restoration.prior.length === 0) { + this.writeTargeting(restoration.slot, 'clearTargeting', [restoration.key]); + } else { + this.writeTargeting(restoration.slot, 'setTargeting', [restoration.key, restoration.prior]); + } + } + + private writeTargeting( + slot: object, + operation: 'clearTargeting' | 'setTargeting', + arguments_: readonly unknown[] + ): unknown { + const write: TargetingWrite = { consumed: false, operation, slot }; + this.targetingWrites.push(write); + try { + return call(slot, operation, arguments_); + } finally { + const index = this.targetingWrites.lastIndexOf(write); + if (index >= 0) this.targetingWrites.splice(index, 1); + } + } + + private consumeTargetingWrite( + slot: object, + operation: 'clearTargeting' | 'setTargeting' + ): boolean { + const write = this.targetingWrites[this.targetingWrites.length - 1]; + if (!write || write.consumed || write.slot !== slot || write.operation !== operation) { + return false; + } + write.consumed = true; + return true; + } + + private invalidateStaleTargetingObservers(): void { + for (const [slot, observer] of this.targetingObservers) { + if (!observer.isCurrent()) this.invalidateTargeting(slot, undefined); + } + } + + private restoreTargetingObservers(): void { + for (const [slot, observer] of this.targetingObservers) { + if (!observer.restore()) this.invalidateTargeting(slot, undefined); + } + this.targetingObservers.clear(); + } + + private captureRetainedTargeting( + retainedSlots: ReadonlySet + ): Map { + const captured = new Map(); + for (const slot of retainedSlots) { + const observer = this.targetingObservers.get(slot); + if (observer && !observer.isCurrent()) this.invalidateTargeting(slot, undefined); + const restorations: TargetingRestorer[] = []; + for (const restoration of this.targetingRestorers) { + if (!restoration.valid || restoration.slot !== slot) continue; + let current: unknown; + try { + current = call(slot, 'getTargeting', [restoration.key]); + } catch { + continue; + } + if (observer && !observer.isCurrent()) { + this.invalidateTargeting(slot, undefined); + break; + } + if ( + restoration.valid && + Array.isArray(current) && + current.length === 1 && + current[0] === restoration.installed + ) { + restorations.push(restoration); + } + } + captured.set(slot, restorations); + } + return captured; + } + + private failRows( + rows: readonly (readonly [FirstDisplayProjectionBidV1, FirstDisplayProjectionSlotV1])[], + callbacks: FirstDisplayGoogletagBatchCallbacks, + reason: FirstDisplayGptFailureReason + ): void { + for (const row of rows) { + try { + callbacks[1](row[1].slot, reason); + } catch { + // One consumer cannot prevent independent slot settlement. + } + } + } + + private timer(callback: () => void, delayMs: number): unknown { + const state: { fired: boolean; handle?: unknown } = { fired: false }; + const handle = this.options.setTimer(() => { + state.fired = true; + if (state.handle !== undefined) this.timers.delete(state.handle); + if (!this.disposed) callback(); + }, delayMs); + state.handle = handle; + if (!state.fired) this.timers.add(handle); + return handle; + } + + private clearOwnedTimer(handle: unknown): void { + if (!this.timers.delete(handle)) return; + try { + this.options.clearTimer(handle); + } catch { + // The generation latch prevents a hostile timer from restoring authority. + } + } + + private removePendingCommand(): void { + const command = this.command; + this.command = undefined; + if (!command || !this.commandQueue) return; + try { + const index = this.commandQueueIndex; + if (index >= 0 && this.commandQueue[index] === command) this.commandQueue.splice(index, 1); + else { + const current = this.commandQueue.indexOf(command); + if (current >= 0) this.commandQueue.splice(current, 1); + } + } catch { + // A poisoned publisher queue cannot keep this generation live. + } + this.commandQueue = undefined; + this.commandQueueIndex = -1; + } +} + +/** Create the sole provisional adapter for one immutable projected GPT winner batch. */ +export function createFirstDisplayGoogletagBatch( + options: FirstDisplayGoogletagBatchOptions +): FirstDisplayGoogletagBatch { + const owner = new FirstDisplayGoogletagBatchOwner(options); + return Object.freeze({ + start: (callbacks: FirstDisplayGoogletagBatchCallbacks) => owner.start(callbacks), + closeIngress: (committedSlotIds: readonly string[]) => owner.closeIngress(committedSlotIds), + captureHandoff: () => owner.captureHandoff(), + captureDiagnosticsHandoff: () => owner.captureDiagnosticsHandoff(), + detachCommittedSlots: (slotIds: readonly string[]) => owner.detachCommittedSlots(slotIds), + dispose: () => owner.dispose(), + }); +} diff --git a/crates/trusted-server-js/lib/src/first_display/agent.ts b/crates/trusted-server-js/lib/src/first_display/agent.ts new file mode 100644 index 000000000..ecfc30060 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/agent.ts @@ -0,0 +1,927 @@ +import type { BootFailureReason } from '../kernel/fallback'; +import type { FirstDisplayNavigationIdentityIssuer } from '../kernel/identity'; +import type { FirstDisplaySliceId } from '../kernel/release_catalog'; +import type { + FirstDisplayGptDiagnosticEventV1, + FirstDisplayGptDiagnosticsV1, +} from '../shared/takeover'; +import type { FirstDisplaySliceActivationContext } from '../shared/first_display_transaction'; +import type { + FinalizedFirstDisplayHandoffV1, + FirstDisplayAgentCaptureFinalizerV1, +} from '../shared/first_display_handoff'; + +import type { + FirstDisplayGoogletagBatchInput, + FirstDisplayGptBoundCycleV1, + FirstDisplayGptHandoffCycleV1, +} from './adapters/googletag'; +import type { FirstDisplayRenderBridgeCapabilityV1 } from './driver'; +import type { FirstDisplayApsProtocolV1 } from './leaf/aps_protocol'; +import type { FirstDisplayGptCapabilityV1, FirstDisplayGptProtocolV1 } from './leaf/gpt_protocol'; +import type { + FirstDisplayRenderOwnerOptionsV1, + FirstDisplayRenderOwnerProtocolV1, +} from './render_journal'; +import type { + FirstDisplaySliceHost, + InitialSliceInstaller, + OptionalFirstDisplaySliceId, +} from './slices/definition'; +import { + acceptServerFirstDisplayBatchV1, + type FirstDisplayAgentBatchV1, + type FirstDisplayAuctionProtocolId, + type FirstDisplayBatchOutcomeV1, +} from './leaf/projection'; + +const MAX_U32 = 4_294_967_295; +const BUNDLE_PARTIAL: BootFailureReason = 'bundle_partial'; +const AUCTION_PROTOCOLS = ['aps', 'gpt', 'prebid'] as const; +const SLICE_PROTOCOLS = ['render_owner', ...AUCTION_PROTOCOLS] as const; +const ACTION_KINDS = new Set(['gpt_adm', 'aps']); +const TERMINAL_RESULTS = new Set(['accepted', 'failed', 'cancelled']); + +export type { + FirstDisplayAuctionProtocolId, + FirstDisplayBatchOutcomeV1, + FirstDisplayBatchV1, + FirstDisplayProjectedKind, +} from './leaf/projection'; +export type FirstDisplayTerminalResult = 'accepted' | 'failed' | 'cancelled'; +export type FirstDisplayAgentState = + 'ready' | 'active' | 'terminal' | 'painted' | 'failed' | 'disposed'; + +export interface FirstDisplayDriver { + readonly start: ( + outcomes: readonly FirstDisplayBatchOutcomeV1[], + onFirstAction: () => boolean, + onTerminal: (slotId: string, result: FirstDisplayTerminalResult, reason: string | null) => void + ) => void; + readonly sealTsAdmission: () => void; + readonly closeIngress: () => boolean; + readonly captureHandoff: () => FirstDisplayDriverHandoffV1 | undefined; + readonly detachCommittedArtifacts: () => boolean; + readonly sweepCommittedArtifacts: () => number; + readonly dispose: () => void; +} + +export interface FirstDisplayDriverHandoffV1 { + readonly artifacts: readonly Readonly<{ + hostPosition: string | null; + hostPositionPriority: string | null; + identity: object; + kind: 'gpt_adm' | 'aps'; + owner: 'trusted_server' | 'publisher'; + slotId: string; + token: string; + }>[]; + readonly cycles: readonly FirstDisplayGptHandoffCycleV1[]; + readonly diagnosticCycles: readonly Readonly<{ + nextCycleOrdinal: number; + quarantines: readonly string[]; + records: readonly Readonly<{ + ordinal: number; + responseIdentifier: string | null; + seen: readonly FirstDisplayGptDiagnosticEventV1[]; + state: 'open' | 'completed' | 'retired'; + }>[]; + slotId: string; + token: string; + unknownPriorCycle: boolean; + }>[]; + readonly clockEpochMs: number; + readonly gptDiagnostics: Readonly; + readonly identities: readonly object[]; + readonly nextReservationOrdinal: number; + readonly nextTraceTokenOrdinal: number; + readonly nextTicketOrdinal: number; + readonly tombstones: readonly Readonly<{ + kind: 'reservation' | 'ticket'; + value: string; + expiresAtMs: number; + ordinal: number; + }>[]; +} + +export interface FirstDisplayPaintScheduler { + readonly hidden: () => boolean; + readonly requestFrame: (callback: () => void) => void; + readonly scheduleHidden: (callback: () => void) => void; +} + +export interface FirstDisplayAgentOptions { + readonly batch: unknown; + readonly production?: Readonly<{ + gpt?: FirstDisplayGptProtocolV1; + gptInput: readonly [ + browser: FirstDisplayGoogletagBatchInput[0], + clearTimer: FirstDisplayGoogletagBatchInput[1], + document: FirstDisplayGoogletagBatchInput[2], + setTimer: FirstDisplayGoogletagBatchInput[3], + diagnosticsActive?: FirstDisplayGoogletagBatchInput[5], + onNativeMutation?: FirstDisplayGoogletagBatchInput[6], + ]; + renderer?: FirstDisplayRenderBridgeCapabilityV1; + }>; + readonly startedAtMs: number; + readonly performance: Readonly<{ + mark: (name: string) => void; + measure?: (name: string, startMark: string, endMark: string) => void; + }>; + readonly paint: FirstDisplayPaintScheduler; + readonly onProtectedPaint: () => void; + readonly onSettled: () => void; + readonly onFailure: (reason: BootFailureReason) => void; + readonly mutationDocument?: Document; + readonly initialMutationRevision?: number; + readonly now?: () => number; + readonly identityIssuer?: FirstDisplayNavigationIdentityIssuer; + readonly parserState?: () => readonly (readonly [ + string, + readonly (readonly [string, string | number | boolean | null])[], + ])[]; + readonly handoff?: Readonly<{ + releaseId: string; + generation: number; + integrationConfigDigest: string; + slices: readonly FirstDisplaySliceId[]; + }>; +} + +/** Bootstrap-owned dependencies supplied only to the release-bound base component. */ +export interface FirstDisplayAgentRegistrationHostV1 { + readonly options: FirstDisplayAgentOptions & + Readonly<{ + gptInput: readonly [ + browser: FirstDisplayGoogletagBatchInput[0], + clearTimer: FirstDisplayGoogletagBatchInput[1], + document: FirstDisplayGoogletagBatchInput[2], + setTimer: FirstDisplayGoogletagBatchInput[3], + diagnosticsActive?: FirstDisplayGoogletagBatchInput[5], + ]; + onAgentReady?: (agent: FirstDisplayAgent) => void; + }>; + readonly sliceBindings: ( + id: string, + observe: (key: unknown, value: unknown) => void, + register: ((protocol: unknown) => () => void) | undefined + ) => readonly [bindings: unknown, config: unknown]; +} + +function createBrowserMessageChannel( + browser: Window +): ReturnType { + const constructor = Reflect.get(browser, 'MessageChannel'); + if (typeof constructor !== 'function') { + throw new TypeError('tsjs'); + } + const channel = Reflect.construct(constructor, []) as Record; + const port1 = Reflect.get(channel, 'port1'); + const port2 = Reflect.get(channel, 'port2'); + if (typeof port1 !== 'object' || port1 === null || typeof port2 !== 'object' || port2 === null) { + throw new TypeError('tsjs'); + } + return { port1, port2 } as ReturnType; +} + +function fillBrowserRandom(browser: Window, bytes: Uint8Array): void { + const crypto = Reflect.get(browser, 'crypto'); + const getRandomValues = + typeof crypto === 'object' && crypto !== null + ? Reflect.get(crypto, 'getRandomValues') + : undefined; + if (typeof getRandomValues !== 'function') throw new TypeError('tsjs'); + Reflect.apply(getRandomValues, crypto, [bytes]); +} + +function readBrowserNow(browser: Window): number { + const performance = Reflect.get(browser, 'performance'); + const now = + typeof performance === 'object' && performance !== null + ? Reflect.get(performance, 'now') + : undefined; + if (typeof now !== 'function') throw new TypeError('tsjs'); + return Reflect.apply(now, performance, []) as number; +} + +export interface PreparedFirstDisplayBaseV1 { + readonly activate: (context: FirstDisplaySliceActivationContext) => void; + readonly sliceHost: FirstDisplaySliceHost; +} + +export interface FirstDisplayAgent { + readonly state: FirstDisplayAgentState; + readonly mutationRevision: number; + readonly initialDisplayCommitted: boolean; + readonly start: () => boolean; + readonly observeNativeMutation: () => boolean; + readonly finalizeHandoff: ( + finalize: FirstDisplayAgentCaptureFinalizerV1 + ) => FinalizedFirstDisplayHandoffV1 | undefined; + readonly detachCommittedArtifacts: () => boolean; + readonly dispose: () => void; +} + +type FirstDisplayRegisteredProtocolId = FirstDisplayAuctionProtocolId | 'render_owner'; + +function protocolIdentity( + candidate: unknown, + expected: FirstDisplayRegisteredProtocolId, + exact = true +): boolean { + try { + const length = exact ? 2 : 3; + if ( + !Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Array.prototype || + !Object.isFrozen(candidate) || + candidate.length !== length || + Reflect.ownKeys(candidate).length !== length + 1 + ) { + return false; + } + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor)) return false; + } + return candidate[0] === 1 && candidate[1] === expected && (exact || candidate[2] !== undefined); + } catch { + return false; + } +} + +function fullProtocolIdentity( + candidate: unknown, + expected: FirstDisplayRegisteredProtocolId +): boolean { + return protocolIdentity(candidate, expected, false); +} + +function sameCycle( + expected: FirstDisplayGptBoundCycleV1, + candidate: FirstDisplayGptBoundCycleV1 +): boolean { + return candidate === expected; +} + +class FirstDisplayAgentOwner implements FirstDisplayAgent { + private readonly agentBatch: FirstDisplayAgentBatchV1 | undefined; + private readonly slotResults = new Map< + string, + FirstDisplayTerminalResult | 'no_bid' | 'failed' | 'cancelled' + >(); + private readonly pending = new Set(); + private readonly reasons = new Map(); + private handoffCapsule: FinalizedFirstDisplayHandoffV1['capsule'] | undefined; + private stateValue: FirstDisplayAgentState = 'ready'; + private mutationObserver: MutationObserver | undefined; + private observedMutationRevision: number; + private displayWasCommitted = false; + private disposedDriver = false; + private failed = false; + private actionStarted = false; + private handoffFinalized = false; + private committedArtifactsDetached = false; + private lastTimingMs: number; + private firstActionAtMs: number | null = null; + private terminalAtMs: number | undefined; + private paintAtMs: number | undefined; + private nextTraceSequence = 1; + private readonly acceptedTrace = new Map< + string, + Readonly<{ atMs: number; historySequence: number }> + >(); + private readonly bound = new Map(); + private productionBatch: FirstDisplayGptCapabilityV1 | undefined; + + public constructor(private readonly options: FirstDisplayAgentOptions) { + this.agentBatch = acceptServerFirstDisplayBatchV1(options.batch); + this.observedMutationRevision = options.initialMutationRevision ?? 0; + this.lastTimingMs = options.startedAtMs; + this.installNativeMutationIngress(); + } + + public get state(): FirstDisplayAgentState { + return this.stateValue; + } + + public get mutationRevision(): number { + return this.observedMutationRevision; + } + + public get initialDisplayCommitted(): boolean { + return this.displayWasCommitted; + } + + public start(): boolean { + if (this.stateValue !== 'ready') return false; + if (!this.agentBatch) return this.fail('abi_mismatch'); + const started = this.readTiming(); + if (started === undefined || started - this.options.startedAtMs >= 10_000) { + return this.fail(BUNDLE_PARTIAL); + } + + let actionCount = 0; + const outcomes = this.agentBatch[2]; + const projection = this.agentBatch[3]; + for (let index = 0; index < outcomes.length; index += 1) { + const outcome = outcomes[index]!; + if (ACTION_KINDS.has(outcome[1])) { + this.pending.add(outcome[0]); + actionCount += 1; + } else { + this.slotResults.set(outcome[0], outcome[1] as 'no_bid' | 'failed' | 'cancelled'); + const decision = projection.auction.results[index]; + this.reasons.set(outcome[0], decision?.outcome === 'failed' ? decision.reason : null); + } + } + this.stateValue = 'active'; + if (actionCount === 0) { + this.recordTerminal(); + return true; + } + try { + if (!this.startProduction()) { + throw new TypeError('tsjs'); + } + return true; + } catch { + return this.fail(BUNDLE_PARTIAL); + } + } + + public observeNativeMutation(): boolean { + if (this.stateValue !== 'painted' || this.failed) return false; + if (this.observedMutationRevision >= MAX_U32) return this.fail(BUNDLE_PARTIAL); + this.observedMutationRevision += 1; + return true; + } + + public finalizeHandoff( + finalize: FirstDisplayAgentCaptureFinalizerV1 + ): FinalizedFirstDisplayHandoffV1 | undefined { + if ( + this.stateValue !== 'painted' || + this.failed || + !this.options.handoff || + this.handoffFinalized || + typeof finalize !== 'function' + ) { + return undefined; + } + try { + if (!this.closeDriverIngress()) throw new TypeError('tsjs'); + this.closeNativeMutationIngress(); + const currentTimeMs = this.readTiming(); + if ( + currentTimeMs === undefined || + !this.agentBatch || + this.terminalAtMs === undefined || + this.paintAtMs === undefined + ) { + throw new TypeError('tsjs'); + } + const finalized = finalize([ + this.options.handoff, + Object.freeze([this.agentBatch[0], this.agentBatch[2]]), + this.slotResults, + this.reasons, + this.acceptedTrace, + this.options.parserState?.() ?? [], + this.productionBatch?.[2](), + this.productionBatch?.[3](), + this.options.production?.renderer?.[7](), + [ + this.options.startedAtMs, + this.firstActionAtMs, + this.terminalAtMs, + this.paintAtMs, + currentTimeMs, + ], + this.nextTraceSequence, + this.observedMutationRevision, + ]); + if (!finalized) throw new TypeError('tsjs'); + this.handoffCapsule = finalized.capsule; + this.handoffFinalized = true; + return finalized; + } catch { + this.fail(BUNDLE_PARTIAL); + return undefined; + } + } + + public detachCommittedArtifacts(): boolean { + if ( + !this.handoffFinalized || + this.committedArtifactsDetached || + this.stateValue !== 'painted' + ) { + return false; + } + if (!this.detachDriverArtifacts()) return false; + this.committedArtifactsDetached = true; + return true; + } + + public dispose(): void { + if (this.stateValue === 'disposed') return; + this.stateValue = 'disposed'; + this.handoffCapsule?.clear(); + this.handoffCapsule = undefined; + this.disposeDriver(); + this.pending.clear(); + } + + private settle(slotId: string, result: FirstDisplayTerminalResult, reason: string | null): void { + if (!this.actionStarted) { + this.fail(BUNDLE_PARTIAL); + return; + } + if (this.stateValue !== 'active') return; + if ( + typeof result !== 'string' || + !TERMINAL_RESULTS.has(result) || + (result === 'accepted' + ? reason !== null + : typeof reason !== 'string' || reason.length === 0 || reason.length > 256) + ) { + this.fail(BUNDLE_PARTIAL); + return; + } + if (!this.pending.has(slotId)) { + if (!this.slotResults.has(slotId)) this.fail(BUNDLE_PARTIAL); + return; + } + if (result === 'accepted') { + const atMs = this.readTiming(); + if (atMs === undefined || this.nextTraceSequence > 4_294_967_295) { + this.fail(BUNDLE_PARTIAL); + return; + } + this.acceptedTrace.set( + slotId, + Object.freeze({ atMs, historySequence: this.nextTraceSequence }) + ); + this.nextTraceSequence += 1; + } + this.pending.delete(slotId); + this.slotResults.set(slotId, result); + this.reasons.set(slotId, reason); + if (result === 'accepted') this.displayWasCommitted = true; + if (this.pending.size === 0) this.recordTerminal(); + } + + private recordTerminal(): void { + if (this.stateValue !== 'active' || this.pending.size !== 0) return; + this.stateValue = 'terminal'; + this.terminalAtMs = this.readTiming(); + if (this.terminalAtMs === undefined) { + this.fail(BUNDLE_PARTIAL); + return; + } + try { + this.options.onSettled(); + } catch { + this.fail(BUNDLE_PARTIAL); + return; + } + this.mark('tsjs:first-display-terminal'); + this.scheduleProtectedPaint(2); + } + + private recordFirstAction(): boolean { + if (this.stateValue !== 'active' || this.actionStarted) return this.fail(BUNDLE_PARTIAL); + const firstDisplayMs = this.readTiming(); + if (firstDisplayMs === undefined || firstDisplayMs - this.options.startedAtMs >= 10_000) { + return this.fail(BUNDLE_PARTIAL); + } + this.actionStarted = true; + this.firstActionAtMs = firstDisplayMs; + this.mark('tsjs:first-display'); + try { + this.options.performance.measure?.( + 'tsjs:boot-to-first-display', + 'tsjs:bids-script', + 'tsjs:first-display' + ); + } catch { + // Timing observability cannot alter display ownership. + } + return true; + } + + private scheduleProtectedPaint(remaining: number): void { + const next = (): void => { + if (this.stateValue !== 'terminal') return; + if (remaining > 1) { + this.scheduleProtectedPaint(remaining - 1); + return; + } + try { + this.options.production?.renderer?.[5](); + } catch { + this.fail(BUNDLE_PARTIAL); + return; + } + this.stateValue = 'painted'; + this.paintAtMs = this.readTiming(); + if (this.paintAtMs === undefined) { + this.fail(BUNDLE_PARTIAL); + return; + } + this.mark('tsjs:first-display-paint'); + try { + this.options.onProtectedPaint(); + } catch { + this.fail(BUNDLE_PARTIAL); + } + }; + try { + if (this.options.paint.hidden()) this.options.paint.scheduleHidden(next); + else this.options.paint.requestFrame(next); + } catch { + this.fail(BUNDLE_PARTIAL); + } + } + + private mark(name: string): void { + try { + this.options.performance.mark(name); + } catch { + // Timing observability cannot alter display ownership. + } + } + + private readTiming(): number | undefined { + try { + const value = this.options.now?.() ?? this.options.startedAtMs; + if (!Number.isFinite(value) || value < 0 || value < this.lastTimingMs) return undefined; + this.lastTimingMs = value; + return value; + } catch { + return undefined; + } + } + + private startProduction(): boolean { + const production = this.options.production; + const batch = this.agentBatch; + const renderer = production?.renderer; + if (!production || !batch || !renderer) return false; + const gptBatch = production.gpt?.[2]( + Object.freeze([ + production.gptInput[0], + production.gptInput[1], + production.gptInput[2], + production.gptInput[3], + batch[3], + production.gptInput[4], + production.gptInput[5], + ]) + ); + if (!gptBatch) return false; + this.productionBatch = gptBatch; + return gptBatch[0]([ + (cycle): void => { + const action = batch[2].find(([slotId]) => slotId === cycle[6]); + const bidIndex = batch[3].bids.indexOf(cycle[0]); + const placement = batch[3].slots.find(({ slot }) => slot === cycle[6]); + if ( + !action || + !ACTION_KINDS.has(action[1]) || + this.bound.has(cycle[6]) || + bidIndex < 0 || + batch[3].bids[bidIndex]?.slot !== cycle[6] || + cycle[5] !== placement + ) { + this.settle(cycle[6], 'failed', 'gpt_request_failed'); + return; + } + this.bound.set(cycle[6], cycle); + if (!renderer[0](cycle, (result, reason) => this.settle(cycle[6], result, reason))) { + this.settle(cycle[6], 'failed', 'internal_error'); + } + }, + (slotId, reason): void => { + const cycle = this.bound.get(slotId); + if (cycle) renderer[2](cycle); + this.settle(slotId, 'failed', reason); + }, + () => this.recordFirstAction(), + (cycle, result): void => { + const exact = this.bound.get(cycle[6]); + if (!exact || !sameCycle(exact, cycle) || !renderer[1](exact, result)) { + this.settle(cycle[6], 'failed', 'gpt_request_failed'); + } + }, + (cycle): void => { + const exact = this.bound.get(cycle[6]); + if (exact && sameCycle(exact, cycle)) renderer[3](exact); + }, + ]); + } + + private acceptedSlotIds(): string[] { + return [...this.slotResults.entries()] + .filter(([, result]) => result === 'accepted') + .map(([slotId]) => slotId); + } + + private closeDriverIngress(): boolean { + const production = this.options.production; + if (!production) return false; + const accepted = this.acceptedSlotIds(); + return ( + (!this.productionBatch || this.productionBatch[1](accepted)) && + (!production.renderer || production.renderer[6]()) + ); + } + + private detachDriverArtifacts(): boolean { + const production = this.options.production; + if (!production) return false; + const accepted = this.acceptedSlotIds(); + return ( + (!this.productionBatch || this.productionBatch[4](accepted)) && + (!production.renderer || production.renderer[8]()) + ); + } + + private fail(reason: BootFailureReason): false { + if (this.failed || this.stateValue === 'disposed') return false; + this.failed = true; + this.stateValue = 'failed'; + try { + this.options.onFailure(reason); + } catch { + // Failure publication cannot restore provisional authority. + } + this.disposeDriver(); + this.pending.clear(); + return false; + } + + private disposeDriver(): void { + if (this.disposedDriver) return; + this.disposedDriver = true; + this.disposeNativeMutationIngress(); + try { + this.productionBatch?.[5](); + } catch { + // Every owned subsystem must still receive its independent disposal attempt. + } + try { + this.options.production?.renderer?.[9](); + } catch { + // Disposal is generation-latched; physical cleanup failure cannot restore authority. + } + this.bound.clear(); + } + + private installNativeMutationIngress(): void { + if (!this.options.handoff || !this.options.mutationDocument) return; + try { + const document = this.options.mutationDocument; + const Observer = document.defaultView?.MutationObserver; + if (!Observer || !document.documentElement) throw new TypeError('tsjs'); + const observer = new Observer((records) => this.observeDomMutations(records)); + observer.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true, + }); + this.mutationObserver = observer; + } catch { + this.fail(BUNDLE_PARTIAL); + } + } + + private observeDomMutations(records: readonly MutationRecord[]): void { + if (records.length > 0) { + try { + this.options.production?.renderer?.[4](); + } catch { + this.fail(BUNDLE_PARTIAL); + return; + } + } + for (const record of records) { + if (this.isOwnedRuntimeInsertion(record)) continue; + if (!this.observeNativeMutation()) return; + } + } + + private isOwnedRuntimeInsertion(record: MutationRecord): boolean { + if (record.type !== 'childList' || record.removedNodes.length !== 0) return false; + const added = [...record.addedNodes]; + return ( + added.length === 1 && + added[0]?.nodeType === 1 && + (added[0] as Element).tagName === 'SCRIPT' && + (added[0] as Element).id === 'trustedserver-js-runtime' + ); + } + + private closeNativeMutationIngress(): void { + const observer = this.mutationObserver; + this.mutationObserver = undefined; + if (!observer) return; + try { + const records = observer.takeRecords(); + observer.disconnect(); + this.observeDomMutations(records); + } catch { + throw new TypeError('tsjs'); + } + } + + private disposeNativeMutationIngress(): void { + const observer = this.mutationObserver; + this.mutationObserver = undefined; + if (!observer) return; + try { + observer.disconnect(); + } catch { + // Generation latching makes a failed physical observer removal inert. + } + } +} + +/** Create the bounded provisional lifecycle owner; it publishes no runtime API. */ +export function createFirstDisplayAgent(options: FirstDisplayAgentOptions): FirstDisplayAgent { + return new FirstDisplayAgentOwner(options); +} + +/** Prepare the bootstrap-owned first-display coordinator for its optional slices. */ +export function prepareFirstDisplayBase( + host: FirstDisplayAgentRegistrationHostV1 +): PreparedFirstDisplayBaseV1 { + const { options, sliceBindings } = host; + const fullProtocols = new Map(); + const parserState = new Map< + string, + { observations: string[]; values: Map } + >(); + const registerParserSlice = (id: string): boolean => { + if (parserState.has(id) || !options.handoff?.slices.includes(id as FirstDisplaySliceId)) { + return false; + } + parserState.set(id, { observations: [], values: new Map() }); + return true; + }; + const observeParserValue = (id: string, key: unknown, value: unknown): boolean => { + const state = parserState.get(id); + if ( + !state || + typeof key !== 'string' || + key.length === 0 || + key.length > 128 || + (value !== null && + typeof value !== 'string' && + typeof value !== 'boolean' && + !(typeof value === 'number' && Number.isFinite(value))) || + (typeof value === 'string' && value.length > 4_096) + ) { + return false; + } + if (!state.values.has(key)) { + if (state.values.size >= 256) return false; + state.observations.push(key); + } + state.values.set(key, value as string | number | boolean | null); + return true; + }; + const snapshotParserState = () => + Object.freeze( + [...parserState].map(([sliceId, state]) => + Object.freeze([ + sliceId, + Object.freeze( + state.observations.map((key) => Object.freeze([key, state.values.get(key)!] as const)) + ), + ] as const) + ) + ); + let agent: FirstDisplayAgent | undefined; + const sliceHost: FirstDisplaySliceHost = Object.freeze({ + activate: ( + id: OptionalFirstDisplaySliceId, + own: FirstDisplaySliceActivationContext['own'], + install?: InitialSliceInstaller + ): void => { + if (typeof install !== 'function') { + throw new TypeError('tsjs'); + } + if (!registerParserSlice(id)) { + throw new TypeError('tsjs'); + } + const protocolId = id.endsWith('_initial') + ? (id.slice(0, -'_initial'.length) as FirstDisplayRegisteredProtocolId) + : undefined; + const observe = (key: unknown, value: unknown): void => { + if (!observeParserValue(id, key, value)) throw new TypeError('tsjs'); + agent?.observeNativeMutation(); + }; + const register = + protocolId && SLICE_PROTOCOLS.includes(protocolId) + ? (protocol: unknown): (() => void) => { + if (!fullProtocolIdentity(protocol, protocolId) || fullProtocols.has(protocolId)) { + throw new TypeError('tsjs'); + } + fullProtocols.set(protocolId, protocol); + return () => { + if (fullProtocols.get(protocolId) === protocol) { + fullProtocols.delete(protocolId); + } + }; + } + : undefined; + // The authenticated bootstrap builds the exact carrier around these + // base-owned observation and protocol capabilities. + const transported = sliceBindings(id, observe, register); + if (!Array.isArray(transported) || transported.length !== 2) { + throw new TypeError('tsjs'); + } + const installed = install(transported[0], own, transported[1]); + if ( + protocolId && + SLICE_PROTOCOLS.includes(protocolId) && + !protocolIdentity(installed, protocolId) + ) { + throw new TypeError('tsjs'); + } + }, + }); + return Object.freeze({ + activate: (context: FirstDisplaySliceActivationContext): void => { + const rendererOwner: { value: FirstDisplayRenderBridgeCapabilityV1 | undefined } = { + value: undefined, + }; + context.own(() => { + if (agent) agent.dispose(); + else rendererOwner.value?.[9](); + }); + context.afterActivate(() => { + const batch = acceptServerFirstDisplayBatchV1(options.batch); + if (!batch) throw new TypeError('tsjs'); + const gpt = fullProtocols.get('gpt'); + const aps = fullProtocols.get('aps'); + const renderOwner = fullProtocols.get('render_owner'); + const requiresRenderOwner = batch[2].some(([, kind]) => ACTION_KINDS.has(kind)); + const activatedAuctionProtocols = AUCTION_PROTOCOLS.filter((id) => fullProtocols.has(id)); + if ( + activatedAuctionProtocols.length !== batch[1].length || + !batch[1].every((id) => fullProtocolIdentity(fullProtocols.get(id), id)) + ) { + throw new TypeError('tsjs'); + } + if ( + requiresRenderOwner !== fullProtocolIdentity(renderOwner, 'render_owner') || + (aps !== undefined && renderOwner === undefined) + ) { + throw new TypeError('tsjs'); + } + const renderOptions: FirstDisplayRenderOwnerOptionsV1 = Object.freeze([ + options.gptInput[0], + options.gptInput[1], + () => createBrowserMessageChannel(options.gptInput[0]), + options.gptInput[2], + (bytes) => fillBrowserRandom(options.gptInput[0], bytes), + () => readBrowserNow(options.gptInput[0]), + () => agent?.observeNativeMutation() === true, + options.gptInput[3], + ]); + const apsProtocol = fullProtocolIdentity(aps, 'aps') + ? (aps as FirstDisplayApsProtocolV1) + : undefined; + const renderStrategy = apsProtocol?.[2].createRenderStrategy(renderOptions); + const renderer = renderOwner + ? (renderOwner as FirstDisplayRenderOwnerProtocolV1)[2](renderOptions, renderStrategy) + : undefined; + rendererOwner.value = renderer; + agent = createFirstDisplayAgent({ + ...options, + mutationDocument: options.gptInput[2], + parserState: snapshotParserState, + production: { + ...(gpt ? { gpt: gpt as FirstDisplayGptProtocolV1 } : {}), + gptInput: Object.freeze([ + options.gptInput[0], + options.gptInput[1], + options.gptInput[2], + options.gptInput[3], + options.gptInput[4], + () => agent?.observeNativeMutation() === true, + ]), + ...(renderer ? { renderer } : {}), + }, + }); + if (!agent.start()) throw new TypeError('tsjs'); + options.onAgentReady?.(agent); + }); + }, + sliceHost, + }); +} diff --git a/crates/trusted-server-js/lib/src/first_display/base_marker.ts b/crates/trusted-server-js/lib/src/first_display/base_marker.ts new file mode 100644 index 000000000..df5dc6ddf --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/base_marker.ts @@ -0,0 +1 @@ +// The logical first-display base is physically co-bundled with the parser-inline bootstrap. diff --git a/crates/trusted-server-js/lib/src/first_display/composition.ts b/crates/trusted-server-js/lib/src/first_display/composition.ts new file mode 100644 index 000000000..830c80e36 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/composition.ts @@ -0,0 +1,107 @@ +import type { FirstDisplaySliceId } from '../kernel/release_catalog'; + +import { installApsInitial } from './leaf/aps_protocol'; +import { installTestlightInitial } from './leaf/callback_capture'; +import { installDidomiInitial } from './leaf/config_guard'; +import { installOsanoInitial, installSourcepointInitial } from './leaf/consent_snapshot'; +import { installPermutiveInitial } from './leaf/context_snapshot'; +import { installCreativeInitial } from './leaf/creative_guard'; +import { installPrebidInitial } from './leaf/prebid_protocol'; +import { + installDataDomeInitial, + installGoogleTagManagerInitial, + installLockrInitial, +} from './leaf/route_guard'; +import { installRenderOwnerInitial } from './render_journal'; +import type { InitialSliceDefinition } from './slices/definition'; +import { installGptInitialSlice } from './slices/gpt'; + +export const RENDER_OWNER_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'render_owner_initial', + install: installRenderOwnerInitial, +}); +export const APS_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'aps_initial', + install: installApsInitial, +}); +export const CREATIVE_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'creative_initial', + install: installCreativeInitial, +}); +export const DATADOME_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'datadome_initial', + install: installDataDomeInitial, +}); +export const DIDOMI_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'didomi_initial', + install: installDidomiInitial, +}); +export const GOOGLE_TAG_MANAGER_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'google_tag_manager_initial', + install: installGoogleTagManagerInitial, +}); +export const GPT_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'gpt_initial', + install: installGptInitialSlice, +}); +export const LOCKR_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'lockr_initial', + install: installLockrInitial, +}); +export const OSANO_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'osano_initial', + install: installOsanoInitial, +}); +export const PERMUTIVE_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'permutive_initial', + install: installPermutiveInitial, +}); +export const SOURCEPOINT_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'sourcepoint_initial', + install: installSourcepointInitial, +}); +export const PREBID_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'prebid_initial', + install: installPrebidInitial, +}); +export const TESTLIGHT_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'testlight_initial', + install: installTestlightInitial, +}); + +export const INITIAL_SLICE_DEFINITIONS: readonly InitialSliceDefinition[] = Object.freeze([ + RENDER_OWNER_INITIAL_SLICE, + APS_INITIAL_SLICE, + CREATIVE_INITIAL_SLICE, + DATADOME_INITIAL_SLICE, + DIDOMI_INITIAL_SLICE, + GOOGLE_TAG_MANAGER_INITIAL_SLICE, + GPT_INITIAL_SLICE, + LOCKR_INITIAL_SLICE, + OSANO_INITIAL_SLICE, + PERMUTIVE_INITIAL_SLICE, + SOURCEPOINT_INITIAL_SLICE, + PREBID_INITIAL_SLICE, + TESTLIGHT_INITIAL_SLICE, +]); + +/** Resolve only the canonical optional slice definitions already selected by the server. */ +export function selectInitialSliceDefinitions( + selected: readonly FirstDisplaySliceId[] +): readonly InitialSliceDefinition[] | undefined { + if (selected[0] !== 'first_display' || selected.length > INITIAL_SLICE_DEFINITIONS.length + 1) { + return undefined; + } + const requested = new Set(selected.slice(1)); + if (requested.size !== selected.length - 1) return undefined; + if ( + (requested.has('aps_initial') && !requested.has('render_owner_initial')) || + (requested.has('render_owner_initial') && !requested.has('gpt_initial')) + ) + return undefined; + const definitions = INITIAL_SLICE_DEFINITIONS.filter(({ id }) => requested.has(id)); + if (definitions.length !== requested.size) return undefined; + const canonical = ['first_display', ...definitions.map(({ id }) => id)]; + if (canonical.some((id, index) => selected[index] !== id)) return undefined; + return Object.freeze(definitions); +} diff --git a/crates/trusted-server-js/lib/src/first_display/driver.ts b/crates/trusted-server-js/lib/src/first_display/driver.ts new file mode 100644 index 000000000..231e34893 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/driver.ts @@ -0,0 +1,391 @@ +import type { + FirstDisplayGoogletagBatchInput, + FirstDisplayGptBoundCycleV1, + FirstDisplayGptRenderResult, +} from './adapters/googletag'; +import type { FirstDisplayDriver, FirstDisplayTerminalResult } from './agent'; +import type { FirstDisplayGptProtocolV1 } from './leaf/gpt_protocol'; +import type { FirstDisplayBatchOutcomeV1, FirstDisplayBatchV1 } from './leaf/projection'; + +const ACTION_KINDS = new Set(['gpt_adm', 'aps']); +const TERMINAL_RESULTS = new Set(['accepted', 'failed', 'cancelled']); + +export interface FirstDisplayRenderBridgeV1 { + readonly bind: ( + cycle: FirstDisplayGptBoundCycleV1, + onTerminal: (result: FirstDisplayTerminalResult, reason: string | null) => void + ) => boolean; + readonly recordGam: ( + cycle: FirstDisplayGptBoundCycleV1, + result: FirstDisplayGptRenderResult + ) => boolean; + readonly recordFailure: (cycle: FirstDisplayGptBoundCycleV1) => boolean; + readonly retire: (cycle: FirstDisplayGptBoundCycleV1) => boolean; + readonly sweepCommittedArtifacts: () => number; + readonly sealTsAdmission: () => void; + readonly closeIngress: () => boolean; + readonly captureHandoff: () => FirstDisplayRenderCaptureV1 | undefined; + readonly detachCommittedArtifacts: () => boolean; + readonly dispose: () => void; +} + +/** Compact authenticated capability passed from the render slice to the inline owner. */ +export type FirstDisplayRenderBridgeCapabilityV1 = readonly [ + bind: FirstDisplayRenderBridgeV1['bind'], + recordGam: FirstDisplayRenderBridgeV1['recordGam'], + recordFailure: FirstDisplayRenderBridgeV1['recordFailure'], + retire: FirstDisplayRenderBridgeV1['retire'], + sweepCommittedArtifacts: FirstDisplayRenderBridgeV1['sweepCommittedArtifacts'], + sealTsAdmission: FirstDisplayRenderBridgeV1['sealTsAdmission'], + closeIngress: FirstDisplayRenderBridgeV1['closeIngress'], + captureHandoff: () => FirstDisplayRenderCaptureV1 | undefined, + detachCommittedArtifacts: FirstDisplayRenderBridgeV1['detachCommittedArtifacts'], + dispose: FirstDisplayRenderBridgeV1['dispose'], +]; + +/** Compact cross-artifact render state; live identity remains tuple-local until takeover. */ +export type FirstDisplayRenderCaptureV1 = readonly [ + artifacts: readonly (readonly [ + hostPosition: string | null, + hostPositionPriority: string | null, + identity: object, + kind: 'gpt_adm' | 'aps', + owner: 'trusted_server' | 'publisher', + slotId: string, + token: string, + ])[], + tombstones: readonly (readonly [ + kind: 'reservation' | 'ticket', + value: string, + expiresAtMs: number, + ordinal: number, + ])[], + clockEpochMs: number, + nextReservationOrdinal: number, + nextTicketOrdinal: number, +]; + +export interface FirstDisplayRenderHandoffArtifactV1 { + readonly hostPosition: string | null; + readonly hostPositionPriority: string | null; + readonly identity: object; + readonly kind: 'gpt_adm' | 'aps'; + readonly owner: 'trusted_server' | 'publisher'; + readonly slotId: string; + readonly token: string; +} + +export interface FirstDisplayRenderHandoffV1 { + readonly artifacts: readonly FirstDisplayRenderHandoffArtifactV1[]; + readonly clockEpochMs: number; + readonly nextReservationOrdinal: number; + readonly nextTicketOrdinal: number; + readonly tombstones: readonly Readonly<{ + kind: 'reservation' | 'ticket'; + value: string; + expiresAtMs: number; + ordinal: number; + }>[]; +} + +export interface FirstDisplayProjectedDriverOptionsV1 { + readonly batch: FirstDisplayBatchV1; + readonly gpt?: FirstDisplayGptProtocolV1; + readonly gptInput: readonly [ + browser: FirstDisplayGoogletagBatchInput[0], + clearTimer: FirstDisplayGoogletagBatchInput[1], + document: FirstDisplayGoogletagBatchInput[2], + setTimer: FirstDisplayGoogletagBatchInput[3], + diagnosticsActive?: FirstDisplayGoogletagBatchInput[5], + onNativeMutation?: FirstDisplayGoogletagBatchInput[6], + ]; + readonly renderer: FirstDisplayRenderBridgeV1; +} + +function sameCycle( + expected: FirstDisplayGptBoundCycleV1, + candidate: FirstDisplayGptBoundCycleV1 +): boolean { + return ( + candidate[6] === expected[6] && + candidate[0] === expected[0] && + candidate[1] === expected[1] && + candidate[2] === expected[2] && + candidate[5] === expected[5] && + candidate[4] === expected[4] && + candidate[3] === expected[3] && + candidate[7] === expected[7] + ); +} + +/** Join GPT's exact physical cycle to the independent APS/ADM render authority. */ +export function createFirstDisplayProjectedDriver( + options: FirstDisplayProjectedDriverOptionsV1 +): FirstDisplayDriver { + const expected = options.batch.outcomes.filter(({ kind }) => ACTION_KINDS.has(kind)); + const expectedBySlot = new Map(expected.map((outcome) => [outcome.slotId, outcome])); + const bound = new Map(); + const settled = new Set(); + const settledResults = new Map(); + const gptBatch = + expected.length === 0 + ? undefined + : options.gpt?.[2]( + Object.freeze([ + options.gptInput[0], + options.gptInput[1], + options.gptInput[2], + options.gptInput[3], + options.batch.projection, + options.gptInput[4], + options.gptInput[5], + ]) + ); + if (expected.length > 0 && !gptBatch) { + throw new TypeError('tsjs'); + } + let started = false; + let disposed = false; + let sealed = false; + let actionStarted = false; + let ingressClosed = false; + let handoffCaptured = false; + let committedArtifactsDetached = false; + let onTerminal: + | ((slotId: string, result: FirstDisplayTerminalResult, reason: string | null) => void) + | undefined; + + const settle = ( + slotId: string, + result: FirstDisplayTerminalResult, + reason: string | null + ): boolean => { + if ( + disposed || + !expectedBySlot.has(slotId) || + settled.has(slotId) || + !TERMINAL_RESULTS.has(result) + ) { + return false; + } + settled.add(slotId); + settledResults.set(slotId, result); + onTerminal?.(slotId, result, reason); + return true; + }; + + return Object.freeze({ + start: ( + outcomes: readonly FirstDisplayBatchOutcomeV1[], + onFirstAction: () => boolean, + terminal: (slotId: string, result: FirstDisplayTerminalResult, reason: string | null) => void + ): void => { + if (started || disposed) throw new TypeError('tsjs'); + if ( + outcomes.length !== expected.length || + outcomes.some((outcome, index) => { + const row = expected[index]; + return !row || row.slotId !== outcome.slotId || row.kind !== outcome.kind; + }) + ) { + throw new TypeError('tsjs'); + } + started = true; + onTerminal = terminal; + const accepted = gptBatch?.[0]([ + (cycle): void => { + const action = expectedBySlot.get(cycle[6]); + const bidIndex = options.batch.projection.bids.indexOf(cycle[0]); + const expectedPlacement = options.batch.projection.slots.find( + ({ slot }) => slot === cycle[6] + ); + if ( + !action || + bound.has(cycle[6]) || + bidIndex < 0 || + options.batch.projection.bids[bidIndex]?.slot !== cycle[6] || + cycle[5] !== expectedPlacement + ) { + settle(cycle[6], 'failed', 'gpt_request_failed'); + return; + } + bound.set(cycle[6], cycle); + if (!options.renderer.bind(cycle, (result, reason) => settle(cycle[6], result, reason))) { + settle(cycle[6], 'failed', 'internal_error'); + } + }, + (slotId, reason): void => { + const cycle = bound.get(slotId); + if (cycle) options.renderer.recordFailure(cycle); + settle(slotId, 'failed', reason); + }, + (): boolean => { + if (actionStarted || disposed) return false; + actionStarted = true; + return onFirstAction(); + }, + (cycle, result): void => { + const exact = bound.get(cycle[6]); + if (!exact || !sameCycle(exact, cycle)) { + settle(cycle[6], 'failed', 'gpt_request_failed'); + return; + } + if (!options.renderer.recordGam(exact, result)) { + settle(cycle[6], 'failed', 'gpt_request_failed'); + } + }, + (cycle): void => { + const exact = bound.get(cycle[6]); + if (exact && sameCycle(exact, cycle)) options.renderer.retire(exact); + }, + ]); + if (accepted !== true) throw new TypeError('tsjs'); + }, + sealTsAdmission: (): void => { + if (sealed || disposed || settled.size !== expected.length) { + throw new TypeError('tsjs'); + } + sealed = true; + options.renderer.sealTsAdmission(); + }, + sweepCommittedArtifacts: (): number => + disposed ? 0 : options.renderer.sweepCommittedArtifacts(), + closeIngress: (): boolean => { + if (disposed || !sealed || ingressClosed) return false; + const acceptedIds = [...settledResults.entries()] + .filter(([, result]) => result === 'accepted') + .map(([slotId]) => slotId); + if (gptBatch && !gptBatch[1](acceptedIds)) return false; + if (!options.renderer.closeIngress()) return false; + ingressClosed = true; + return true; + }, + captureHandoff: () => { + if (disposed || !ingressClosed || handoffCaptured) return undefined; + const gptCycles = gptBatch?.[2]() ?? Object.freeze([]); + const gptDiagnostics = + gptBatch?.[3]() ?? Object.freeze([Object.freeze([]), Object.freeze([]), 1, 0, 0] as const); + const render = options.renderer.captureHandoff(); + if (!render) return undefined; + const acceptedIds = new Set( + [...settledResults.entries()] + .filter(([, result]) => result === 'accepted') + .map(([slotId]) => slotId) + ); + const cycles = gptCycles.flatMap((captured) => { + const cycle = bound.get(captured[0]); + if ( + !cycle || + !acceptedIds.has(captured[0]) || + cycle[7] !== captured[4] || + cycle[4] !== captured[5] + ) { + return []; + } + const element = + cycle[1].id === captured[1] ? cycle[1] : options.gptInput[2].getElementById(captured[1]); + const ElementConstructor = options.gptInput[2].defaultView?.HTMLElement; + if (!ElementConstructor || !(element instanceof ElementConstructor)) return []; + return [ + Object.freeze([ + cycle[0], + element, + cycle[2], + captured[2], + cycle[4], + cycle[5], + cycle[6], + cycle[7], + captured[3], + ] as const), + ]; + }); + const diagnosticCycles = gptDiagnostics[0].filter((cycle) => acceptedIds.has(cycle[0])); + if ( + cycles.length !== acceptedIds.size || + diagnosticCycles.length !== cycles.length || + diagnosticCycles.some((cycle) => { + const physical = cycles.find((candidate) => candidate[6] === cycle[0]); + return !physical || physical[7] !== cycle[1]; + }) || + render[0].some((artifact) => !acceptedIds.has(artifact[5])) + ) { + return undefined; + } + const identities = [ + ...cycles.map((cycle) => cycle[4]), + ...render[0].map((artifact) => artifact[2]), + ]; + if (new Set(identities).size !== identities.length) return undefined; + handoffCaptured = true; + return Object.freeze({ + artifacts: Object.freeze( + render[0].map((artifact) => ({ + hostPosition: artifact[0], + hostPositionPriority: artifact[1], + identity: artifact[2], + kind: artifact[3], + owner: artifact[4], + slotId: artifact[5], + token: artifact[6], + })) + ), + clockEpochMs: render[2], + cycles: Object.freeze(cycles), + diagnosticCycles: Object.freeze( + diagnosticCycles.map((cycle) => ({ + slotId: cycle[0], + token: cycle[1], + nextCycleOrdinal: cycle[2], + unknownPriorCycle: cycle[3], + quarantines: cycle[4], + records: cycle[5].map((record) => ({ + ordinal: record[0], + responseIdentifier: record[1], + seen: record[2], + state: record[3], + })), + })) + ), + gptDiagnostics: Object.freeze({ + facts: Object.freeze([...gptDiagnostics[1]]), + overflowCount: gptDiagnostics[3], + dropCount: gptDiagnostics[4], + }), + identities: Object.freeze(identities), + nextTraceTokenOrdinal: gptDiagnostics[2], + nextReservationOrdinal: render[3], + nextTicketOrdinal: render[4], + tombstones: Object.freeze( + render[1].map((entry) => ({ + kind: entry[0], + value: entry[1], + expiresAtMs: entry[2], + ordinal: entry[3], + })) + ), + }); + }, + detachCommittedArtifacts: (): boolean => { + if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { + return false; + } + const acceptedIds = [...settledResults.entries()] + .filter(([, result]) => result === 'accepted') + .map(([slotId]) => slotId); + if (gptBatch && !gptBatch[4](acceptedIds)) return false; + if (!options.renderer.detachCommittedArtifacts()) return false; + committedArtifactsDetached = true; + return true; + }, + dispose: (): void => { + if (disposed) return; + disposed = true; + gptBatch?.[5](); + options.renderer.dispose(); + bound.clear(); + settledResults.clear(); + onTerminal = undefined; + }, + }); +} diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts b/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts new file mode 100644 index 000000000..ab2e060d2 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts @@ -0,0 +1,318 @@ +import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; +import { createFirstDisplayApsRenderStrategy } from '../render_bridge'; +import type { + FirstDisplayRenderOwnerOptionsV1, + FirstDisplayRenderStrategyV1, +} from '../render_journal'; + +export type FirstDisplayApsDocumentMessageV1 = + | Readonly<{ kind: 'document_accepted' }> + | Readonly<{ kind: 'runner_loaded' }> + | Readonly<{ kind: 'render_completed' }> + | Readonly<{ + kind: 'render_failed'; + reason: 'descriptor_invalid' | 'runner_no_load' | 'runner_failed'; + }>; + +export type FirstDisplayApsWindowMessageV1 = + | Readonly<{ kind: 'bootstrap_ready'; bootstrap: string }> + | Readonly<{ + kind: 'container_ready'; + bootstrap: string; + renderer: string; + }>; + +export interface FirstDisplayApsBootstrapPolicyV2 { + readonly creativeOrigin: string; + readonly tagType: 'iframe' | 'script'; +} + +export interface FirstDisplayApsPolicyV1 { + readonly version: 1; + readonly id: 'aps'; + readonly publisherOrigin: string; + readonly rendererUrl: string; + readonly sandbox: string; + readonly permanentSandbox: string; + readonly deadlines: Readonly<{ + documentAcceptanceMs: 3_000; + completionMs: 10_000; + }>; + readonly isBootstrapNonce: (candidate: unknown) => candidate is string; + readonly isRendererNonce: (candidate: unknown) => candidate is string; + readonly bootstrapPolicy: ( + renderer: unknown + ) => Readonly | undefined; + readonly parseDocumentMessage: ( + candidate: unknown, + expectedNonce: string + ) => FirstDisplayApsDocumentMessageV1 | undefined; + readonly parseWindowMessage: (candidate: unknown) => FirstDisplayApsWindowMessageV1 | undefined; + readonly createRenderStrategy: ( + options: FirstDisplayRenderOwnerOptionsV1 + ) => FirstDisplayRenderStrategyV1; +} + +export type FirstDisplayApsProtocolV1 = readonly [ + version: 1, + id: 'aps', + policy: FirstDisplayApsPolicyV1, +]; + +interface ApsInitialBindings { + readonly observe: (name: 'protocol_version', value: number) => void; + readonly publisherOrigin: string; + readonly register: (protocol: FirstDisplayApsProtocolV1) => () => void; +} + +const OPAQUE_ID = /^[bn]1_[A-Za-z0-9_-]{22}$/; +const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/; +const FAILURE_REASONS = new Set(['descriptor_invalid', 'runner_no_load', 'runner_failed']); +const SANDBOX = + 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; +const PERMANENT_SANDBOX = + 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation'; + +function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return undefined; + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + !ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) + ) { + return undefined; + } + const result: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result[key] = descriptor.value; + } + return result; + } catch { + return undefined; + } +} + +function bindings(candidate: unknown): ApsInitialBindings | undefined { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + !Object.isFrozen(candidate) + ) { + return undefined; + } + const fields = exactRecord(candidate, ['observe', 'publisherOrigin', 'register']); + if ( + !fields || + typeof fields.observe !== 'function' || + typeof fields.publisherOrigin !== 'string' || + typeof fields.register !== 'function' + ) { + return undefined; + } + const origin = new URL(fields.publisherOrigin); + const loopbackHttp = + origin.protocol === 'http:' && + (origin.hostname === 'localhost' || + origin.hostname === '[::1]' || + LOOPBACK_IPV4.test(origin.hostname)); + if ( + origin.origin !== fields.publisherOrigin || + (origin.protocol !== 'https:' && !loopbackHttp) || + origin.username !== '' || + origin.password !== '' + ) { + return undefined; + } + return fields as unknown as ApsInitialBindings; + } catch { + return undefined; + } +} + +function exactOpaqueId(candidate: unknown, prefix: 'b1_' | 'n1_'): candidate is string { + return typeof candidate === 'string' && candidate.startsWith(prefix) && OPAQUE_ID.test(candidate); +} + +function parseDocumentMessage( + candidate: unknown, + expectedNonce: string +): FirstDisplayApsDocumentMessageV1 | undefined { + if (!exactOpaqueId(expectedNonce, 'n1_')) return undefined; + const base = exactRecord(candidate, ['message', 'version', 'nonce']); + if (base && base.version === 1 && base.nonce === expectedNonce) { + if (base.message === 'TS APS Document Accepted') { + return Object.freeze({ kind: 'document_accepted' }); + } + if (base.message === 'TS APS Runner Loaded') { + return Object.freeze({ kind: 'runner_loaded' }); + } + if (base.message === 'TS APS Render Completed') { + return Object.freeze({ kind: 'render_completed' }); + } + } + const failed = exactRecord(candidate, ['message', 'version', 'nonce', 'reason']); + if ( + failed?.message === 'TS APS Render Failed' && + failed.version === 1 && + failed.nonce === expectedNonce && + typeof failed.reason === 'string' && + FAILURE_REASONS.has(failed.reason) + ) { + return Object.freeze({ + kind: 'render_failed', + reason: failed.reason as 'descriptor_invalid' | 'runner_no_load' | 'runner_failed', + }); + } + return undefined; +} + +function parseWindowMessage(candidate: unknown): FirstDisplayApsWindowMessageV1 | undefined { + if (typeof candidate !== 'string' || candidate.length > 4_096) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(candidate) as unknown; + } catch { + return undefined; + } + const bootstrap = exactRecord(parsed, ['message', 'version', 'bootstrapNonce']); + if ( + bootstrap?.message === 'TS APS Bootstrap Ready' && + bootstrap.version === 1 && + exactOpaqueId(bootstrap.bootstrapNonce, 'b1_') && + candidate === + JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce: bootstrap.bootstrapNonce, + }) + ) { + return Object.freeze({ kind: 'bootstrap_ready', bootstrap: bootstrap.bootstrapNonce }); + } + const container = exactRecord(parsed, ['message', 'version', 'bootstrapNonce', 'rendererNonce']); + if ( + container?.message === 'TS APS Container Ready' && + container.version === 1 && + exactOpaqueId(container.bootstrapNonce, 'b1_') && + exactOpaqueId(container.rendererNonce, 'n1_') && + candidate === + JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce: container.bootstrapNonce, + rendererNonce: container.rendererNonce, + }) + ) { + return Object.freeze({ + kind: 'container_ready', + bootstrap: container.bootstrapNonce, + renderer: container.rendererNonce, + }); + } + return undefined; +} + +function bootstrapPolicy( + candidate: unknown, + publisherOrigin: string +): Readonly | undefined { + try { + const renderer = exactRecord( + candidate, + Object.prototype.hasOwnProperty.call(candidate, 'creativeId') + ? [ + 'aaxResponse', + 'accountId', + 'bidId', + 'creativeId', + 'creativeUrl', + 'height', + 'tagType', + 'type', + 'version', + 'width', + ] + : [ + 'aaxResponse', + 'accountId', + 'bidId', + 'creativeUrl', + 'height', + 'tagType', + 'type', + 'version', + 'width', + ] + ); + if ( + !renderer || + renderer.type !== 'aps' || + renderer.version !== 1 || + (renderer.tagType !== 'iframe' && renderer.tagType !== 'script') || + typeof renderer.creativeUrl !== 'string' + ) { + return undefined; + } + const creative = new URL(renderer.creativeUrl); + if ( + creative.protocol !== 'https:' || + creative.hostname === '' || + creative.username !== '' || + creative.password !== '' || + creative.origin === publisherOrigin + ) { + return undefined; + } + return Object.freeze({ creativeOrigin: creative.origin, tagType: renderer.tagType }); + } catch { + return undefined; + } +} + +/** Register the exact APS reservation identity and renderer-document protocol. */ +export function installApsInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): readonly [version: 1, id: 'aps'] { + const value = bindings(candidate); + if (!value || typeof own !== 'function') throw new TypeError('tsjs'); + const publisherOrigin = value['publisherOrigin']; + const rendererUrl = new URL('/integrations/aps/renderer/v2', publisherOrigin).href; + const policy: FirstDisplayApsPolicyV1 = Object.freeze({ + version: 1, + id: 'aps', + publisherOrigin: value.publisherOrigin, + rendererUrl, + sandbox: SANDBOX, + permanentSandbox: PERMANENT_SANDBOX, + deadlines: Object.freeze({ + documentAcceptanceMs: 3_000, + completionMs: 10_000, + }), + isBootstrapNonce: (input: unknown): input is string => exactOpaqueId(input, 'b1_'), + isRendererNonce: (input: unknown): input is string => exactOpaqueId(input, 'n1_'), + bootstrapPolicy: (renderer: unknown) => bootstrapPolicy(renderer, value.publisherOrigin), + parseDocumentMessage, + parseWindowMessage, + createRenderStrategy: (options: FirstDisplayRenderOwnerOptionsV1) => + createFirstDisplayApsRenderStrategy(options, policy), + }); + const protocol: FirstDisplayApsProtocolV1 = Object.freeze([1, 'aps', policy]); + const release = value.register(protocol); + if (typeof release !== 'function') throw new TypeError('tsjs'); + own(release); + value.observe('protocol_version', 1); + return Object.freeze([1, 'aps']); +} diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/browser_route_owner.ts b/crates/trusted-server-js/lib/src/first_display/leaf/browser_route_owner.ts new file mode 100644 index 000000000..d79c6c2ed --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/leaf/browser_route_owner.ts @@ -0,0 +1,294 @@ +type BrowserRouteKind = 'script' | 'preload' | 'prefetch' | 'beacon' | 'fetch'; + +export interface FirstDisplayBrowserRouteRuleV1 { + readonly matches: (kind: BrowserRouteKind, url: string) => boolean; + readonly rewrite: (url: string) => string; +} + +export interface FirstDisplayBrowserRouteOwnerV1 { + readonly register: (rule: FirstDisplayBrowserRouteRuleV1, network?: boolean) => () => void; + readonly dispose: () => void; +} + +interface RouteRegistration { + readonly rule: FirstDisplayBrowserRouteRuleV1; + readonly network: boolean; +} + +function restoreOwnedProperty( + owner: object, + key: PropertyKey, + installed: unknown, + original: unknown +): void { + try { + if (Reflect.get(owner, key) === installed) Reflect.set(owner, key, original); + } catch { + // Browser-owned or publisher-hardened surfaces may reject restoration. + // Every other surface must still receive its independent best-effort restore. + } +} + +function route( + registrations: readonly RouteRegistration[], + kind: BrowserRouteKind, + url: string +): string { + let current = url; + for (const registration of registrations) { + if ((kind === 'beacon' || kind === 'fetch') && !registration.network) continue; + try { + if (registration.rule.matches(kind, current)) current = registration.rule.rewrite(current); + } catch { + // A malformed integration rule cannot suppress the remaining route owners. + } + } + return current; +} + +/** Create one document-scoped parser-time route owner shared by every selected slice. */ +export function createFirstDisplayBrowserRouteOwner( + routeDocument: Document, + browser: Window +): FirstDisplayBrowserRouteOwnerV1 { + const registrations: RouteRegistration[] = []; + const ElementConstructor = routeDocument.defaultView?.Element ?? Element; + const prototype = ElementConstructor.prototype; + const appendChild = prototype.appendChild; + const insertBefore = prototype.insertBefore; + const append = prototype.append; + const prepend = prototype.prepend; + const replaceChildren = prototype.replaceChildren; + const navigator = browser.navigator; + const sendBeacon = navigator.sendBeacon; + const fetch = browser.fetch; + let disposed = false; + let networkInstalled = false; + + const rewriteNode = (node: Node): void => { + if (node.nodeType !== Node.ELEMENT_NODE) return; + const element = node as Element; + if (element.tagName === 'SCRIPT') { + const script = element as HTMLScriptElement; + const source = script.getAttribute('src') ?? script.src; + const rewritten = source && route(registrations, 'script', source); + if (rewritten && rewritten !== source) script.src = rewritten; + return; + } + if (element.tagName !== 'LINK') return; + const link = element as HTMLLinkElement; + const rel = link.getAttribute('rel'); + if ((rel !== 'preload' && rel !== 'prefetch') || link.getAttribute('as') !== 'script') return; + const source = link.getAttribute('href') ?? link.href; + const rewritten = source && route(registrations, rel, source); + if (rewritten && rewritten !== source) link.href = rewritten; + }; + const rewriteTree = (node: Node): void => { + rewriteNode(node); + if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) + return; + for (const nested of (node as Element | DocumentFragment).querySelectorAll( + 'script[src],link[href]' + )) { + rewriteNode(nested); + } + }; + const rewriteVariadic = (nodes: readonly (Node | string)[]): void => { + for (const node of nodes) { + if (typeof node !== 'string') rewriteTree(node); + } + }; + const appendWrapper: typeof prototype.appendChild = function ( + this: Element, + node: T + ): T { + rewriteTree(node); + return Reflect.apply(appendChild, this, [node]) as T; + }; + const insertWrapper: typeof prototype.insertBefore = function ( + this: Element, + node: T, + child: Node | null + ): T { + rewriteTree(node); + return Reflect.apply(insertBefore, this, [node, child]) as T; + }; + const appendVariadicWrapper: typeof prototype.append = function ( + this: Element, + ...nodes: (Node | string)[] + ): void { + rewriteVariadic(nodes); + Reflect.apply(append, this, nodes); + }; + const prependWrapper: typeof prototype.prepend = function ( + this: Element, + ...nodes: (Node | string)[] + ): void { + rewriteVariadic(nodes); + Reflect.apply(prepend, this, nodes); + }; + const replaceChildrenWrapper: typeof prototype.replaceChildren = function ( + this: Element, + ...nodes: (Node | string)[] + ): void { + rewriteVariadic(nodes); + Reflect.apply(replaceChildren, this, nodes); + }; + const restoreDom = (): void => { + restoreOwnedProperty(prototype, 'appendChild', appendWrapper, appendChild); + restoreOwnedProperty(prototype, 'insertBefore', insertWrapper, insertBefore); + restoreOwnedProperty(prototype, 'append', appendVariadicWrapper, append); + restoreOwnedProperty(prototype, 'prepend', prependWrapper, prepend); + restoreOwnedProperty(prototype, 'replaceChildren', replaceChildrenWrapper, replaceChildren); + }; + + const Observer = routeDocument.defaultView?.MutationObserver; + let observer: MutationObserver | undefined; + try { + prototype.appendChild = appendWrapper; + prototype.insertBefore = insertWrapper; + prototype.append = appendVariadicWrapper; + prototype.prepend = prependWrapper; + prototype.replaceChildren = replaceChildrenWrapper; + observer = Observer + ? new Observer((records) => { + for (const record of records) { + for (const node of record.addedNodes) rewriteTree(node); + } + }) + : undefined; + observer?.observe(routeDocument.documentElement, { childList: true, subtree: true }); + } catch (error) { + try { + observer?.disconnect(); + } catch { + // Continue restoring every installed insertion surface. + } + restoreDom(); + throw error; + } + + const beaconWrapper = function (url: string | URL, data?: BodyInit | null): boolean { + return Reflect.apply(sendBeacon, navigator, [ + route(registrations, 'beacon', String(url)), + data, + ]); + }; + const fetchWrapper = function (input: RequestInfo | URL, init?: RequestInit): Promise { + let source: string | undefined; + if (typeof input === 'string' || input instanceof URL) source = String(input); + else if (typeof Request !== 'undefined' && input instanceof Request) source = input.url; + const rewritten = source && route(registrations, 'fetch', source); + let request = input; + if (rewritten && rewritten !== source) { + if (typeof Request !== 'undefined' && input instanceof Request) { + const requestInit: RequestInit & { duplex?: 'half' } = { + cache: input.cache, + credentials: input.credentials, + headers: input.headers, + integrity: input.integrity, + keepalive: input.keepalive, + method: input.method, + mode: input.mode, + redirect: input.redirect, + referrer: input.referrer, + referrerPolicy: input.referrerPolicy, + signal: input.signal, + }; + if (input.body !== null && input.method !== 'GET' && input.method !== 'HEAD') { + requestInit.body = input.body as BodyInit; + requestInit.duplex = 'half'; + } + request = new Request(rewritten, requestInit); + } else { + request = rewritten; + } + } + return Reflect.apply(fetch, browser, [request, init]); + }; + + const restoreNetwork = (): void => { + if (!networkInstalled) return; + networkInstalled = false; + restoreOwnedProperty(navigator, 'sendBeacon', beaconWrapper, sendBeacon); + restoreOwnedProperty(browser, 'fetch', fetchWrapper, fetch); + }; + const installNetwork = (): void => { + if (networkInstalled) return; + try { + if (typeof sendBeacon === 'function') navigator.sendBeacon = beaconWrapper; + if (typeof fetch === 'function') browser.fetch = fetchWrapper; + networkInstalled = true; + } catch (error) { + restoreOwnedProperty(navigator, 'sendBeacon', beaconWrapper, sendBeacon); + restoreOwnedProperty(browser, 'fetch', fetchWrapper, fetch); + throw error; + } + }; + const dispose = (): void => { + if (disposed) return; + disposed = true; + registrations.length = 0; + try { + observer?.disconnect(); + } catch { + // Generation latching makes an unremovable observer inert. + } + restoreDom(); + restoreNetwork(); + }; + + return Object.freeze({ + register: (rule: FirstDisplayBrowserRouteRuleV1, network = false): (() => void) => { + if ( + disposed || + !rule || + typeof rule.matches !== 'function' || + typeof rule.rewrite !== 'function' + ) { + throw new TypeError('tsjs'); + } + const registration = Object.freeze({ rule, network }); + registrations.push(registration); + try { + if (network) installNetwork(); + } catch (error) { + registrations.pop(); + if (registrations.length === 0) dispose(); + throw error; + } + let active = true; + return (): void => { + if (!active) return; + active = false; + const index = registrations.indexOf(registration); + if (index >= 0) registrations.splice(index, 1); + if (!registrations.some((entry) => entry.network)) restoreNetwork(); + if (registrations.length === 0) dispose(); + }; + }, + dispose, + }); +} + +let defaultOwner: FirstDisplayBrowserRouteOwnerV1 | undefined; +let defaultRegistrations = 0; + +/** Register through the document singleton used by direct unit consumers. */ +export function registerFirstDisplayBrowserRoute( + rule: FirstDisplayBrowserRouteRuleV1, + network = false +): () => void { + if (!defaultOwner) defaultOwner = createFirstDisplayBrowserRouteOwner(document, window); + const owner = defaultOwner; + const release = owner.register(rule, network); + defaultRegistrations += 1; + let active = true; + return (): void => { + if (!active) return; + active = false; + release(); + defaultRegistrations -= 1; + if (defaultRegistrations === 0 && defaultOwner === owner) defaultOwner = undefined; + }; +} diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/callback_capture.ts b/crates/trusted-server-js/lib/src/first_display/leaf/callback_capture.ts new file mode 100644 index 000000000..3320693cd --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/leaf/callback_capture.ts @@ -0,0 +1,167 @@ +import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; + +interface TestlightGlobal { + que?: unknown[]; + [key: string]: unknown; +} + +interface TestlightTarget { + testlight?: TestlightGlobal; +} + +interface TestlightInitialBindings { + readonly enqueue: (callback: () => void) => void; + readonly observe: (name: 'callback_count', count: number) => void; + readonly target: TestlightTarget; +} + +function snapshotBindings(candidate: unknown): TestlightInitialBindings | undefined { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + !Object.isFrozen(candidate) || + Reflect.ownKeys(candidate).length !== 3 + ) { + return undefined; + } + const values: Record = {}; + for (const key of ['enqueue', 'observe', 'target']) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + values[key] = descriptor.value; + } + if ( + typeof values.enqueue !== 'function' || + typeof values.observe !== 'function' || + typeof values.target !== 'object' || + values.target === null + ) { + return undefined; + } + return { + enqueue: values.enqueue as TestlightInitialBindings['enqueue'], + observe: values.observe as TestlightInitialBindings['observe'], + target: values.target as TestlightTarget, + }; + } catch { + return undefined; + } +} + +function ownQueueValues(candidate: unknown): unknown[] { + if (!Array.isArray(candidate)) return []; + const entries: Array = []; + try { + for (const key of Reflect.ownKeys(candidate)) { + if (typeof key !== 'string' || !/^(0|[1-9][0-9]*)$/.test(key)) continue; + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || index >= 4_294_967_295) continue; + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (descriptor?.enumerable && 'value' in descriptor) entries.push([index, descriptor.value]); + } + } catch { + return []; + } + entries.sort(([left], [right]) => left - right); + return entries.map(([, value]) => value); +} + +/** Capture preexisting and later Testlight callbacks into the bootstrap ingress once. */ +export function installTestlightInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): void { + const bindings = snapshotBindings(candidate); + if (!bindings || typeof own !== 'function') { + throw new TypeError('invalid Testlight initial bindings'); + } + const { enqueue, observe, target } = bindings; + const previousTargetDescriptor = Object.getOwnPropertyDescriptor(target, 'testlight'); + if (previousTargetDescriptor && !('value' in previousTargetDescriptor)) { + throw new TypeError('Testlight publisher global accessor is unsupported'); + } + const currentGlobal = previousTargetDescriptor?.value; + const global: TestlightGlobal = + typeof currentGlobal === 'object' && currentGlobal !== null ? currentGlobal : {}; + const createdGlobal = global !== currentGlobal; + if (createdGlobal && !Reflect.set(target, 'testlight', global)) { + throw new TypeError('Testlight publisher global is not writable'); + } + const previousQueueDescriptor = Object.getOwnPropertyDescriptor(global, 'que'); + if (previousQueueDescriptor && !('value' in previousQueueDescriptor)) { + throw new TypeError('Testlight publisher queue accessor is unsupported'); + } + const originalQueue = + previousQueueDescriptor && + 'value' in previousQueueDescriptor && + Array.isArray(previousQueueDescriptor.value) + ? previousQueueDescriptor.value + : undefined; + const queue = ownQueueValues(originalQueue); + if (originalQueue) originalQueue.length = 0; + Object.defineProperty(global, 'que', { + configurable: true, + enumerable: true, + value: queue, + writable: true, + }); + + let active = true; + own(() => { + if (!active) return; + active = false; + try { + if (Object.getOwnPropertyDescriptor(global, 'que')?.value === queue) { + if (previousQueueDescriptor) Object.defineProperty(global, 'que', previousQueueDescriptor); + else Reflect.deleteProperty(global, 'que'); + } + if ( + createdGlobal && + Object.getOwnPropertyDescriptor(target, 'testlight')?.value === global && + Reflect.ownKeys(global).length === 0 + ) { + if (previousTargetDescriptor) { + Object.defineProperty(target, 'testlight', previousTargetDescriptor); + } else { + Reflect.deleteProperty(target, 'testlight'); + } + } + } catch { + // Publisher replacement wins over provisional rollback. + } + }); + + let forwarded = 0; + const forward = (values: readonly unknown[]): void => { + for (const value of values) { + if (typeof value !== 'function') continue; + try { + enqueue(value as () => void); + } catch { + // One publisher callback or hostile ingress cannot block later callbacks. + } + forwarded += 1; + } + observe('callback_count', forwarded); + }; + const pending = ownQueueValues(queue); + queue.length = 0; + const nativePush = queue.push.bind(queue); + Object.defineProperty(queue, 'push', { + configurable: true, + enumerable: false, + value: (...values: unknown[]): number => { + if (!active) return nativePush(...values); + const length = nativePush(...values); + const later = ownQueueValues(queue); + queue.length = 0; + forward(later); + return length; + }, + writable: false, + }); + forward(pending); +} diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/config_guard.ts b/crates/trusted-server-js/lib/src/first_display/leaf/config_guard.ts new file mode 100644 index 000000000..fdab0fbeb --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/leaf/config_guard.ts @@ -0,0 +1,165 @@ +import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; + +interface DidomiConfig { + sdkPath?: string; + [key: string]: unknown; +} + +interface DidomiTarget { + didomiConfig?: DidomiConfig; + readonly location: { readonly origin: string }; +} + +interface DidomiInitialBindings { + readonly config: Readonly<{ proxyPath: string }>; + readonly observe: (name: 'sdk_path', value: string) => void; + readonly target: DidomiTarget; +} + +function exactDataRecord( + value: unknown, + keys: readonly string[] +): Record | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + !Object.isFrozen(value) + ) { + return undefined; + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + !ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) + ) { + return undefined; + } + const result: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result[key] = descriptor.value; + } + return result; + } catch { + return undefined; + } +} + +function snapshotBindings(candidate: unknown): DidomiInitialBindings | undefined { + const fields = exactDataRecord(candidate, ['config', 'observe', 'target']); + const config = exactDataRecord(fields?.config, ['proxyPath']); + if ( + !fields || + !config || + typeof config.proxyPath !== 'string' || + !config.proxyPath.startsWith('/') || + config.proxyPath.startsWith('//') || + config.proxyPath.startsWith('/\\') || + config.proxyPath.length > 2_048 || + config.proxyPath.includes('?') || + config.proxyPath.includes('#') || + typeof fields.observe !== 'function' || + typeof fields.target !== 'object' || + fields.target === null + ) { + return undefined; + } + const target = fields.target as DidomiTarget; + if (typeof target.location?.origin !== 'string') return undefined; + return { + config: Object.freeze({ proxyPath: config.proxyPath }), + observe: fields.observe as DidomiInitialBindings['observe'], + target, + }; +} + +function sameDescriptor( + left: PropertyDescriptor | undefined, + right: PropertyDescriptor | undefined +): boolean { + return Boolean( + left && + right && + 'value' in left && + 'value' in right && + left.value === right.value && + left.configurable === right.configurable && + left.enumerable === right.enumerable && + left.writable === right.writable + ); +} + +/** Install only Didomi's same-origin parser-time SDK path and own its rollback. */ +export function installDidomiInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): void { + const bindings = snapshotBindings(candidate); + if (!bindings || typeof own !== 'function') + throw new TypeError('invalid Didomi initial bindings'); + const { config, observe, target } = bindings; + const origin = new URL(target.location.origin).origin; + const parsed = new URL(config.proxyPath, origin); + if (parsed.origin !== origin || parsed.username !== '' || parsed.password !== '') { + throw new TypeError('Didomi proxy path must remain on the publisher origin'); + } + const installedPath = `${parsed.origin}${parsed.pathname}`; + const previousTargetDescriptor = Object.getOwnPropertyDescriptor(target, 'didomiConfig'); + if (previousTargetDescriptor && !('value' in previousTargetDescriptor)) { + throw new TypeError('Didomi publisher config accessor is unsupported'); + } + let publisherConfig = previousTargetDescriptor?.value as DidomiConfig | undefined; + const created = publisherConfig === undefined; + if (created) { + publisherConfig = {}; + if (!Reflect.set(target, 'didomiConfig', publisherConfig)) { + throw new TypeError('Didomi publisher config is not writable'); + } + } + if (typeof publisherConfig !== 'object' || publisherConfig === null) { + throw new TypeError('Didomi publisher config is invalid'); + } + const previousSdkDescriptor = Object.getOwnPropertyDescriptor(publisherConfig, 'sdkPath'); + if (previousSdkDescriptor && !('value' in previousSdkDescriptor)) { + throw new TypeError('Didomi sdkPath accessor is unsupported'); + } + if (!Reflect.set(publisherConfig, 'sdkPath', installedPath)) { + throw new TypeError('Didomi sdkPath is not writable'); + } + const installedSdkDescriptor = Object.getOwnPropertyDescriptor(publisherConfig, 'sdkPath'); + let active = true; + own(() => { + if (!active) return; + active = false; + try { + if (target.didomiConfig !== publisherConfig) return; + if ( + !sameDescriptor( + Object.getOwnPropertyDescriptor(publisherConfig, 'sdkPath'), + installedSdkDescriptor + ) + ) { + return; + } + if (previousSdkDescriptor) { + Object.defineProperty(publisherConfig, 'sdkPath', previousSdkDescriptor); + } else { + Reflect.deleteProperty(publisherConfig, 'sdkPath'); + } + if (created && Reflect.ownKeys(publisherConfig).length === 0) { + if (previousTargetDescriptor) { + Object.defineProperty(target, 'didomiConfig', previousTargetDescriptor); + } else { + Reflect.deleteProperty(target, 'didomiConfig'); + } + } + } catch { + // Publisher replacement wins over provisional rollback. + } + }); + observe('sdk_path', installedPath); +} diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/consent_snapshot.ts b/crates/trusted-server-js/lib/src/first_display/leaf/consent_snapshot.ts new file mode 100644 index 000000000..ed6948ace --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/leaf/consent_snapshot.ts @@ -0,0 +1,543 @@ +import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; + +export type FirstDisplayConsentRouteKindV1 = 'script' | 'preload' | 'prefetch'; + +export interface FirstDisplayConsentRouteRuleV1 { + readonly id: 'sourcepoint'; + readonly matches: (kind: FirstDisplayConsentRouteKindV1, url: string) => boolean; + readonly rewrite: (url: string) => string; +} + +interface CookieDocument { + cookie: string; +} + +interface StorageReader { + readonly length: number; + readonly getItem: (key: string) => string | null; + readonly key: (index: number) => string | null; +} + +interface SourcepointInitialBindings { + readonly config: Readonly<{ rewriteSdk: boolean }>; + readonly document: CookieDocument; + readonly observe: (name: string, value: string | number) => void; + readonly origin: string; + readonly registerRoute: (rule: FirstDisplayConsentRouteRuleV1) => () => void; + readonly storage: StorageReader; +} + +interface OsanoInitialBindings { + readonly clearTimer: (handle: unknown) => void; + readonly document: CookieDocument; + readonly observe: (name: string, value: string | number) => void; + readonly setTimer: (callback: () => void, delayMs: number) => unknown; + readonly target: OsanoTarget; +} + +interface OsanoTarget { + readonly __uspapi?: ( + command: 'getUSPData', + version: 1, + callback: (data?: unknown, success?: boolean) => void + ) => void; + readonly __gpp?: (command: 'ping', callback: (data?: unknown, success?: boolean) => void) => void; + readonly __tcfapi?: ( + command: 'getTCData', + version: 2, + callback: (data?: unknown, success?: boolean) => void + ) => void; +} + +interface ConsentWrite { + readonly name: string; + readonly value: string; +} + +interface ConsentResult { + readonly writes: readonly ConsentWrite[]; + readonly clears: readonly string[]; +} + +const SCRIPT_KINDS = new Set(['script', 'preload', 'prefetch']); +const OSANO_MARKER = '_ts_consent_src'; +const OSANO_VALUE = 'osano'; +const OSANO_TARGETS = ['us_privacy', '__gpp', '__gpp_sid', 'euconsent-v2'] as const; +const SOURCEPOINT_MARKER = '_ts_gpp_src'; +const SOURCEPOINT_VALUE = 'sp'; +const MAX_STORAGE_ENTRIES = 512; + +function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + !Object.isFrozen(value) + ) { + return undefined; + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + !ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) + ) { + return undefined; + } + const result: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result[key] = descriptor.value; + } + return result; + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readCookie(document: CookieDocument, name: string): string | undefined { + try { + const prefix = `${name}=`; + return document.cookie + .split('; ') + .find((entry) => entry.startsWith(prefix)) + ?.slice(prefix.length); + } catch { + return undefined; + } +} + +function writeCookie(document: CookieDocument, name: string, value: string): void { + document.cookie = `${name}=${value}; Path=/; Secure; SameSite=Lax`; +} + +function clearCookie(document: CookieDocument, name: string): void { + document.cookie = `${name}=; Path=/; Secure; SameSite=Lax; Max-Age=0`; +} + +function createCookieOwner(document: CookieDocument): { + readonly clear: (name: string) => void; + readonly dispose: () => void; + readonly write: (name: string, value: string) => void; +} { + const previous = new Map(); + const installed = new Map(); + const remember = (name: string): void => { + if (!previous.has(name)) previous.set(name, readCookie(document, name)); + }; + return Object.freeze({ + clear: (name: string): void => { + remember(name); + clearCookie(document, name); + installed.set(name, undefined); + }, + dispose: (): void => { + for (const [name, installedValue] of [...installed].reverse()) { + const current = readCookie(document, name); + if (current !== installedValue) continue; + const previousValue = previous.get(name); + if (previousValue === undefined) clearCookie(document, name); + else writeCookie(document, name, previousValue); + } + installed.clear(); + previous.clear(); + }, + write: (name: string, value: string): void => { + remember(name); + writeCookie(document, name, value); + installed.set(name, value); + }, + }); +} + +function sourcepointBindings(candidate: unknown): SourcepointInitialBindings | undefined { + const fields = exactRecord(candidate, [ + 'config', + 'document', + 'observe', + 'origin', + 'registerRoute', + 'storage', + ]); + const config = exactRecord(fields?.config, ['rewriteSdk']); + if ( + !fields || + !config || + typeof config.rewriteSdk !== 'boolean' || + typeof fields.document !== 'object' || + fields.document === null || + typeof (fields.document as CookieDocument).cookie !== 'string' || + typeof fields.observe !== 'function' || + typeof fields.origin !== 'string' || + typeof fields.registerRoute !== 'function' || + typeof fields.storage !== 'object' || + fields.storage === null + ) { + return undefined; + } + const storage = fields.storage as StorageReader; + if ( + typeof storage.length !== 'number' || + typeof storage.key !== 'function' || + typeof storage.getItem !== 'function' + ) { + return undefined; + } + try { + if (new URL(fields.origin).origin !== fields.origin) return undefined; + } catch { + return undefined; + } + return { + config: Object.freeze({ rewriteSdk: config.rewriteSdk }), + document: fields.document as CookieDocument, + observe: fields.observe as SourcepointInitialBindings['observe'], + origin: fields.origin, + registerRoute: fields.registerRoute as SourcepointInitialBindings['registerRoute'], + storage, + }; +} + +function normalizeSourcepointUrl(url: string): URL | undefined { + const trimmed = url.trim(); + if (!trimmed) return undefined; + try { + return new URL( + trimmed.startsWith('//') + ? `https:${trimmed}` + : /^https?:\/\//.test(trimmed) + ? trimmed + : `https://${trimmed}` + ); + } catch { + return undefined; + } +} + +function sourcepointConsent( + storage: StorageReader +): Readonly<{ applicableSections?: readonly number[]; gppString: string }> | undefined { + let length: number; + try { + length = Math.min(Math.max(0, storage.length), MAX_STORAGE_ENTRIES); + } catch { + return undefined; + } + for (let index = 0; index < length; index += 1) { + let key: string | null; + let raw: string | null; + try { + key = storage.key(index); + if (!key?.startsWith('_sp_user_consent_')) continue; + raw = storage.getItem(key); + } catch { + continue; + } + if (!raw) continue; + try { + const payload = JSON.parse(raw); + const gppData = payload?.gppData; + if (typeof gppData?.gppString === 'string' && gppData.gppString.length > 0) { + return Object.freeze({ + gppString: gppData.gppString, + ...(Array.isArray(gppData.applicableSections) && + gppData.applicableSections.every((value: unknown) => typeof value === 'number') + ? { applicableSections: Object.freeze([...gppData.applicableSections]) } + : {}), + }); + } + for (const [name, section] of Object.entries(payload ?? {})) { + if (name === 'gppData' || !isRecord(section)) continue; + const consentString = section.consentString; + if (typeof consentString !== 'string' || !consentString.includes('~')) continue; + let applicableSections: readonly number[] | undefined; + if ( + Array.isArray(section.applicableSections) && + section.applicableSections.every((value) => typeof value === 'number') + ) { + applicableSections = Object.freeze([...section.applicableSections]); + } else if (Array.isArray(section.consentStrings)) { + const ids = section.consentStrings + .map((entry: unknown) => (isRecord(entry) ? entry.sectionId : undefined)) + .filter((value: unknown): value is number => typeof value === 'number'); + if (ids.length > 0) applicableSections = Object.freeze(ids); + } + return Object.freeze({ + gppString: consentString, + ...(applicableSections ? { applicableSections } : {}), + }); + } + } catch { + // Continue to the next origin-scoped Sourcepoint payload. + } + } + return undefined; +} + +/** Own Sourcepoint's one-shot SDK route and initial GPP storage mirror. */ +export function installSourcepointInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): void { + const bindings = sourcepointBindings(candidate); + if (!bindings || typeof own !== 'function') { + throw new TypeError('invalid Sourcepoint initial bindings'); + } + if (bindings.config.rewriteSdk) { + const release = bindings.registerRoute( + Object.freeze({ + id: 'sourcepoint' as const, + matches: (kind: FirstDisplayConsentRouteKindV1, url: string) => + SCRIPT_KINDS.has(kind) && + normalizeSourcepointUrl(url)?.hostname === 'cdn.privacy-mgmt.com', + rewrite: (url: string) => { + const parsed = normalizeSourcepointUrl(url); + return parsed + ? `${bindings.origin}/integrations/sourcepoint/cdn${parsed.pathname}${parsed.search}` + : url; + }, + }) + ); + if (typeof release !== 'function') throw new TypeError('invalid Sourcepoint route disposer'); + own(release); + } + + const cookies = createCookieOwner(bindings.document); + own(cookies.dispose); + const consent = sourcepointConsent(bindings.storage); + const marker = readCookie(bindings.document, SOURCEPOINT_MARKER); + if (!consent) { + if (marker === SOURCEPOINT_VALUE) { + cookies.clear('__gpp'); + cookies.clear('__gpp_sid'); + cookies.clear(SOURCEPOINT_MARKER); + } + bindings.observe('gpp_snapshot', 0); + return; + } + const existingGpp = readCookie(bindings.document, '__gpp'); + if (existingGpp && existingGpp !== consent.gppString && marker !== SOURCEPOINT_VALUE) { + bindings.observe('gpp_snapshot', 0); + return; + } + cookies.write(SOURCEPOINT_MARKER, SOURCEPOINT_VALUE); + cookies.write('__gpp', consent.gppString); + if (consent.applicableSections && consent.applicableSections.length > 0) { + cookies.write('__gpp_sid', consent.applicableSections.join(',')); + } else { + cookies.clear('__gpp_sid'); + } + bindings.observe('gpp_snapshot', consent.gppString.length); +} + +function osanoBindings(candidate: unknown): OsanoInitialBindings | undefined { + const fields = exactRecord(candidate, [ + 'clearTimer', + 'document', + 'observe', + 'setTimer', + 'target', + ]); + if ( + !fields || + typeof fields.clearTimer !== 'function' || + typeof fields.document !== 'object' || + fields.document === null || + typeof (fields.document as CookieDocument).cookie !== 'string' || + typeof fields.observe !== 'function' || + typeof fields.setTimer !== 'function' || + typeof fields.target !== 'object' || + fields.target === null + ) { + return undefined; + } + return fields as unknown as OsanoInitialBindings; +} + +function emptyResult(): ConsentResult { + return Object.freeze({ writes: Object.freeze([]), clears: Object.freeze([]) }); +} + +function canWriteOsano(document: CookieDocument): boolean { + const marker = readCookie(document, OSANO_MARKER); + if (marker === OSANO_VALUE) return true; + if (marker !== undefined) return false; + return OSANO_TARGETS.every((name) => readCookie(document, name) === undefined); +} + +/** Own the initial one-shot Osano USP/GPP/TCF snapshot, without later lifecycle hooks. */ +export function installOsanoInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): void { + const bindings = osanoBindings(candidate); + if (!bindings || typeof own !== 'function') throw new TypeError('invalid Osano initial bindings'); + + const cookies = createCookieOwner(bindings.document); + const timers = new Set(); + let active = true; + own(() => { + if (!active) return; + active = false; + for (const timer of timers) { + try { + bindings.clearTimer(timer); + } catch { + // Continue releasing the remaining initial API timeouts. + } + } + timers.clear(); + cookies.dispose(); + }); + + const results: ConsentResult[] = []; + let remaining = 3; + const finish = (result: ConsentResult): void => { + if (!active) return; + results.push(result); + remaining -= 1; + if (remaining !== 0) return; + const writes = results.flatMap((entry) => entry.writes); + const clears = results.flatMap((entry) => entry.clears); + if ((writes.length === 0 && clears.length === 0) || !canWriteOsano(bindings.document)) { + bindings.observe('consent_snapshot', 0); + return; + } + const writtenNames = new Set(writes.map(({ name }) => name)); + for (const name of clears) if (!writtenNames.has(name)) cookies.clear(name); + for (const { name, value } of writes) cookies.write(name, value); + const hasTarget = OSANO_TARGETS.some( + (name) => readCookie(bindings.document, name) !== undefined + ); + if (hasTarget) cookies.write(OSANO_MARKER, OSANO_VALUE); + else if (readCookie(bindings.document, OSANO_MARKER) === OSANO_VALUE) { + cookies.clear(OSANO_MARKER); + } + bindings.observe('consent_snapshot', writes.length + clears.length); + }; + + const invoke = ( + available: boolean, + call: (callback: (data?: unknown, success?: boolean) => void) => void, + parse: (data: unknown, success: boolean | undefined) => ConsentResult + ): void => { + if (!available) { + finish(emptyResult()); + return; + } + let settled = false; + const timerBox: { value?: unknown } = {}; + const done = (result: ConsentResult): void => { + if (settled) return; + settled = true; + if (timerBox.value !== undefined) { + timers.delete(timerBox.value); + try { + bindings.clearTimer(timerBox.value); + } catch { + // Timer cancellation failure does not permit a second settlement. + } + } + finish(result); + }; + const handle = bindings.setTimer(() => done(emptyResult()), 500); + timerBox.value = handle; + if (settled) { + try { + bindings.clearTimer(handle); + } catch { + // A synchronous timer cannot reopen an already-settled signal. + } + } else { + timers.add(handle); + } + try { + call((data, success) => { + try { + done(parse(data, success)); + } catch { + done(emptyResult()); + } + }); + } catch { + done(emptyResult()); + } + }; + + const usp = bindings.target.__uspapi; + invoke( + typeof usp === 'function', + (callback) => usp?.('getUSPData', 1, callback), + (data, success) => { + if (success === false || !isRecord(data)) return emptyResult(); + if ('uspString' in data && typeof data.uspString !== 'string') return emptyResult(); + return typeof data.uspString === 'string' && data.uspString.length > 0 + ? Object.freeze({ + writes: Object.freeze([{ name: 'us_privacy', value: data.uspString }]), + clears: Object.freeze([]), + }) + : emptyResult(); + } + ); + + const gpp = bindings.target.__gpp; + invoke( + typeof gpp === 'function', + (callback) => gpp?.('ping', callback), + (data, success) => { + if (success === false || !isRecord(data) || data.signalStatus !== 'ready') { + return emptyResult(); + } + if ('gppString' in data && typeof data.gppString !== 'string') return emptyResult(); + if ( + 'applicableSections' in data && + data.applicableSections !== undefined && + (!Array.isArray(data.applicableSections) || + !data.applicableSections.every((value) => typeof value === 'number')) + ) { + return emptyResult(); + } + if (typeof data.gppString !== 'string' || data.gppString.length === 0) { + return emptyResult(); + } + const sections = data.applicableSections as number[] | undefined; + const includeSections = + Array.isArray(sections) && sections.length > 0 && !sections.includes(-1); + return Object.freeze({ + writes: Object.freeze([ + { name: '__gpp', value: data.gppString }, + ...(includeSections ? [{ name: '__gpp_sid', value: sections.join(',') }] : []), + ]), + clears: Object.freeze(includeSections ? [] : ['__gpp_sid']), + }); + } + ); + + const tcf = bindings.target.__tcfapi; + invoke( + typeof tcf === 'function', + (callback) => tcf?.('getTCData', 2, callback), + (data, success) => { + if ( + success === false || + !isRecord(data) || + !['tcloaded', 'useractioncomplete'].includes(data.eventStatus as string) || + ('tcString' in data && typeof data.tcString !== 'string') || + typeof data.tcString !== 'string' || + data.tcString.length === 0 + ) { + return emptyResult(); + } + return Object.freeze({ + writes: Object.freeze([{ name: 'euconsent-v2', value: data.tcString }]), + clears: Object.freeze([]), + }); + } + ); +} diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/context_snapshot.ts b/crates/trusted-server-js/lib/src/first_display/leaf/context_snapshot.ts new file mode 100644 index 000000000..73b3317f9 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/leaf/context_snapshot.ts @@ -0,0 +1,277 @@ +import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; + +export type FirstDisplayContextRouteKindV1 = 'script' | 'preload' | 'prefetch'; + +export interface FirstDisplayContextRouteRuleV1 { + readonly id: 'permutive'; + readonly matches: (kind: FirstDisplayContextRouteKindV1, url: string) => boolean; + readonly rewrite: (url: string) => string; +} + +const CONFIG_FIELDS = [ + 'apiHost', + 'apiProtocol', + 'cdnBaseUrl', + 'cdnProtocol', + 'secureSignalsApiHost', + 'segmentSyncApiHost', +] as const; +const SCRIPT_KINDS = new Set(['script', 'preload', 'prefetch']); +const MAX_SEGMENTS = 100; +const MAX_SEGMENT_LENGTH = 256; +const MAX_SERIALIZED_SEGMENTS_LENGTH = 4_096; + +type ConfigField = (typeof CONFIG_FIELDS)[number]; +type PermutiveConfig = Record; + +interface PermutiveSdk { + readonly config: PermutiveConfig; +} + +interface PermutiveInitialBindings { + readonly clearTimer: (handle: unknown) => void; + readonly getSdk: () => PermutiveSdk | undefined; + readonly host: string; + readonly observe: (name: string, value: string | number) => void; + readonly origin: string; + readonly protocol: 'http:' | 'https:'; + readonly readStorage: (key: string) => string | null; + readonly registerRoute: (rule: FirstDisplayContextRouteRuleV1) => () => void; + readonly setTimer: (callback: () => void, delayMs: number) => unknown; +} + +function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + !Object.isFrozen(value) + ) { + return undefined; + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + !ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) + ) { + return undefined; + } + const fields: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + fields[key] = descriptor.value; + } + return fields; + } catch { + return undefined; + } +} + +function snapshotBindings(candidate: unknown): PermutiveInitialBindings | undefined { + const fields = exactRecord(candidate, [ + 'clearTimer', + 'getSdk', + 'host', + 'observe', + 'origin', + 'protocol', + 'readStorage', + 'registerRoute', + 'setTimer', + ]); + if ( + !fields || + typeof fields.clearTimer !== 'function' || + typeof fields.getSdk !== 'function' || + typeof fields.host !== 'string' || + fields.host.length === 0 || + fields.host.length > 255 || + fields.host.includes('/') || + typeof fields.observe !== 'function' || + typeof fields.origin !== 'string' || + !['http:', 'https:'].includes(fields.protocol as string) || + typeof fields.readStorage !== 'function' || + typeof fields.registerRoute !== 'function' || + typeof fields.setTimer !== 'function' + ) { + return undefined; + } + try { + const origin = new URL(fields.origin).origin; + if (origin !== fields.origin) return undefined; + if (new URL(origin).protocol !== fields.protocol) return undefined; + } catch { + return undefined; + } + return fields as unknown as PermutiveInitialBindings; +} + +function normalizedSegments(candidate: unknown): readonly string[] { + if (!Array.isArray(candidate) || candidate.length === 0) return Object.freeze([]); + const values: string[] = []; + for (let index = 0; index < candidate.length && values.length < MAX_SEGMENTS; index += 1) { + const value = candidate[index]; + if (typeof value !== 'string' && typeof value !== 'number') continue; + const normalized = String(value); + if (normalized.length === 0 || normalized.length > MAX_SEGMENT_LENGTH) continue; + const candidateValues = [...values, normalized]; + if (JSON.stringify(candidateValues).length > MAX_SERIALIZED_SEGMENTS_LENGTH) break; + values.push(normalized); + } + return Object.freeze(values); +} + +/** Parse the exact current-main Permutive storage shapes without importing its persistent owner. */ +export function snapshotPermutiveInitialSegments(raw: string | null): readonly string[] { + if (!raw) return Object.freeze([]); + try { + const data = JSON.parse(raw); + const primary = normalizedSegments(data?.core?.cohorts?.all); + if (primary.length > 0) return primary; + const uploads = data?.eventPublication?.eventUpload; + if (!Array.isArray(uploads)) return Object.freeze([]); + for (let index = uploads.length - 1; index >= 0; index -= 1) { + const entry = uploads[index]; + if (!Array.isArray(entry) || entry.length < 2) continue; + const fallback = normalizedSegments(entry[1]?.event?.properties?.segments); + if (fallback.length > 0) return fallback; + } + } catch { + // Malformed or hostile storage is equivalent to no context. + } + return Object.freeze([]); +} + +function isPermutiveSdkUrl(url: string): boolean { + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + return ( + parsed.protocol === 'https:' && + (hostname === 'cdn.permutive.com' || hostname.endsWith('.edge.permutive.app')) && + parsed.pathname.toLowerCase().endsWith('-web.js') && + parsed.search === '' && + parsed.hash === '' + ); + } catch { + return false; + } +} + +function bestEffort(action: () => void): void { + try { + action(); + } catch { + // Independent provisional resources must continue releasing in reverse order. + } +} + +/** Own Permutive's one-shot route, auction context, and bounded SDK config readiness. */ +export function installPermutiveInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): void { + const bindings = snapshotBindings(candidate); + if (!bindings || typeof own !== 'function') { + throw new TypeError('invalid Permutive initial bindings'); + } + + const releaseRoute = bindings.registerRoute( + Object.freeze({ + id: 'permutive' as const, + matches: (kind: FirstDisplayContextRouteKindV1, url: string) => + SCRIPT_KINDS.has(kind) && isPermutiveSdkUrl(url), + rewrite: () => `${bindings.origin}/integrations/permutive/sdk`, + }) + ); + if (typeof releaseRoute !== 'function') throw new TypeError('invalid Permutive route disposer'); + own(releaseRoute); + + let initialSegments: readonly string[] = Object.freeze([]); + try { + initialSegments = snapshotPermutiveInitialSegments(bindings.readStorage('permutive-app')); + } catch { + // Hostile storage is equivalent to no initial Permutive context. + } + if (initialSegments.length > 0) { + bindings.observe('segments', JSON.stringify(initialSegments)); + } + + let active = true; + let timer: unknown; + let ownedConfig: PermutiveConfig | undefined; + let installedValues: PermutiveConfig | undefined; + let previousValues: PermutiveConfig | undefined; + own(() => { + if (!active) return; + active = false; + if (timer !== undefined) { + bestEffort(() => bindings.clearTimer(timer)); + timer = undefined; + } + const config = ownedConfig; + const installed = installedValues; + const previous = previousValues; + if (!config || !installed || !previous) return; + for (const field of CONFIG_FIELDS) { + bestEffort(() => { + if (config[field] === installed[field]) config[field] = previous[field]; + }); + } + }); + + const installConfig = (config: PermutiveConfig): boolean => { + const protocol = bindings.protocol === 'https:' ? 'https' : 'http'; + const next: PermutiveConfig = { + apiHost: `${bindings.host}/integrations/permutive/api`, + apiProtocol: protocol, + cdnBaseUrl: `${bindings.host}/integrations/permutive/cdn`, + cdnProtocol: protocol, + secureSignalsApiHost: `${bindings.host}/integrations/permutive/secure-signal`, + segmentSyncApiHost: `${bindings.host}/integrations/permutive/sync`, + }; + const previous = {} as PermutiveConfig; + const written: ConfigField[] = []; + try { + for (const field of CONFIG_FIELDS) previous[field] = config[field]; + for (const field of CONFIG_FIELDS) { + config[field] = next[field]; + written.push(field); + } + } catch { + for (const field of written.reverse()) { + bestEffort(() => { + if (config[field] === next[field]) config[field] = previous[field]; + }); + } + return false; + } + ownedConfig = config; + installedValues = next; + previousValues = previous; + bindings.observe('sdk_config', bindings.host); + return true; + }; + + let attempts = 0; + const check = (): void => { + timer = undefined; + if (!active) return; + attempts += 1; + try { + const sdk = bindings.getSdk(); + if (sdk?.config && installConfig(sdk.config)) return; + } catch { + // An unreadable SDK is treated as not ready until the bounded deadline. + } + if (attempts >= 50) { + bindings.observe('readiness_timeout', attempts); + return; + } + timer = bindings.setTimer(check, 50); + }; + check(); +} diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/creative_guard.ts b/crates/trusted-server-js/lib/src/first_display/leaf/creative_guard.ts new file mode 100644 index 000000000..9dbd38bf6 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/leaf/creative_guard.ts @@ -0,0 +1,83 @@ +import type { CreativeBootV1 } from '../../core/types'; +import { + createCreativeStartup, + type CreativeStartupOptions, +} from '../../integrations/creative/startup'; +import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; + +interface CreativeInitialBindings extends Pick< + CreativeStartupOptions, + 'document' | 'installClickGuard' | 'installDynamicIframeProxy' | 'installDynamicImageProxy' +> { + readonly observe: (name: 'guard_count', value: number) => void; +} + +function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + !Object.isFrozen(value) + ) { + return undefined; + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + !ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) + ) { + return undefined; + } + const result: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result[key] = descriptor.value; + } + return result; + } catch { + return undefined; + } +} + +function snapshotConfig(candidate: unknown): Readonly | undefined { + const config = exactRecord(candidate, ['version', 'enabled', 'clickGuard', 'renderGuard']); + if ( + !config || + config.version !== 1 || + config.enabled !== true || + typeof config.clickGuard !== 'boolean' || + typeof config.renderGuard !== 'boolean' || + (!config.clickGuard && !config.renderGuard) + ) { + return undefined; + } + return Object.freeze({ + version: 1, + enabled: true, + clickGuard: config.clickGuard, + renderGuard: config.renderGuard, + }); +} + +/** Install the selected parser-time creative guards under the agent rollback owner. */ +export function installCreativeInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'], + configCandidate: unknown +): void { + const bindings = candidate as CreativeInitialBindings; + const config = snapshotConfig(configCandidate); + if (!config || typeof own !== 'function') throw new TypeError('tsjs'); + const startup = createCreativeStartup({ + document: bindings.document, + installClickGuard: bindings.installClickGuard, + installDynamicIframeProxy: bindings.installDynamicIframeProxy, + installDynamicImageProxy: bindings.installDynamicImageProxy, + }); + const release = startup.activate(config); + own(release); + bindings.observe('guard_count', (config.clickGuard ? 1 : 0) + (config.renderGuard ? 2 : 0)); +} diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts b/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts new file mode 100644 index 000000000..50a3c7b2c --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts @@ -0,0 +1,165 @@ +import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; +import type { + FirstDisplayGoogletagBatch, + FirstDisplayGoogletagBatchCallbacks, + FirstDisplayGoogletagBatchInput, + FirstDisplayGptCaptureCycleV1, + FirstDisplayGptDiagnosticsHandoffV1, +} from '../adapters/googletag'; +import { enqueueFirstDisplayGamAttribution } from '../adapters/googletag'; + +export type { FirstDisplayGptCaptureCycleV1 } from '../adapters/googletag'; + +export interface FirstDisplayGptRequestPlanV1 { + readonly operations: readonly ('display' | 'refresh')[]; + readonly requestOperation: 0 | 1; +} + +export type FirstDisplayGptProtocolV1 = readonly [ + version: 1, + id: 'gpt', + createBatch: (input: FirstDisplayGoogletagBatchInput) => FirstDisplayGptCapabilityV1, +]; + +export type FirstDisplayGptDiagnosticsCaptureV1 = FirstDisplayGptDiagnosticsHandoffV1; + +export type FirstDisplayGptCapabilityV1 = readonly [ + start: (callbacks: FirstDisplayGoogletagBatchCallbacks) => boolean, + closeIngress: (committedSlotIds: readonly string[]) => boolean, + captureHandoff: () => readonly FirstDisplayGptCaptureCycleV1[] | undefined, + captureDiagnosticsHandoff: () => FirstDisplayGptDiagnosticsCaptureV1 | undefined, + detachCommittedSlots: (slotIds: readonly string[]) => boolean, + dispose: () => void, +]; + +export interface FirstDisplayGptBatchPolicyV1 { + readonly deadlines: Readonly<{ + externalReadyMs: 10_000; + requestStartMs: 3_000; + completionMs: 10_000; + }>; + readonly requestPlan: (candidate: unknown) => FirstDisplayGptRequestPlanV1 | undefined; + readonly classifyRenderEnded: (candidate: unknown) => 'gam_empty' | 'nonempty_gam' | undefined; +} + +interface GptInitialBindings { + readonly browser: Window & { googletag?: unknown }; + readonly observe: (name: 'gam' | 'v', value: boolean | number) => void; + readonly register: (protocol: FirstDisplayGptProtocolV1) => () => void; +} + +export type FirstDisplayGptBatchFactoryV1 = ( + input: FirstDisplayGoogletagBatchInput, + policy: FirstDisplayGptBatchPolicyV1 +) => FirstDisplayGoogletagBatch; + +function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return undefined; + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + !ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) + ) { + return undefined; + } + const result: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result[key] = descriptor.value; + } + return result; + } catch { + return undefined; + } +} + +function requestPlan(candidate: unknown): FirstDisplayGptRequestPlanV1 | undefined { + const fields = exactRecord(candidate, ['initialLoadDisabled', 'ownership']); + if ( + !fields || + !Object.isFrozen(candidate) || + typeof fields.initialLoadDisabled !== 'boolean' || + (fields.ownership !== 'publisher' && fields.ownership !== 'trusted_server') + ) { + return undefined; + } + if (fields.ownership === 'publisher') { + return Object.freeze({ operations: Object.freeze(['refresh'] as const), requestOperation: 0 }); + } + if (fields.initialLoadDisabled) { + return Object.freeze({ + operations: Object.freeze(['display', 'refresh'] as const), + requestOperation: 1, + }); + } + return Object.freeze({ operations: Object.freeze(['display'] as const), requestOperation: 0 }); +} + +function classifyRenderEnded(candidate: unknown): 'gam_empty' | 'nonempty_gam' | undefined { + const fields = exactRecord(candidate, ['isEmpty']); + if (!fields || typeof fields.isEmpty !== 'boolean') return undefined; + return fields.isEmpty ? 'gam_empty' : 'nonempty_gam'; +} + +/** Register the sole provisional GPT request planning and cycle-attribution policy. */ +export function installGptInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'], + createBatch: FirstDisplayGptBatchFactoryV1, + configCandidate: unknown +): readonly [version: 1, id: 'gpt'] { + // The authenticated bootstrap is the sole caller and owns this capability object. + const value = candidate as GptInitialBindings; + const gamAttributionEnabled = ( + configCandidate as Readonly<{ gamAttributionEnabled?: unknown }> | undefined + )?.gamAttributionEnabled; + if ( + typeof own !== 'function' || + typeof createBatch !== 'function' || + typeof gamAttributionEnabled !== 'boolean' + ) { + throw new TypeError('tsjs'); + } + const policy: FirstDisplayGptBatchPolicyV1 = Object.freeze({ + deadlines: Object.freeze({ + externalReadyMs: 10_000, + requestStartMs: 3_000, + completionMs: 10_000, + }), + requestPlan, + classifyRenderEnded, + }); + const protocol: FirstDisplayGptProtocolV1 = Object.freeze([ + 1, + 'gpt', + (input: FirstDisplayGoogletagBatchInput): FirstDisplayGptCapabilityV1 => { + const batch = createBatch(input, policy); + return Object.freeze([ + batch.start, + batch.closeIngress, + batch.captureHandoff, + batch.captureDiagnosticsHandoff, + batch.detachCommittedSlots, + batch.dispose, + ]); + }, + ]); + const release = value.register(protocol); + if (typeof release !== 'function') throw new TypeError('tsjs'); + own(release); + value.observe('gam', gamAttributionEnabled); + value.observe('v', 1); + if (gamAttributionEnabled && !enqueueFirstDisplayGamAttribution(value.browser)) { + throw new TypeError('tsjs'); + } + return Object.freeze([1, 'gpt']); +} diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/prebid_protocol.ts b/crates/trusted-server-js/lib/src/first_display/leaf/prebid_protocol.ts new file mode 100644 index 000000000..55c4a64bb --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/leaf/prebid_protocol.ts @@ -0,0 +1,254 @@ +import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; + +export interface FirstDisplayPreparedTrustedBidV1 { + readonly auctionId: string; + readonly adUnitCode: string; + readonly bid: Readonly<{ + readonly requestId: string; + readonly adId: string; + readonly cpm: number; + readonly width: number; + readonly height: number; + readonly ad: ''; + readonly ttl: 300; + readonly creativeId: string; + readonly netRevenue: true; + readonly currency: 'USD'; + readonly bidderCode: 'trustedServer'; + readonly meta: Readonly<{ + readonly advertiserDomains: readonly string[]; + readonly tsAuctionId: string; + readonly tsBidId: string; + readonly tsAdmHash?: string; + }>; + }>; +} + +export interface FirstDisplayPrebidPolicyV1 { + readonly version: 1; + readonly id: 'prebid'; + readonly bidderCode: 'trustedServer'; + readonly maxPendingOperations: 64; + readonly externalReadyMs: 10_000; + readonly admissionLeaseMs: 10_000; + readonly renderReservationMs: 900_000; + readonly normalizeEidSource: (candidate: unknown) => string | undefined; + readonly snapshotTrustedBid: (candidate: unknown) => FirstDisplayPreparedTrustedBidV1 | undefined; +} + +export type FirstDisplayPrebidProtocolV1 = readonly [ + version: 1, + id: 'prebid', + policy: FirstDisplayPrebidPolicyV1, +]; + +interface PrebidInitialBindings { + readonly observe: (name: 'protocol_version', value: number) => void; + readonly register: (protocol: FirstDisplayPrebidProtocolV1) => () => void; +} + +const textEncoder = new TextEncoder(); +const RESERVATION = /^r1_[A-Za-z0-9_-]{22}$/; + +function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + !Object.isFrozen(value) + ) { + return undefined; + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + !ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) + ) { + return undefined; + } + const result: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result[key] = descriptor.value; + } + return result; + } catch { + return undefined; + } +} + +function bindings(candidate: unknown): PrebidInitialBindings | undefined { + const fields = exactRecord(candidate, ['observe', 'register']); + return fields && typeof fields.observe === 'function' && typeof fields.register === 'function' + ? (fields as unknown as PrebidInitialBindings) + : undefined; +} + +function validString(candidate: unknown, maximumBytes: number): candidate is string { + if ( + typeof candidate !== 'string' || + candidate.length === 0 || + textEncoder.encode(candidate).byteLength > maximumBytes + ) { + return false; + } + for (let index = 0; index < candidate.length; index += 1) { + const code = candidate.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return false; + } + return true; +} + +function frozenStrings( + candidate: unknown, + maximumLength: number, + maximumBytes: number +): readonly string[] | undefined { + if ( + !Array.isArray(candidate) || + !Object.isFrozen(candidate) || + candidate.length > maximumLength || + Reflect.ownKeys(candidate).length !== candidate.length + 1 + ) { + return undefined; + } + const result: string[] = []; + for (let index = 0; index < candidate.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index)); + if ( + !descriptor?.enumerable || + !('value' in descriptor) || + !validString(descriptor.value, maximumBytes) + ) { + return undefined; + } + result.push(descriptor.value); + } + return Object.freeze(result); +} + +function normalizeEidSource(candidate: unknown): string | undefined { + if (typeof candidate !== 'string') return undefined; + const normalized = candidate.trim().toLowerCase(); + return validString(normalized, 256) ? normalized : undefined; +} + +function snapshotTrustedBid(candidate: unknown): FirstDisplayPreparedTrustedBidV1 | undefined { + try { + const prepared = exactRecord(candidate, ['auctionId', 'adUnitCode', 'bid']); + const bid = exactRecord(prepared?.bid, [ + 'requestId', + 'adId', + 'cpm', + 'width', + 'height', + 'ad', + 'ttl', + 'creativeId', + 'netRevenue', + 'currency', + 'bidderCode', + 'meta', + ]); + if ( + !prepared || + !bid || + !validString(prepared.auctionId, 128) || + !validString(prepared.adUnitCode, 256) || + !validString(bid.requestId, 128) || + typeof bid.adId !== 'string' || + !RESERVATION.test(bid.adId) || + typeof bid.cpm !== 'number' || + !Number.isFinite(bid.cpm) || + bid.cpm < 0 || + typeof bid.width !== 'number' || + !Number.isInteger(bid.width) || + bid.width < 1 || + bid.width > 4096 || + typeof bid.height !== 'number' || + !Number.isInteger(bid.height) || + bid.height < 1 || + bid.height > 4096 || + bid.ad !== '' || + bid.ttl !== 300 || + !validString(bid.creativeId, 256) || + bid.netRevenue !== true || + bid.currency !== 'USD' || + bid.bidderCode !== 'trustedServer' + ) { + return undefined; + } + const hasAdmHash = Object.prototype.hasOwnProperty.call(bid.meta, 'tsAdmHash'); + const meta = exactRecord( + bid.meta, + hasAdmHash + ? ['advertiserDomains', 'tsAuctionId', 'tsBidId', 'tsAdmHash'] + : ['advertiserDomains', 'tsAuctionId', 'tsBidId'] + ); + const advertiserDomains = frozenStrings(meta?.advertiserDomains, 16, 256); + if ( + !meta || + !advertiserDomains || + meta.tsAuctionId !== prepared.auctionId || + !validString(meta.tsBidId, 256) || + (meta.tsAdmHash !== undefined && !validString(meta.tsAdmHash, 128)) + ) { + return undefined; + } + const frozenMeta = Object.freeze({ + advertiserDomains, + tsAuctionId: meta.tsAuctionId as string, + tsBidId: meta.tsBidId, + ...(meta.tsAdmHash === undefined ? {} : { tsAdmHash: meta.tsAdmHash }), + }); + return Object.freeze({ + auctionId: prepared.auctionId, + adUnitCode: prepared.adUnitCode, + bid: Object.freeze({ + requestId: bid.requestId, + adId: bid.adId, + cpm: bid.cpm, + width: bid.width, + height: bid.height, + ad: '', + ttl: 300, + creativeId: bid.creativeId, + netRevenue: true, + currency: 'USD', + bidderCode: 'trustedServer', + meta: frozenMeta, + }), + }) as FirstDisplayPreparedTrustedBidV1; + } catch { + return undefined; + } +} + +/** Register initial Prebid artifact, queue, EID, bidder, and reservation admission policy. */ +export function installPrebidInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): readonly [version: 1, id: 'prebid'] { + const value = bindings(candidate); + if (!value || typeof own !== 'function') throw new TypeError('tsjs'); + const policy: FirstDisplayPrebidPolicyV1 = Object.freeze({ + version: 1, + id: 'prebid', + bidderCode: 'trustedServer', + maxPendingOperations: 64, + externalReadyMs: 10_000, + admissionLeaseMs: 10_000, + renderReservationMs: 900_000, + normalizeEidSource, + snapshotTrustedBid, + }); + const protocol: FirstDisplayPrebidProtocolV1 = Object.freeze([1, 'prebid', policy]); + const release = value.register(protocol); + if (typeof release !== 'function') throw new TypeError('tsjs'); + own(release); + value.observe('protocol_version', 1); + return Object.freeze([1, 'prebid']); +} diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/projection.ts b/crates/trusted-server-js/lib/src/first_display/leaf/projection.ts new file mode 100644 index 000000000..66a319817 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/leaf/projection.ts @@ -0,0 +1,601 @@ +const HASH = /^[0-9a-f]{64}$/; +const AUCTION_ID = /^[A-Za-z0-9._:-]{1,128}$/; +const CANDIDATE_ID = /^[A-Za-z0-9_-]{12}$/; +const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const RESERVATION_ID = /^r1_[A-Za-z0-9_-]{22}$/; +const TARGETING_KEY = /^[A-Za-z0-9_]{1,20}$/; +const MAX_SLOTS = 256; +const MAX_FORMATS = 64; +const MAX_TARGETING = 32; +const MAX_PROJECTION_BYTES = 8 * 1024 * 1024; +const MAX_ADM_BYTES = 512 * 1024; +const MAX_APS_ENVELOPE_BASE64_BYTES = 349_528; +const FAILURE_REASONS = new Set([ + 'auction_disabled', + 'consent_denied', + 'slot_not_eligible', + 'provider_timeout', + 'provider_error', + 'invalid_provider_response', + 'mediation_failed', + 'winner_not_renderable', + 'identity_generation_failed', + 'internal_error', +]); + +export type FirstDisplayAuctionProtocolId = 'aps' | 'gpt' | 'prebid'; +export type FirstDisplayProjectedKind = 'no_bid' | 'failed' | 'gpt_adm' | 'aps'; + +export interface FirstDisplayBatchOutcomeV1 { + readonly slotId: string; + readonly kind: FirstDisplayProjectedKind; +} + +export interface FirstDisplayProjectionSlotV1 { + readonly slot: string; + readonly gamUnitPath: string; + readonly divId: string; + readonly formats: readonly (readonly [number, number])[]; + readonly targeting: Readonly>; +} + +export interface FirstDisplayAdmSourceV1 { + readonly type: 'adm'; + readonly version: 1; + readonly adm: string; + readonly width: number; + readonly height: number; +} + +export interface FirstDisplayApsSourceV1 { + readonly type: 'aps'; + readonly version: 1; + readonly accountId: string; + readonly bidId: string; + readonly creativeId?: string; + readonly tagType: 'iframe' | 'script'; + readonly creativeUrl: string; + readonly aaxResponse: string; + readonly width: number; + readonly height: number; +} + +export interface FirstDisplayProjectionBidV1 { + readonly candidateId: string; + readonly slot: string; + readonly provider: string; + readonly upstreamBidId: string; + readonly cpm: number; + readonly currency: 'USD'; + readonly targeting: Readonly>; + readonly rendererReservationId: string; + readonly renderSource: FirstDisplayAdmSourceV1 | FirstDisplayApsSourceV1; +} + +export type FirstDisplayProjectionDecisionV1 = + | Readonly<{ slot: string; outcome: 'winner'; candidateId: string }> + | Readonly<{ slot: string; outcome: 'no_bid' }> + | Readonly<{ slot: string; outcome: 'failed'; reason: string }>; + +export interface FirstDisplayProjectionV1 { + readonly version: 1; + readonly auction: Readonly<{ + version: 1; + auctionId: string; + results: readonly FirstDisplayProjectionDecisionV1[]; + }>; + readonly slots: readonly FirstDisplayProjectionSlotV1[]; + readonly bids: readonly FirstDisplayProjectionBidV1[]; +} + +export interface FirstDisplayBatchV1 { + readonly version: 1; + readonly projectionDigest: string; + readonly requiredProtocols: readonly FirstDisplayAuctionProtocolId[]; + readonly outcomes: readonly FirstDisplayBatchOutcomeV1[]; + readonly projection: FirstDisplayProjectionV1; +} + +/** Closure-compact batch retained by the bootstrap-owned first-display agent. */ +export type FirstDisplayAgentBatchV1 = readonly [ + projectionDigest: string, + requiredProtocols: readonly FirstDisplayAuctionProtocolId[], + outcomes: readonly (readonly [slotId: string, kind: FirstDisplayProjectedKind])[], + projection: FirstDisplayProjectionV1, +]; + +/** + * Admit the immutable same-script server projection without cloning it again. + * + * Rust owns the exhaustive projection grammar and the bootstrap recursively freezes + * that generated data before this authenticated release artifact can receive it. + */ +export function acceptServerFirstDisplayBatchV1( + candidate: unknown +): FirstDisplayAgentBatchV1 | undefined { + try { + const envelope = candidate as Readonly<{ + version: unknown; + projectionDigest: unknown; + projection: FirstDisplayProjectionV1; + }>; + const projection = envelope.projection; + const results = projection.auction.results; + const { slots, bids } = projection; + // The authenticated bootstrap constructed this envelope from the already + // bounded, recursively frozen server transport. This leaf only derives the + // compact action plan and rechecks the bid/result relationships it consumes. + if (!Object.isFrozen(candidate)) return undefined; + const outcomes: Array = []; + let winner = 0; + let aps = false; + let prebid = false; + for (let index = 0; index < results.length; index += 1) { + const decision = results[index]!; + const slot = slots[index]!; + if (decision.slot !== slot.slot) return undefined; + if (decision.outcome !== 'winner') { + if (decision.outcome !== 'no_bid' && decision.outcome !== 'failed') return undefined; + outcomes.push(Object.freeze([decision.slot, decision.outcome] as const)); + continue; + } + const bid = bids[winner++]; + if ( + !bid || + bid.candidateId !== decision.candidateId || + bid.slot !== decision.slot || + (bid.renderSource.type !== 'adm' && bid.renderSource.type !== 'aps') + ) { + return undefined; + } + aps ||= bid.renderSource.type === 'aps'; + prebid ||= bid.provider === 'prebid'; + outcomes.push( + Object.freeze([decision.slot, bid.renderSource.type === 'aps' ? 'aps' : 'gpt_adm']) + ); + } + if (winner !== bids.length) return undefined; + return Object.freeze([ + envelope.projectionDigest as string, + Object.freeze([ + ...(aps ? (['aps'] as const) : []), + ...(winner ? (['gpt'] as const) : []), + ...(prebid ? (['prebid'] as const) : []), + ]), + Object.freeze(outcomes), + projection, + ]); + } catch { + return undefined; + } +} + +const textEncoder = new TextEncoder(); + +function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + !Object.isFrozen(value) + ) { + return undefined; + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + !ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) + ) { + return undefined; + } + const result: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result[key] = descriptor.value; + } + return result; + } catch { + return undefined; + } +} + +function exactArray(value: unknown, maximum: number): readonly unknown[] | undefined { + try { + if ( + !Array.isArray(value) || + Object.getPrototypeOf(value) !== Array.prototype || + !Object.isFrozen(value) || + value.length > maximum || + Reflect.ownKeys(value).length !== value.length + 1 + ) { + return undefined; + } + const result: unknown[] = []; + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result.push(descriptor.value); + } + return result; + } catch { + return undefined; + } +} + +function validString( + value: unknown, + maximumBytes: number, + options: Readonly<{ allowControls?: boolean; maximumScalars?: number }> = {} +): value is string { + if (typeof value !== 'string' || value.length === 0) return false; + let scalars = 0; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (options.allowControls !== true && (code <= 0x1f || code === 0x7f)) return false; + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + scalars += 1; + if (options.maximumScalars !== undefined && scalars > options.maximumScalars) return false; + } + return textEncoder.encode(value).byteLength <= maximumBytes; +} + +function validDimension(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isInteger(value) && + Number.isFinite(value) && + value >= 1 && + value <= 4096 + ); +} + +function snapshotTargeting(value: unknown): Readonly> | undefined { + const record = exactRecord(value, Reflect.ownKeys(value as object) as string[]); + if (!record) return undefined; + const keys = Object.keys(record).sort(); + if (keys.length > MAX_TARGETING) return undefined; + const result: Record = {}; + for (const key of keys) { + const entry = record[key]; + if ( + key === 'hb_adid' || + !TARGETING_KEY.test(key) || + !validString(entry, 160, { maximumScalars: 40 }) + ) { + return undefined; + } + result[key] = entry; + } + return Object.freeze(result); +} + +function snapshotFormats(value: unknown): readonly (readonly [number, number])[] | undefined { + const formats = exactArray(value, MAX_FORMATS); + if (!formats || formats.length === 0) return undefined; + const result: Array = []; + for (const candidate of formats) { + const pair = exactArray(candidate, 2); + if (!pair || pair.length !== 2 || !validDimension(pair[0]) || !validDimension(pair[1])) { + return undefined; + } + result.push(Object.freeze([pair[0], pair[1]])); + } + return Object.freeze(result); +} + +function snapshotSlot(value: unknown): FirstDisplayProjectionSlotV1 | undefined { + const slot = exactRecord(value, ['slot', 'gamUnitPath', 'divId', 'formats', 'targeting']); + const formats = snapshotFormats(slot?.formats); + const targeting = snapshotTargeting(slot?.targeting); + if ( + !slot || + !validString(slot.slot, 256) || + !validString(slot.gamUnitPath, 256) || + !validString(slot.divId, 256) || + !formats || + !targeting + ) { + return undefined; + } + return Object.freeze({ + slot: slot.slot, + gamUnitPath: slot.gamUnitPath, + divId: slot.divId, + formats, + targeting, + }); +} + +function snapshotAdm(value: unknown): FirstDisplayAdmSourceV1 | undefined { + const source = exactRecord(value, ['type', 'version', 'adm', 'width', 'height']); + if ( + !source || + source.type !== 'adm' || + source.version !== 1 || + !validString(source.adm, MAX_ADM_BYTES, { allowControls: true }) || + !validDimension(source.width) || + !validDimension(source.height) + ) { + return undefined; + } + return Object.freeze({ + type: 'adm', + version: 1, + adm: source.adm, + width: source.width, + height: source.height, + }); +} + +function snapshotAps(value: unknown): FirstDisplayApsSourceV1 | undefined { + const raw = exactRecord( + value, + Object.prototype.hasOwnProperty.call(value, 'creativeId') + ? [ + 'type', + 'version', + 'accountId', + 'bidId', + 'creativeId', + 'tagType', + 'creativeUrl', + 'aaxResponse', + 'width', + 'height', + ] + : [ + 'type', + 'version', + 'accountId', + 'bidId', + 'tagType', + 'creativeUrl', + 'aaxResponse', + 'width', + 'height', + ] + ); + if ( + !raw || + raw.type !== 'aps' || + raw.version !== 1 || + !validString(raw.accountId, 1024, { allowControls: true }) || + !validString(raw.bidId, 64) || + (raw.creativeId !== undefined && !validString(raw.creativeId, 1024, { allowControls: true })) || + (raw.tagType !== 'iframe' && raw.tagType !== 'script') || + !validString(raw.creativeUrl, 4096) || + typeof raw.aaxResponse !== 'string' || + raw.aaxResponse.length > MAX_APS_ENVELOPE_BASE64_BYTES || + !validDimension(raw.width) || + !validDimension(raw.height) + ) { + return undefined; + } + return Object.freeze({ + type: 'aps', + version: 1, + accountId: raw.accountId, + bidId: raw.bidId, + ...(raw.creativeId === undefined ? {} : { creativeId: raw.creativeId }), + tagType: raw.tagType, + creativeUrl: raw.creativeUrl, + aaxResponse: raw.aaxResponse, + width: raw.width, + height: raw.height, + }); +} + +function snapshotBid(value: unknown): FirstDisplayProjectionBidV1 | undefined { + const bid = exactRecord(value, [ + 'candidateId', + 'slot', + 'provider', + 'upstreamBidId', + 'cpm', + 'currency', + 'targeting', + 'rendererReservationId', + 'renderSource', + ]); + const targeting = snapshotTargeting(bid?.targeting); + const sourceRecord = exactRecord( + bid?.renderSource, + Reflect.ownKeys((bid?.renderSource ?? {}) as object) as string[] + ); + const renderSource = + sourceRecord?.type === 'adm' + ? snapshotAdm(bid?.renderSource) + : sourceRecord?.type === 'aps' + ? snapshotAps(bid?.renderSource) + : undefined; + if ( + !bid || + typeof bid.candidateId !== 'string' || + !CANDIDATE_ID.test(bid.candidateId) || + !validString(bid.slot, 256) || + typeof bid.provider !== 'string' || + !PROVIDER_ID.test(bid.provider) || + !validString(bid.upstreamBidId, 64) || + typeof bid.cpm !== 'number' || + !Number.isFinite(bid.cpm) || + bid.cpm < 0 || + bid.currency !== 'USD' || + !targeting || + typeof bid.rendererReservationId !== 'string' || + !RESERVATION_ID.test(bid.rendererReservationId) || + !renderSource + ) { + return undefined; + } + return Object.freeze({ + candidateId: bid.candidateId as string, + slot: bid.slot, + provider: bid.provider as string, + upstreamBidId: bid.upstreamBidId, + cpm: bid.cpm, + currency: 'USD', + targeting, + rendererReservationId: bid.rendererReservationId, + renderSource, + }); +} + +function snapshotDecision(value: unknown): FirstDisplayProjectionDecisionV1 | undefined { + const base = exactRecord(value, Reflect.ownKeys((value ?? {}) as object) as string[]); + if (!base || !validString(base.slot, 256)) return undefined; + if (base.outcome === 'winner') { + const winner = exactRecord(value, ['slot', 'outcome', 'candidateId']); + return winner && typeof winner.candidateId === 'string' && CANDIDATE_ID.test(winner.candidateId) + ? Object.freeze({ + slot: winner.slot as string, + outcome: 'winner', + candidateId: winner.candidateId as string, + }) + : undefined; + } + if (base.outcome === 'no_bid') { + return exactRecord(value, ['slot', 'outcome']) + ? Object.freeze({ slot: base.slot, outcome: 'no_bid' }) + : undefined; + } + if (base.outcome === 'failed') { + const failed = exactRecord(value, ['slot', 'outcome', 'reason']); + return failed && typeof failed.reason === 'string' && FAILURE_REASONS.has(failed.reason) + ? Object.freeze({ slot: base.slot, outcome: 'failed', reason: failed.reason }) + : undefined; + } + return undefined; +} + +/** Validate and freeze the sole server-projected batch admitted by the lean agent. */ +export function snapshotFirstDisplayBatchV1(candidate: unknown): FirstDisplayBatchV1 | undefined { + try { + const envelope = exactRecord(candidate, ['version', 'projectionDigest', 'projection']); + const rawProjection = exactRecord(envelope?.projection, [ + 'version', + 'auction', + 'slots', + 'bids', + ]); + const rawAuction = exactRecord(rawProjection?.auction, ['version', 'auctionId', 'results']); + const rawDecisions = exactArray(rawAuction?.results, MAX_SLOTS); + const rawSlots = exactArray(rawProjection?.slots, MAX_SLOTS); + const rawBids = exactArray(rawProjection?.bids, MAX_SLOTS); + if ( + !envelope || + envelope.version !== 1 || + typeof envelope.projectionDigest !== 'string' || + !HASH.test(envelope.projectionDigest) || + !rawProjection || + rawProjection.version !== 1 || + !rawAuction || + rawAuction.version !== 1 || + typeof rawAuction.auctionId !== 'string' || + !AUCTION_ID.test(rawAuction.auctionId) || + !rawDecisions || + rawDecisions.length === 0 || + !rawSlots || + rawSlots.length !== rawDecisions.length || + !rawBids + ) { + return undefined; + } + + const decisions: FirstDisplayProjectionDecisionV1[] = []; + const slots: FirstDisplayProjectionSlotV1[] = []; + const bids: FirstDisplayProjectionBidV1[] = []; + const slotIds = new Set(); + for (let index = 0; index < rawDecisions.length; index += 1) { + const decision = snapshotDecision(rawDecisions[index]); + const slot = snapshotSlot(rawSlots[index]); + if (!decision || !slot || decision.slot !== slot.slot || slotIds.has(slot.slot)) { + return undefined; + } + slotIds.add(slot.slot); + decisions.push(decision); + slots.push(slot); + } + + const candidateIds = new Set(); + const reservationIds = new Set(); + for (const rawBid of rawBids) { + const bid = snapshotBid(rawBid); + if ( + !bid || + candidateIds.has(bid.candidateId) || + reservationIds.has(bid.rendererReservationId) + ) { + return undefined; + } + candidateIds.add(bid.candidateId); + reservationIds.add(bid.rendererReservationId); + bids.push(bid); + } + + const outcomes: FirstDisplayBatchOutcomeV1[] = []; + let winnerIndex = 0; + let aps = false; + let prebid = false; + for (const decision of decisions) { + if (decision.outcome === 'winner') { + const bid = bids[winnerIndex]; + if (!bid || bid.candidateId !== decision.candidateId || bid.slot !== decision.slot) { + return undefined; + } + winnerIndex += 1; + aps ||= bid.renderSource.type === 'aps'; + prebid ||= bid.provider === 'prebid'; + outcomes.push( + Object.freeze({ + slotId: decision.slot, + kind: bid.renderSource.type === 'aps' ? 'aps' : 'gpt_adm', + }) + ); + } else { + outcomes.push( + Object.freeze({ + slotId: decision.slot, + kind: decision.outcome, + }) + ); + } + } + if (winnerIndex !== bids.length) return undefined; + + const projection: FirstDisplayProjectionV1 = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: rawAuction.auctionId, + results: Object.freeze(decisions), + }), + slots: Object.freeze(slots), + bids: Object.freeze(bids), + }); + if (textEncoder.encode(JSON.stringify(projection)).byteLength > MAX_PROJECTION_BYTES) { + return undefined; + } + return Object.freeze({ + version: 1, + projectionDigest: envelope.projectionDigest, + requiredProtocols: Object.freeze([ + ...(aps ? (['aps'] as const) : []), + ...(winnerIndex > 0 ? (['gpt'] as const) : []), + ...(prebid ? (['prebid'] as const) : []), + ]), + outcomes: Object.freeze(outcomes), + projection, + }); + } catch { + return undefined; + } +} diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/route_guard.ts b/crates/trusted-server-js/lib/src/first_display/leaf/route_guard.ts new file mode 100644 index 000000000..1ec10f3c4 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/leaf/route_guard.ts @@ -0,0 +1,266 @@ +import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; + +export type FirstDisplayRouteKindV1 = 'script' | 'preload' | 'prefetch' | 'beacon' | 'fetch'; + +export interface FirstDisplayRouteRuleV1 { + readonly id: 'datadome' | 'google_tag_manager' | 'lockr'; + readonly matches: (kind: FirstDisplayRouteKindV1, url: string) => boolean; + readonly rewrite: (url: string) => string; +} + +interface RouteBindings { + readonly observe: (name: string, value: string | number) => void; + readonly origin: string; + readonly register: (rule: FirstDisplayRouteRuleV1) => () => void; +} + +interface LockrSdk { + host: string; +} + +interface LockrBindings extends RouteBindings { + readonly clearTimer: (handle: unknown) => void; + readonly getSdk: () => LockrSdk | undefined; + readonly host: string; + readonly protocol: string; + readonly setTimer: (callback: () => void, delayMs: number) => unknown; +} + +const DATADOME_URL = /^(?:https?:)?\/\/js\.datadome\.co(?:\/|$)|^js\.datadome\.co(?:\/|$)/i; +const GTM_URL = + /^(?:https?:)?(?:\/\/)?(www\.(googletagmanager|google-analytics)\.com|analytics\.google\.com)(?:\/|$)/i; +const GTM_PATHS = new Set(['/gtm.js', '/gtag/js', '/gtag.js', '/collect', '/g/collect']); +const SCRIPT_KINDS = new Set(['script', 'preload', 'prefetch']); +const GTM_KINDS = new Set([ + 'script', + 'preload', + 'prefetch', + 'beacon', + 'fetch', +]); + +function exactBindings( + candidate: unknown, + keys: readonly string[] +): Record | undefined { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + !Object.isFrozen(candidate) + ) { + return undefined; + } + const ownKeys = Reflect.ownKeys(candidate); + if ( + ownKeys.length !== keys.length || + !ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) + ) { + return undefined; + } + const fields: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + fields[key] = descriptor.value; + } + return fields; + } catch { + return undefined; + } +} + +function routeBindings(candidate: unknown): RouteBindings | undefined { + const fields = exactBindings(candidate, ['observe', 'origin', 'register']); + if ( + !fields || + typeof fields.observe !== 'function' || + typeof fields.origin !== 'string' || + typeof fields.register !== 'function' + ) { + return undefined; + } + try { + const parsed = new URL(fields.origin); + if (!/^https?:$/.test(parsed.protocol) || parsed.origin !== fields.origin) return undefined; + } catch { + return undefined; + } + return fields as unknown as RouteBindings; +} + +function absoluteExternalUrl(url: string): URL | undefined { + try { + const normalized = url.startsWith('//') + ? `https:${url}` + : /^https?:/i.test(url) + ? url + : `https://${url}`; + return new URL(normalized); + } catch { + return undefined; + } +} + +function ownRule( + bindings: RouteBindings, + own: FirstDisplaySliceActivationContext['own'], + rule: FirstDisplayRouteRuleV1 +): void { + const release = bindings.register(Object.freeze(rule)); + if (typeof release !== 'function') throw new TypeError('tsjs'); + own(release); + bindings.observe('route_guard', rule.id); +} + +/** Own DataDome's initial script/preload first-party routing. */ +export function installDataDomeInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): void { + const bindings = routeBindings(candidate); + if (!bindings) throw new TypeError('tsjs'); + ownRule(bindings, own, { + id: 'datadome', + matches: (kind, url) => SCRIPT_KINDS.has(kind) && DATADOME_URL.test(url), + rewrite: (url) => { + const parsed = absoluteExternalUrl(url); + const suffix = parsed ? `${parsed.pathname}${parsed.search}` : '/tags.js'; + return `${bindings.origin}/integrations/datadome${suffix}`; + }, + }); +} + +function isGtmUrl(url: string): boolean { + if (!GTM_URL.test(url)) return false; + const parsed = absoluteExternalUrl(url); + return Boolean(parsed && GTM_PATHS.has(parsed.pathname)); +} + +/** Own GTM/GA's initial script, preload, beacon, and fetch first-party routing. */ +export function installGoogleTagManagerInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): void { + const bindings = routeBindings(candidate); + if (!bindings) throw new TypeError('tsjs'); + ownRule(bindings, own, { + id: 'google_tag_manager', + matches: (kind, url) => GTM_KINDS.has(kind) && isGtmUrl(url), + rewrite: (url) => { + const parsed = absoluteExternalUrl(url); + const suffix = parsed ? `${parsed.pathname}${parsed.search}` : '/gtm.js'; + return `${bindings.origin}/integrations/google_tag_manager${suffix}`; + }, + }); +} + +function isLockrUrl(url: string): boolean { + const lower = url.toLowerCase(); + return ( + lower.includes('aim.loc.kr') || + (lower.includes('identity.loc.kr') && lower.includes('identity-lockr') && lower.endsWith('.js')) + ); +} + +function snapshotLockrBindings(candidate: unknown): LockrBindings | undefined { + const fields = exactBindings(candidate, [ + 'clearTimer', + 'getSdk', + 'host', + 'observe', + 'origin', + 'protocol', + 'register', + 'setTimer', + ]); + if ( + !fields || + typeof fields.clearTimer !== 'function' || + typeof fields.getSdk !== 'function' || + typeof fields.host !== 'string' || + fields.host.length === 0 || + fields.host.length > 255 || + fields.host.includes('/') || + typeof fields.observe !== 'function' || + typeof fields.origin !== 'string' || + typeof fields.protocol !== 'string' || + !['http:', 'https:'].includes(fields.protocol) || + typeof fields.register !== 'function' || + typeof fields.setTimer !== 'function' + ) { + return undefined; + } + const base = routeBindings( + Object.freeze({ observe: fields.observe, origin: fields.origin, register: fields.register }) + ); + return base ? (fields as unknown as LockrBindings) : undefined; +} + +/** Own Lockr's initial route guard and bounded API-host readiness observation. */ +export function installLockrInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): void { + const bindings = snapshotLockrBindings(candidate); + if (!bindings) throw new TypeError('tsjs'); + ownRule(bindings, own, { + id: 'lockr', + matches: (kind, url) => SCRIPT_KINDS.has(kind) && isLockrUrl(url), + rewrite: () => `${bindings.origin}/integrations/lockr/sdk`, + }); + + let active = true; + let timer: unknown; + let ownedSdk: LockrSdk | undefined; + let installedHost: string | undefined; + let previousHost: string | undefined; + own(() => { + if (!active) return; + active = false; + if (timer !== undefined) { + try { + bindings.clearTimer(timer); + } catch { + // Continue through independent SDK restoration. + } + timer = undefined; + } + try { + if (ownedSdk && installedHost !== undefined && ownedSdk.host === installedHost) { + ownedSdk.host = previousHost ?? ownedSdk.host; + } + } catch { + // Publisher replacement wins over provisional rollback. + } + }); + + let attempts = 0; + const check = (): void => { + timer = undefined; + if (!active) return; + attempts += 1; + try { + const sdk = bindings.getSdk(); + if (sdk && typeof sdk.host === 'string' && sdk.host.length > 0) { + const nextHost = `${bindings.protocol}//${bindings.host}/integrations/lockr/api`; + previousHost = sdk.host; + sdk.host = nextHost; + ownedSdk = sdk; + installedHost = nextHost; + bindings.observe('sdk_host', nextHost); + return; + } + } catch { + // Treat an unreadable or unwritable SDK as not ready. + } + if (attempts >= 50) { + bindings.observe('readiness_timeout', attempts); + return; + } + timer = bindings.setTimer(check, 50); + }; + check(); +} diff --git a/crates/trusted-server-js/lib/src/first_display/registration_client.ts b/crates/trusted-server-js/lib/src/first_display/registration_client.ts new file mode 100644 index 000000000..05c393ff6 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/registration_client.ts @@ -0,0 +1,20 @@ +declare const __TSJS_EMBEDDED_RELEASE_ID_V1__: string; + +import type { FirstDisplayComponentRegistrationV1 } from '../shared/first_display_registration'; + +/** Submit one immutable component record and the synchronous current-script identity. */ +export function registerCurrentFirstDisplayComponent( + id: string, + install: FirstDisplayComponentRegistrationV1['install'] +): boolean { + try { + const target = window.tsjs as unknown as { + _registerFirstDisplay?: (candidate: readonly unknown[]) => boolean; + }; + return ( + target._registerFirstDisplay?.([1, id, __TSJS_EMBEDDED_RELEASE_ID_V1__, install]) === true + ); + } catch { + return false; + } +} diff --git a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts new file mode 100644 index 000000000..4e2175267 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts @@ -0,0 +1,783 @@ +import type { FirstDisplayGptBoundCycleV1 } from './adapters/googletag'; +import type { FirstDisplayApsPolicyV1 } from './leaf/aps_protocol'; +import type { + FirstDisplayCommittedRenderArtifactV1, + FirstDisplayRenderOwnerOptionsV1, + FirstDisplayRenderStrategyAttemptV1, + FirstDisplayRenderStrategyCallbacksV1, + FirstDisplayRenderStrategyV1, +} from './render_journal'; + +const RENDERER_DOCUMENT_NO_LOAD = 'renderer_document_no_load'; +const INTERNAL_ERROR = 'internal_error'; +const RUNNER_FAILED = 'runner_failed'; +const WINNER_NOT_RENDERABLE = 'winner_not_renderable'; +const MAX_DRAWS = 8; +const BASE64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +interface PortLike { + readonly addEventListener?: (name: string, listener: (event: unknown) => void) => void; + readonly close: () => void; + readonly postMessage: (message: unknown, transfer?: readonly PortLike[]) => void; + readonly removeEventListener?: (name: string, listener: (event: unknown) => void) => void; + readonly start?: () => void; +} + +interface ApsAttempt { + readonly callbacks: FirstDisplayRenderStrategyCallbacksV1; + readonly cycle: FirstDisplayGptBoundCycleV1; + readonly overlay: boolean; + active: boolean; + accepted: boolean; + bootstrapNavigated: boolean; + bootstrapNonceInternal: string; + bootstrapSource: object | undefined; + completionTimer: unknown; + documentAccepted: boolean; + documentPort: PortLike | undefined; + documentRelease: (() => void) | undefined; + documentTimer: unknown; + frame: HTMLIFrameElement; + hostPositionOwned: boolean; + pendingTerminal: + | 'completed' + | typeof WINNER_NOT_RENDERABLE + | 'runner_no_load' + | typeof RUNNER_FAILED + | undefined; + previousHostPosition: string; + previousHostPositionPriority: string; + rendererNonceInternal: string; +} + +function eventField( + event: unknown, + name: 'data' | 'origin' | 'ports' | 'source', + trustedPrototype: object | undefined +): unknown { + try { + if (typeof event !== 'object' || event === null) return undefined; + const own = Object.getOwnPropertyDescriptor(event, name); + if (own) return 'value' in own ? own.value : undefined; + const prototype = Object.getPrototypeOf(event); + if (!trustedPrototype || prototype !== trustedPrototype) return undefined; + const inherited = Object.getOwnPropertyDescriptor(trustedPrototype, name); + return inherited?.get ? Reflect.apply(inherited.get, event, []) : undefined; + } catch { + return undefined; + } +} + +function usablePort(value: unknown): value is PortLike { + try { + return ( + typeof value === 'object' && + value !== null && + typeof Reflect.get(value, 'postMessage') === 'function' && + typeof Reflect.get(value, 'close') === 'function' + ); + } catch { + return false; + } +} + +function exactPorts( + event: unknown, + trustedPrototype: object | undefined, + expected: 0 | 1 +): readonly PortLike[] | undefined { + const value = eventField(event, 'ports', trustedPrototype); + try { + if (!Array.isArray(value)) return undefined; + const length = Object.getOwnPropertyDescriptor(value, 'length'); + if (!length || !('value' in length) || !Number.isSafeInteger(length.value)) return undefined; + let exact = + length.value === expected && + Object.getPrototypeOf(value) === Array.prototype && + Object.getOwnPropertySymbols(value).length === 0 && + Object.getOwnPropertyNames(value).length === length.value + 1; + const ports: PortLike[] = []; + const seen = new Set(); + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor) || !usablePort(descriptor.value)) { + exact = false; + continue; + } + if (seen.has(descriptor.value)) { + exact = false; + continue; + } + seen.add(descriptor.value); + ports.push(descriptor.value); + } + if (exact && ports.length === expected) return ports; + for (const port of ports) closePort(port); + return undefined; + } catch { + return undefined; + } +} + +function eventSource(event: unknown, trustedPrototype: object | undefined): object | undefined { + const value = eventField(event, 'source', trustedPrototype); + return (typeof value === 'object' || typeof value === 'function') && value !== null + ? value + : undefined; +} + +function closePort(port: PortLike | undefined): void { + try { + port?.close(); + } catch { + // The endpoint is already generation-inert. + } +} + +function post(port: PortLike, message: unknown): boolean { + try { + Reflect.apply(port.postMessage, port, [message, []]); + return true; + } catch { + return false; + } +} + +function postWindow(target: object, message: string): boolean { + try { + const postMessage = Reflect.get(target, 'postMessage'); + if (typeof postMessage !== 'function') return false; + Reflect.apply(postMessage, target, [message, '*', []]); + return true; + } catch { + return false; + } +} + +function encodeOpaque(bytes: Uint8Array): string { + let output = ''; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 6) { + bits -= 6; + output += BASE64URL[(buffer >>> bits) & 63]; + } + buffer &= (1 << bits) - 1; + } + if (bits > 0) output += BASE64URL[(buffer << (6 - bits)) & 63]; + return output; +} + +function snapshotFrameAttributes(frame: HTMLIFrameElement): string | undefined { + try { + return frame.outerHTML; + } catch { + return undefined; + } +} + +/** Create the APS-owned URL, nonce, document-port, and overlay strategy. */ +export function createFirstDisplayApsRenderStrategy( + options: FirstDisplayRenderOwnerOptionsV1, + aps: FirstDisplayApsPolicyV1 +): FirstDisplayRenderStrategyV1 { + const attempts = new Set(); + const bootstrapNonces = new Map(); + const rendererNonces = new Map(); + const timers = new Set(); + let disposed = false; + const messageEventPrototype = (() => { + try { + const constructor = Reflect.get(options[0], 'MessageEvent'); + const prototype = + typeof constructor === 'function' ? Reflect.get(constructor, 'prototype') : undefined; + return typeof prototype === 'object' && prototype !== null ? prototype : undefined; + } catch { + return undefined; + } + })(); + + const notifyNativeMutation = (): void => { + try { + options[6]?.(); + } catch { + // Observation cannot alter admitted APS state. + } + }; + + const clearOwnedTimer = (handle: unknown): void => { + if (handle === undefined || !timers.delete(handle)) return; + try { + options[1](handle); + } catch { + // Timer state is already detached. + } + }; + + const arm = (callback: () => void, delayMs: number): unknown => { + let handle: unknown; + let scheduling = true; + let firedSynchronously = false; + try { + handle = options[7](() => { + if (scheduling) { + firedSynchronously = true; + return; + } + if (!timers.delete(handle)) return; + callback(); + }, delayMs); + } catch { + handle = undefined; + } + scheduling = false; + if (handle === undefined) return undefined; + if (firedSynchronously) { + try { + options[1](handle); + } catch { + // Synchronous timers are refused regardless of cleanup outcome. + } + return undefined; + } + timers.add(handle); + return handle; + }; + + const mint = ( + prefix: 'b1_' | 'n1_', + registry: ReadonlyMap + ): string | undefined => { + for (let draw = 0; draw < MAX_DRAWS; draw += 1) { + const bytes = new Uint8Array(16); + try { + options[4](bytes); + } catch { + return undefined; + } + const candidate = `${prefix}${encodeOpaque(bytes)}`; + if (!registry.has(candidate)) return candidate; + } + return undefined; + }; + + const configureFrame = ( + frame: HTMLIFrameElement, + width: number, + height: number, + overlay: boolean + ): void => { + frame.setAttribute('sandbox', aps.sandbox); + frame.setAttribute('referrerpolicy', 'no-referrer'); + frame.setAttribute('width', String(width)); + frame.setAttribute('height', String(height)); + frame.setAttribute('scrolling', 'no'); + frame.setAttribute('frameborder', '0'); + frame.setAttribute('marginwidth', '0'); + frame.setAttribute('marginheight', '0'); + frame.setAttribute('title', 'Ad content'); + frame.setAttribute('aria-label', 'Advertisement'); + frame.setAttribute( + 'style', + overlay + ? `border: 0; display: block; height: ${height}px; inset: 0; margin: 0; overflow: hidden; position: absolute; visibility: hidden; width: ${width}px; z-index: 2147483647;` + : `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` + ); + }; + + const restoreHostPosition = (attempt: ApsAttempt): void => { + if (!attempt.hostPositionOwned) return; + attempt.hostPositionOwned = false; + try { + const style = attempt.cycle[1].style; + if ( + style.getPropertyValue('position') !== 'relative' || + style.getPropertyPriority('position') !== '' + ) + return; + if (attempt.previousHostPosition === '') style.removeProperty('position'); + else + style.setProperty( + 'position', + attempt.previousHostPosition, + attempt.previousHostPositionPriority + ); + } catch { + // Compare-owned restoration never overwrites publisher changes. + } + }; + + const acquireHostPosition = (attempt: ApsAttempt): boolean => { + if (!attempt.overlay) return true; + try { + const host = attempt.cycle[1]; + const browser = host.ownerDocument.defaultView; + if (!browser || !attempt.cycle[2]()) return false; + if (browser.getComputedStyle(host).position !== 'static') return true; + attempt.previousHostPosition = host.style.getPropertyValue('position'); + attempt.previousHostPositionPriority = host.style.getPropertyPriority('position'); + host.style.setProperty('position', 'relative'); + attempt.hostPositionOwned = + host.style.getPropertyValue('position') === 'relative' && + host.style.getPropertyPriority('position') === ''; + return attempt.hostPositionOwned && attempt.cycle[2](); + } catch { + return false; + } + }; + + const exactFrame = (attempt: ApsAttempt, permanent: boolean): boolean => { + try { + return ( + attempt.active && + attempt.cycle[2]() && + attempt.frame.isConnected && + attempt.frame.parentNode === attempt.cycle[1] && + attempt.frame.contentWindow === attempt.bootstrapSource && + attempt.frame.getAttribute('src') === + `${aps.rendererUrl}#${attempt.bootstrapNonceInternal}` && + attempt.frame.src === `${aps.rendererUrl}#${attempt.bootstrapNonceInternal}` && + attempt.frame.getAttribute('sandbox') === + (permanent ? aps.permanentSandbox : aps.sandbox) && + (!attempt.hostPositionOwned || + (attempt.cycle[1].style.getPropertyValue('position') === 'relative' && + attempt.cycle[1].style.getPropertyPriority('position') === '')) + ); + } catch { + return false; + } + }; + + const releasePort = (attempt: ApsAttempt): void => { + const release = attempt.documentRelease; + const port = attempt.documentPort; + attempt.documentRelease = undefined; + attempt.documentPort = undefined; + try { + release?.(); + } catch { + // Port closure remains authoritative. + } + closePort(port); + }; + + const detachAttempt = (attempt: ApsAttempt): void => { + clearOwnedTimer(attempt.documentTimer); + clearOwnedTimer(attempt.completionTimer); + attempt.documentTimer = undefined; + attempt.completionTimer = undefined; + if (bootstrapNonces.get(attempt.bootstrapNonceInternal) === attempt) { + bootstrapNonces.delete(attempt.bootstrapNonceInternal); + } + if (rendererNonces.get(attempt.rendererNonceInternal) === attempt) { + rendererNonces.delete(attempt.rendererNonceInternal); + } + attempts.delete(attempt); + releasePort(attempt); + }; + + const cancelAttempt = (attempt: ApsAttempt): void => { + if (!attempt.active && !attempt.accepted) return; + attempt.active = false; + attempt.accepted = false; + detachAttempt(attempt); + attempt.frame.onload = null; + attempt.frame.onerror = null; + try { + attempt.frame.remove(); + } catch { + // The exact node cannot regain authority. + } + restoreHostPosition(attempt); + notifyNativeMutation(); + }; + + const fail = (attempt: ApsAttempt, reason: string): void => { + if (!attempt.active) return; + attempt.active = false; + detachAttempt(attempt); + attempt.frame.onload = null; + attempt.frame.onerror = null; + try { + attempt.frame.remove(); + } catch { + // Failed APS identity is already detached. + } + restoreHostPosition(attempt); + notifyNativeMutation(); + try { + attempt.callbacks.fail(reason); + } catch { + // Consumers cannot restore APS authority. + } + }; + + const installDocumentPort = ( + attempt: ApsAttempt, + port: PortLike, + receive: (event: unknown) => void, + receiveError: () => void + ): boolean => { + try { + if (typeof port.addEventListener !== 'function') return false; + let live = true; + const release = (): void => { + if (!live) return; + live = false; + try { + if (typeof port.removeEventListener === 'function') { + Reflect.apply(port.removeEventListener, port, ['message', receive]); + Reflect.apply(port.removeEventListener, port, ['messageerror', receiveError]); + } + } catch { + // Port closure remains authoritative. + } + }; + Reflect.apply(port.addEventListener, port, ['message', receive]); + Reflect.apply(port.addEventListener, port, ['messageerror', receiveError]); + attempt.documentRelease = release; + if (typeof port.start === 'function') Reflect.apply(port.start, port, []); + return live && attempt.active && attempt.documentPort === port; + } catch { + return false; + } + }; + + const complete = (attempt: ApsAttempt): void => { + if (!attempt.active || !exactFrame(attempt, true)) { + fail(attempt, 'slot_unresolved'); + return; + } + if (attempt.overlay) { + try { + attempt.frame.style.setProperty('visibility', 'visible'); + if (attempt.frame.style.getPropertyValue('visibility') !== 'visible') { + fail(attempt, INTERNAL_ERROR); + return; + } + } catch { + fail(attempt, INTERNAL_ERROR); + return; + } + } + const attributes = snapshotFrameAttributes(attempt.frame); + const frameWindow = attempt.frame.contentWindow; + const frameSource = attempt.frame.src; + const frameSourceDocument = attempt.frame.srcdoc; + if (attributes === undefined || !frameWindow) { + fail(attempt, INTERNAL_ERROR); + return; + } + attempt.active = false; + attempt.accepted = true; + detachAttempt(attempt); + attempt.frame.onload = null; + attempt.frame.onerror = null; + let artifactLive = true; + const retire = (): void => { + if (!artifactLive) return; + artifactLive = false; + attempt.accepted = false; + try { + attempt.frame.remove(); + } catch { + // Exact-node retirement is best-effort. + } + restoreHostPosition(attempt); + notifyNativeMutation(); + }; + const artifact: FirstDisplayCommittedRenderArtifactV1 = Object.freeze({ + hostPosition: attempt.hostPositionOwned ? attempt.previousHostPosition : null, + hostPositionPriority: attempt.hostPositionOwned ? attempt.previousHostPositionPriority : null, + identity: attempt.frame, + kind: 'aps', + owner: 'trusted_server', + slotId: attempt.cycle[6], + token: attempt.cycle[0].rendererReservationId, + current: () => { + try { + return ( + artifactLive && + attempt.accepted && + attempt.cycle[2]() && + attempt.frame.isConnected && + attempt.frame.parentNode === attempt.cycle[1] && + attempt.frame.contentWindow === frameWindow && + attempt.frame.src === frameSource && + attempt.frame.srcdoc === frameSourceDocument && + snapshotFrameAttributes(attempt.frame) === attributes && + (!attempt.hostPositionOwned || + (attempt.cycle[1].style.getPropertyValue('position') === 'relative' && + attempt.cycle[1].style.getPropertyPriority('position') === '')) + ); + } catch { + return false; + } + }, + retire, + }); + notifyNativeMutation(); + try { + attempt.callbacks.accept(artifact); + } catch { + retire(); + } + }; + + const handleDocument = (attempt: ApsAttempt, event: unknown): void => { + if (!attempt.active || !exactFrame(attempt, true)) { + fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); + return; + } + if (!exactPorts(event, messageEventPrototype, 0)) { + fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); + return; + } + const parsed = aps.parseDocumentMessage( + eventField(event, 'data', messageEventPrototype), + attempt.rendererNonceInternal + ); + if (!parsed) { + fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); + return; + } + if (parsed.kind === 'document_accepted') { + if (attempt.documentAccepted) return; + attempt.documentAccepted = true; + if (rendererNonces.get(attempt.rendererNonceInternal) === attempt) { + rendererNonces.delete(attempt.rendererNonceInternal); + } + clearOwnedTimer(attempt.documentTimer); + attempt.documentTimer = undefined; + attempt.completionTimer = arm(() => fail(attempt, RUNNER_FAILED), aps.deadlines.completionMs); + if (attempt.completionTimer === undefined) { + fail(attempt, INTERNAL_ERROR); + return; + } + const pending = attempt.pendingTerminal; + attempt.pendingTerminal = undefined; + if (pending === 'completed') complete(attempt); + else if (pending) fail(attempt, pending); + return; + } + if (parsed.kind === 'runner_loaded') return; + if (parsed.kind === 'render_completed') { + if (attempt.documentAccepted) complete(attempt); + else if (!attempt.pendingTerminal) attempt.pendingTerminal = 'completed'; + else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; + } + const failureReason = + parsed.reason === 'descriptor_invalid' ? WINNER_NOT_RENDERABLE : parsed.reason; + if (attempt.documentAccepted) fail(attempt, failureReason); + else if (!attempt.pendingTerminal) attempt.pendingTerminal = failureReason; + else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + }; + + const dispatch = (event: unknown): void => { + if (disposed) return; + const data = eventField(event, 'data', messageEventPrototype); + const message = aps.parseWindowMessage(data); + if (!message) return; + if (message.kind === 'bootstrap_ready') { + const nonce = message.bootstrap; + const attempt = bootstrapNonces.get(nonce); + const source = eventSource(event, messageEventPrototype); + if ( + !attempt || + eventField(event, 'origin', messageEventPrototype) !== 'null' || + source !== attempt.bootstrapSource + ) + return; + if (!exactPorts(event, messageEventPrototype, 0)) { + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; + } + if (attempt.bootstrapNavigated || !exactFrame(attempt, false)) { + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; + } + const policy = aps.bootstrapPolicy(attempt.cycle[0].renderSource); + if (!policy) { + fail(attempt, WINNER_NOT_RENDERABLE); + return; + } + try { + attempt.frame.setAttribute('sandbox', aps.permanentSandbox); + } catch { + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; + } + const navigation = JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce: nonce, + rendererNonce: attempt.rendererNonceInternal, + creativeOrigin: policy.creativeOrigin, + tagType: policy.tagType, + }); + if (!exactFrame(attempt, true) || !postWindow(source!, navigation)) { + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; + } + attempt.bootstrapNavigated = true; + return; + } + + const { bootstrap: bootstrapNonce, renderer: rendererNonce } = message; + const attempt = bootstrapNonces.get(bootstrapNonce); + const source = eventSource(event, messageEventPrototype); + if ( + !attempt || + rendererNonces.get(rendererNonce) !== attempt || + eventField(event, 'origin', messageEventPrototype) !== 'null' || + source !== attempt.bootstrapSource + ) + return; + const ports = exactPorts(event, messageEventPrototype, 1); + const port = ports?.[0]; + if ( + !port || + !attempt.bootstrapNavigated || + attempt.documentPort || + !exactFrame(attempt, true) + ) { + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; + } + attempt.documentPort = port; + const listening = installDocumentPort( + attempt, + port, + (portEvent) => handleDocument(attempt, portEvent), + () => fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD) + ); + if (!listening || !attempt.active) { + if (attempt.active) fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; + } + bootstrapNonces.delete(bootstrapNonce); + if ( + !post(port, { + version: 1, + nonce: rendererNonce, + ['publisherOrigin']: aps.publisherOrigin, + renderer: attempt.cycle[0].renderSource, + }) || + !attempt.active || + !exactFrame(attempt, true) + ) + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + }; + + try { + options[0].addEventListener('message', dispatch as EventListener, true); + } catch { + throw new TypeError('tsjs'); + } + + return Object.freeze({ + supports: (source: unknown): boolean => { + try { + return aps.bootstrapPolicy(source) !== undefined; + } catch { + return false; + } + }, + start: ( + cycle: FirstDisplayGptBoundCycleV1, + overlay: boolean, + callbacks: FirstDisplayRenderStrategyCallbacksV1 + ): FirstDisplayRenderStrategyAttemptV1 | undefined => { + const source = cycle[0].renderSource; + if (disposed || source.type !== 'aps' || !cycle[2]() || !aps.bootstrapPolicy(source)) { + return undefined; + } + const bootstrapNonce = mint('b1_', bootstrapNonces); + const rendererNonce = mint('n1_', rendererNonces); + if ( + !bootstrapNonce || + !rendererNonce || + !aps.isBootstrapNonce(bootstrapNonce) || + !aps.isRendererNonce(rendererNonce) + ) + return undefined; + let frame: HTMLIFrameElement; + try { + frame = options[3].createElement('iframe'); + configureFrame(frame, source.width, source.height, overlay); + } catch { + return undefined; + } + const attempt: ApsAttempt = { + active: true, + accepted: false, + bootstrapNavigated: false, + bootstrapNonceInternal: bootstrapNonce, + bootstrapSource: undefined, + callbacks, + completionTimer: undefined, + cycle, + documentAccepted: false, + documentPort: undefined, + documentRelease: undefined, + documentTimer: undefined, + frame, + hostPositionOwned: false, + overlay, + pendingTerminal: undefined, + previousHostPosition: '', + previousHostPositionPriority: '', + rendererNonceInternal: rendererNonce, + }; + attempts.add(attempt); + bootstrapNonces.set(bootstrapNonce, attempt); + rendererNonces.set(rendererNonce, attempt); + try { + frame.onerror = () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + frame.src = `${aps.rendererUrl}#${bootstrapNonce}`; + if (!acquireHostPosition(attempt)) { + cancelAttempt(attempt); + return undefined; + } + cycle[1].appendChild(frame); + const frameWindow = frame.contentWindow; + if (!frameWindow) { + cancelAttempt(attempt); + return undefined; + } + attempt.bootstrapSource = frameWindow; + attempt.documentTimer = arm( + () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD), + aps.deadlines.documentAcceptanceMs + ); + if (attempt.documentTimer === undefined || !exactFrame(attempt, false)) { + cancelAttempt(attempt); + return undefined; + } + } catch { + cancelAttempt(attempt); + return undefined; + } + notifyNativeMutation(); + return Object.freeze({ cancel: () => cancelAttempt(attempt) }); + }, + dispose: (): void => { + if (disposed) return; + disposed = true; + try { + options[0].removeEventListener('message', dispatch as EventListener, true); + } catch { + // Generation state remains authoritative. + } + for (const attempt of [...attempts]) cancelAttempt(attempt); + for (const handle of [...timers]) clearOwnedTimer(handle); + attempts.clear(); + bootstrapNonces.clear(); + rendererNonces.clear(); + }, + }); +} diff --git a/crates/trusted-server-js/lib/src/first_display/render_journal.ts b/crates/trusted-server-js/lib/src/first_display/render_journal.ts new file mode 100644 index 000000000..2d6e470ae --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/render_journal.ts @@ -0,0 +1,1585 @@ +import { PUC_DYNAMIC_OWNER } from '../kernel/contracts/puc_dynamic_owner'; +import type { FirstDisplaySliceActivationContext } from '../shared/first_display_transaction'; + +import type { + FirstDisplayGptBoundCycleV1, + FirstDisplayGptRenderResult, +} from './adapters/googletag'; +import type { + FirstDisplayRenderBridgeCapabilityV1, + FirstDisplayRenderBridgeV1, + FirstDisplayRenderHandoffArtifactV1, +} from './driver'; + +const ADM_SANDBOX = + 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; +const INTERNAL_ERROR = 'internal_error'; +const ADM_DOCUMENT_NO_LOAD = 'adm_document_no_load'; +const NAVIGATION_DISPOSED = 'navigation_disposed'; +const CLAIM_DEADLINE_MS = 3_000; +const INSERTION_DEADLINE_MS = 1_000; +const ADM_LOAD_DEADLINE_MS = 5_000; +const TICKET_TTL_MS = 3_000; +const RESERVATION_TTL_MS = 15 * 60 * 1_000; +const MAX_CAPABILITIES = 320; +const MAX_DRAWS = 8; +const MAX_GLOBAL_MESSAGE_BYTES = 4_096; +const MAX_DOMAIN_BYTES = 2_048; +const MAX_OWNER_BYTES = 64 * 1_024; +const MAX_RESPONSE_BYTES = 72 * 1_024; +const RESERVATION_ID = /^r1_[A-Za-z0-9_-]{22}$/; +const TICKET_ID = /^t1_[A-Za-z0-9_-]{22}$/; +const BASE64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const textEncoder = new TextEncoder(); + +export interface FirstDisplayPortLikeV1 { + readonly addEventListener?: (name: string, listener: (event: unknown) => void) => void; + readonly close: () => void; + readonly postMessage: (message: unknown, transfer?: readonly FirstDisplayPortLikeV1[]) => void; + readonly removeEventListener?: (name: string, listener: (event: unknown) => void) => void; + readonly start?: () => void; +} + +export interface FirstDisplayChannelLikeV1 { + readonly port1: FirstDisplayPortLikeV1; + readonly port2: FirstDisplayPortLikeV1; +} + +/** Compact authenticated capability crossing from the inline owner. */ +export type FirstDisplayRenderOwnerOptionsV1 = readonly [ + browser: Window, + clearTimer: (handle: unknown) => void, + createChannel: () => FirstDisplayChannelLikeV1, + document: Document, + fillRandom: (bytes: Uint8Array) => void, + now: () => number, + onNativeMutation: (() => boolean) | undefined, + setTimer: (callback: () => void, delayMs: number) => unknown, +]; + +export interface FirstDisplayCommittedRenderArtifactV1 extends FirstDisplayRenderHandoffArtifactV1 { + readonly current: () => boolean; + readonly retire: () => void; +} + +export interface FirstDisplayRenderStrategyCallbacksV1 { + readonly accept: (artifact: FirstDisplayCommittedRenderArtifactV1) => void; + readonly fail: (reason: string) => void; +} + +export interface FirstDisplayRenderStrategyAttemptV1 { + readonly cancel: () => void; +} + +/** Source-specific authority is narrowed before it reaches the render owner. */ +export interface FirstDisplayRenderStrategyV1 { + readonly supports: (source: unknown) => boolean; + readonly start: ( + cycle: FirstDisplayGptBoundCycleV1, + overlay: boolean, + callbacks: FirstDisplayRenderStrategyCallbacksV1 + ) => FirstDisplayRenderStrategyAttemptV1 | undefined; + readonly dispose: () => void; +} + +export type FirstDisplayRenderOwnerProtocolV1 = readonly [ + version: 1, + id: 'render_owner', + createRenderBridge: ( + options: FirstDisplayRenderOwnerOptionsV1, + strategy?: FirstDisplayRenderStrategyV1 + ) => FirstDisplayRenderBridgeCapabilityV1, +]; + +interface RenderOwnerInitialBindings { + readonly observe: (name: 'protocol_version', value: number) => void; + readonly register: (protocol: FirstDisplayRenderOwnerProtocolV1) => () => void; +} + +interface PendingClaim { + readonly port: FirstDisplayPortLikeV1; + readonly source: object; +} + +interface Attempt { + readonly cycle: FirstDisplayGptBoundCycleV1; + readonly onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void; + readonly reservationId: string; + active: boolean; + claim: PendingClaim | undefined; + claimTimer: unknown; + controlPort: FirstDisplayPortLikeV1 | undefined; + controlRelease: (() => void) | undefined; + execution: FirstDisplayRenderStrategyAttemptV1 | undefined; + gam: FirstDisplayGptRenderResult | undefined; + insertionTimer: unknown; + inserted: boolean; + ownerSource: object | undefined; + ownerTicket: string | undefined; + phaseValue: + | 'waiting_for_gam_and_claim' + | 'waiting_for_owner' + | 'waiting_for_insertion' + | 'rendering_direct'; + ticket: string | undefined; +} + +interface LiveTicket { + readonly attempt: Attempt; + readonly expiresAtInternal: number; + readonly ordinalInternal: number; + readonly registryState: 'live'; + timer?: unknown; +} + +interface TicketTombstone { + readonly expiresAtInternal: number; + readonly ordinalInternal: number; + readonly registryState: 'tombstone'; + timer?: unknown; +} + +type TicketEntry = LiveTicket | TicketTombstone; + +interface ReservationEntry { + readonly expiresAtInternal: number; + readonly ordinalInternal: number; + readonly registryState: 'live' | 'tombstone'; +} + +function utf8Length(value: string): number { + return textEncoder.encode(value).byteLength; +} + +function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + if (Object.getOwnPropertyNames(value).length !== keys.length) return undefined; + const result: Record = {}; + for (let index = 0; index < keys.length; index += 1) { + const name = keys[index]; + if (!name) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result[name] = descriptor.value; + } + return result; + } catch { + return undefined; + } +} + +function parseJson(source: string): unknown { + if (utf8Length(source) > MAX_GLOBAL_MESSAGE_BYTES) return undefined; + try { + return JSON.parse(source) as unknown; + } catch { + return undefined; + } +} + +function routingMessage( + data: unknown +): readonly [message: string | undefined, adId: string | undefined, ticket: string | undefined] { + const value = typeof data === 'string' ? parseJson(data) : data; + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return [undefined, undefined, undefined]; + } + const read = (name: string): string | undefined => { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + return descriptor?.enumerable && 'value' in descriptor && typeof descriptor.value === 'string' + ? descriptor.value + : undefined; + }; + const message = read('message'); + const adId = read('adId'); + const lifecycleTicket = read('lifecycleTicket'); + return [message, adId, lifecycleTicket]; + } catch { + return [undefined, undefined, undefined]; + } +} + +function exactPrebidRequest(data: unknown): string | undefined { + if (typeof data !== 'string') return undefined; + const fields = exactRecord(parseJson(data), ['message', 'adId', 'adServerDomain']); + return fields?.message === 'Prebid Request' && + typeof fields.adId === 'string' && + RESERVATION_ID.test(fields.adId) && + typeof fields.adServerDomain === 'string' && + fields.adServerDomain.length > 0 && + utf8Length(fields.adServerDomain) <= MAX_DOMAIN_BYTES && + data === + JSON.stringify({ + message: 'Prebid Request', + adId: fields.adId, + adServerDomain: fields.adServerDomain, + }) + ? fields.adId + : undefined; +} + +function exactOwnerRegistration( + data: unknown +): readonly [adId: string, ticket: string] | undefined { + if (typeof data !== 'string') return undefined; + const fields = exactRecord(parseJson(data), ['message', 'adId', 'version', 'lifecycleTicket']); + return fields?.message === 'TS Render Owner Register' && + fields.version === 1 && + typeof fields.adId === 'string' && + RESERVATION_ID.test(fields.adId) && + typeof fields.lifecycleTicket === 'string' && + TICKET_ID.test(fields.lifecycleTicket) && + data === + JSON.stringify({ + message: 'TS Render Owner Register', + adId: fields.adId, + version: 1, + lifecycleTicket: fields.lifecycleTicket, + }) + ? [fields.adId, fields.lifecycleTicket] + : undefined; +} + +function eventField( + event: unknown, + name: 'data' | 'ports' | 'source', + trustedPrototype: object | undefined +): unknown { + try { + if (typeof event !== 'object' || event === null) return undefined; + const own = Object.getOwnPropertyDescriptor(event, name); + if (own) return 'value' in own ? own.value : undefined; + const prototype = Object.getPrototypeOf(event); + if (!trustedPrototype || prototype !== trustedPrototype) return undefined; + const inherited = Object.getOwnPropertyDescriptor(trustedPrototype, name); + return inherited?.get ? Reflect.apply(inherited.get, event, []) : undefined; + } catch { + return undefined; + } +} + +function usablePort(value: unknown): value is FirstDisplayPortLikeV1 { + try { + return ( + typeof value === 'object' && + value !== null && + typeof Reflect.get(value, 'postMessage') === 'function' && + typeof Reflect.get(value, 'close') === 'function' + ); + } catch { + return false; + } +} + +function inspectPorts( + event: unknown, + trustedPrototype: object | undefined +): + | Readonly<{ + exact: boolean; + originalCount: number; + ports: readonly FirstDisplayPortLikeV1[]; + }> + | undefined { + const value = eventField(event, 'ports', trustedPrototype); + try { + if (!Array.isArray(value)) return undefined; + const length = Object.getOwnPropertyDescriptor(value, 'length'); + if (!length || !('value' in length) || !Number.isSafeInteger(length.value)) return undefined; + let exact = + Object.getPrototypeOf(value) === Array.prototype && + Object.getOwnPropertySymbols(value).length === 0 && + Object.getOwnPropertyNames(value).length === length.value + 1; + const ports: FirstDisplayPortLikeV1[] = []; + const seen = new Set(); + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor) || !usablePort(descriptor.value)) { + exact = false; + continue; + } + if (seen.has(descriptor.value)) { + exact = false; + continue; + } + seen.add(descriptor.value); + ports.push(descriptor.value); + } + return { exact, originalCount: length.value, ports }; + } catch { + return undefined; + } +} + +function eventSource(event: unknown, trustedPrototype: object | undefined): object | undefined { + const value = eventField(event, 'source', trustedPrototype); + return (typeof value === 'object' || typeof value === 'function') && value !== null + ? value + : undefined; +} + +function suppress(event: unknown): boolean { + try { + if (typeof event !== 'object' || event === null) return false; + const stop = Reflect.get(event, 'stopImmediatePropagation'); + if (typeof stop !== 'function') return false; + Reflect.apply(stop, event, []); + return true; + } catch { + return false; + } +} + +function closePort(port: FirstDisplayPortLikeV1 | undefined): void { + try { + port?.close(); + } catch { + // The endpoint is already generation-inert. + } +} + +function post( + port: FirstDisplayPortLikeV1, + data: unknown, + transfer: readonly FirstDisplayPortLikeV1[] = [] +): boolean { + try { + Reflect.apply(port.postMessage, port, [data, transfer]); + return true; + } catch { + return false; + } +} + +function installPortListeners( + port: FirstDisplayPortLikeV1, + receive: (event: unknown) => void, + receiveError: () => void, + publishRelease: (release: () => void) => void +): boolean { + try { + if (typeof port.addEventListener !== 'function') return false; + let live = true; + const release = (): void => { + if (!live) return; + live = false; + try { + if (typeof port.removeEventListener === 'function') { + Reflect.apply(port.removeEventListener, port, ['message', receive]); + Reflect.apply(port.removeEventListener, port, ['messageerror', receiveError]); + } + } catch { + // Port closure remains authoritative. + } + }; + Reflect.apply(port.addEventListener, port, ['message', receive]); + Reflect.apply(port.addEventListener, port, ['messageerror', receiveError]); + publishRelease(release); + if (typeof port.start === 'function') Reflect.apply(port.start, port, []); + return live; + } catch { + return false; + } +} + +function encodeOpaque(bytes: Uint8Array): string { + let output = ''; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 6) { + bits -= 6; + output += BASE64URL[(buffer >>> bits) & 63]; + } + buffer &= (1 << bits) - 1; + } + if (bits > 0) output += BASE64URL[(buffer << (6 - bits)) & 63]; + return output; +} + +function refusedResponse(adId: string): string { + return JSON.stringify({ + message: 'Prebid Response', + adId, + rendererVersion: '4', + tsOwner: { version: 1, status: 'refused' }, + }); +} + +function ownerRefused(adId: string): string { + return JSON.stringify({ message: 'TS Render Owner Refused', adId, version: 1 }); +} + +function snapshotFrameAttributes(frame: HTMLIFrameElement): string | undefined { + try { + return JSON.stringify( + [...frame.attributes] + .map((attribute) => [attribute.name, attribute.value] as const) + .sort(([left], [right]) => left.localeCompare(right)) + ); + } catch { + return undefined; + } +} + +function exactPublisherFrame(document: Document, source: object): HTMLIFrameElement | undefined { + try { + const frames = document.querySelectorAll('iframe'); + let selected: HTMLIFrameElement | undefined; + for (let index = 0; index < frames.length; index += 1) { + const candidate = frames.item(index); + if (candidate?.isConnected && candidate.contentWindow === source) { + if (selected) return undefined; + selected = candidate; + } + } + return selected; + } catch { + return undefined; + } +} + +function resizeCollapsedPucShell( + document: Document, + source: object, + width: number, + height: number +): boolean { + try { + const browser = document.defaultView; + const selected = exactPublisherFrame(document, source); + if (!browser || !selected || width <= 0 || height <= 0) return false; + const onePixelAttribute = (element: Element, name: 'width' | 'height'): boolean => { + const value = element.getAttribute(name); + if (value === null || !/^\d+(?:\.\d+)?$/.test(value)) return false; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed <= 1; + }; + const ordinaryCollapsed = (element: HTMLElement): boolean => { + const style = browser.getComputedStyle(element); + const pixel = (value: string): boolean => { + const match = /^(\d+(?:\.\d+)?)px$/.exec(value); + return match !== null && Number(match[1]) <= 1; + }; + return ( + style.position !== 'fixed' && + style.position !== 'sticky' && + pixel(style.width) && + pixel(style.height) + ); + }; + if ( + !onePixelAttribute(selected, 'width') || + !onePixelAttribute(selected, 'height') || + !ordinaryCollapsed(selected) || + selected.closest('a,[data-anchor-status]') !== null + ) + return false; + const wrapper = selected.parentElement; + if ( + !wrapper || + wrapper === document.body || + wrapper === document.documentElement || + wrapper.tagName === 'A' || + !wrapper.isConnected || + !ordinaryCollapsed(wrapper) || + wrapper.closest('a,[data-anchor-status]') !== null + ) + return false; + selected.style.setProperty('width', `${width}px`); + selected.style.setProperty('height', `${height}px`); + wrapper.style.setProperty('width', `${width}px`); + wrapper.style.setProperty('height', `${height}px`); + return true; + } catch { + return false; + } +} + +function configureAdmFrame(frame: HTMLIFrameElement, width: number, height: number): void { + frame.setAttribute('sandbox', ADM_SANDBOX); + frame.setAttribute('referrerpolicy', 'no-referrer'); + frame.setAttribute('width', String(width)); + frame.setAttribute('height', String(height)); + frame.setAttribute('scrolling', 'no'); + frame.setAttribute('frameborder', '0'); + frame.setAttribute('marginwidth', '0'); + frame.setAttribute('marginheight', '0'); + frame.setAttribute('title', 'Ad content'); + frame.setAttribute('aria-label', 'Advertisement'); + frame.setAttribute( + 'style', + `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` + ); +} + +function validCycle( + cycle: FirstDisplayGptBoundCycleV1, + strategy: FirstDisplayRenderStrategyV1 | undefined +): boolean { + try { + const source = cycle[0].renderSource; + const document = cycle[1].ownerDocument; + let exactElementMatches = 0; + const elements = document.getElementsByTagName('*'); + for (let index = 0; index < elements.length; index += 1) { + const candidate = elements.item(index); + if (candidate?.id !== cycle[1].id) continue; + if (candidate !== cycle[1]) return false; + exactElementMatches += 1; + } + return ( + cycle[2]() && + cycle[6] === cycle[0].slot && + cycle[6] === cycle[5].slot && + exactElementMatches === 1 && + document.getElementById(cycle[1].id) === cycle[1] && + RESERVATION_ID.test(cycle[0].rendererReservationId) && + (source.type === 'adm' || strategy?.supports(source) === true) + ); + } catch { + return false; + } +} + +function publisherArtifact( + document: Document, + attempt: Attempt +): FirstDisplayCommittedRenderArtifactV1 | undefined { + const source = attempt.ownerSource; + if (!source) return undefined; + const frame = exactPublisherFrame(document, source); + const attributes = frame ? snapshotFrameAttributes(frame) : undefined; + const parent = frame?.parentNode; + const frameWindow = frame?.contentWindow; + if (!frame || attributes === undefined || !parent || !frameWindow) return undefined; + return Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + identity: frame, + kind: 'gpt_adm' as const, + owner: 'publisher' as const, + slotId: attempt.cycle[6], + token: attempt.reservationId, + current: () => { + try { + return ( + frame.ownerDocument === document && + frame.isConnected && + frame.parentNode === parent && + frame.contentWindow === frameWindow && + frame.contentWindow === source && + snapshotFrameAttributes(frame) === attributes + ); + } catch { + return false; + } + }, + retire: () => undefined, + }); +} + +function directAdmExecution( + document: Document, + cycle: FirstDisplayGptBoundCycleV1, + callbacks: FirstDisplayRenderStrategyCallbacksV1, + setTimer: (callback: () => void, delayMs: number) => unknown, + clearTimer: (handle: unknown) => void +): FirstDisplayRenderStrategyAttemptV1 | undefined { + const source = cycle[0].renderSource; + if (source.type !== 'adm') return undefined; + let live = true; + let timer: unknown; + let frame: HTMLIFrameElement | undefined; + const retire = (): void => { + if (!live) return; + live = false; + try { + if (timer !== undefined) clearTimer(timer); + } catch { + // Exact-node retirement remains authoritative. + } + if (!frame) return; + frame.onload = null; + frame.onerror = null; + try { + frame.remove(); + } catch { + // The exact node cannot regain authority. + } + }; + try { + const created = document.createElement('iframe'); + frame = created; + configureAdmFrame(created, source.width, source.height); + const intended = `${source.adm}`; + created.onload = () => { + if (!live) return; + const attributes = snapshotFrameAttributes(created); + const frameWindow = created.contentWindow; + if ( + !cycle[2]() || + created.parentNode !== cycle[1] || + created.srcdoc !== intended || + created.getAttribute('src') !== null || + attributes === undefined || + !frameWindow + ) { + callbacks.fail(ADM_DOCUMENT_NO_LOAD); + return; + } + if (timer !== undefined) clearTimer(timer); + timer = undefined; + created.onload = null; + created.onerror = null; + callbacks.accept( + Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + identity: created, + kind: 'gpt_adm' as const, + owner: 'trusted_server' as const, + slotId: cycle[6], + token: cycle[0].rendererReservationId, + current: () => { + try { + return ( + live && + cycle[2]() && + created.isConnected && + created.parentNode === cycle[1] && + created.contentWindow === frameWindow && + created.srcdoc === intended && + created.getAttribute('src') === null && + snapshotFrameAttributes(created) === attributes + ); + } catch { + return false; + } + }, + retire, + }) + ); + }; + created.onerror = () => callbacks.fail(ADM_DOCUMENT_NO_LOAD); + created.srcdoc = intended; + cycle[1].appendChild(created); + let scheduling = true; + let firedSynchronously = false; + timer = setTimer(() => { + if (scheduling) { + firedSynchronously = true; + return; + } + if (live) callbacks.fail(ADM_DOCUMENT_NO_LOAD); + }, ADM_LOAD_DEADLINE_MS); + scheduling = false; + if (timer === undefined || firedSynchronously) { + if (timer !== undefined) clearTimer(timer); + retire(); + return undefined; + } + return Object.freeze({ cancel: retire }); + } catch { + retire(); + return undefined; + } +} + +/** Own the bounded, source-neutral render journal for one first-display batch. */ +export function createFirstDisplayRenderJournal( + options: FirstDisplayRenderOwnerOptionsV1, + strategy?: FirstDisplayRenderStrategyV1 +): FirstDisplayRenderBridgeV1 { + const attempts = new Map(); + const reservations = new Map(); + const tickets = new Map(); + const committed = new Map(); + const timers = new Set(); + let disposed = false; + let sealed = false; + let ingressClosed = false; + let handoffCaptured = false; + let committedArtifactsDetached = false; + let nextTicketOrdinal = 1; + let nextReservationOrdinal = 1; + let lastNow = Number.NEGATIVE_INFINITY; + const messageEventPrototype = (() => { + try { + const constructor = Reflect.get(options[0], 'MessageEvent'); + const prototype = + typeof constructor === 'function' ? Reflect.get(constructor, 'prototype') : undefined; + return typeof prototype === 'object' && prototype !== null ? prototype : undefined; + } catch { + return undefined; + } + })(); + + const readNow = (): number | undefined => { + try { + const value = options[5](); + if (!Number.isFinite(value) || value < 0 || value < lastNow) return undefined; + lastNow = value; + return value; + } catch { + return undefined; + } + }; + + const notifyNativeMutation = (): void => { + try { + options[6]?.(); + } catch { + // Observation cannot alter the admitted event. + } + }; + + const clearOwnedTimer = (handle: unknown): void => { + if (handle === undefined || !timers.delete(handle)) return; + try { + options[1](handle); + } catch { + // Timer state is already generation-inert. + } + }; + + const arm = (callback: () => void, delayMs: number): unknown => { + let handle: unknown; + let scheduling = true; + let firedSynchronously = false; + try { + handle = options[7](() => { + if (scheduling) { + firedSynchronously = true; + return; + } + if (!timers.delete(handle)) return; + callback(); + }, delayMs); + } catch { + handle = undefined; + } + scheduling = false; + if (handle === undefined) return undefined; + if (firedSynchronously) { + try { + options[1](handle); + } catch { + // Synchronous timers are refused regardless of cleanup outcome. + } + return undefined; + } + timers.add(handle); + return handle; + }; + + const mint = (registry: ReadonlyMap): string | undefined => { + for (let draw = 0; draw < MAX_DRAWS; draw += 1) { + const bytes = new Uint8Array(16); + try { + options[4](bytes); + } catch { + return undefined; + } + const candidate = `t1_${encodeOpaque(bytes)}`; + if (!registry.has(candidate)) return candidate; + } + return undefined; + }; + + const retireTicket = (attempt: Attempt): void => { + const ticket = attempt.ticket; + attempt.ticket = undefined; + if (!ticket) return; + const entry = tickets.get(ticket); + if (entry?.registryState !== 'live' || entry.attempt !== attempt) return; + tickets.set(ticket, { + registryState: 'tombstone', + expiresAtInternal: entry.expiresAtInternal, + ordinalInternal: entry.ordinalInternal, + timer: entry.timer, + }); + }; + + const retireReservation = (attempt: Attempt): void => { + const entry = reservations.get(attempt.reservationId); + if (entry?.registryState !== 'live') return; + reservations.set(attempt.reservationId, { + expiresAtInternal: entry.expiresAtInternal, + ordinalInternal: entry.ordinalInternal, + registryState: 'tombstone', + }); + }; + + const releaseAttempt = ( + attempt: Attempt, + cancelExecution: boolean + ): Readonly<{ + claim?: PendingClaim; + controlPort?: FirstDisplayPortLikeV1; + controlRelease?: () => void; + execution?: FirstDisplayRenderStrategyAttemptV1; + }> => { + clearOwnedTimer(attempt.claimTimer); + clearOwnedTimer(attempt.insertionTimer); + attempt.claimTimer = undefined; + attempt.insertionTimer = undefined; + const claim = attempt.claim; + const controlPort = attempt.controlPort; + const controlRelease = attempt.controlRelease; + const execution = cancelExecution ? attempt.execution : undefined; + attempt.claim = undefined; + attempt.controlPort = undefined; + attempt.controlRelease = undefined; + attempt.execution = undefined; + attempt.ownerSource = undefined; + attempt.ownerTicket = undefined; + retireTicket(attempt); + retireReservation(attempt); + attempts.delete(attempt.reservationId); + return { + ...(claim ? { claim } : {}), + ...(controlPort ? { controlPort } : {}), + ...(controlRelease ? { controlRelease } : {}), + ...(execution ? { execution } : {}), + }; + }; + + const settle = ( + attempt: Attempt, + result: 'accepted' | 'failed' | 'cancelled', + reason = result === 'cancelled' ? NAVIGATION_DISPOSED : INTERNAL_ERROR, + candidateArtifact?: FirstDisplayCommittedRenderArtifactV1 + ): boolean => { + if (!attempt.active) return false; + if (result === 'accepted') { + if ( + !candidateArtifact || + candidateArtifact.slotId !== attempt.cycle[6] || + candidateArtifact.token !== attempt.reservationId || + candidateArtifact.current() !== true + ) { + return settle(attempt, 'failed', INTERNAL_ERROR); + } + committed.set(attempt.cycle[6], candidateArtifact); + } + attempt.active = false; + const ticket = attempt.ownerTicket; + const released = releaseAttempt(attempt, result !== 'accepted'); + notifyNativeMutation(); + try { + released.controlRelease?.(); + } catch { + // State was detached before publisher-controlled cleanup. + } + try { + released.execution?.cancel(); + } catch { + // Strategy authority is detached from this generation. + } + if (released.controlPort && ticket) { + const settlement: Record = { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: ticket, + outcome: result, + }; + post(released.controlPort, settlement); + } + closePort(released.claim?.port); + closePort(released.controlPort); + try { + attempt.onTerminal(result, result === 'accepted' ? null : reason); + } catch { + // A terminal observer cannot restore detached authority. + } + return true; + }; + + const fail = (attempt: Attempt, reason: string, refuseClaim = false): boolean => { + if (!attempt.active) return false; + if (refuseClaim && attempt.claim) { + const claim = attempt.claim; + attempt.claim = undefined; + post(claim.port, refusedResponse(attempt.reservationId)); + closePort(claim.port); + if (!attempt.active) return false; + } + return settle(attempt, 'failed', reason); + }; + + const issueTicket = (attempt: Attempt): string | undefined => { + if (tickets.size >= MAX_CAPABILITIES || utf8Length(PUC_DYNAMIC_OWNER) > MAX_OWNER_BYTES) { + return undefined; + } + const ticket = mint(tickets); + if (!ticket || !TICKET_ID.test(ticket)) return undefined; + const issuedAt = readNow(); + if (issuedAt === undefined) return undefined; + const expiresAt = issuedAt + TICKET_TTL_MS; + const ordinal = nextTicketOrdinal; + nextTicketOrdinal += 1; + const entry: LiveTicket = { + registryState: 'live', + attempt, + expiresAtInternal: expiresAt, + ordinalInternal: ordinal, + }; + tickets.set(ticket, entry); + attempt.ticket = ticket; + entry.timer = arm(() => { + const current = tickets.get(ticket); + if (current !== entry) { + const observedAt = readNow(); + if ( + current?.registryState === 'tombstone' && + observedAt !== undefined && + current.expiresAtInternal <= observedAt + ) { + tickets.delete(ticket); + notifyNativeMutation(); + } + return; + } + tickets.delete(ticket); + attempt.ticket = undefined; + notifyNativeMutation(); + fail(attempt, 'owner_registration_timeout'); + }, TICKET_TTL_MS); + if (entry.timer === undefined) { + tickets.delete(ticket); + attempt.ticket = undefined; + return undefined; + } + return ticket; + }; + + const join = (attempt: Attempt): boolean => { + const claim = attempt.claim; + if ( + !attempt.active || + !claim || + attempt.gam !== 'nonempty_gam' || + attempt.phaseValue !== 'waiting_for_gam_and_claim' + ) + return false; + clearOwnedTimer(attempt.claimTimer); + attempt.claimTimer = undefined; + const ticket = issueTicket(attempt); + if (!ticket) return fail(attempt, 'capability_registry_full', true); + const response = JSON.stringify({ + message: 'Prebid Response', + adId: attempt.reservationId, + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: attempt.cycle[0].renderSource.type, + lifecycleTicket: ticket, + }, + }); + attempt.claim = undefined; + attempt.ownerSource = claim.source; + attempt.phaseValue = 'waiting_for_owner'; + retireReservation(attempt); + const source = attempt.cycle[0].renderSource; + resizeCollapsedPucShell(options[3], claim.source, source.width, source.height); + if ( + !attempt.active || + attempt.phaseValue !== 'waiting_for_owner' || + attempt.ownerSource !== claim.source || + attempt.ticket !== ticket + ) { + closePort(claim.port); + return false; + } + const posted = utf8Length(response) <= MAX_RESPONSE_BYTES && post(claim.port, response); + closePort(claim.port); + if (!posted && attempt.active) return fail(attempt, INTERNAL_ERROR); + return posted; + }; + + const beginExecution = (attempt: Attempt, overlay: boolean): boolean => { + type Pending = + | Readonly<{ kind: 'accept'; artifact: FirstDisplayCommittedRenderArtifactV1 }> + | Readonly<{ kind: 'fail'; reason: string }>; + let starting = true; + let pending: Pending | undefined; + let duplicate = false; + const enqueue = (next: Pending): void => { + if (!attempt.active) return; + if (starting) { + if (pending) duplicate = true; + else pending = next; + return; + } + if (next.kind === 'accept') settle(attempt, 'accepted', INTERNAL_ERROR, next.artifact); + else fail(attempt, next.reason); + }; + const callbacks = Object.freeze({ + accept: (artifact: FirstDisplayCommittedRenderArtifactV1) => + enqueue(Object.freeze({ kind: 'accept' as const, artifact })), + fail: (reason: string) => enqueue(Object.freeze({ kind: 'fail' as const, reason })), + }); + let execution: FirstDisplayRenderStrategyAttemptV1 | undefined; + if (attempt.cycle[0].renderSource.type === 'adm') { + if (overlay) return false; + execution = directAdmExecution(options[3], attempt.cycle, callbacks, options[7], options[1]); + } else if (strategy?.supports(attempt.cycle[0].renderSource) === true) { + try { + execution = strategy.start(attempt.cycle, overlay, callbacks); + } catch { + execution = undefined; + } + } + starting = false; + if (!execution || duplicate) { + try { + execution?.cancel(); + } catch { + // Refused execution cannot retain authority. + } + return false; + } + attempt.execution = execution; + if (pending?.kind === 'accept') settle(attempt, 'accepted', INTERNAL_ERROR, pending.artifact); + else if (pending?.kind === 'fail') fail(attempt, pending.reason); + return true; + }; + + const ownerInserted = (attempt: Attempt): void => { + if (!attempt.active || attempt.phaseValue !== 'waiting_for_insertion' || attempt.inserted) + return; + attempt.inserted = true; + clearOwnedTimer(attempt.insertionTimer); + attempt.insertionTimer = undefined; + if (attempt.cycle[0].renderSource.type === 'adm') { + attempt.insertionTimer = arm(() => fail(attempt, ADM_DOCUMENT_NO_LOAD), ADM_LOAD_DEADLINE_MS); + if (attempt.insertionTimer === undefined) fail(attempt, INTERNAL_ERROR); + } + }; + + const handleOwnerControl = (attempt: Attempt, event: unknown): void => { + if (!attempt.active) return; + const ports = inspectPorts(event, messageEventPrototype); + if (!ports?.exact || ports.originalCount !== 0 || ports.ports.length !== 0) { + fail(attempt, INTERNAL_ERROR); + return; + } + const message = exactRecord(eventField(event, 'data', messageEventPrototype), [ + 'message', + 'version', + 'lifecycleTicket', + ]); + const ticket = attempt.ownerTicket; + if ( + message?.message === 'TS Owner Inserted' && + message.version === 1 && + message.lifecycleTicket === ticket + ) { + ownerInserted(attempt); + return; + } + if (attempt.cycle[0].renderSource.type !== 'adm') { + fail(attempt, INTERNAL_ERROR); + return; + } + if ( + message?.message === 'TS ADM Loaded' && + message.version === 1 && + message.lifecycleTicket === ticket && + attempt.inserted + ) { + clearOwnedTimer(attempt.insertionTimer); + attempt.insertionTimer = undefined; + const artifact = publisherArtifact(options[3], attempt); + if (artifact) settle(attempt, 'accepted', INTERNAL_ERROR, artifact); + else fail(attempt, ADM_DOCUMENT_NO_LOAD); + return; + } + if ( + message?.message === 'TS ADM Failed' && + message.version === 1 && + message.lifecycleTicket === ticket + ) { + fail(attempt, ADM_DOCUMENT_NO_LOAD); + return; + } + fail(attempt, ADM_DOCUMENT_NO_LOAD); + }; + + const startOwner = (attempt: Attempt): boolean => { + const controlPort = attempt.controlPort; + const ticket = attempt.ownerTicket; + if (!controlPort || !ticket || !attempt.active) return false; + if (!validCycle(attempt.cycle, strategy)) return fail(attempt, 'slot_unresolved'); + attempt.insertionTimer = arm( + () => fail(attempt, 'owner_insertion_timeout'), + INSERTION_DEADLINE_MS + ); + if (attempt.insertionTimer === undefined) return fail(attempt, INTERNAL_ERROR); + if (attempt.cycle[0].renderSource.type === 'adm') { + return ( + post(controlPort, { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: ticket, + source: attempt.cycle[0].renderSource, + }) || fail(attempt, INTERNAL_ERROR) + ); + } + if (!beginExecution(attempt, true)) return fail(attempt, 'winner_not_renderable'); + if (attempt.active) ownerInserted(attempt); + return ( + post(controlPort, { + message: 'TS APS Top Mount Started', + version: 1, + lifecycleTicket: ticket, + }) || fail(attempt, INTERNAL_ERROR) + ); + }; + + const handleOwnerRegistration = ( + event: unknown, + data: unknown, + routing: ReturnType + ): void => { + const ticket = routing[2]; + if (!ticket) return; + const beforeSuppress = tickets.get(ticket); + if (!beforeSuppress || !suppress(event)) return; + const entry = tickets.get(ticket); + const inspection = inspectPorts(event, messageEventPrototype); + const responsePort = inspection?.ports[0]; + const refuse = (): void => { + if (responsePort) post(responsePort, ownerRefused(routing[1] ?? '')); + for (const port of inspection?.ports ?? []) closePort(port); + }; + if (entry !== beforeSuppress || entry.registryState !== 'live') { + refuse(); + return; + } + const exact = exactOwnerRegistration(data); + const attempt = entry.attempt; + if ( + !exact || + !inspection?.exact || + inspection.originalCount !== 1 || + inspection.ports.length !== 1 || + !responsePort || + exact[0] !== attempt.reservationId || + exact[1] !== ticket || + eventSource(event, messageEventPrototype) !== attempt.ownerSource || + !attempt.active || + attempt.phaseValue !== 'waiting_for_owner' + ) { + refuse(); + if (attempt.active) fail(attempt, 'bridge_id_mismatch'); + return; + } + let channel: FirstDisplayChannelLikeV1; + try { + channel = options[2](); + } catch { + post(responsePort, ownerRefused(attempt.reservationId)); + closePort(responsePort); + fail(attempt, INTERNAL_ERROR); + return; + } + if ( + tickets.get(ticket) !== entry || + !attempt.active || + attempt.phaseValue !== 'waiting_for_owner' + ) { + closePort(channel.port1); + closePort(channel.port2); + refuse(); + return; + } + retireTicket(attempt); + attempt.ownerTicket = ticket; + attempt.controlPort = channel.port1; + attempt.phaseValue = 'waiting_for_insertion'; + const listening = installPortListeners( + channel.port1, + (message) => handleOwnerControl(attempt, message), + () => + fail( + attempt, + attempt.cycle[0].renderSource.type === 'adm' ? ADM_DOCUMENT_NO_LOAD : INTERNAL_ERROR + ), + (release) => { + attempt.controlRelease = release; + } + ); + if (!listening || !attempt.active) { + closePort(responsePort); + closePort(channel.port2); + if (attempt.active) fail(attempt, INTERNAL_ERROR); + return; + } + const registered = JSON.stringify({ + message: 'TS Render Owner Registered', + adId: attempt.reservationId, + version: 1, + lifecycleTicket: ticket, + }); + const posted = post(responsePort, registered, [channel.port2]); + closePort(responsePort); + closePort(channel.port2); + if (!posted || !attempt.active || !startOwner(attempt)) { + if (attempt.active) fail(attempt, INTERNAL_ERROR); + } + }; + + const dispatch = (event: unknown): void => { + if (disposed || ingressClosed) return; + const data = eventField(event, 'data', messageEventPrototype); + const routing = routingMessage(data); + if (routing[0] === 'TS Render Owner Register' && routing[2]) { + notifyNativeMutation(); + handleOwnerRegistration(event, data, routing); + return; + } + const adId = routing[1]; + if (routing[0] !== 'Prebid Request' || !adId) return; + const beforeSuppress = reservations.get(adId); + if (!beforeSuppress || !suppress(event)) return; + notifyNativeMutation(); + const reservationState = reservations.get(adId); + const inspection = inspectPorts(event, messageEventPrototype); + const responsePort = inspection?.ports[0]; + const refuse = (): void => { + if (responsePort) post(responsePort, refusedResponse(adId)); + for (const port of inspection?.ports ?? []) closePort(port); + }; + if ( + reservationState !== beforeSuppress || + reservationState.registryState !== 'live' || + !inspection?.exact || + inspection.originalCount !== 1 || + inspection.ports.length !== 1 || + !responsePort + ) { + refuse(); + return; + } + const exact = exactPrebidRequest(data); + const attempt = attempts.get(adId); + const source = eventSource(event, messageEventPrototype); + if ( + !exact || + exact !== adId || + !attempt?.active || + attempt.phaseValue !== 'waiting_for_gam_and_claim' || + attempt.claim || + !source + ) { + refuse(); + return; + } + attempt.claim = Object.freeze({ port: responsePort, source }); + if (attempt.gam === 'nonempty_gam') join(attempt); + }; + + try { + options[0].addEventListener('message', dispatch as EventListener, true); + } catch { + throw new TypeError('tsjs'); + } + + const sweepCommittedArtifacts = (): number => { + if (disposed || committedArtifactsDetached) return 0; + let retired = 0; + for (const [slotId, artifact] of [...committed.entries()]) { + if (committed.get(slotId) !== artifact || artifact.current()) continue; + committed.delete(slotId); + try { + artifact.retire(); + } catch { + // Invalidated identity is already detached from the journal. + } + retired += 1; + } + if (retired > 0) notifyNativeMutation(); + return retired; + }; + + return Object.freeze({ + bind: ( + cycle: FirstDisplayGptBoundCycleV1, + onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void + ): boolean => { + if ( + disposed || + ingressClosed || + sealed || + typeof onTerminal !== 'function' || + !validCycle(cycle, strategy) || + reservations.size >= MAX_CAPABILITIES || + reservations.has(cycle[0].rendererReservationId) + ) + return false; + const observedAt = readNow(); + if (observedAt === undefined) return false; + const expiresAt = observedAt + RESERVATION_TTL_MS; + const ordinal = nextReservationOrdinal; + if (!Number.isFinite(expiresAt) || expiresAt <= observedAt || ordinal > 4_294_967_295) { + return false; + } + const attempt: Attempt = { + active: true, + claim: undefined, + claimTimer: undefined, + controlPort: undefined, + controlRelease: undefined, + cycle, + execution: undefined, + gam: undefined, + insertionTimer: undefined, + inserted: false, + onTerminal, + ownerSource: undefined, + ownerTicket: undefined, + phaseValue: 'waiting_for_gam_and_claim', + reservationId: cycle[0].rendererReservationId, + ticket: undefined, + }; + attempts.set(attempt.reservationId, attempt); + reservations.set(attempt.reservationId, { + expiresAtInternal: expiresAt, + ordinalInternal: ordinal, + registryState: 'live', + }); + nextReservationOrdinal += 1; + return true; + }, + recordGam: ( + cycle: FirstDisplayGptBoundCycleV1, + result: FirstDisplayGptRenderResult + ): boolean => { + const attempt = attempts.get(cycle[0].rendererReservationId); + if ( + !attempt?.active || + attempt.cycle !== cycle || + attempt.gam || + attempt.phaseValue !== 'waiting_for_gam_and_claim' + ) + return false; + attempt.gam = result; + if (result === 'gam_empty') { + attempt.phaseValue = 'rendering_direct'; + retireReservation(attempt); + const claim = attempt.claim; + attempt.claim = undefined; + if (claim) { + post(claim.port, refusedResponse(attempt.reservationId)); + closePort(claim.port); + if (!attempt.active) return false; + } + return beginExecution(attempt, false) || fail(attempt, 'winner_not_renderable'); + } + if (attempt.claim) return join(attempt); + attempt.claimTimer = arm(() => fail(attempt, 'bridge_claim_timeout'), CLAIM_DEADLINE_MS); + return attempt.claimTimer !== undefined || fail(attempt, INTERNAL_ERROR); + }, + recordFailure: (cycle: FirstDisplayGptBoundCycleV1): boolean => { + const attempt = attempts.get(cycle[0].rendererReservationId); + return Boolean( + attempt?.active && attempt.cycle === cycle && fail(attempt, 'gpt_request_failed') + ); + }, + retire: (cycle: FirstDisplayGptBoundCycleV1): boolean => { + if (disposed || committedArtifactsDetached) return false; + const artifact = committed.get(cycle[6]); + if (!artifact || artifact.token !== cycle[0].rendererReservationId) return false; + committed.delete(cycle[6]); + try { + artifact.retire(); + } catch { + // Exact identity is already retired from the journal. + } + notifyNativeMutation(); + return true; + }, + sweepCommittedArtifacts, + sealTsAdmission: (): void => { + if (disposed || [...attempts.values()].some((attempt) => attempt.active)) { + throw new TypeError('tsjs'); + } + sealed = true; + }, + closeIngress: (): boolean => { + if ( + disposed || + ingressClosed || + !sealed || + [...attempts.values()].some((attempt) => attempt.active) + ) + return false; + ingressClosed = true; + try { + options[0].removeEventListener('message', dispatch as EventListener, true); + } catch { + // Closed generation state remains authoritative. + } + for (const handle of [...timers]) clearOwnedTimer(handle); + return true; + }, + captureHandoff: () => { + if (disposed || !ingressClosed || handoffCaptured) return undefined; + sweepCommittedArtifacts(); + const observedAt = readNow(); + if (observedAt === undefined) return undefined; + const reservationTombstones = [...reservations.entries()] + .filter( + ([, entry]) => entry.registryState === 'tombstone' && entry.expiresAtInternal > observedAt + ) + .map(([value, entry]) => + Object.freeze([ + 'reservation' as const, + value, + entry.expiresAtInternal, + entry.ordinalInternal, + ] as const) + ); + const ticketTombstones = [...tickets.entries()] + .filter( + ([, entry]) => entry.registryState === 'tombstone' && entry.expiresAtInternal > observedAt + ) + .map(([value, entry]) => + Object.freeze([ + 'ticket' as const, + value, + entry.expiresAtInternal, + entry.ordinalInternal, + ] as const) + ); + const artifacts = [...committed.values()].map((artifact) => + Object.freeze([ + artifact.hostPosition, + artifact.hostPositionPriority, + artifact.identity, + artifact.kind, + artifact.owner, + artifact.slotId, + artifact.token, + ] as const) + ); + handoffCaptured = true; + return Object.freeze([ + Object.freeze(artifacts), + Object.freeze([...reservationTombstones, ...ticketTombstones]), + observedAt, + nextReservationOrdinal, + nextTicketOrdinal, + ] as const); + }, + detachCommittedArtifacts: (): boolean => { + if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { + return false; + } + if (sweepCommittedArtifacts() > 0) return false; + committedArtifactsDetached = true; + return true; + }, + dispose: (): void => { + if (disposed) return; + disposed = true; + if (!ingressClosed) { + ingressClosed = true; + try { + options[0].removeEventListener('message', dispatch as EventListener, true); + } catch { + // Generation latching keeps a failed physical removal inert. + } + } + for (const attempt of [...attempts.values()]) { + if (attempt.active) settle(attempt, 'cancelled', NAVIGATION_DISPOSED); + } + for (const handle of [...timers]) clearOwnedTimer(handle); + if (!committedArtifactsDetached) { + for (const artifact of committed.values()) { + try { + artifact.retire(); + } catch { + // Exact artifact retirement is best-effort after ownership removal. + } + } + } + committed.clear(); + attempts.clear(); + reservations.clear(); + tickets.clear(); + try { + strategy?.dispose(); + } catch { + // Strategy authority is generation-latched. + } + }, + }); +} + +/** Install the one-use release-private source-neutral initial render owner. */ +export function installRenderOwnerInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): readonly [version: 1, id: 'render_owner'] { + // The authenticated bootstrap is the sole caller and owns this capability object. + const bindings = candidate as RenderOwnerInitialBindings; + if (typeof own !== 'function') throw new TypeError('tsjs'); + let consumed = false; + let created: FirstDisplayRenderBridgeV1 | undefined; + const protocol: FirstDisplayRenderOwnerProtocolV1 = Object.freeze([ + 1, + 'render_owner', + (options: FirstDisplayRenderOwnerOptionsV1, strategy?: FirstDisplayRenderStrategyV1) => { + if (consumed) throw new TypeError('tsjs'); + consumed = true; + created = createFirstDisplayRenderJournal(options, strategy); + return Object.freeze([ + created.bind, + created.recordGam, + created.recordFailure, + created.retire, + created.sweepCommittedArtifacts, + created.sealTsAdmission, + created.closeIngress, + created.captureHandoff, + created.detachCommittedArtifacts, + created.dispose, + ] as FirstDisplayRenderBridgeCapabilityV1); + }, + ]); + const release = bindings.register(protocol); + if (typeof release !== 'function') throw new TypeError('tsjs'); + own(() => { + const bridge = created; + created = undefined; + try { + bridge?.dispose(); + } finally { + release(); + } + }); + bindings.observe('protocol_version', 1); + return Object.freeze([1, 'render_owner']); +} diff --git a/crates/trusted-server-js/lib/src/first_display/slices/aps.ts b/crates/trusted-server-js/lib/src/first_display/slices/aps.ts new file mode 100644 index 000000000..1af82ec1d --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/aps.ts @@ -0,0 +1,4 @@ +import { installApsInitial } from '../leaf/aps_protocol'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; + +registerCurrentFirstDisplayComponent('aps_initial', installApsInitial); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/creative.ts b/crates/trusted-server-js/lib/src/first_display/slices/creative.ts new file mode 100644 index 000000000..bf5bbb01f --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/creative.ts @@ -0,0 +1,22 @@ +import { installCreativeInitial } from '../leaf/creative_guard'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; +import { installClickGuard } from '../../integrations/creative/click'; +import { installDynamicIframeProxy } from '../../integrations/creative/iframe'; +import { installDynamicImageProxy } from '../../integrations/creative/image'; + +import type { InitialSliceInstaller } from './definition'; + +const installCreativeInitialSlice: InitialSliceInstaller = (candidate, own, config) => + installCreativeInitial( + Object.freeze({ + document, + installClickGuard: () => installClickGuard(false), + installDynamicIframeProxy: () => installDynamicIframeProxy(false), + installDynamicImageProxy: () => installDynamicImageProxy(false), + observe: (candidate as Readonly<{ observe: unknown }>).observe, + }), + own, + config + ); + +registerCurrentFirstDisplayComponent('creative_initial', installCreativeInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts b/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts new file mode 100644 index 000000000..37a5cb632 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts @@ -0,0 +1,18 @@ +import { installDataDomeInitial } from '../leaf/route_guard'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; + +import type { InitialSliceInstaller } from './definition'; + +export const installDataDomeInitialSlice: InitialSliceInstaller = (candidate, own) => { + const bindings = candidate as Readonly<{ observe: unknown; register: unknown }>; + return installDataDomeInitial( + Object.freeze({ + observe: bindings.observe, + origin: location.origin, + register: bindings.register, + }), + own + ); +}; + +registerCurrentFirstDisplayComponent('datadome_initial', installDataDomeInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/definition.ts b/crates/trusted-server-js/lib/src/first_display/slices/definition.ts new file mode 100644 index 000000000..c8d171642 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/definition.ts @@ -0,0 +1,23 @@ +import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; +import type { FirstDisplaySliceId } from '../../kernel/release_catalog'; + +export type OptionalFirstDisplaySliceId = Exclude; + +export interface FirstDisplaySliceHost { + readonly activate: ( + id: OptionalFirstDisplaySliceId, + own: FirstDisplaySliceActivationContext['own'], + install: InitialSliceInstaller + ) => void; +} + +export type InitialSliceInstaller = ( + bindings: unknown, + own: FirstDisplaySliceActivationContext['own'], + config: unknown +) => unknown; + +export interface InitialSliceDefinition { + readonly id: OptionalFirstDisplaySliceId; + readonly install: InitialSliceInstaller; +} diff --git a/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts b/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts new file mode 100644 index 000000000..c9bb85928 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts @@ -0,0 +1,16 @@ +import { installDidomiInitial } from '../leaf/config_guard'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; + +import type { InitialSliceInstaller } from './definition'; + +export const installDidomiInitialSlice: InitialSliceInstaller = (candidate, own, config) => + installDidomiInitial( + Object.freeze({ + config, + observe: (candidate as Readonly<{ observe: unknown }>).observe, + target: window, + }), + own + ); + +registerCurrentFirstDisplayComponent('didomi_initial', installDidomiInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts b/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts new file mode 100644 index 000000000..0c1d350be --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts @@ -0,0 +1,21 @@ +import { installGoogleTagManagerInitial } from '../leaf/route_guard'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; + +import type { InitialSliceInstaller } from './definition'; + +export const installGoogleTagManagerInitialSlice: InitialSliceInstaller = (candidate, own) => { + const bindings = candidate as Readonly<{ observe: unknown; register: unknown }>; + return installGoogleTagManagerInitial( + Object.freeze({ + observe: bindings.observe, + origin: location.origin, + register: bindings.register, + }), + own + ); +}; + +registerCurrentFirstDisplayComponent( + 'google_tag_manager_initial', + installGoogleTagManagerInitialSlice +); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts b/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts new file mode 100644 index 000000000..824971a52 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts @@ -0,0 +1,25 @@ +import { createFirstDisplayGoogletagBatch } from '../adapters/googletag'; +import { installGptInitial } from '../leaf/gpt_protocol'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; + +import type { InitialSliceInstaller } from './definition'; + +export const installGptInitialSlice: InitialSliceInstaller = (candidate, own, config) => + installGptInitial( + candidate, + own, + (input, protocol) => + createFirstDisplayGoogletagBatch({ + browser: input[0], + clearTimer: input[1], + document: input[2], + setTimer: input[3], + projection: input[4], + ...(input[5] === undefined ? {} : { diagnosticsActive: input[5] }), + ...(input[6] === undefined ? {} : { onNativeMutation: input[6] }), + protocol, + }), + config + ); + +registerCurrentFirstDisplayComponent('gpt_initial', installGptInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts b/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts new file mode 100644 index 000000000..830146419 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts @@ -0,0 +1,23 @@ +import { installLockrInitial } from '../leaf/route_guard'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; + +import type { InitialSliceInstaller } from './definition'; + +export const installLockrInitialSlice: InitialSliceInstaller = (candidate, own) => { + const bindings = candidate as Readonly<{ observe: unknown; register: unknown }>; + return installLockrInitial( + Object.freeze({ + clearTimer: (handle: unknown) => window.clearTimeout(handle as number), + getSdk: () => Reflect.get(window, 'identityLockr'), + host: location.host, + observe: bindings.observe, + origin: location.origin, + protocol: location.protocol, + register: bindings.register, + setTimer: (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), + }), + own + ); +}; + +registerCurrentFirstDisplayComponent('lockr_initial', installLockrInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/osano.ts b/crates/trusted-server-js/lib/src/first_display/slices/osano.ts new file mode 100644 index 000000000..f88463483 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/osano.ts @@ -0,0 +1,18 @@ +import { installOsanoInitial } from '../leaf/consent_snapshot'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; + +import type { InitialSliceInstaller } from './definition'; + +export const installOsanoInitialSlice: InitialSliceInstaller = (candidate, own) => + installOsanoInitial( + Object.freeze({ + clearTimer: (handle: unknown) => window.clearTimeout(handle as number), + document, + observe: (candidate as Readonly<{ observe: unknown }>).observe, + setTimer: (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), + target: window, + }), + own + ); + +registerCurrentFirstDisplayComponent('osano_initial', installOsanoInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts b/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts new file mode 100644 index 000000000..3fa181e05 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts @@ -0,0 +1,24 @@ +import { installPermutiveInitial } from '../leaf/context_snapshot'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; + +import type { InitialSliceInstaller } from './definition'; + +export const installPermutiveInitialSlice: InitialSliceInstaller = (candidate, own) => { + const bindings = candidate as Readonly<{ observe: unknown; register: unknown }>; + return installPermutiveInitial( + Object.freeze({ + clearTimer: (handle: unknown) => window.clearTimeout(handle as number), + getSdk: () => Reflect.get(window, 'permutive'), + host: location.host, + observe: bindings.observe, + origin: location.origin, + protocol: location.protocol, + readStorage: (key: string) => window.localStorage.getItem(key), + registerRoute: bindings.register, + setTimer: (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), + }), + own + ); +}; + +registerCurrentFirstDisplayComponent('permutive_initial', installPermutiveInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts b/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts new file mode 100644 index 000000000..96a6241e1 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts @@ -0,0 +1,4 @@ +import { installPrebidInitial } from '../leaf/prebid_protocol'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; + +registerCurrentFirstDisplayComponent('prebid_initial', installPrebidInitial); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts b/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts new file mode 100644 index 000000000..1d3fdd38d --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts @@ -0,0 +1,4 @@ +import { installRenderOwnerInitial } from '../render_journal'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; + +registerCurrentFirstDisplayComponent('render_owner_initial', installRenderOwnerInitial); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts b/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts new file mode 100644 index 000000000..6acc7bf63 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts @@ -0,0 +1,21 @@ +import { installSourcepointInitial } from '../leaf/consent_snapshot'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; + +import type { InitialSliceInstaller } from './definition'; + +export const installSourcepointInitialSlice: InitialSliceInstaller = (candidate, own, config) => { + const bindings = candidate as Readonly<{ observe: unknown; register: unknown }>; + return installSourcepointInitial( + Object.freeze({ + config, + document, + observe: bindings.observe, + origin: location.origin, + registerRoute: bindings.register, + storage: window.localStorage, + }), + own + ); +}; + +registerCurrentFirstDisplayComponent('sourcepoint_initial', installSourcepointInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts b/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts new file mode 100644 index 000000000..5752ea376 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts @@ -0,0 +1,4 @@ +import { installTestlightInitial } from '../leaf/callback_capture'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; + +registerCurrentFirstDisplayComponent('testlight_initial', installTestlightInitial); diff --git a/crates/trusted-server-js/lib/src/index.ts b/crates/trusted-server-js/lib/src/index.ts index aa0f7931d..74caed3a8 100644 --- a/crates/trusted-server-js/lib/src/index.ts +++ b/crates/trusted-server-js/lib/src/index.ts @@ -1,11 +1,28 @@ -// Barrel re-export for convenience and tests. -// At build time, each module (core + integrations) is built as a separate IIFE -// by build-all.mjs. The Rust server concatenates the enabled modules at runtime. export type { - AdUnit, + AddAdUnitsResult, + CreativeBootV1, + DiagnosticsBootV1, GptDiagnosticsApi, GptDiagnosticsExportV1, GptDiagnosticsRequestCycle, + ProgrammaticAdUnit, + RenderFailureReason, + RenderTraceDiagnostics, + RenderTracePathV1, + RenderTraceRecord, + RenderTraceServedFromV1, + RequestAdsOptions, + RequestAdsResult, + RequestAdsSlotResult, TsjsApi, + TsjsBootV1, + TsjsCommandQueue, + TsjsDiagnostics, + TsjsFallbackApi, + TsjsKernelApi, + TsjsLog, + TsjsLogLevel, } from './core/types'; -export { log } from './core/log'; +export { AdUnitRegistrationError, type AdUnitRegistrationErrorCode } from './core/registry'; +export { RequestAdsInputError, type RequestAdsInputErrorCode } from './core/contracts/request_ads'; +export { TsjsUnavailableError } from './kernel/fallback'; diff --git a/crates/trusted-server-js/lib/src/integrations/aps/documents.ts b/crates/trusted-server-js/lib/src/integrations/aps/documents.ts new file mode 100644 index 000000000..01fd174fc --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/aps/documents.ts @@ -0,0 +1 @@ +export * from '../../shared/aps_documents'; diff --git a/crates/trusted-server-js/lib/src/integrations/aps/index.ts b/crates/trusted-server-js/lib/src/integrations/aps/index.ts new file mode 100644 index 000000000..4c2cad1a5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/aps/index.ts @@ -0,0 +1,11 @@ +import { EMBEDDED_RELEASE_ID } from '../../core/release'; + +import { createApsIntegrationRegistration } from './module'; + +if (typeof window !== 'undefined') { + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [createApsIntegrationRegistration(EMBEDDED_RELEASE_ID)]); + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/aps/module.ts b/crates/trusted-server-js/lib/src/integrations/aps/module.ts new file mode 100644 index 000000000..596e34fc1 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/aps/module.ts @@ -0,0 +1,163 @@ +import type { MessagingAdapter } from '../../adapters/messaging'; +import { isEmptyIntegrationConfigV1 } from '../../shared/integration_config_validators'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../kernel/integration_registry'; +import type { + ArtifactHostPositionLeaseRegistry, + BootstrapNonceRegistry, + CommittedRenderArtifact, + RendererNonceRegistry, + RenderAttempt, +} from '../../services/render'; +import { validatePersistentFirstDisplaySliceAdoptionV1 } from '../../shared/takeover'; +import type { PucApsMountInput } from '../../services/puc_bridge'; + +import { + renderDirectApsAttempt, + renderPucApsAttempt, + resolveApsRendererV2Url, + validateApsRenderer, +} from './render'; + +interface RenderCapability { + readonly bindArtifactGuard: ( + artifact: CommittedRenderArtifact, + current: () => boolean + ) => boolean; + readonly bootstrapNonces: BootstrapNonceRegistry; + readonly hostPositions: ArtifactHostPositionLeaseRegistry; + readonly publisherOrigin: string; + readonly rendererNonces: RendererNonceRegistry; + readonly registerRenderer: ( + type: 'aps', + renderer: (attempt: RenderAttempt, container: HTMLElement) => boolean + ) => () => void; +} + +interface MessagesCapability { + readonly messaging: MessagingAdapter; + readonly registerApsValidation: ( + validation: Readonly<{ + readonly expectedPublisherOrigin: string; + readonly expectedRendererUrl: string; + readonly validateApsRenderer: (candidate: unknown) => boolean; + }> + ) => () => void; +} + +function capability( + interfaces: Readonly>, + key: string +): Value { + const value = interfaces[key]; + if (typeof value !== 'object' || value === null || !Object.isFrozen(value)) { + throw new TypeError(`APS requires ${key}`); + } + return value as Value; +} + +/** APS owns only its renderer implementation; shared services remain provider capabilities. */ +export function createApsIntegrationRegistration(releaseId: string): IntegrationRegistration { + const prepare = (context: IntegrationPrepareContext) => { + if (!isEmptyIntegrationConfigV1(context.config)) { + throw new TypeError('APS integration config is invalid'); + } + const render = capability(context.interfaces, 'render.v1'); + const messages = capability(context.interfaces, 'messages.v1'); + if ( + typeof render.bindArtifactGuard !== 'function' || + typeof render.registerRenderer !== 'function' || + typeof render.publisherOrigin !== 'string' || + typeof render.hostPositions !== 'object' || + render.hostPositions === null || + !Object.isFrozen(render.hostPositions) || + typeof render.hostPositions.bindOwned !== 'function' || + typeof render.hostPositions.inherit !== 'function' || + typeof render.hostPositions.claim !== 'function' || + typeof render.hostPositions.current !== 'function' || + typeof render.hostPositions.release !== 'function' || + typeof messages.messaging !== 'object' || + messages.messaging === null || + typeof messages.registerApsValidation !== 'function' + ) { + throw new TypeError('APS capability graph is malformed'); + } + const rendererUrl = resolveApsRendererV2Url(render.publisherOrigin); + if (!rendererUrl) throw new TypeError('APS publisher origin is invalid'); + let active = false; + const renderer = (attempt: RenderAttempt, container: HTMLElement): boolean => + active && + renderDirectApsAttempt({ + attempt, + bindArtifactGuard: render.bindArtifactGuard, + bootstrapNonces: render.bootstrapNonces, + container, + messaging: messages.messaging, + nonces: render.rendererNonces, + publisherOrigin: render.publisherOrigin, + }); + const renderPuc = (input: PucApsMountInput): boolean => + active && + renderPucApsAttempt({ + ...input, + bindArtifactGuard: render.bindArtifactGuard, + bootstrapNonces: render.bootstrapNonces, + hostPositions: render.hostPositions, + messaging: messages.messaging, + nonces: render.rendererNonces, + publisherOrigin: render.publisherOrigin, + }); + const apsCapability = Object.freeze({ render: renderer, renderPuc }); + const validation = Object.freeze({ + expectedPublisherOrigin: render.publisherOrigin, + expectedRendererUrl: rendererUrl, + validateApsRenderer: (candidate: unknown): boolean => + validateApsRenderer(candidate, render.publisherOrigin) !== undefined, + }); + context.onDispose(() => { + active = false; + }); + return Object.freeze({ + activate: (activation: IntegrationActivationContext) => { + if (active) throw new Error('APS already activated'); + if ( + activation.adoption !== undefined && + !validatePersistentFirstDisplaySliceAdoptionV1( + activation.adoption, + 'aps_initial', + (state) => + state.values.length === 1 && + state.values[0]?.[0] === 'protocol_version' && + state.values[0][1] === 1 + ) + ) { + throw new TypeError('APS first-display parser state is invalid'); + } + const validationRelease: { current?: () => void } = {}; + const rendererRelease: { current?: () => void } = {}; + activation.onDispose(() => validationRelease.current?.()); + activation.onDispose(() => rendererRelease.current?.()); + activation.onDispose(() => { + active = false; + }); + validationRelease.current = messages.registerApsValidation(validation); + rendererRelease.current = render.registerRenderer('aps', renderer); + active = true; + }, + interfaces: Object.freeze({ + 'aps.v1': apsCapability, + }), + }); + }; + return Object.freeze({ + abi: 1 as const, + id: 'aps', + phase: 'takeover' as const, + releaseId, + prepareSync: prepare, + prepare, + }); +} 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 610271bd1..414314c52 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -1,724 +1,1083 @@ -import { log } from '../../core/log'; -import { findSlot } from '../../core/render'; -import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; - -export const APS_RENDERER_PATH = '/integrations/aps/renderer'; -export const APS_RENDERING_MODE_ATTRIBUTE_NAME = 'data-ts-aps-rendering-mode'; -export const APS_PREBID_CREATIVE_RUNNER_URL = - 'https://client.aps.amazon-adsystem.com/prebid-creative.js'; -export const APS_NATIVE_RENDERER_TIMEOUT_MS = 10_000; +import type { ApsRendererV1 } from '../../core/types'; +import { validateApsRenderer } from '../../core/contracts/aps_renderer'; +import type { MessagingAdapter, MessagingPort } from '../../adapters/messaging'; +import type { + ArtifactHostPositionLeaseRegistry, + BootstrapNonceRegistry, + CommittedRenderArtifact, + RenderAttempt, + RenderFailureReason, + RendererNonceRegistry, +} from '../../services/render'; +import type { ApsSlotMountBinding } from '../../services/slots'; + +const objectFreezeIntrinsic = Object.freeze; +const objectGetPrototypeOfIntrinsic = Object.getPrototypeOf; +const regexpTestIntrinsic = RegExp.prototype.test; +const bootstrapNoncePattern = /^b1_[A-Za-z0-9_-]{22}$/; +const rendererNoncePattern = /^n1_[A-Za-z0-9_-]{22}$/; +const loopbackIpv4Pattern = /^127(?:\.\d{1,3}){3}$/; +const iframeNamespace = 'http://www.w3.org/1999/xhtml'; +const directDomAvailable = + typeof document !== 'undefined' && + typeof HTMLIFrameElement !== 'undefined' && + typeof Document !== 'undefined' && + typeof Node !== 'undefined' && + typeof Element !== 'undefined' && + typeof EventTarget !== 'undefined' && + typeof HTMLCollection !== 'undefined'; +const directRenderDocument = directDomAvailable ? document : undefined; +const directIframePrototype = directDomAvailable ? HTMLIFrameElement.prototype : undefined; +const documentCreateElementIntrinsic = directDomAvailable + ? Document.prototype.createElement + : undefined; +const documentGetElementByIdIntrinsic = directDomAvailable + ? Document.prototype.getElementById + : undefined; +const nodeAppendChildIntrinsic = directDomAvailable ? Node.prototype.appendChild : undefined; +const nodeRemoveChildIntrinsic = directDomAvailable ? Node.prototype.removeChild : undefined; +const elementRemoveIntrinsic = directDomAvailable ? Element.prototype.remove : undefined; +const elementSetAttributeIntrinsic = directDomAvailable + ? Element.prototype.setAttribute + : undefined; +const elementGetAttributeIntrinsic = directDomAvailable + ? Element.prototype.getAttribute + : undefined; +const eventTargetAddListenerIntrinsic = directDomAvailable + ? EventTarget.prototype.addEventListener + : undefined; +const eventTargetRemoveListenerIntrinsic = directDomAvailable + ? EventTarget.prototype.removeEventListener + : undefined; +const htmlCollectionItemIntrinsic = directDomAvailable ? HTMLCollection.prototype.item : undefined; +const nodeOwnerDocumentGetter = directDomAvailable + ? Object.getOwnPropertyDescriptor(Node.prototype, 'ownerDocument')?.get + : undefined; +const nodeParentNodeGetter = directDomAvailable + ? Object.getOwnPropertyDescriptor(Node.prototype, 'parentNode')?.get + : undefined; +const nodeIsConnectedGetter = directDomAvailable + ? Object.getOwnPropertyDescriptor(Node.prototype, 'isConnected')?.get + : undefined; +const elementLocalNameGetter = directDomAvailable + ? Object.getOwnPropertyDescriptor(Element.prototype, 'localName')?.get + : undefined; +const elementNamespaceGetter = directDomAvailable + ? Object.getOwnPropertyDescriptor(Element.prototype, 'namespaceURI')?.get + : undefined; +const elementChildrenGetter = directDomAvailable + ? Object.getOwnPropertyDescriptor(Element.prototype, 'children')?.get + : undefined; +const elementIdGetter = directDomAvailable + ? Object.getOwnPropertyDescriptor(Element.prototype, 'id')?.get + : undefined; +const htmlCollectionLengthGetter = directDomAvailable + ? Object.getOwnPropertyDescriptor(HTMLCollection.prototype, 'length')?.get + : undefined; +const iframeContentWindowGetter = directDomAvailable + ? Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow')?.get + : undefined; +const iframeSourceGetter = directDomAvailable + ? Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src')?.get + : undefined; + +export { parseApsRendererDescriptor, validateApsRenderer } from '../../core/contracts/aps_renderer'; + +export const APS_RENDERER_V2_PATH = '/integrations/aps/renderer/v2'; 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; - -const MAX_ACCOUNT_ID_BYTES = 1024; -const MAX_CREATIVE_ID_BYTES = 1024; -const MAX_CREATIVE_URL_BYTES = 4096; -const MAX_RENDER_ENVELOPE_BYTES = 256 * 1024; -const MAX_RENDER_ENVELOPE_BASE64_BYTES = 4 * Math.ceil(MAX_RENDER_ENVELOPE_BYTES / 3); -const DESCRIPTOR_KEYS = [ - 'aaxResponse', - 'accountId', - 'bidId', - 'creativeUrl', - 'height', - 'tagType', - 'type', - 'version', - 'width', -] as const; -const DESCRIPTOR_KEYS_WITH_CREATIVE_ID = [...DESCRIPTOR_KEYS, 'creativeId'].sort(); -const activeFrames = new WeakMap(); -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; -const MAX_PREBID_RENDERER_ENTRIES = 256; -const DEFAULT_PREBID_RENDERER_TTL_SECONDS = 300; -const MAX_PREBID_RENDERER_TTL_SECONDS = 3600; -const MAX_PREBID_ID_BYTES = 1024; - -type ValidatedRendererCacheEntry = { - publisherOrigin: string; - renderer: ApsRendererV1; -}; -const validatedRendererCache = new WeakMap(); -const nativeDispatches = new Map(); -const publisherNativeRendering = - typeof document !== 'undefined' && - document.currentScript?.getAttribute(APS_RENDERING_MODE_ATTRIBUTE_NAME) === 'publisher_native'; +export const APS_PERMANENT_SANDBOX = + 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation'; -function releaseNativeDispatch(slotId: string, dispatch: symbol): boolean { - if (nativeDispatches.get(slotId) !== dispatch) return false; - nativeDispatches.delete(slotId); - return true; +/** Validate, copy, and freeze one APS tagged render source. */ +export function prepareApsRenderSource( + input: unknown, + publisherOrigin?: string +): Readonly | undefined { + try { + const renderer = validateApsRenderer(input, publisherOrigin); + return renderer + ? (Reflect.apply(objectFreezeIntrinsic, Object, [renderer]) as Readonly) + : undefined; + } catch { + return undefined; + } } -function sourceBelongsToElement( - source: MessageEventSource | null | undefined, - element: HTMLElement -): boolean { - return source - ? Array.from(element.querySelectorAll('iframe')).some( - (iframe) => iframe.contentWindow === source - ) - : false; +export interface DirectApsAttemptOptions { + readonly attempt: RenderAttempt; + readonly bindArtifactGuard: ( + artifact: CommittedRenderArtifact, + current: () => boolean + ) => boolean; + readonly bootstrapNonces: BootstrapNonceRegistry; + readonly container: HTMLElement; + readonly messaging: MessagingAdapter; + readonly nonces: RendererNonceRegistry; + readonly publisherOrigin: string; } -function sourceMatchedCandidates( - candidates: HTMLElement[], - source?: MessageEventSource | null -): HTMLElement[] { - if (!source) return candidates; - return candidates.filter((element) => sourceBelongsToElement(source, element)); +export interface PucApsAttemptOptions extends DirectApsAttemptOptions { + readonly baseArtifact: CommittedRenderArtifact; + readonly bindArtifact: ApsSlotMountBinding['bindArtifact']; + readonly hostPositions: ArtifactHostPositionLeaseRegistry; + readonly isBindingCurrent: () => boolean; + readonly onArtifactTransferred: () => void; } -function dynamicSlotCandidates( - divIdPrefix: string, - source?: MessageEventSource | null -): HTMLElement[] { - const candidates = Array.from(document.querySelectorAll('[id]')).filter( - (element) => element.id.startsWith(divIdPrefix) && !element.id.endsWith('-container') - ); - return sourceMatchedCandidates(candidates, source); -} +type ApsTopPageAttemptOptions = + | (DirectApsAttemptOptions & Readonly<{ mode: 'direct' }>) + | (PucApsAttemptOptions & Readonly<{ mode: 'puc_overlay' }>); -function uniqueSlotCandidate(candidates: HTMLElement[]): HTMLElement | null { - return candidates.length === 1 ? candidates[0]! : null; +function freeze(value: Value): Readonly { + return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; } -function findApsContainer(slotId: string, source?: MessageEventSource | null): HTMLElement | null { +export function resolveApsRendererV2Url(publisherOrigin: string): string | undefined { try { - const mapping = window.tsjs?.divToSlotId ?? {}; - const mappedCandidates = sourceMatchedCandidates( - Object.entries(mapping) - .filter(([, mappedSlotId]) => mappedSlotId === slotId) - .map(([divId]) => findSlot(divId)) - .filter((element): element is HTMLElement => element !== null), - source - ); - const mapped = uniqueSlotCandidate(mappedCandidates); - if (mapped) return mapped; - - if (slotId.endsWith('-container')) { - const inner = findSlot(slotId.slice(0, -'-container'.length)); - if (inner) return source && !sourceBelongsToElement(source, inner) ? null : inner; - } - - const direct = findSlot(slotId); - if (direct && !direct.id.endsWith('-container')) { - return source && !sourceBelongsToElement(source, direct) ? null : direct; - } - - const configuredDivId = window.tsjs?.adSlots?.find((slot) => slot.id === slotId)?.div_id; - if (configuredDivId) { - const configured = findSlot(configuredDivId); - if (configured) { - return source && !sourceBelongsToElement(source, configured) ? null : configured; - } - - const dynamic = uniqueSlotCandidate(dynamicSlotCandidates(configuredDivId, source)); - if (dynamic) return dynamic; + const origin = new URL(publisherOrigin); + const loopbackHttp = + origin.protocol === 'http:' && + (origin.hostname === 'localhost' || + origin.hostname === '[::1]' || + (Reflect.apply(regexpTestIntrinsic, loopbackIpv4Pattern, [origin.hostname]) as boolean)); + if ( + origin.origin !== publisherOrigin || + (origin.protocol !== 'https:' && !loopbackHttp) || + origin.username !== '' || + origin.password !== '' + ) { + return undefined; } - - return uniqueSlotCandidate(dynamicSlotCandidates(slotId, source)); + const rendererUrl = new URL(APS_RENDERER_V2_PATH, origin); + if ( + rendererUrl.origin !== origin.origin || + rendererUrl.pathname !== APS_RENDERER_V2_PATH || + rendererUrl.search !== '' || + rendererUrl.hash !== '' + ) { + return undefined; + } + return rendererUrl.href; } catch { - return null; + return undefined; } } -function cancelPendingApsRendering(slotId: string, source?: MessageEventSource | null): void { - const container = findApsContainer(slotId, source); - if (container) pendingFrameCancels.get(container)?.(); +function mapNonceIssueFailure( + reason: 'capability_registry_full' | 'identity_generation_failed' | 'invalid_attempt' +): RenderFailureReason { + return reason === 'invalid_attempt' ? 'internal_error' : reason; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); +function mapRunnerFailure(reason: unknown): RenderFailureReason | undefined { + if (reason === 'descriptor_invalid') return 'winner_not_renderable'; + if (reason === 'runner_no_load' || reason === 'runner_failed') return reason; + return undefined; } -function hasExactKeys( +function readNonceIssueResult( value: unknown, - expected: readonly string[] -): value is Record { - if (!isRecord(value)) return false; - const actual = Object.keys(value).sort(); - const sortedExpected = [...expected].sort(); - return ( - actual.length === sortedExpected.length && - actual.every((key, index) => key === sortedExpected[index]) - ); -} - -/** Parse only the versioned descriptor shape; decoded-envelope trust checks happen separately. */ -export function parseApsRendererDescriptor(value: unknown): ApsRendererV1 | undefined { - if ( - !hasExactKeys(value, DESCRIPTOR_KEYS) && - !hasExactKeys(value, DESCRIPTOR_KEYS_WITH_CREATIVE_ID) - ) { - return undefined; - } - - if ( - value.type !== 'aps' || - value.version !== 1 || - typeof value.accountId !== 'string' || - value.accountId.length === 0 || - new TextEncoder().encode(value.accountId).length > MAX_ACCOUNT_ID_BYTES || - typeof value.bidId !== 'string' || - value.bidId.length === 0 || - (Object.prototype.hasOwnProperty.call(value, 'creativeId') && - (typeof value.creativeId !== 'string' || - value.creativeId.length === 0 || - new TextEncoder().encode(value.creativeId).length > MAX_CREATIVE_ID_BYTES)) || - (value.tagType !== 'iframe' && value.tagType !== 'script') || - typeof value.creativeUrl !== 'string' || - typeof value.aaxResponse !== 'string' || - value.aaxResponse.length > MAX_RENDER_ENVELOPE_BASE64_BYTES || - !Number.isSafeInteger(value.width) || - (value.width as number) <= 0 || - !Number.isSafeInteger(value.height) || - (value.height as number) <= 0 - ) { + pattern: RegExp +): + | Readonly<{ ok: true; nonce: string }> + | Readonly<{ + ok: false; + reason: 'capability_registry_full' | 'identity_generation_failed' | 'invalid_attempt'; + }> + | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + !Object.isFrozen(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const names = Object.getOwnPropertyNames(value); + if (names.length !== 2) return undefined; + const ok = Object.getOwnPropertyDescriptor(value, 'ok'); + if (!ok || !ok.enumerable || !('value' in ok)) return undefined; + if (ok.value === true) { + const nonce = Object.getOwnPropertyDescriptor(value, 'nonce'); + if ( + !nonce || + !nonce.enumerable || + !('value' in nonce) || + typeof nonce.value !== 'string' || + !(Reflect.apply(regexpTestIntrinsic, pattern, [nonce.value]) as boolean) + ) { + return undefined; + } + return freeze({ ok: true as const, nonce: nonce.value }); + } + if (ok.value !== false) return undefined; + const reason = Object.getOwnPropertyDescriptor(value, 'reason'); + if ( + !reason || + !reason.enumerable || + !('value' in reason) || + (reason.value !== 'capability_registry_full' && + reason.value !== 'identity_generation_failed' && + reason.value !== 'invalid_attempt') + ) { + return undefined; + } + return freeze({ ok: false as const, reason: reason.value }); + } catch { return undefined; } - - return value as unknown as ApsRendererV1; } -function decodeStandardBase64(value: string): Uint8Array | undefined { +/** Drive one APS attempt through the shared three-phase top-page mount protocol. */ +export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boolean { if ( - value.length === 0 || - value.length % 4 !== 0 || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + !directRenderDocument || + !directIframePrototype || + typeof documentCreateElementIntrinsic !== 'function' || + typeof documentGetElementByIdIntrinsic !== 'function' || + typeof nodeAppendChildIntrinsic !== 'function' || + typeof nodeRemoveChildIntrinsic !== 'function' || + typeof elementRemoveIntrinsic !== 'function' || + typeof elementSetAttributeIntrinsic !== 'function' || + typeof elementGetAttributeIntrinsic !== 'function' || + typeof eventTargetAddListenerIntrinsic !== 'function' || + typeof eventTargetRemoveListenerIntrinsic !== 'function' || + typeof htmlCollectionItemIntrinsic !== 'function' || + typeof nodeOwnerDocumentGetter !== 'function' || + typeof nodeParentNodeGetter !== 'function' || + typeof nodeIsConnectedGetter !== 'function' || + typeof elementLocalNameGetter !== 'function' || + typeof elementNamespaceGetter !== 'function' || + typeof elementChildrenGetter !== 'function' || + typeof elementIdGetter !== 'function' || + typeof htmlCollectionLengthGetter !== 'function' || + typeof iframeContentWindowGetter !== 'function' || + typeof iframeSourceGetter !== 'function' ) { - return undefined; + return false; } + let attempt: RenderAttempt; + let bindArtifactGuard: DirectApsAttemptOptions['bindArtifactGuard']; + let bootstrapNonces: BootstrapNonceRegistry; + let rendererNonces: RendererNonceRegistry; + let messaging: MessagingAdapter; + let container: HTMLElement; + let publisherOrigin: string; + let sourceCandidate: unknown; + let attemptId: string; + let attemptSlot: string; + let attemptGeneration: object; + let navigationGeneration: object; + let ownerDocument: Document; + let mode: ApsTopPageAttemptOptions['mode']; + let baseArtifact: CommittedRenderArtifact | undefined; + let bindArtifact: ApsSlotMountBinding['bindArtifact'] | undefined; + let hostPositions: ArtifactHostPositionLeaseRegistry | undefined; + let isBindingCurrent: () => boolean; + let onArtifactTransferred: () => void; + let expectedContainerId: string | undefined; try { - const binary = atob(value); - if (binary.length > MAX_RENDER_ENVELOPE_BYTES || btoa(binary) !== value) return undefined; - return Uint8Array.from(binary, (character) => character.charCodeAt(0)); + attempt = options.attempt; + bindArtifactGuard = options.bindArtifactGuard; + bootstrapNonces = options.bootstrapNonces; + rendererNonces = options.nonces; + messaging = options.messaging; + container = options.container; + publisherOrigin = options.publisherOrigin; + sourceCandidate = attempt.renderSource; + attemptId = attempt.id; + attemptSlot = attempt.slot; + attemptGeneration = attempt.generation; + navigationGeneration = attempt.navigationGeneration; + ownerDocument = Reflect.apply(nodeOwnerDocumentGetter, container, []) as Document; + mode = options.mode; + baseArtifact = options.mode === 'puc_overlay' ? options.baseArtifact : undefined; + bindArtifact = options.mode === 'puc_overlay' ? options.bindArtifact : undefined; + hostPositions = options.mode === 'puc_overlay' ? options.hostPositions : undefined; + isBindingCurrent = options.mode === 'puc_overlay' ? options.isBindingCurrent : () => true; + onArtifactTransferred = + options.mode === 'puc_overlay' ? options.onArtifactTransferred : () => undefined; + expectedContainerId = + options.mode === 'puc_overlay' + ? (Reflect.apply(elementIdGetter, container, []) as string) + : undefined; } catch { - return undefined; + return false; } -} - -function validCreativeUrl(value: string, publisherOrigin: string): boolean { - if (new TextEncoder().encode(value).length > MAX_CREATIVE_URL_BYTES) return false; + let exactDocumentOrigin: boolean; try { - const url = new URL(value); - return ( - url.protocol === 'https:' && - url.username === '' && - url.password === '' && - url.origin !== publisherOrigin - ); + exactDocumentOrigin = + ownerDocument === directRenderDocument && + ownerDocument.defaultView?.location.origin === publisherOrigin; } catch { + exactDocumentOrigin = false; + } + const renderer = prepareApsRenderSource(sourceCandidate, publisherOrigin); + const rendererUrl = resolveApsRendererV2Url(publisherOrigin); + let creativeOrigin: string | undefined; + try { + creativeOrigin = renderer ? new URL(renderer.creativeUrl).origin : undefined; + } catch { + creativeOrigin = undefined; + } + if ( + !exactDocumentOrigin || + !renderer || + !rendererUrl || + !creativeOrigin || + (mode === 'puc_overlay' && !expectedContainerId) + ) { + try { + attempt.fail('winner_not_renderable'); + } catch { + // Invalid input remains rejected even when the attempt boundary is hostile. + } return false; } -} -/** Fully validate the exact APS envelope and cross-check every duplicated descriptor field. */ -export function validateApsRenderer( - value: unknown, - publisherOrigin = window.location.origin -): ApsRendererV1 | undefined { - if (isRecord(value)) { - const cached = validatedRendererCache.get(value); - if (cached?.publisherOrigin === publisherOrigin) return cached.renderer; + let installCaptureMethod: MessagingAdapter['installCaptureListener']; + let extractPortsMethod: MessagingAdapter['extractTransferredPorts']; + let postWindowMethod: MessagingAdapter['postWindow']; + let parseMessageMethod: MessagingAdapter['parseProtocolMessage']; + let issueBootstrapMethod: BootstrapNonceRegistry['issue']; + let bindBootstrapMethod: BootstrapNonceRegistry['bindSource']; + let consumeBootstrapMethod: BootstrapNonceRegistry['consume']; + let issueRendererMethod: RendererNonceRegistry['issue']; + let bindRendererMethod: RendererNonceRegistry['bindSource']; + let consumeRendererMethod: RendererNonceRegistry['consume']; + let beginDirectMethod: RenderAttempt['beginDirect']; + let beginDocumentMethod: RenderAttempt['beginApsDocument']; + let documentAcceptedMethod: RenderAttempt['apsDocumentAccepted']; + let acceptMethod: RenderAttempt['accept']; + let failMethod: RenderAttempt['fail']; + let snapshotMethod: RenderAttempt['snapshot']; + try { + installCaptureMethod = messaging.installCaptureListener; + extractPortsMethod = messaging.extractTransferredPorts; + postWindowMethod = messaging.postWindow; + parseMessageMethod = messaging.parseProtocolMessage; + issueBootstrapMethod = bootstrapNonces.issue; + bindBootstrapMethod = bootstrapNonces.bindSource; + consumeBootstrapMethod = bootstrapNonces.consume; + issueRendererMethod = rendererNonces.issue; + bindRendererMethod = rendererNonces.bindSource; + consumeRendererMethod = rendererNonces.consume; + beginDirectMethod = attempt.beginDirect; + beginDocumentMethod = attempt.beginApsDocument; + documentAcceptedMethod = attempt.apsDocumentAccepted; + acceptMethod = attempt.accept; + failMethod = attempt.fail; + snapshotMethod = attempt.snapshot; + if ( + typeof installCaptureMethod !== 'function' || + typeof extractPortsMethod !== 'function' || + typeof postWindowMethod !== 'function' || + typeof parseMessageMethod !== 'function' || + typeof issueBootstrapMethod !== 'function' || + typeof bindBootstrapMethod !== 'function' || + typeof consumeBootstrapMethod !== 'function' || + typeof issueRendererMethod !== 'function' || + typeof bindRendererMethod !== 'function' || + typeof consumeRendererMethod !== 'function' || + typeof beginDocumentMethod !== 'function' || + typeof documentAcceptedMethod !== 'function' || + typeof acceptMethod !== 'function' || + typeof failMethod !== 'function' || + typeof snapshotMethod !== 'function' || + (mode === 'direct' + ? typeof beginDirectMethod !== 'function' || + Reflect.apply(beginDirectMethod, attempt, []) !== true + : attempt.snapshot().state !== 'waiting_for_insertion' || !isBindingCurrent()) + ) { + return false; + } + } catch { + return false; } - const renderer = parseApsRendererDescriptor(value); - if (!renderer || !validCreativeUrl(renderer.creativeUrl, publisherOrigin)) return undefined; - - const bytes = decodeStandardBase64(renderer.aaxResponse); - if (!bytes) return undefined; + const fail = (reason: RenderFailureReason): false => { + try { + Reflect.apply(failMethod, attempt, [reason]); + } catch { + // The attempt's terminal latch owns failure authority. + } + return false; + }; + const attemptState = (): ReturnType['state'] | undefined => { + try { + return Reflect.apply(snapshotMethod, attempt, []).state; + } catch { + return undefined; + } + }; + const exactAttemptIdentity = (): boolean => { + try { + return ( + attempt.id === attemptId && + attempt.slot === attemptSlot && + attempt.generation === attemptGeneration && + attempt.navigationGeneration === navigationGeneration && + (mode === 'direct' || isBindingCurrent()) + ); + } catch { + return false; + } + }; - let decoded: unknown; + let bootstrapIssue: unknown; try { - decoded = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + bootstrapIssue = Reflect.apply(issueBootstrapMethod, bootstrapNonces, [{ attempt }]); } catch { - return undefined; + return fail('identity_generation_failed'); } + const issuedBootstrap = readNonceIssueResult(bootstrapIssue, bootstrapNoncePattern); + if (!issuedBootstrap) return fail('identity_generation_failed'); + if (!issuedBootstrap.ok) return fail(mapNonceIssueFailure(issuedBootstrap.reason)); - if (!hasExactKeys(decoded, ['seatbid'])) return undefined; - const seatbids = decoded.seatbid; - if (!Array.isArray(seatbids) || seatbids.length !== 1) return undefined; - const seat = seatbids[0]; - if (!hasExactKeys(seat, ['bid']) || !Array.isArray(seat.bid) || seat.bid.length !== 1) { - return undefined; + let rendererIssue: unknown; + try { + rendererIssue = Reflect.apply(issueRendererMethod, rendererNonces, [{ attempt }]); + } catch { + return fail('identity_generation_failed'); } + const issuedRenderer = readNonceIssueResult(rendererIssue, rendererNoncePattern); + if (!issuedRenderer) return fail('identity_generation_failed'); + if (!issuedRenderer.ok) return fail(mapNonceIssueFailure(issuedRenderer.reason)); - const bid = seat.bid[0]; - if (!hasExactKeys(bid, ['ext', 'h', 'id', 'price', 'w'])) return undefined; - if (!hasExactKeys(bid.ext, ['creativeurl', 'tagtype'])) return undefined; + const bootstrapNonce = issuedBootstrap.nonce; + const rendererNonce = issuedRenderer.nonce; - 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 - ) { - return undefined; + let iframe: HTMLIFrameElement; + try { + iframe = Reflect.apply(documentCreateElementIntrinsic, ownerDocument, [ + 'iframe', + ]) as HTMLIFrameElement; + if ( + typeof iframe !== 'object' || + iframe === null || + Reflect.apply(objectGetPrototypeOfIntrinsic, Object, [iframe]) !== directIframePrototype || + Reflect.apply(nodeOwnerDocumentGetter, iframe, []) !== ownerDocument || + Reflect.apply(elementLocalNameGetter, iframe, []) !== 'iframe' || + Reflect.apply(elementNamespaceGetter, iframe, []) !== iframeNamespace || + Reflect.apply(nodeParentNodeGetter, iframe, []) !== null || + Reflect.apply(nodeIsConnectedGetter, iframe, []) === true + ) { + return fail('renderer_document_no_load'); + } + const attributes = [ + ['title', 'Ad content'], + ['scrolling', 'no'], + ['frameborder', '0'], + ['width', String(renderer.width)], + ['height', String(renderer.height)], + ['aria-label', 'Advertisement'], + ['marginheight', '0'], + ['marginwidth', '0'], + ['sandbox', APS_RENDERER_SANDBOX], + [ + 'style', + mode === 'puc_overlay' + ? 'border: 0; display: block; height: ' + + String(renderer.height) + + 'px; inset: 0; margin: 0; overflow: hidden; position: absolute; visibility: hidden; width: ' + + String(renderer.width) + + 'px; z-index: 2147483647' + : 'border: 0; display: block; height: ' + + String(renderer.height) + + 'px; margin: 0; overflow: hidden; width: ' + + String(renderer.width) + + 'px', + ], + ] as const; + for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex += 1) { + const attribute = attributes[attributeIndex]; + if (attribute) { + Reflect.apply(elementSetAttributeIntrinsic, iframe, [attribute[0], attribute[1]]); + } + } + } catch { + return fail('renderer_document_no_load'); } - const validated = Object.freeze({ ...renderer }) as ApsRendererV1; - validatedRendererCache.set(value as object, { publisherOrigin, renderer: validated }); - validatedRendererCache.set(validated, { publisherOrigin, renderer: validated }); - return validated; -} + let disposed = false; + let artifactOwnedByAttempt = false; + let insertionCommitted = false; + let appendInProgress = false; + let frameErrorObserved = false; + let frameSource: object | undefined; + let bootstrapBound = false; + let navigationPosted = false; + let containerReady = false; + let envelopeSent = false; + let documentAccepted = false; + let documentPort: MessagingPort | undefined; + let releaseGlobalListener: (() => void) | undefined; + let insertionPredecessors: readonly Element[] = []; + let baseArtifactDisposed = false; + let baseArtifactTransferred = false; + let hostPositionBound = false; + const expectedFrameSource = rendererUrl + '#' + bootstrapNonce; + + const disposeBaseArtifact = (): void => { + if (mode !== 'puc_overlay' || !baseArtifactTransferred || baseArtifactDisposed) return; + baseArtifactDisposed = true; + try { + baseArtifact?.dispose(); + } catch { + // The combined artifact remains terminal even when prior cleanup is hostile. + } + }; -function validPrebidIdentity(value: unknown): value is string { - return ( - typeof value === 'string' && - value.length > 0 && - new TextEncoder().encode(value).length <= MAX_PREBID_ID_BYTES - ); -} + const restoreHostPosition = (): void => { + try { + hostPositions?.release(artifact); + } catch { + // Compare-owned style restoration cannot expand into publisher styles. + } + }; -function validPrebidAdId(value: unknown): value is string { - return validPrebidIdentity(value) && /^[A-Za-z0-9-]+$/.test(value); -} + const bootstrapListenerResource = freeze({ + close: (): void => { + const release = releaseGlobalListener; + releaseGlobalListener = undefined; + if (!release) return; + try { + release(); + } catch { + // Capture-listener removal remains best-effort and exact-once. + } + }, + }); -function prunePrebidRenderers(registry: Record, now: number): void { - for (const [adId, entry] of Object.entries(registry)) { - if (!Number.isFinite(entry.expiresAt) || entry.expiresAt <= now) delete registry[adId]; - } + const removeFrameErrorListener = (): void => { + try { + Reflect.apply(eventTargetRemoveListenerIntrinsic, iframe, ['error', onFrameError]); + } catch { + // Listener cleanup cannot interrupt terminal resource disposal. + } + }; - const entries = Object.entries(registry); - if (entries.length <= MAX_PREBID_RENDERER_ENTRIES) return; - entries - .sort(([, left], [, right]) => left.registeredAt - right.registeredAt) - .slice(0, entries.length - MAX_PREBID_RENDERER_ENTRIES) - .forEach(([adId]) => delete registry[adId]); -} + let artifactBinding: + | Readonly<{ + commit: () => boolean; + finalize: () => void; + isCurrent: () => boolean; + previousArtifact: CommittedRenderArtifact | undefined; + release: () => void; + rollback: () => void; + }> + | undefined; + const artifact: CommittedRenderArtifact = freeze({ + kind: 'aps_mount' as const, + attemptId, + slot: attemptSlot, + navigationGeneration, + dispose: (): void => { + if (disposed) return; + disposed = true; + removeFrameErrorListener(); + bootstrapListenerResource.close(); + try { + documentPort?.close(); + } catch { + // The renderer registry also owns exact-once retained-port cleanup. + } + try { + Reflect.apply(elementRemoveIntrinsic, iframe, []); + } catch { + // DOM removal remains best-effort under a hostile publisher realm. + } + restoreHostPosition(); + artifactBinding?.release(); + disposeBaseArtifact(); + }, + }); -/** Bind Prebid's generated ad ID to a fully validated APS renderer capability. */ -export function registerApsPrebidRenderer( - adId: unknown, - adUnitCode: unknown, - input: unknown, - ttlSeconds: unknown = DEFAULT_PREBID_RENDERER_TTL_SECONDS, - lifecycle?: { markUsed(): void } -): boolean { - if ( - !validPrebidAdId(adId) || - !validPrebidIdentity(adUnitCode) || - typeof lifecycle?.markUsed !== 'function' - ) { - return false; + const persistentFrameCurrent = (): boolean => { + try { + return ( + insertionCommitted && + !disposed && + Reflect.apply(nodeOwnerDocumentGetter, iframe, []) === ownerDocument && + Reflect.apply(nodeParentNodeGetter, iframe, []) === container && + Reflect.apply(nodeIsConnectedGetter, iframe, []) === true && + (mode !== 'puc_overlay' || + (Reflect.apply(elementIdGetter, container, []) === expectedContainerId && + Reflect.apply(documentGetElementByIdIntrinsic, ownerDocument, [expectedContainerId]) === + container)) && + Reflect.apply(iframeContentWindowGetter, iframe, []) === frameSource && + Reflect.apply(elementGetAttributeIntrinsic, iframe, ['src']) === expectedFrameSource && + Reflect.apply(iframeSourceGetter, iframe, []) === expectedFrameSource && + Reflect.apply(elementGetAttributeIntrinsic, iframe, ['sandbox']) === + APS_PERMANENT_SANDBOX && + (mode !== 'puc_overlay' || + !hostPositionBound || + hostPositions?.current(artifact) === true) && + (mode !== 'puc_overlay' || artifactBinding?.isCurrent() === true) + ); + } catch { + return false; + } + }; + if (!bindArtifactGuard(artifact, persistentFrameCurrent)) { + artifact.dispose(); + return fail('internal_error'); } - const renderer = validateApsRenderer(input); - if (!renderer) return false; - - const now = Date.now(); - const boundedTtlSeconds = - typeof ttlSeconds === 'number' && Number.isFinite(ttlSeconds) && ttlSeconds > 0 - ? Math.min(ttlSeconds, MAX_PREBID_RENDERER_TTL_SECONDS) - : DEFAULT_PREBID_RENDERER_TTL_SECONDS; - const tsjs = (window.tsjs ??= {} as TsjsApi); - const registry = (tsjs.apsPrebidRenderers ??= Object.create(null) as Record< - string, - ApsPrebidRendererEntry - >); - prunePrebidRenderers(registry, now); - - if (!(adId in registry) && Object.keys(registry).length >= MAX_PREBID_RENDERER_ENTRIES) { - const oldest = Object.entries(registry).sort( - ([, left], [, right]) => left.registeredAt - right.registeredAt - )[0]; - if (oldest) delete registry[oldest[0]]; + if (mode === 'puc_overlay') { + try { + artifactBinding = bindArtifact?.(artifact); + } catch { + artifactBinding = undefined; + } + if (!artifactBinding) { + artifact.dispose(); + return fail('slot_unresolved'); + } } - registry[adId] = { - adUnitCode, - renderer, - registeredAt: now, - expiresAt: now + boundedTtlSeconds * 1000, - markUsed: lifecycle.markUsed, + const startupFailure = (reason: RenderFailureReason): false => { + if (!artifactOwnedByAttempt) artifact.dispose(); + return fail(reason); }; - return true; -} -/** Return an unexpired Prebid APS capability without consuming it. */ -export function getApsPrebidRenderer(adId: string): ApsPrebidRendererEntry | undefined { - if (!validPrebidAdId(adId)) return undefined; - const registry = window.tsjs?.apsPrebidRenderers; - const entry = registry?.[adId]; - if (!entry) return undefined; - if ( - !Number.isFinite(entry.expiresAt) || - entry.expiresAt <= Date.now() || - typeof entry.markUsed !== 'function' - ) { - delete registry![adId]; - return undefined; - } - return entry; -} - -/** Atomically consume the exact capability previously returned by the registry. */ -export function consumeApsPrebidRenderer(adId: string, expected: ApsPrebidRendererEntry): boolean { - const registry = window.tsjs?.apsPrebidRenderers; - if (!registry || registry[adId] !== expected) return false; - delete registry[adId]; - return true; -} + const snapshotContainerPredecessors = (): readonly Element[] => { + const predecessors: Element[] = []; + try { + const children = Reflect.apply(elementChildrenGetter, container, []) as HTMLCollection; + const length = Reflect.apply(htmlCollectionLengthGetter, children, []) as number; + for (let childIndex = 0; childIndex < length; childIndex += 1) { + const child = Reflect.apply(htmlCollectionItemIntrinsic, children, [ + childIndex, + ]) as Element | null; + if (child && child !== iframe) predecessors[predecessors.length] = child; + } + } catch { + // Failure to inspect publisher siblings cannot expand cleanup authority. + } + return predecessors; + }; -export interface DispatchApsRenderingOptions { - slotId: string; - renderer: unknown; - source?: MessageEventSource | null; - /** Existing Trusted Server owner, invoked only in the default mode. */ - trustedServer: (renderer: ApsRendererV1) => boolean; -} + const commitContainer = (predecessors: readonly Element[]): void => { + try { + for (let childIndex = predecessors.length - 1; childIndex >= 0; childIndex -= 1) { + const child = predecessors[childIndex]; + if ( + child && + child !== iframe && + Reflect.apply(nodeParentNodeGetter, child, []) === container + ) { + Reflect.apply(nodeRemoveChildIntrinsic, container, [child]); + } + } + } catch { + // A hostile predecessor cannot revoke the accepted artifact. + } + }; -/** - * Dispatch a validated APS descriptor to exactly one configured rendering owner. - * - * Native mode loads APS's fixed Prebid creative runner in a publisher-origin friendly - * frame. Superseded attempts are cancelled and never fall back to the opaque renderer. - */ -export function dispatchApsRendering({ - slotId, - renderer: input, - source, - trustedServer, -}: DispatchApsRenderingOptions): boolean | Promise { - // Every native attempt supersedes a pending frame for this slot, including an - // invalid replacement. Default mode preserves a valid in-flight frame until a - // validated replacement reaches renderApsCreative. - if (publisherNativeRendering) cancelPendingApsRendering(slotId, source); - const dispatch = Symbol(slotId); - nativeDispatches.set(slotId, dispatch); - - const renderer = validateApsRenderer(input); - if (!renderer) { - releaseNativeDispatch(slotId, dispatch); - log.warn('APS renderer: rejected descriptor'); - return false; - } - if (!publisherNativeRendering) { + const acquireOverlayPosition = (): boolean => { + if (mode !== 'puc_overlay') return true; try { - return trustedServer(renderer); - } finally { - releaseNativeDispatch(slotId, dispatch); + if (!isBindingCurrent()) return false; + const view = ownerDocument.defaultView; + if (!view) return false; + const computed = view.getComputedStyle(container); + if (computed.position !== 'static') { + const previousArtifact = artifactBinding?.previousArtifact; + hostPositionBound = Boolean( + previousArtifact && hostPositions?.inherit(artifact, previousArtifact, container) + ); + return true; + } + const style = container.style; + const previousPosition = style.getPropertyValue('position'); + const previousPriority = style.getPropertyPriority('position'); + style.setProperty('position', 'relative'); + if ( + style.getPropertyValue('position') !== 'relative' || + style.getPropertyPriority('position') !== '' + ) { + return false; + } + hostPositionBound = + hostPositions?.bindOwned(artifact, container, previousPosition, previousPriority) === true; + return hostPositionBound && isBindingCurrent(); + } catch { + return false; } - } + }; - let rendering: Promise; - try { - rendering = renderApsPublisherNative({ slotId, renderer, source }); - } catch { - releaseNativeDispatch(slotId, dispatch); - log.warn('APS native renderer: failed to start publisher-origin frame'); - return Promise.resolve(false); - } + const claimOverlayPositionLease = (): boolean => { + if (mode !== 'puc_overlay' || !hostPositionBound) return true; + try { + return isBindingCurrent() && hostPositions?.claim(artifact) === true; + } catch { + return false; + } + }; - return rendering.then((accepted) => { - if (!releaseNativeDispatch(slotId, dispatch)) { - if (accepted) log.warn('APS native renderer: ignored stale completion'); + const exactFrameBinding = (permanent: boolean): boolean => { + try { + return ( + insertionCommitted && + !disposed && + exactAttemptIdentity() && + Reflect.apply(nodeOwnerDocumentGetter, iframe, []) === ownerDocument && + Reflect.apply(nodeParentNodeGetter, iframe, []) === container && + Reflect.apply(nodeIsConnectedGetter, iframe, []) === true && + Reflect.apply(iframeContentWindowGetter, iframe, []) === frameSource && + Reflect.apply(elementGetAttributeIntrinsic, iframe, ['src']) === expectedFrameSource && + Reflect.apply(iframeSourceGetter, iframe, []) === expectedFrameSource && + Reflect.apply(elementGetAttributeIntrinsic, iframe, ['sandbox']) === + (permanent ? APS_PERMANENT_SANDBOX : APS_RENDERER_SANDBOX) + ); + } catch { return false; } - return accepted; - }); -} + }; -interface RenderApsPublisherNativeOptions { - slotId: string; - renderer: unknown; - source?: MessageEventSource | null; -} + const eventField = (event: unknown, key: 'data' | 'origin' | 'source'): unknown => { + try { + return typeof event === 'object' && event !== null ? Reflect.get(event, key) : undefined; + } catch { + return undefined; + } + }; -function prepareApsRunnerDocument( - frameWindow: Window & typeof globalThis, - frameDocument: Document -): void { - for (const element of [frameDocument.documentElement, frameDocument.body]) { - element.style.margin = '0px'; - element.style.padding = '0px'; - } + const exactGlobalMessage = ( + kind: 'apsBootstrapReady' | 'apsContainerReady', + data: unknown, + expected: Readonly> + ): boolean => { + if (typeof data !== 'string' || data !== JSON.stringify(expected)) return false; + const parsed = Reflect.apply(parseMessageMethod, messaging, [kind, data]); + if (!parsed) return false; + const expectedKeys = Object.keys(expected); + for (let keyIndex = 0; keyIndex < expectedKeys.length; keyIndex += 1) { + const key = expectedKeys[keyIndex]; + if (!key || parsed[key] !== expected[key]) return false; + } + return Object.keys(parsed).length === expectedKeys.length; + }; - const normalizeFrame = (node: Node): void => { + const bootstrapExpectation = () => + freeze({ + nonce: bootstrapNonce, + attempt, + generation: attemptGeneration, + source: frameSource!, + port: bootstrapListenerResource, + }); + const rendererExpectation = (port: MessagingPort) => + freeze({ + nonce: rendererNonce, + attempt, + generation: attemptGeneration, + source: frameSource!, + port, + }); + + const receiveDocument = (event: unknown): void => { if ( - node instanceof frameWindow.HTMLIFrameElement && - node.parentElement === frameDocument.body + disposed || + !containerReady || + !envelopeSent || + !documentPort || + !frameSource || + !exactFrameBinding(true) ) { - node.style.display = 'block'; + if (!disposed && containerReady && frameSource && !exactFrameBinding(true)) { + fail(documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); + } + return; } - }; - Array.from(frameDocument.body.children).forEach(normalizeFrame); - new frameWindow.MutationObserver((records) => { - for (const record of records) record.addedNodes.forEach(normalizeFrame); - }).observe(frameDocument.body, { childList: true }); -} - -/** Render the exact selected response through APS's fixed runner in a friendly iframe. */ -function renderApsPublisherNative({ - slotId, - renderer: input, - source, -}: RenderApsPublisherNativeOptions): Promise { - const renderer = validateApsRenderer(input); - const container = findApsContainer(slotId, source); - if (!renderer || !container) { - log.warn( - renderer ? 'APS native renderer: slot not found' : 'APS renderer: rejected descriptor' - ); - return Promise.resolve(false); - } - - // Keep an already committed creative visible until the replacement runner loads. - pendingFrameCancels.get(container)?.(); - const iframe = document.createElement('iframe'); - iframe.title = 'Ad content'; - iframe.width = String(renderer.width); - iframe.height = String(renderer.height); - iframe.style.border = '0'; - iframe.style.display = 'none'; - activeFrames.set(container, iframe); - - return new Promise((resolve) => { - let settled = false; - let runner: HTMLScriptElement | undefined; - - const cleanup = (): void => { - window.clearTimeout(timeoutId); - runner?.removeEventListener('load', commit); - runner?.removeEventListener('error', fail); - }; - const finish = (accepted: boolean, warning?: string): void => { - if (settled) return; - settled = true; - cleanup(); - if (pendingFrameCancels.get(container) === cancel) pendingFrameCancels.delete(container); - - if (!accepted || activeFrames.get(container) !== iframe || !iframe.isConnected) { - if (activeFrames.get(container) === iframe) activeFrames.delete(container); - iframe.remove(); - if (warning) log.warn(warning); - resolve(false); + const data = eventField(event, 'data'); + const accepted = Reflect.apply(parseMessageMethod, messaging, ['apsDocumentAccepted', data]); + if (accepted?.['nonce'] === rendererNonce) { + if (documentAccepted || attemptState() !== 'waiting_for_document') return; + if ( + Reflect.apply(consumeRendererMethod, rendererNonces, [ + rendererExpectation(documentPort), + ]) === true && + Reflect.apply(documentAcceptedMethod, attempt, []) === true + ) { + documentAccepted = true; + } else { + fail('renderer_document_no_load'); + } + return; + } + const loaded = Reflect.apply(parseMessageMethod, messaging, ['apsRunnerLoaded', data]); + if (loaded?.['nonce'] === rendererNonce) return; + const completed = Reflect.apply(parseMessageMethod, messaging, ['apsRenderCompleted', data]); + if (completed?.['nonce'] === rendererNonce) { + if (!documentAccepted) return; + if (mode === 'puc_overlay') { + try { + if (!exactFrameBinding(true) || !isBindingCurrent()) { + fail('slot_unresolved'); + return; + } + iframe.style.setProperty('visibility', 'visible'); + if (iframe.style.getPropertyValue('visibility') !== 'visible') { + fail('internal_error'); + return; + } + } catch { + fail('internal_error'); + return; + } + } + let artifactAssociationCommitted = false; + if (mode === 'puc_overlay') { + try { + artifactAssociationCommitted = artifactBinding?.commit() === true; + } catch { + artifactAssociationCommitted = false; + } + if (!artifactAssociationCommitted) { + fail('slot_unresolved'); + return; + } + if (!claimOverlayPositionLease()) { + artifactBinding?.rollback(); + fail('slot_unresolved'); + return; + } + } + const accepted = (() => { + try { + return Reflect.apply(acceptMethod, attempt, []) === true; + } catch { + return false; + } + })(); + if (!accepted) { + if (artifactAssociationCommitted) artifactBinding?.rollback(); + if (!disposed) fail('internal_error'); return; } - - for (const child of Array.from(container.children)) { - if (child !== iframe) child.remove(); + if (artifactAssociationCommitted) artifactBinding?.finalize(); + if (!disposed && exactFrameBinding(true) && mode === 'direct') { + commitContainer(insertionPredecessors); } - iframe.style.display = ''; - resolve(true); - }; - const cancel = (): void => finish(false); - function fail(): void { - finish(false, 'APS native renderer: creative runner failed'); - } - function commit(): void { - finish(true); + return; } + const failed = Reflect.apply(parseMessageMethod, messaging, ['apsRenderFailed', data]); + if (failed?.['nonce'] !== rendererNonce) return; + const reason = mapRunnerFailure(failed['reason']); + if (reason) Reflect.apply(failMethod, attempt, [reason]); + }; - const timeoutId = window.setTimeout( - () => finish(false, 'APS native renderer: creative runner timed out'), - APS_NATIVE_RENDERER_TIMEOUT_MS - ); - pendingFrameCancels.set(container, cancel); - container.appendChild(iframe); + const receiveDocumentError = (): void => { + if (disposed || !containerReady) return; + fail(documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); + }; - try { - const frameWindow = iframe.contentWindow as - | (Window & - typeof globalThis & { - _aps: Map> }>; - }) - | null; - const frameDocument = iframe.contentDocument; - if (!frameWindow || !frameDocument) { - fail(); + const receiveGlobal = (event: MessageEvent): void => { + if ( + disposed || + !frameSource || + eventField(event, 'source') !== frameSource || + eventField(event, 'origin') !== 'null' + ) { + return; + } + const data = eventField(event, 'data'); + + if (!bootstrapBound) { + const expectedReady = freeze({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }); + if (!exactGlobalMessage('apsBootstrapReady', data, expectedReady)) return; + const ports = Reflect.apply(extractPortsMethod, messaging, [event, 0]); + if (!ports) { + fail('renderer_document_no_load'); return; } - - frameDocument.open(); - frameDocument.write( - '' + - '' - ); - frameDocument.close(); - prepareApsRunnerDocument(frameWindow, frameDocument); - frameWindow._aps = new Map(); - frameWindow._aps.set(renderer.accountId, { - queue: [ - new frameWindow.CustomEvent('prebid/creative/render', { - detail: { aaxResponse: renderer.aaxResponse, seatBidId: renderer.bidId }, - }), - ], - store: new Map([['listeners', new Map()]]), + if (!exactFrameBinding(false) || attemptState() !== 'waiting_for_document') { + fail('renderer_document_no_load'); + return; + } + if (Reflect.apply(bindBootstrapMethod, bootstrapNonces, [bootstrapExpectation()]) !== true) { + fail('renderer_document_no_load'); + return; + } + bootstrapBound = true; + try { + Reflect.apply(elementSetAttributeIntrinsic, iframe, ['sandbox', APS_PERMANENT_SANDBOX]); + } catch { + fail('renderer_document_no_load'); + return; + } + if (!exactFrameBinding(true)) { + fail('renderer_document_no_load'); + return; + } + const navigation = JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce, + creativeOrigin, + tagType: renderer.tagType, }); + if ( + Reflect.apply(postWindowMethod, messaging, [frameSource, navigation, '*', []]) !== true || + !exactFrameBinding(true) + ) { + fail('renderer_document_no_load'); + return; + } + navigationPosted = true; + return; + } - runner = frameDocument.createElement('script'); - runner.src = APS_PREBID_CREATIVE_RUNNER_URL; - runner.addEventListener('load', commit, { once: true }); - runner.addEventListener('error', fail, { once: true }); - frameDocument.head.appendChild(runner); + if (!navigationPosted || containerReady) return; + const expectedContainerReady = freeze({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, + }); + if (!exactGlobalMessage('apsContainerReady', data, expectedContainerReady)) return; + if (!exactFrameBinding(true) || attemptState() !== 'waiting_for_document') { + fail('renderer_document_no_load'); + return; + } + const ports = Reflect.apply(extractPortsMethod, messaging, [event, 1]); + const port = ports?.[0]; + if (!port) { + fail('renderer_document_no_load'); + return; + } + documentPort = port; + if ( + Reflect.apply(bindRendererMethod, rendererNonces, [rendererExpectation(port)]) !== true || + Reflect.apply(consumeBootstrapMethod, bootstrapNonces, [bootstrapExpectation()]) !== true + ) { + try { + port.close(); + } catch { + // A rejected transferred endpoint remains locally owned. + } + fail('renderer_document_no_load'); + return; + } + containerReady = true; + bootstrapListenerResource.close(); + try { + port.listen(receiveDocument, receiveDocumentError); } catch { - fail(); + fail('renderer_document_no_load'); + return; } - }); -} + const envelope = freeze({ + version: 1 as const, + nonce: rendererNonce, + publisherOrigin, + renderer, + }); + if (port.post(envelope, []) !== true || !exactFrameBinding(true)) { + fail('renderer_document_no_load'); + return; + } + envelopeSent = true; + }; -function createNonce(): string | undefined { - if (typeof crypto === 'undefined' || typeof crypto.getRandomValues !== 'function') - return undefined; - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); -} + function onFrameError(): void { + if ( + disposed || + (!insertionCommitted && + !(appendInProgress && Reflect.apply(nodeParentNodeGetter!, iframe, []) === container)) + ) { + return; + } + frameErrorObserved = true; + if (artifactOwnedByAttempt) { + fail(documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); + } + } -/** - * Return the absolute same-publisher URL used by direct and Universal Creative rendering. - * - * This intentionally inherits the publisher page scheme for same-origin deployments, - * including local development. APS endpoints and third-party creative URLs remain - * HTTPS-only. - */ -export function apsRendererUrl(pageOrigin = window.location.origin): string | undefined { try { - const origin = new URL(pageOrigin); - const url = new URL(APS_RENDERER_PATH, origin); + const initialState = mode === 'direct' ? 'rendering_direct' : 'waiting_for_insertion'; + releaseGlobalListener = Reflect.apply(installCaptureMethod, messaging, [receiveGlobal]); + if (!releaseGlobalListener) return startupFailure('renderer_document_no_load'); + Reflect.apply(eventTargetAddListenerIntrinsic, iframe, ['error', onFrameError]); + Reflect.apply(elementSetAttributeIntrinsic, iframe, ['src', expectedFrameSource]); if ( - url.origin !== origin.origin || - url.pathname !== APS_RENDERER_PATH || - url.search !== '' || - url.hash !== '' + Reflect.apply(elementGetAttributeIntrinsic, iframe, ['src']) !== expectedFrameSource || + Reflect.apply(iframeSourceGetter!, iframe, []) !== expectedFrameSource || + attemptState() !== initialState || + Reflect.apply(nodeIsConnectedGetter, container, []) !== true ) { - return undefined; + return startupFailure('renderer_document_no_load'); + } + insertionPredecessors = mode === 'direct' ? snapshotContainerPredecessors() : []; + if ( + disposed || + attemptState() !== initialState || + Reflect.apply(nodeIsConnectedGetter, container, []) !== true || + !acquireOverlayPosition() + ) { + return startupFailure('renderer_document_no_load'); + } + appendInProgress = true; + try { + Reflect.apply(nodeAppendChildIntrinsic, container, [iframe]); + } finally { + appendInProgress = false; } - return url.href; + if (Reflect.apply(nodeParentNodeGetter, iframe, []) !== container) { + return startupFailure('renderer_document_no_load'); + } + insertionCommitted = true; + const insertedSource = Reflect.apply(iframeContentWindowGetter, iframe, []) as Window | null; + if (!insertedSource) { + return startupFailure('renderer_document_no_load'); + } + frameSource = insertedSource; + if (!exactFrameBinding(false)) { + return startupFailure('renderer_document_no_load'); + } + if (Reflect.apply(beginDocumentMethod, attempt, [artifact]) !== true) { + return startupFailure('internal_error'); + } + artifactOwnedByAttempt = true; + if (mode === 'puc_overlay') { + try { + onArtifactTransferred(); + baseArtifactTransferred = true; + } catch { + return fail('internal_error'); + } + } + if ( + disposed || + frameErrorObserved || + attemptState() !== 'waiting_for_document' || + !exactFrameBinding(false) + ) { + return fail('renderer_document_no_load'); + } + return true; } catch { - return undefined; + return startupFailure('renderer_document_no_load'); } } -export interface RenderApsCreativeOptions { - slotId: string; - renderer: unknown; +/** Drive one direct APS attempt through the shared top-page mount service. */ +export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolean { + return mountApsTopPageAttempt({ ...options, mode: 'direct' }); } -/** Render APS through the static endpoint under an outer opaque-origin sandbox. */ -export function renderApsCreative({ slotId, renderer: input }: RenderApsCreativeOptions): boolean { - const renderer = validateApsRenderer(input); - const rendererUrl = apsRendererUrl(); - const nonce = createNonce(); - if (!renderer || !rendererUrl || !nonce) { - log.warn('APS renderer: rejected descriptor'); - return false; - } - - const container = document.getElementById(slotId); - if (!container) { - log.warn('APS renderer: slot not found'); - return false; - } - - const iframe = document.createElement('iframe'); - iframe.title = 'Ad content'; - iframe.width = String(renderer.width); - iframe.height = String(renderer.height); - iframe.style.border = '0'; - iframe.style.display = 'none'; - iframe.setAttribute('sandbox', APS_RENDERER_SANDBOX); - iframe.src = `${rendererUrl}#tsaps=${nonce}`; - - // A replacement must cancel a pending frame, not merely detach it: its - // message listener and ready timeout would otherwise remain live until expiry. - pendingFrameCancels.get(container)?.(); - activeFrames.set(container, iframe); - - let settled = false; - const cleanup = (): void => { - window.removeEventListener('message', receive); - window.clearTimeout(timeoutId); - }; - const cancel = (): void => { - if (settled) return; - settled = true; - cleanup(); - if (pendingFrameCancels.get(container) === cancel) pendingFrameCancels.delete(container); - if (activeFrames.get(container) === iframe) activeFrames.delete(container); - iframe.remove(); - }; - const fail = (): void => { - if (settled) return; - cancel(); - log.warn('APS renderer: frame load failed'); - }; - const commit = (): void => { - if (settled || activeFrames.get(container) !== iframe || !iframe.isConnected) return; - settled = true; - cleanup(); - if (pendingFrameCancels.get(container) === cancel) pendingFrameCancels.delete(container); - for (const child of Array.from(container.children)) { - if (child !== iframe) child.remove(); - } - iframe.style.display = ''; - }; - function receive(event: MessageEvent): void { - if (event.source !== iframe.contentWindow || !hasExactKeys(event.data, ['message', 'nonce'])) { - return; - } - if (event.data.nonce !== nonce) return; - if (event.data.message === RENDERER_READY_MESSAGE) commit(); - else if (event.data.message === RENDERER_FAILED_MESSAGE) fail(); - } - - window.addEventListener('message', receive); - iframe.addEventListener( - 'load', - () => { - if (settled || activeFrames.get(container) !== iframe || !iframe.isConnected) return; - try { - const target = iframe.contentWindow; - if (!target) { - fail(); - return; - } - target.postMessage({ nonce, renderer }, '*'); - } catch { - fail(); - } - }, - { once: true } - ); - iframe.addEventListener('error', fail, { once: true }); - - const timeoutId = window.setTimeout(fail, RENDERER_READY_TIMEOUT_MS); - pendingFrameCancels.set(container, cancel); - container.appendChild(iframe); - return true; +/** Drive one PUC APS attempt through a hidden top-page overlay. */ +export function renderPucApsAttempt(options: PucApsAttemptOptions): boolean { + return mountApsTopPageAttempt({ ...options, mode: 'puc_overlay' }); } - -/** - * Static source executed by Prebid Universal Creative's dynamic-renderer frame. - * It reads only the validated descriptor and trusted absolute endpoint URL from data. - */ -export const APS_UNIVERSAL_CREATIVE_RENDERER = String.raw`(function(){window.render=function(d,_h,w){return new Promise(function(resolve,reject){ -try{var r=d&&d.apsRenderer,u=d&&d.rendererUrl;if(!r||typeof u!=="string")throw new Error("invalid APS renderer data"); -var p=new URL(u);if((p.protocol!=="https:"&&p.protocol!=="http:")||p.username||p.password||p.pathname!=="${APS_RENDERER_PATH}"||p.search||p.hash)throw new Error("invalid APS renderer URL"); -var c=w.crypto;if(!c||typeof c.getRandomValues!=="function")throw new Error("APS renderer randomness unavailable"); -var b=new Uint8Array(16);c.getRandomValues(b);var s="";for(var i=0;i }; type Diff = { add: Record; del: string[] }; - -// Rebuild URLs already written to an anchor's href by an earlier repair pass -// (the opaque-origin GET fallback). They are not `/first-party/click` URLs, so -// they cannot be canonicalized and deliberately never replace the canonical -// `data-tsclick`. Without remembering them, a later click would canonicalize -// the fallback against the original signed click, fail the base comparison, and -// navigate the pre-mutation URL — silently dropping the mutation the fallback -// exists to carry. -const pendingRebuilds = new WeakMap(); +type PendingRebuilds = WeakMap; // Allow query/localStorage flag to crank logging when debugging creatives. function enableDebugFromEnv(): void { @@ -96,7 +90,7 @@ function equalCanon(a: Canon, b: Canon): boolean { const bk = Object.keys(b.params).sort(); if (ak.length !== bk.length) return false; for (let i = 0; i < ak.length; i++) { - const k = ak[i]; + const k = ak[i]!; if (k !== bk[i] || a.params[k] !== b.params[k]) return false; } return true; @@ -163,7 +157,13 @@ function buildProxyRebuildUrl(tsClickStr: string, diff: Diff): string { // does not answer, and always fails — so the guard skips it and recovers via // the GET navigation fallback, which the edge answers with a 302 chain (no // CORS applies to navigations). -async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Promise { +async function rebuildClick( + a: AnchorLike, + tsClickStr: string, + diff: Diff, + pendingRebuilds: PendingRebuilds, + isActive: () => boolean +): Promise { const addKeys = Object.keys(diff.add); const delKeys = diff.del; if (addKeys.length === 0 && delKeys.length === 0) { @@ -173,6 +173,7 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom const fallback = buildProxyRebuildUrl(tsClickStr, diff); if (typeof fetch !== 'function' || hasOpaqueOrigin()) { + if (!isActive()) return tsClickStr; try { const el = a as Element; el.setAttribute('href', fallback); @@ -193,6 +194,7 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom body: JSON.stringify(payload), credentials: 'same-origin', }); + if (!isActive()) return tsClickStr; if (!resp.ok) { log.warn('tsjs-creative:click: proxy-rebuild HTTP error', resp.status); try { @@ -204,9 +206,10 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom return fallback; } const data = (await resp.json()) as { href?: string; base?: string } | null; + if (!isActive()) return tsClickStr; const href = data && typeof data.href === 'string' ? data.href : null; if (href) { - persistRebuiltClick(a, href); + persistRebuiltClick(a, href, pendingRebuilds); log.info('tsjs-creative:click: rebuilt click', { added: addKeys, removed: delKeys, @@ -214,9 +217,11 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom return href; } } catch (err) { + if (!isActive()) return tsClickStr; log.warn('tsjs-creative:click: proxy-rebuild request failed', err); } + if (!isActive()) return tsClickStr; try { const el = a as Element; el.setAttribute('href', fallback); @@ -227,7 +232,12 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom } // Work out the href we should navigate to after accounting for creative rewrites. -async function computeFinalUrl(a: AnchorLike, tsClickStr: string): Promise { +async function computeFinalUrl( + a: AnchorLike, + tsClickStr: string, + pendingRebuilds: PendingRebuilds, + isActive: () => boolean +): Promise { const orig = canonFromFirstPartyClick(tsClickStr); if (!orig) return tsClickStr; @@ -264,7 +274,7 @@ async function computeFinalUrl(a: AnchorLike, tsClickStr: string): Promise MAX_REBUILD_URL_LENGTH && - resolved.includes(REBUILD_PATH) && + isRebuildNavigationUrl(resolved) && submitRebuildNavigation(a, resolved) ) { return; @@ -361,10 +374,6 @@ function navigate(a: AnchorLike, url: string, isMiddle: boolean): void { // compare against — is only updated when the value is itself a signed // /first-party/click URL. Writing the GET proxy-rebuild fallback there would // make every later canonicalization fail and lose subsequent mutations. -// Root-relative form of a first-party URL — the shape the server-side rewriter -// emits and the shape `data-tsclick` must keep. `href` is absolutized so it -// resolves inside the srcdoc frame, but the canonical attribute has to stay in -// the server's format: it is echoed back as the rebuild payload's `tsclick`. function canonicalClickValue(resolved: string): string { try { const url = new URL(resolved, TRUSTED_BASE_URL); @@ -374,7 +383,11 @@ function canonicalClickValue(resolved: string): string { } } -function persistRebuiltClick(anchor: AnchorLike, finalUrl: string): void { +function persistRebuiltClick( + anchor: AnchorLike, + finalUrl: string, + pendingRebuilds: PendingRebuilds +): void { // Persist the validated, absolutized URL — never the raw input. Beyond // enforcing the http(s) allowlist, an absolute URL keeps the anchor's // default navigation working inside the srcdoc iframe, where a relative @@ -403,11 +416,18 @@ function persistRebuiltClick(anchor: AnchorLike, finalUrl: string): void { } // Give the creative one microtask to finish mutations before we lock in the href. -async function rebuildIfNeeded(anchor: AnchorLike, tsClickStr: string): Promise { - let finalUrl = await computeFinalUrl(anchor, tsClickStr); +async function rebuildIfNeeded( + anchor: AnchorLike, + tsClickStr: string, + pendingRebuilds: PendingRebuilds, + isActive: () => boolean +): Promise { + let finalUrl = await computeFinalUrl(anchor, tsClickStr, pendingRebuilds, isActive); + if (!isActive()) return tsClickStr; if (finalUrl === tsClickStr) { await delay(); - finalUrl = await computeFinalUrl(anchor, tsClickStr); + if (!isActive()) return tsClickStr; + finalUrl = await computeFinalUrl(anchor, tsClickStr, pendingRebuilds, isActive); } return finalUrl; } @@ -416,17 +436,25 @@ async function rebuildIfNeeded(anchor: AnchorLike, tsClickStr: string): Promise< async function guardNavigation( anchor: AnchorLike, tsClickStr: string, - isMiddle: boolean + isMiddle: boolean, + pendingRebuilds: PendingRebuilds, + isActive: () => boolean ): Promise { - const finalUrl = await rebuildIfNeeded(anchor, tsClickStr); + const finalUrl = await rebuildIfNeeded(anchor, tsClickStr, pendingRebuilds, isActive); + if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { - persistRebuiltClick(anchor, finalUrl); + persistRebuiltClick(anchor, finalUrl, pendingRebuilds); } navigate(anchor, finalUrl || tsClickStr, isMiddle); } // Entry point for click/auxclick handlers: prevent default and queue guarded nav. -function handleGuardedClick(ev: Event, isMiddle: boolean): void { +function handleGuardedClick( + ev: Event, + isMiddle: boolean, + pendingRebuilds: PendingRebuilds, + isActive: () => boolean +): void { const anchor = closestAnchor(ev.target); if (!anchor) return; @@ -436,7 +464,9 @@ function handleGuardedClick(ev: Event, isMiddle: boolean): void { ev.preventDefault(); const runNavigation = () => { - void guardNavigation(anchor, tsClickStr, isMiddle).catch((err) => { + if (!isActive()) return; + void guardNavigation(anchor, tsClickStr, isMiddle, pendingRebuilds, isActive).catch((err) => { + if (!isActive()) return; log.warn('tsjs-creative:click: failed to compute final URL', err); navigate(anchor, tsClickStr, isMiddle); }); @@ -446,16 +476,23 @@ function handleGuardedClick(ev: Event, isMiddle: boolean): void { } // Observe href/data-tsclick mutations and repair anchors that third parties touch. -function monitorAnchorMutations(): void { - if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return; +function monitorAnchorMutations( + pendingRebuilds: PendingRebuilds, + isActive: () => boolean +): CreativeGuardHandle { + if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') { + return Object.freeze({ dispose: () => undefined, scan: () => undefined }); + } const schedule = createMutationScheduler((anchor) => { + if (!isActive()) return; const tsClickStr = anchor.getAttribute('data-tsclick') || ''; if (!tsClickStr) return; - void rebuildIfNeeded(anchor, tsClickStr) + void rebuildIfNeeded(anchor, tsClickStr, pendingRebuilds, isActive) .then((finalUrl) => { + if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { - persistRebuiltClick(anchor, finalUrl); + persistRebuiltClick(anchor, finalUrl, pendingRebuilds); } }) .catch((err) => { @@ -463,14 +500,14 @@ function monitorAnchorMutations(): void { }); }); - const scan = () => { + const scan = (): void => { + if (!isActive()) return; const anchors = document.querySelectorAll('a[data-tsclick], area[data-tsclick]'); anchors.forEach((anchor) => schedule(anchor)); }; - scan(); - const observer = new MutationObserver((records) => { + if (!isActive()) return; for (const record of records) { if (record.type !== 'attributes') continue; const target = record.target; @@ -485,27 +522,64 @@ function monitorAnchorMutations(): void { attributes: true, attributeFilter: ['href', 'data-tsclick'], }); + + let disposed = false; + return Object.freeze({ + dispose: (): void => { + if (disposed) return; + disposed = true; + observer.disconnect(); + schedule.dispose(); + }, + scan, + }); } // Wire up capture-phase click handlers + mutation observers to protect clicks. -export function installClickGuard(): void { +export function installClickGuard(scanInitially = true): CreativeGuardHandle { if (log.getLevel && log.getLevel() === 'warn') { log.setLevel('info'); } enableDebugFromEnv(); log.info('tsjs-creative:click: installing click guard'); + // Opaque rebuild recognition belongs to this exact guard generation. A new + // installation must never inherit a disposed generation's anchor state. + const pendingRebuilds: PendingRebuilds = new WeakMap(); + let active = true; + const isActive = (): boolean => active; const onClick = (ev: Event) => { - handleGuardedClick(ev, false); + if (!active) return; + handleGuardedClick(ev, false, pendingRebuilds, isActive); }; const onAuxClick = (ev: MouseEvent) => { + if (!active) return; if (ev.button !== 1) return; - handleGuardedClick(ev, true); + handleGuardedClick(ev, true, pendingRebuilds, isActive); }; document.addEventListener('click', onClick, true); document.addEventListener('auxclick', onAuxClick as EventListener, true); - monitorAnchorMutations(); + let mutations: CreativeGuardHandle | undefined; + const dispose = (): void => { + if (!active) return; + active = false; + document.removeEventListener('click', onClick, true); + document.removeEventListener('auxclick', onAuxClick as EventListener, true); + mutations?.dispose(); + }; + try { + mutations = monitorAnchorMutations(pendingRebuilds, isActive); + const handle = Object.freeze({ + dispose, + scan: (): void => mutations?.scan(), + }); + if (scanInitially) handle.scan(); + return handle; + } catch (error) { + dispose(); + throw error; + } } diff --git a/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts b/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts index 3cab3465c..79a59c2b7 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts @@ -1,7 +1,8 @@ import { log } from '../../core/log'; -import { createMutationScheduler } from '../../shared/scheduler'; +import { createMutationScheduler, type MutationScheduler } from '../../shared/scheduler'; import type { ProxySignOutcome } from './proxy_sign'; +import type { CreativeGuardHandle } from './startup'; type ElementWithSrc = Element & { src: string }; @@ -16,6 +17,11 @@ type FactoryFunction = { new (...args: unknown[]): E; } & ((...args: unknown[]) => E); +interface InstancePatch { + readonly installed: PropertyDescriptor; + readonly original: PropertyDescriptor | undefined; +} + export interface DynamicSrcProxyOptions { elementConstructor: ElementCtor | undefined; selector: string; @@ -28,309 +34,418 @@ export interface DynamicSrcProxyOptions { signProxy(raw: string, element: E): Promise; } +function sameDescriptor( + left: PropertyDescriptor | undefined, + right: PropertyDescriptor | undefined +): boolean { + if (!left || !right) return left === right; + return ( + left.configurable === right.configurable && + left.enumerable === right.enumerable && + left.get === right.get && + left.set === right.set && + left.value === right.value && + left.writable === right.writable + ); +} + +function inertHandle(): CreativeGuardHandle { + return Object.freeze({ dispose: () => undefined, scan: () => undefined }); +} + export function createDynamicSrcProxy( options: DynamicSrcProxyOptions -): () => void { +): (scanInitially?: boolean) => CreativeGuardHandle { const attr = (options.attributeName ?? 'src').toLowerCase(); const tagName = options.tagName.toLowerCase(); + let installedHandle: CreativeGuardHandle | undefined; - const assignments = new WeakMap(); - const lastProcessed = new WeakMap(); - let sequence = 0; - let proxyInstalled = false; - let observerInstalled = false; - let nativeSet: ((this: E, value: string) => void) | undefined; - let nativeGet: ((this: E) => string) | undefined; - let nativeSetAttribute: (this: E, name: string, value: string) => void = () => undefined; - let nativeSetAttributeNS: - | ((this: E, namespace: string | null, name: string, value: string) => void) - | undefined; - const wrappedInstances = new WeakSet(); - let createElementPatched = false; - let factoryPatched = false; - const nativeCreateElement = - typeof document === 'undefined' ? undefined : document.createElement.bind(document); + return function install(scanInitially = true): CreativeGuardHandle { + if (installedHandle) return installedHandle; + const ctor = options.elementConstructor; + if (typeof ctor !== 'function') { + installedHandle = inertHandle(); + return installedHandle; + } - function apply(element: E, value: string): void { - try { - if (typeof nativeSet === 'function') { - nativeSet.call(element, value); - } else { - nativeSetAttribute.call(element, attr, value); - } - } catch (err) { - log.debug(`${options.logPrefix}: failed to apply ${options.resourceName} ${attr}`, err); + const sourceDescriptor = Object.getOwnPropertyDescriptor(ctor.prototype, attr); + if (!sourceDescriptor || typeof sourceDescriptor.set !== 'function') { + log.debug(`${options.logPrefix}: ${ctor.name} proxy install skipped (no setter)`); + installedHandle = inertHandle(); + return installedHandle; } - } - function proxyAssignment(element: E, rawInput: string): void { - const raw = String(rawInput || ''); - const last = lastProcessed.get(element); - if (last === raw) return; - lastProcessed.set(element, raw); + const assignments = new WeakMap(); + const lastProcessed = new WeakMap(); + const instancePatches = new Map(); + const nativeSet = sourceDescriptor.set as (this: E, value: string) => void; + const nativeGet = + typeof sourceDescriptor.get === 'function' + ? (sourceDescriptor.get as (this: E) => string) + : undefined; + const nativeSetAttribute = ctor.prototype.setAttribute as ( + this: E, + name: string, + value: string + ) => void; + const nativeSetAttributeNS = + typeof ctor.prototype.setAttributeNS === 'function' + ? (ctor.prototype.setAttributeNS as ( + this: E, + namespace: string | null, + name: string, + value: string + ) => void) + : undefined; + const originalSetAttribute = Object.getOwnPropertyDescriptor(ctor.prototype, 'setAttribute'); + const originalSetAttributeNS = Object.getOwnPropertyDescriptor( + ctor.prototype, + 'setAttributeNS' + ); + const targetDocument = typeof document === 'undefined' ? undefined : document; + const nativeCreateElement = targetDocument?.createElement; + const originalCreateElement = targetDocument + ? Object.getOwnPropertyDescriptor(targetDocument, 'createElement') + : undefined; + let active = true; + let sequence = 0; + let observer: MutationObserver | undefined; + let scheduler: MutationScheduler | undefined; + let installedSource: PropertyDescriptor | undefined; + let installedSetAttribute: PropertyDescriptor | undefined; + let installedSetAttributeNS: PropertyDescriptor | undefined; + let installedCreateElement: PropertyDescriptor | undefined; + let factoryTarget: Record | undefined; + let factoryOriginal: PropertyDescriptor | undefined; + let installedFactory: PropertyDescriptor | undefined; + + const restore = ( + target: object, + key: PropertyKey, + owned: PropertyDescriptor | undefined, + original: PropertyDescriptor | undefined + ): void => { + try { + if (!sameDescriptor(Object.getOwnPropertyDescriptor(target, key), owned)) return; + if (original) Object.defineProperty(target, key, original); + else Reflect.deleteProperty(target, key); + } catch (error) { + log.debug(`${options.logPrefix}: failed to restore ${String(key)}`, error); + } + }; + + const apply = (element: E, value: string): void => { + try { + nativeSet.call(element, value); + } catch (error) { + try { + nativeSetAttribute.call(element, attr, value); + } catch (fallbackError) { + log.debug( + `${options.logPrefix}: failed to apply ${options.resourceName} ${attr}`, + error, + fallbackError + ); + } + } + }; - const requestId = ++sequence; - assignments.set(element, { raw, requestId }); + const proxyAssignment = (element: E, rawInput: string): void => { + if (!active) { + apply(element, String(rawInput ?? '')); + return; + } + const raw = String(rawInput || ''); + const last = lastProcessed.get(element); + if (last === raw) return; + lastProcessed.set(element, raw); - const proxyable = options.shouldProxy(raw, element); - if (!proxyable || typeof fetch !== 'function') { - log.info(`${options.logPrefix}: skipping proxy for ${attr}`, { - reason: proxyable ? 'no-fetch' : 'non-proxyable', - raw, - }); - assignments.delete(element); - apply(element, raw); - return; - } + const requestId = ++sequence; + assignments.set(element, { raw, requestId }); - log.info(`${options.logPrefix}: signing ${options.resourceName} ${attr}`, { raw }); - void options - .signProxy(raw, element) - .then((result) => { - const current = assignments.get(element); - if (!current || current.requestId !== requestId) return; + let proxyable = false; + try { + proxyable = options.shouldProxy(raw, element); + } catch (error) { + log.warn(`${options.logPrefix}: ${options.resourceName} policy failed`, error); + } + if (!proxyable || typeof fetch !== 'function') { + log.info(`${options.logPrefix}: skipping proxy for ${attr}`, { + reason: proxyable ? 'no-fetch' : 'non-proxyable', + raw, + }); assignments.delete(element); - if (result.outcome === 'blocked') { - log.warn(`${options.logPrefix}: blocked dynamic ${options.resourceName} ${attr}`, { - raw, - }); - return; - } + apply(element, raw); + return; + } - const finalUrl = result.outcome === 'signed' ? result.href : raw; - if (result.outcome === 'signed') { - log.info(`${options.logPrefix}: proxied dynamic ${options.resourceName}`, { - base: raw, - finalUrl, - }); - } - lastProcessed.set(element, finalUrl); - apply(element, finalUrl); - }) - .catch((err) => { - const current = assignments.get(element); - if (!current || current.requestId !== requestId) return; + log.info(`${options.logPrefix}: signing ${options.resourceName} ${attr}`, { raw }); + let signing: Promise; + try { + signing = options.signProxy(raw, element); + } catch (error) { assignments.delete(element); log.warn( `${options.logPrefix}: failed to proxy dynamic ${options.resourceName}; using raw ${attr}`, - err + error ); - lastProcessed.set(element, raw); apply(element, raw); - }); - } - - function monitorMutations(ctor: ElementCtor): void { - if (observerInstalled) return; - if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return; - - const schedule = createMutationScheduler((element) => { - ensureInstancePatched(element); - const fromAttr = element.getAttribute(attr) || ''; - const liveValue = (element as unknown as { [key: string]: string | undefined })[attr] || ''; - const raw = fromAttr || liveValue; - if (!raw) return; - log.info(`${options.logPrefix}: observed ${attr} set`, { raw }); - proxyAssignment(element, raw); - }); - - const scan = () => { - document.querySelectorAll(options.selector).forEach((el) => { - schedule(el as E); - }); - }; - - log.info(`${options.logPrefix}: initial ${options.resourceName} scan`); - scan(); - - const observer = new MutationObserver((records) => { - for (const record of records) { - if (record.type === 'attributes') { - const target = record.target; - if (target instanceof ctor && record.attributeName === attr) { - schedule(target as E); + return; + } + void signing + .then((result) => { + if (!active) return; + const current = assignments.get(element); + if (!current || current.requestId !== requestId) return; + assignments.delete(element); + if (result.outcome === 'blocked') { + log.warn(`${options.logPrefix}: blocked dynamic ${options.resourceName} ${attr}`, { + raw, + }); + return; } - continue; - } + const finalUrl = result.outcome === 'signed' ? result.href : raw; + if (result.outcome === 'signed') { + log.info(`${options.logPrefix}: proxied dynamic ${options.resourceName}`, { + base: raw, + finalUrl, + }); + } + lastProcessed.set(element, finalUrl); + apply(element, finalUrl); + }) + .catch((error) => { + if (!active) return; + const current = assignments.get(element); + if (!current || current.requestId !== requestId) return; + assignments.delete(element); + log.warn( + `${options.logPrefix}: failed to proxy dynamic ${options.resourceName}; using raw ${attr}`, + error + ); + lastProcessed.set(element, raw); + apply(element, raw); + }); + }; - if (record.type === 'childList') { - record.addedNodes.forEach((node) => { - if (node instanceof ctor) { - schedule(node as E); + const ensureInstancePatched = (element: E | null | undefined): void => { + if (!active || !element || instancePatches.has(element)) return; + const original = Object.getOwnPropertyDescriptor(element, attr); + try { + Object.defineProperty(element, attr, { + configurable: true, + enumerable: true, + get(this: E) { + const pending = assignments.get(this); + if (pending) return pending.raw; + return nativeGet ? nativeGet.call(this) : ''; + }, + set(this: E, value: string) { + if (!active) { + apply(this, String(value ?? '')); return; } - if (!(node instanceof Element)) return; - node.querySelectorAll(options.selector).forEach((el) => schedule(el as E)); - }); - } + log.info(`${options.logPrefix}: ${tagName} instance ${attr} set`, value); + proxyAssignment(this, String(value ?? '')); + }, + }); + const installed = Object.getOwnPropertyDescriptor(element, attr); + if (installed) instancePatches.set(element, { installed, original }); + } catch (error) { + log.debug(`${options.logPrefix}: failed to patch ${tagName} instance ${attr}`, error); } - }); - - observer.observe(document, { - subtree: true, - childList: true, - attributes: true, - attributeFilter: [attr], - }); - - observerInstalled = true; - log.info(`${options.logPrefix}: mutation observer active`); - } + }; - function ensureInstancePatched(element: E | null | undefined): void { - if (!element || wrappedInstances.has(element)) return; - wrappedInstances.add(element); - try { - Object.defineProperty(element, attr, { - configurable: true, - enumerable: true, - get(this: E) { - const pending = assignments.get(this); - if (pending) return pending.raw; - return nativeGet ? nativeGet.call(this) : ''; - }, - set(this: E, value: string) { - log.info(`${options.logPrefix}: ${tagName} instance ${attr} set`, value); - proxyAssignment(this, String(value ?? '')); - }, + const scan = (): void => { + if (!active || !targetDocument || !scheduler) return; + targetDocument.querySelectorAll(options.selector).forEach((element) => { + scheduler?.(element as E); }); - } catch (err) { - log.debug(`${options.logPrefix}: failed to patch ${tagName} instance ${attr}`, err); - } - } + }; - function patchDocumentCreateElement(): void { - if (createElementPatched || typeof document === 'undefined' || !nativeCreateElement) return; - createElementPatched = true; - document.createElement = function patchedCreateElement( - this: Document, - name: string, - options?: ElementCreationOptions - ): HTMLElement { - const el = nativeCreateElement(name, options); - if (typeof name === 'string' && name.toLowerCase() === tagName) { - ensureInstancePatched(el as unknown as E); + const dispose = (): void => { + if (!active) return; + active = false; + observer?.disconnect(); + scheduler?.dispose(); + for (const [element, patch] of instancePatches) { + restore(element, attr, patch.installed, patch.original); } - return el; - } as typeof document.createElement; - } - - function patchFactory(): void { - if (!options.factoryName || factoryPatched) return; - const globalObj = globalThis as Record; - const factory = globalObj[options.factoryName]; - if (typeof factory !== 'function') return; - const factoryFn = factory as FactoryFunction; - - const WrappedFactory = function (this: unknown, ...args: unknown[]) { - const instance = Reflect.construct(factoryFn, args, new.target ?? WrappedFactory) as E; - ensureInstancePatched(instance); - return instance; + instancePatches.clear(); + if (targetDocument) { + restore(targetDocument, 'createElement', installedCreateElement, originalCreateElement); + } + if (factoryTarget && options.factoryName) { + restore(factoryTarget, options.factoryName, installedFactory, factoryOriginal); + } + restore(ctor.prototype, 'setAttributeNS', installedSetAttributeNS, originalSetAttributeNS); + restore(ctor.prototype, 'setAttribute', installedSetAttribute, originalSetAttribute); + restore(ctor.prototype, attr, installedSource, sourceDescriptor); + if (installedHandle === handle) installedHandle = undefined; }; - Object.defineProperty(WrappedFactory, 'length', { - value: factoryFn.length, - configurable: true, - }); - Object.defineProperty(WrappedFactory, 'name', { - value: options.factoryName, - configurable: true, - }); - WrappedFactory.prototype = factoryFn.prototype; - Object.setPrototypeOf(WrappedFactory, factoryFn); - - globalObj[options.factoryName] = WrappedFactory as unknown; - factoryPatched = true; - } - - return function install(): void { - if (proxyInstalled) return; - const ctor = options.elementConstructor; - if (typeof ctor !== 'function') return; - - log.info(`${options.logPrefix}: installing dynamic ${options.resourceName} proxy hooks`); - - const descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, attr); - if (!descriptor || typeof descriptor.set !== 'function') { - log.debug(`${options.logPrefix}: ${ctor.name} proxy install skipped (no setter)`); - return; - } - - nativeSet = descriptor.set as typeof nativeSet; - nativeGet = - typeof descriptor.get === 'function' ? (descriptor.get as typeof nativeGet) : undefined; - nativeSetAttribute = ctor.prototype.setAttribute as typeof nativeSetAttribute; - nativeSetAttributeNS = - typeof ctor.prototype.setAttributeNS === 'function' - ? (ctor.prototype.setAttributeNS as typeof nativeSetAttributeNS) - : undefined; + const handle = Object.freeze({ dispose, scan }); - let prototypePatched = false; - if (descriptor.configurable !== false) { - try { + try { + log.info(`${options.logPrefix}: installing dynamic ${options.resourceName} proxy hooks`); + let prototypePatched = false; + if (sourceDescriptor.configurable !== false) { Object.defineProperty(ctor.prototype, attr, { configurable: true, - enumerable: descriptor.enumerable ?? true, + enumerable: sourceDescriptor.enumerable ?? true, get(this: E) { - log.info(`${options.logPrefix}: ${ctor.name} ${attr} get`); const pending = assignments.get(this); if (pending) return pending.raw; return nativeGet ? nativeGet.call(this) : ''; }, set(this: E, value: string) { + if (!active) { + apply(this, String(value ?? '')); + return; + } log.info(`${options.logPrefix}: ${ctor.name} ${attr} set`, value); proxyAssignment(this, String(value ?? '')); }, }); + installedSource = Object.getOwnPropertyDescriptor(ctor.prototype, attr); prototypePatched = true; - } catch (err) { - log.debug(`${options.logPrefix}: failed to patch prototype ${attr}`, err); - } - } else { - log.debug(`${options.logPrefix}: prototype ${attr} not configurable; using fallback`); - } - - ctor.prototype.setAttribute = function patchedSetAttribute( - this: E, - name: string, - value: string - ) { - log.debug(`${options.logPrefix}: ${ctor.name} setAttribute`, { name, value }); - if (typeof name === 'string' && name.toLowerCase() === attr) { - proxyAssignment(this, String(value ?? '')); - return; + } else { + log.debug(`${options.logPrefix}: prototype ${attr} not configurable; using fallback`); } - nativeSetAttribute.call(this, name, value); - }; - if (nativeSetAttributeNS) { - ctor.prototype.setAttributeNS = function patchedSetAttributeNS( + ctor.prototype.setAttribute = function patchedSetAttribute( this: E, - namespace: string | null, name: string, value: string ): void { - log.debug(`${options.logPrefix}: ${ctor.name} setAttributeNS`, { namespace, name, value }); - if (typeof name === 'string' && name.toLowerCase() === attr) { - proxyAssignment(this, String(value ?? '')); + if (!active || typeof name !== 'string' || name.toLowerCase() !== attr) { + nativeSetAttribute.call(this, name, value); return; } - nativeSetAttributeNS!.call(this, namespace, name, value); + log.debug(`${options.logPrefix}: ${ctor.name} setAttribute`, { name, value }); + proxyAssignment(this, String(value ?? '')); }; - } + installedSetAttribute = Object.getOwnPropertyDescriptor(ctor.prototype, 'setAttribute'); + + if (nativeSetAttributeNS) { + ctor.prototype.setAttributeNS = function patchedSetAttributeNS( + this: E, + namespace: string | null, + name: string, + value: string + ): void { + if (!active || typeof name !== 'string' || name.toLowerCase() !== attr) { + nativeSetAttributeNS.call(this, namespace, name, value); + return; + } + log.debug(`${options.logPrefix}: ${ctor.name} setAttributeNS`, { + namespace, + name, + value, + }); + proxyAssignment(this, String(value ?? '')); + }; + installedSetAttributeNS = Object.getOwnPropertyDescriptor(ctor.prototype, 'setAttributeNS'); + } - proxyInstalled = true; - log.info(`${options.logPrefix}: dynamic ${options.resourceName} proxy installed`); + if (!prototypePatched) { + if (targetDocument && nativeCreateElement) { + targetDocument + .querySelectorAll(options.selector) + .forEach((element) => ensureInstancePatched(element as E)); + targetDocument.createElement = function patchedCreateElement( + this: Document, + name: string, + creationOptions?: ElementCreationOptions + ): HTMLElement { + const element = nativeCreateElement.call(this, name, creationOptions); + if (active && typeof name === 'string' && name.toLowerCase() === tagName) { + ensureInstancePatched(element as unknown as E); + } + return element; + } as typeof targetDocument.createElement; + installedCreateElement = Object.getOwnPropertyDescriptor(targetDocument, 'createElement'); + } - if (!prototypePatched) { - log.info(`${options.logPrefix}: using instance-level proxy fallback`); - if (typeof document !== 'undefined') { - document.querySelectorAll(options.selector).forEach((el) => ensureInstancePatched(el as E)); + if (options.factoryName) { + const globalObject = globalThis as Record; + const factory = globalObject[options.factoryName]; + if (typeof factory === 'function') { + const factoryFunction = factory as FactoryFunction; + factoryTarget = globalObject; + factoryOriginal = Object.getOwnPropertyDescriptor(globalObject, options.factoryName); + const WrappedFactory = function (this: unknown, ...args: unknown[]) { + const instance = Reflect.construct( + factoryFunction, + args, + new.target ?? WrappedFactory + ) as E; + if (active) ensureInstancePatched(instance); + return instance; + }; + Object.defineProperty(WrappedFactory, 'length', { + value: factoryFunction.length, + configurable: true, + }); + Object.defineProperty(WrappedFactory, 'name', { + value: options.factoryName, + configurable: true, + }); + WrappedFactory.prototype = factoryFunction.prototype; + Object.setPrototypeOf(WrappedFactory, factoryFunction); + globalObject[options.factoryName] = WrappedFactory; + installedFactory = Object.getOwnPropertyDescriptor(globalObject, options.factoryName); + } + } } - patchDocumentCreateElement(); - patchFactory(); - } - monitorMutations(ctor); + if (targetDocument && typeof MutationObserver !== 'undefined') { + scheduler = createMutationScheduler((element) => { + if (!active) return; + ensureInstancePatched(element); + const fromAttribute = element.getAttribute(attr) || ''; + const liveValue = + (element as unknown as { [key: string]: string | undefined })[attr] || ''; + const raw = fromAttribute || liveValue; + if (!raw) return; + log.info(`${options.logPrefix}: observed ${attr} set`, { raw }); + proxyAssignment(element, raw); + }); + observer = new MutationObserver((records) => { + if (!active) return; + for (const record of records) { + if (record.type === 'attributes') { + const target = record.target; + if (target instanceof ctor && record.attributeName === attr) scheduler?.(target as E); + continue; + } + if (record.type !== 'childList') continue; + record.addedNodes.forEach((node) => { + if (node instanceof ctor) { + scheduler?.(node as E); + return; + } + if (!(node instanceof Element)) return; + node + .querySelectorAll(options.selector) + .forEach((element) => scheduler?.(element as E)); + }); + } + }); + observer.observe(targetDocument, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: [attr], + }); + } + + installedHandle = handle; + if (scanInitially) scan(); + return handle; + } catch (error) { + dispose(); + throw error; + } }; } diff --git a/crates/trusted-server-js/lib/src/integrations/creative/iframe.ts b/crates/trusted-server-js/lib/src/integrations/creative/iframe.ts index 24c003373..a23d1b19f 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/iframe.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/iframe.ts @@ -1,6 +1,7 @@ // Dynamic iframe proxy guard: routes iframe src assignments through the first-party proxy. import { createDynamicSrcProxy } from './dynamic_src_guard'; import { shouldProxyExternalUrl, signProxyUrl } from './proxy_sign'; +import type { CreativeGuardHandle } from './startup'; const installProxy = createDynamicSrcProxy({ elementConstructor: typeof HTMLIFrameElement === 'undefined' ? undefined : HTMLIFrameElement, @@ -12,6 +13,6 @@ const installProxy = createDynamicSrcProxy({ signProxy: (raw) => signProxyUrl(raw), }); -export function installDynamicIframeProxy(): void { - installProxy(); +export function installDynamicIframeProxy(scanInitially = true): CreativeGuardHandle { + return installProxy(scanInitially); } diff --git a/crates/trusted-server-js/lib/src/integrations/creative/image.ts b/crates/trusted-server-js/lib/src/integrations/creative/image.ts index dc608fc32..d64a62c95 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/image.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/image.ts @@ -1,6 +1,7 @@ // Dynamic image proxy guard: intercepts sources and routes them via first-party proxy. import { createDynamicSrcProxy } from './dynamic_src_guard'; import { shouldProxyExternalUrl, signProxyUrl } from './proxy_sign'; +import type { CreativeGuardHandle } from './startup'; // NOTE: This module intentionally logs at info level in the hot paths so that when // creatives crash before reaching a console, we still have breadcrumbs showing how @@ -20,6 +21,6 @@ const installProxy = createDynamicSrcProxy({ }); // Prepare global hooks so every img.src assignment flows through Trusted Server first. -export function installDynamicImageProxy(): void { - installProxy(); +export function installDynamicImageProxy(scanInitially = true): CreativeGuardHandle { + return installProxy(scanInitially); } diff --git a/crates/trusted-server-js/lib/src/integrations/creative/index.ts b/crates/trusted-server-js/lib/src/integrations/creative/index.ts index 395553562..610fa053c 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/index.ts @@ -1,97 +1,13 @@ -// Entry point for the creative runtime: wires up click + image + iframe guards globally. -import { log } from '../../core/log'; -import type { TsCreativeConfig, CreativeWindow, TsCreativeApi } from '../../shared/globals'; -import { creativeGlobal, resolveWindow } from '../../shared/globals'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -import { installClickGuard } from './click'; -import { installDynamicImageProxy } from './image'; -import { installDynamicIframeProxy } from './iframe'; +import { createCreativeIntegrationRegistration } from './module'; -export { installDynamicImageProxy } from './image'; -export { installDynamicIframeProxy } from './iframe'; - -const DEFAULT_CONFIG: Required = { - clickGuard: true, - renderGuard: false, -}; - -let currentConfig: Required = { ...DEFAULT_CONFIG }; -let guardsInstallTriggered = false; -let clickGuardInstalled = false; -let renderGuardInstalled = false; - -function applyConfig(): void { - if (currentConfig.clickGuard && !clickGuardInstalled) { - installClickGuard(); - clickGuardInstalled = true; +if (typeof window !== 'undefined') { + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createCreativeIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); } - - if (currentConfig.renderGuard && !renderGuardInstalled) { - installDynamicImageProxy(); - installDynamicIframeProxy(); - renderGuardInstalled = true; - } -} - -function mergeConfig(cfg: TsCreativeConfig): void { - currentConfig = { - clickGuard: cfg.clickGuard ?? currentConfig.clickGuard, - renderGuard: cfg.renderGuard ?? currentConfig.renderGuard, - }; - creativeGlobal.tsCreativeConfig = { ...currentConfig }; } - -export function setCreativeConfig(cfg: TsCreativeConfig): void { - mergeConfig(cfg); - if (guardsInstallTriggered) { - applyConfig(); - } -} - -export function getCreativeConfig(): TsCreativeConfig { - return { ...currentConfig }; -} - -// Public entry for creative runtime: install click + image protections once per page. -export function installGuards(): void { - if (!guardsInstallTriggered) { - guardsInstallTriggered = true; - } - applyConfig(); -} - -export const tsCreative: TsCreativeApi = { - installGuards, - setConfig: setCreativeConfig, - getConfig: getCreativeConfig, -}; - -try { - creativeGlobal.tscreative = tsCreative; -} catch (err) { - log.debug('tsjs-creative: failed to expose global tscreative', err); -} - -export default tsCreative; - -(function auto() { - // Auto-install on load so publishers just reference the bundle. - const maybeWindow = resolveWindow(); - if (!maybeWindow || typeof document === 'undefined') return; - - const win = maybeWindow as CreativeWindow; - const initialConfig = creativeGlobal.tsCreativeConfig ?? win.tsCreativeConfig; - if (initialConfig) { - mergeConfig(initialConfig); - } else { - creativeGlobal.tsCreativeConfig = { ...currentConfig }; - } - if (win.__ts_creative_installed) return; - win.__ts_creative_installed = true; - - installGuards(); - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => installGuards()); - } -})(); diff --git a/crates/trusted-server-js/lib/src/integrations/creative/module.ts b/crates/trusted-server-js/lib/src/integrations/creative/module.ts new file mode 100644 index 000000000..0a228c38d --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/creative/module.ts @@ -0,0 +1,138 @@ +import type { CreativeBootV1 } from '../../core/types'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../kernel/integration_registry'; +import type { RuntimeCapabilityV1 } from '../../kernel/runtime'; +import { validatePersistentFirstDisplaySliceAdoptionV1 } from '../../shared/takeover'; + +import { installClickGuard } from './click'; +import { installDynamicIframeProxy } from './iframe'; +import { installDynamicImageProxy } from './image'; +import { createCreativeStartup } from './startup'; + +export const CREATIVE_INTEGRATION_ID = 'creative' as const; + +function validFirstDisplayAdoption(candidate: unknown): boolean { + return validatePersistentFirstDisplaySliceAdoptionV1(candidate, 'creative_initial', (state) => { + const value = state.values[0]?.[1]; + return ( + state.values.length === 1 && + state.values[0]?.[0] === 'guard_count' && + typeof value === 'number' && + Number.isInteger(value) && + value >= 1 && + value <= 3 + ); + }); +} + +function readCreativeBoot(candidate: unknown): Readonly | undefined { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + Object.getOwnPropertySymbols(candidate).length !== 0 + ) { + return undefined; + } + const keys = Object.getOwnPropertyNames(candidate).sort(); + const expected = ['clickGuard', 'enabled', 'renderGuard', 'version']; + if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { + return undefined; + } + const values: Record = {}; + for (let index = 0; index < expected.length; index += 1) { + const key = expected[index]; + if (!key) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + values[key] = descriptor.value; + } + return values['version'] === 1 && + typeof values['enabled'] === 'boolean' && + typeof values['clickGuard'] === 'boolean' && + typeof values['renderGuard'] === 'boolean' && + (values['enabled'] || (!values['clickGuard'] && !values['renderGuard'])) + ? (candidate as Readonly) + : undefined; + } catch { + return undefined; + } +} + +function readRuntimeCapability( + interfaces: Readonly> +): RuntimeCapabilityV1 | undefined { + try { + const descriptor = Object.getOwnPropertyDescriptor(interfaces, 'runtime.v1'); + if (!descriptor || !('value' in descriptor)) return undefined; + const candidate = descriptor.value; + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + !(candidate as RuntimeCapabilityV1).document + ) { + return undefined; + } + return candidate as RuntimeCapabilityV1; + } catch { + return undefined; + } +} + +/** Build the inert, release-bound creative module for the coordinated runtime. */ +export function createCreativeIntegrationRegistration(releaseId: string): IntegrationRegistration { + const prepare = ({ config, interfaces }: IntegrationPrepareContext) => { + const creative = readCreativeBoot(config); + if (!creative) throw new TypeError('Creative boot configuration is invalid'); + const runtimeCapability = readRuntimeCapability(interfaces); + const runtimeDocument = runtimeCapability?.document; + if (!runtimeDocument) throw new TypeError('Creative runtime capability is unavailable'); + if (!creative.enabled || (!creative.clickGuard && !creative.renderGuard)) { + return Object.freeze({ + activate: ({ adoption }: IntegrationActivationContext) => { + if (adoption !== undefined && !validFirstDisplayAdoption(adoption)) { + throw new TypeError('Creative first-display parser state is invalid'); + } + }, + }); + } + const runtime = createCreativeStartup({ + document: runtimeDocument, + installClickGuard: () => installClickGuard(false), + installDynamicIframeProxy: () => installDynamicIframeProxy(false), + installDynamicImageProxy: () => installDynamicImageProxy(false), + }); + + return Object.freeze({ + activate: ({ adoption, afterCommit, onDispose }: IntegrationActivationContext) => { + if (adoption !== undefined && !validFirstDisplayAdoption(adoption)) { + throw new TypeError('Creative first-display parser state is invalid'); + } + const runtimeRelease: { value?: () => void } = {}; + onDispose(() => runtimeRelease.value?.()); + const releaseRuntime = runtime.activate(creative); + if (typeof releaseRuntime !== 'function') { + throw new TypeError('Creative integration activation disposer is unavailable'); + } + runtimeRelease.value = releaseRuntime; + afterCommit(() => runtime.start(creative)); + }, + }); + }; + return Object.freeze({ + abi: 1, + id: CREATIVE_INTEGRATION_ID, + phase: 'takeover', + releaseId, + prepareSync: prepare, + prepare, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts b/crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts index 2ea3f7ec9..0ac5601b0 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts @@ -21,9 +21,7 @@ export function shouldProxyExternalUrl(raw: string): boolean { } export type ProxySignOutcome = - | { outcome: 'signed'; href: string } - | { outcome: 'fallback' } - | { outcome: 'blocked' }; + { outcome: 'signed'; href: string } | { outcome: 'fallback' } | { outcome: 'blocked' }; const FALLBACK: ProxySignOutcome = { outcome: 'fallback' }; diff --git a/crates/trusted-server-js/lib/src/integrations/creative/startup.ts b/crates/trusted-server-js/lib/src/integrations/creative/startup.ts new file mode 100644 index 000000000..916a3fbe2 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/creative/startup.ts @@ -0,0 +1,128 @@ +import type { CreativeBootV1 } from '../../core/types'; + +export interface CreativeGuardHandle { + readonly dispose: () => void; + readonly scan: () => void; +} + +export interface CreativeStartup { + readonly activate: (config: Readonly) => () => void; + readonly start: (config: Readonly) => void; +} + +export interface CreativeStartupOptions { + readonly document: { + readonly readyState: DocumentReadyState; + addEventListener(type: 'DOMContentLoaded', listener: () => void, options: { once: true }): void; + removeEventListener(type: 'DOMContentLoaded', listener: () => void): void; + }; + readonly installClickGuard: () => CreativeGuardHandle; + readonly installDynamicIframeProxy: () => CreativeGuardHandle; + readonly installDynamicImageProxy: () => CreativeGuardHandle; +} + +function sameBoot(left: Readonly, right: Readonly): boolean { + return ( + left.version === right.version && + left.enabled === right.enabled && + left.clickGuard === right.clickGuard && + left.renderGuard === right.renderGuard + ); +} + +function validHandle(candidate: unknown): candidate is CreativeGuardHandle { + return ( + typeof candidate === 'object' && + candidate !== null && + typeof Reflect.get(candidate, 'dispose') === 'function' && + typeof Reflect.get(candidate, 'scan') === 'function' + ); +} + +/** Own creative guard installation separately from the post-commit initial scan. */ +export function createCreativeStartup(options: CreativeStartupOptions): CreativeStartup { + const handles: CreativeGuardHandle[] = []; + let activated = false; + let activatedBoot: Readonly | undefined; + let readyListener: (() => void) | undefined; + let released = false; + let started = false; + + const scan = (): void => { + if (released) return; + for (let index = 0; index < handles.length; index += 1) { + try { + handles[index]?.scan(); + } catch { + // One hostile guard scan cannot suppress the remaining active guards. + } + } + }; + + const disposeHandles = (): void => { + for (let index = handles.length - 1; index >= 0; index -= 1) { + try { + handles[index]?.dispose(); + } catch { + // Continue releasing every previously installed guard. + } + } + handles.length = 0; + }; + + const install = (installer: () => CreativeGuardHandle): void => { + const handle = installer(); + if (!validHandle(handle)) throw new TypeError('Creative guard handle is invalid'); + handles.push(handle); + }; + + return Object.freeze({ + activate: (config: Readonly): (() => void) => { + if (activated || released) throw new Error('Creative startup is already activated'); + activated = true; + activatedBoot = config; + try { + if (config.enabled && config.clickGuard) install(options.installClickGuard); + if (config.enabled && config.renderGuard) { + install(options.installDynamicImageProxy); + install(options.installDynamicIframeProxy); + } + if (handles.length > 0 && options.document.readyState === 'loading') { + readyListener = () => scan(); + options.document.addEventListener('DOMContentLoaded', readyListener, { once: true }); + } + } catch (error) { + const listener = readyListener; + readyListener = undefined; + try { + if (listener) options.document.removeEventListener('DOMContentLoaded', listener); + } catch { + // Preserve the activation failure while completing owned guard rollback. + } finally { + disposeHandles(); + } + throw error; + } + return (): void => { + if (released) return; + released = true; + const listener = readyListener; + readyListener = undefined; + try { + if (listener) options.document.removeEventListener('DOMContentLoaded', listener); + } finally { + disposeHandles(); + } + }; + }, + start: (config: Readonly): void => { + if (started) throw new Error('Creative startup is already started'); + started = true; + if (released) return; + if (!activated || !activatedBoot || !sameBoot(activatedBoot, config)) { + throw new Error('Creative startup is unavailable'); + } + if (handles.length > 0 && options.document.readyState !== 'loading') scan(); + }, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/datadome/index.ts b/crates/trusted-server-js/lib/src/integrations/datadome/index.ts index b7dacdebc..5a24093bd 100644 --- a/crates/trusted-server-js/lib/src/integrations/datadome/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/datadome/index.ts @@ -1,23 +1,13 @@ -import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -import { installDataDomeGuard } from './script_guard'; - -/** - * DataDome integration for tsjs - * - * Installs a script guard to intercept dynamically inserted DataDome SDK - * scripts and rewrites them to use the first-party proxy endpoint. - * - * The guard intercepts: - * - Script elements with src containing js.datadome.co - * - Link preload elements for DataDome scripts - * - * URLs are rewritten to preserve the original path: - * - https://js.datadome.co/tags.js -> /integrations/datadome/tags.js - * - https://js.datadome.co/js/check -> /integrations/datadome/js/check - */ +import { createDataDomeIntegrationRegistration } from './module'; if (typeof window !== 'undefined') { - installDataDomeGuard(); - log.info('DataDome integration initialized'); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createDataDomeIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); + } } diff --git a/crates/trusted-server-js/lib/src/integrations/datadome/module.ts b/crates/trusted-server-js/lib/src/integrations/datadome/module.ts new file mode 100644 index 000000000..a606a07e5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/datadome/module.ts @@ -0,0 +1,60 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { isEmptyIntegrationConfigV1 } from '../../shared/integration_config_validators'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; +import { log } from '../../core/log'; + +import { installDataDomeGuard, resetGuardState } from './script_guard'; + +export const DATADOME_INTEGRATION_ID = 'datadome' as const; + +export interface DataDomeRuntimeDependencies { + readonly installGuard: () => void; + readonly resetGuard: () => void; + readonly started: () => void; +} + +/** Own the reversible DataDome script/preload guard for one runtime. */ +export function createDataDomeRuntime( + dependencies: DataDomeRuntimeDependencies = { + installGuard: installDataDomeGuard, + resetGuard: resetGuardState, + started: () => log.info('DataDome integration initialized'), + } +): IntegrationLifecycleRuntime { + return Object.freeze({ + activate: (_config: unknown) => { + try { + dependencies.installGuard(); + } catch (error) { + try { + dependencies.resetGuard(); + } catch { + // Preserve the activation failure after best-effort rollback. + } + throw error; + } + let active = true; + return (): void => { + if (!active) return; + active = false; + dependencies.resetGuard(); + }; + }, + start: (_config: unknown) => dependencies.started(), + }); +} + +export function createDataDomeIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(DATADOME_INTEGRATION_ID, release, { + createOwnedRuntime: () => createDataDomeRuntime(), + firstDisplaySliceId: 'datadome_initial', + validateConfig: isEmptyIntegrationConfigV1, + validateFirstDisplayState: (state) => + state.values.length === 1 && + state.values[0]?.[0] === 'route_guard' && + state.values[0][1] === 'datadome', + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/didomi/index.ts b/crates/trusted-server-js/lib/src/integrations/didomi/index.ts index 3595b2f9a..618c1b068 100644 --- a/crates/trusted-server-js/lib/src/integrations/didomi/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/didomi/index.ts @@ -1,53 +1,13 @@ -import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -const DEFAULT_CONSENT_PROXY_PATH = '/integrations/didomi/consent/'; - -type DidomiConfig = { - sdkPath?: string; - [key: string]: unknown; -}; - -type DidomiWindow = Window & { - didomiConfig?: DidomiConfig; - __tsjs_didomi?: { proxyPath?: string }; -}; - -/** Read the server-injected proxy path, falling back to the default. */ -function getConsentProxyPath(win: DidomiWindow): string { - return win.__tsjs_didomi?.proxyPath ?? DEFAULT_CONSENT_PROXY_PATH; -} - -function buildProxySdkPath(win: DidomiWindow): string { - const proxyPath = getConsentProxyPath(win); - const base = win.location?.origin ?? win.location?.href; - if (!base) return proxyPath; - const url = new URL(proxyPath, base); - return `${url.origin}${url.pathname}`; -} - -export function installDidomiSdkProxy(): boolean { - if (typeof window === 'undefined') return false; - - const win = window as DidomiWindow; - const config = (win.didomiConfig ??= {}); - const previousSdkPath = - typeof config.sdkPath === 'string' && config.sdkPath.length > 0 - ? config.sdkPath - : 'https://sdk.privacy-center.org/'; - - const proxiedSdkPath = buildProxySdkPath(win); - config.sdkPath = proxiedSdkPath; - - log.info('didomi sdkPath overridden for trusted server proxy', { - previousSdkPath, - sdkPath: proxiedSdkPath, - }); - - return true; -} +import { createDidomiIntegrationRegistration } from './module'; if (typeof window !== 'undefined') { - installDidomiSdkProxy(); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createDidomiIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); + } } - -export default installDidomiSdkProxy; diff --git a/crates/trusted-server-js/lib/src/integrations/didomi/module.ts b/crates/trusted-server-js/lib/src/integrations/didomi/module.ts new file mode 100644 index 000000000..cf6a296ff --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/didomi/module.ts @@ -0,0 +1,156 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; +import { log } from '../../core/log'; + +export const DIDOMI_INTEGRATION_ID = 'didomi' as const; + +interface DidomiConfig { + sdkPath?: string; + [key: string]: unknown; +} + +export interface DidomiRuntimeTarget { + didomiConfig?: DidomiConfig; + readonly location: { readonly href?: string; readonly origin?: string }; +} + +export interface DidomiRuntimeDependencies { + readonly started: () => void; + readonly target: DidomiRuntimeTarget; +} + +function didomiBootConfig(candidate: unknown): candidate is Readonly<{ proxyPath: string }> { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + Reflect.ownKeys(candidate).length !== 1 + ) { + return false; + } + const descriptor = Object.getOwnPropertyDescriptor(candidate, 'proxyPath'); + return Boolean( + descriptor?.enumerable && + 'value' in descriptor && + typeof descriptor.value === 'string' && + descriptor.value.startsWith('/') && + !descriptor.value.startsWith('//') && + !descriptor.value.startsWith('/\\') && + descriptor.value.length <= 2_048 && + !descriptor.value.includes('?') && + !descriptor.value.includes('#') + ); + } catch { + return false; + } +} + +function sameDescriptor( + left: PropertyDescriptor | undefined, + right: PropertyDescriptor | undefined +): boolean { + return Boolean( + left && + right && + 'value' in left && + 'value' in right && + left.value === right.value && + left.configurable === right.configurable && + left.enumerable === right.enumerable && + left.writable === right.writable + ); +} + +/** Own only Didomi's proxied `sdkPath`, preserving all publisher configuration. */ +export function createDidomiRuntime( + dependencies: DidomiRuntimeDependencies = { + started: () => log.info('Didomi integration initialized'), + target: window as DidomiRuntimeTarget, + } +): IntegrationLifecycleRuntime { + return Object.freeze({ + activate: (candidate: unknown): (() => void) => { + if (!didomiBootConfig(candidate)) throw new TypeError('Didomi config is invalid'); + const base = dependencies.target.location.origin ?? dependencies.target.location.href; + if (!base) throw new TypeError('Didomi publisher origin is unavailable'); + const parsed = new URL(candidate.proxyPath, base); + if (parsed.origin !== new URL(base).origin) { + throw new TypeError('Didomi proxy path must remain on the publisher origin'); + } + const installedPath = `${parsed.origin}${parsed.pathname}`; + const previousTargetDescriptor = Object.getOwnPropertyDescriptor( + dependencies.target, + 'didomiConfig' + ); + let config = dependencies.target.didomiConfig; + const created = config === undefined; + if (created) { + config = {}; + if (!Reflect.set(dependencies.target, 'didomiConfig', config)) { + throw new TypeError('Didomi publisher config is not writable'); + } + } + if (typeof config !== 'object' || config === null) { + throw new TypeError('Didomi publisher config is invalid'); + } + const previousSdkDescriptor = Object.getOwnPropertyDescriptor(config, 'sdkPath'); + if (previousSdkDescriptor && !('value' in previousSdkDescriptor)) { + throw new TypeError('Didomi sdkPath accessor is unsupported'); + } + if (!Reflect.set(config, 'sdkPath', installedPath)) { + throw new TypeError('Didomi sdkPath is not writable'); + } + const installedSdkDescriptor = Object.getOwnPropertyDescriptor(config, 'sdkPath'); + let active = true; + return (): void => { + if (!active) return; + active = false; + try { + if (dependencies.target.didomiConfig !== config) return; + const current = Object.getOwnPropertyDescriptor(config, 'sdkPath'); + if (!sameDescriptor(current, installedSdkDescriptor)) return; + if (previousSdkDescriptor) + Object.defineProperty(config, 'sdkPath', previousSdkDescriptor); + else Reflect.deleteProperty(config, 'sdkPath'); + if ( + created && + Reflect.ownKeys(config).length === 0 && + Object.getOwnPropertyDescriptor(dependencies.target, 'didomiConfig')?.value === config + ) { + if (previousTargetDescriptor) { + Object.defineProperty(dependencies.target, 'didomiConfig', previousTargetDescriptor); + } else { + Reflect.deleteProperty(dependencies.target, 'didomiConfig'); + } + } + } catch { + // Publisher replacement wins over cleanup. + } + }; + }, + start: (_config: unknown): void => dependencies.started(), + }); +} + +export function createDidomiIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(DIDOMI_INTEGRATION_ID, release, { + createOwnedRuntime: () => createDidomiRuntime(), + firstDisplaySliceId: 'didomi_initial', + validateConfig: didomiBootConfig, + validateFirstDisplayState: (state) => { + const value = state.values[0]?.[1]; + return ( + state.values.length === 1 && + state.values[0]?.[0] === 'sdk_path' && + typeof value === 'string' && + value.length > 0 + ); + }, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts b/crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts index ca73f2482..5da50a318 100644 --- a/crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts @@ -1,31 +1,13 @@ -import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -import { installGtmBeaconGuard } from './script_guard'; -import { installGtmGuard } from './script_guard'; - -/** - * Google Tag Manager integration for tsjs - * - * Installs guards to intercept GTM and Google Analytics traffic: - * - * 1. **Script guard** — intercepts dynamically inserted ` -// The HTML pipeline currently injects that inline script before the unified -// bundle, so the explicit call is best-effort only. To make activation robust -// regardless of script order, the module also checks for a pre-set enable flag -// immediately after registering the function. if (typeof window !== 'undefined') { - const win = window as unknown as Record; - - win.__tsjs_installGptShim = installGptShim; - - if (win.__tsjs_gpt_enabled === true) { - installGptShim(); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [createGptIntegrationRegistration(EMBEDDED_RELEASE_ID)]); } - - installTrustedServerPageTargeting(); - installTsAdInit(); - installSpaAuctionHook(); - installSlimPrebidLoader(); - installTsRenderBridge(); } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/later.ts b/crates/trusted-server-js/lib/src/integrations/gpt/later.ts new file mode 100644 index 000000000..146da8136 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt/later.ts @@ -0,0 +1,214 @@ +import { EMBEDDED_RELEASE_ID } from '../../core/release'; +import { isGptIntegrationConfigV1 } from '../../shared/integration_config_validators'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../kernel/integration_registry'; + +type GptLaterNavigationResult = + | Readonly<{ + status: 'committed'; + navigationGeneration: object; + current: true; + }> + | Readonly<{ + status: 'rejected'; + navigationGeneration: object; + current: boolean; + }>; + +interface GptLaterCapabilityV1 { + readonly activateLaterLifecycle: () => Readonly<{ + readonly navigate: (path: string) => PromiseLike; + readonly release: () => void; + }>; +} + +interface RuntimeDocumentCapability { + readonly document: Document; +} + +function restoreHistoryMethod( + history: History, + name: 'pushState' | 'replaceState', + previous: PropertyDescriptor | undefined, + installed: History['pushState'] +): void { + try { + const current = Object.getOwnPropertyDescriptor(history, name); + if (!current || !('value' in current) || current.value !== installed) return; + if (previous) Object.defineProperty(history, name, previous); + else Reflect.deleteProperty(history, name); + } catch { + // A publisher replacement remains authoritative; the disposed wrapper is inert. + } +} + +/** Build the release-bound post-first-display GPT registration. */ +export function createGptLaterIntegrationRegistration(releaseId: string): IntegrationRegistration { + return Object.freeze({ + abi: 1, + id: 'gpt_later', + phase: 'deferred', + releaseId, + prepare: ({ config, interfaces }: IntegrationPrepareContext) => { + const gpt = interfaces['gpt.v1'] as GptLaterCapabilityV1 | undefined; + const runtime = interfaces['runtime.v1'] as RuntimeDocumentCapability | undefined; + for (const key of ['slots.v1', 'auction.v1', 'render.v1', 'trace.v1']) { + const capability = interfaces[key]; + if (typeof capability !== 'object' || capability === null || !Object.isFrozen(capability)) { + throw new TypeError(`GPT later requires ${key}`); + } + } + if ( + !isGptIntegrationConfigV1(config) || + !runtime || + !Object.isFrozen(runtime) || + !runtime.document?.defaultView || + !gpt || + !Object.isFrozen(gpt) || + typeof gpt.activateLaterLifecycle !== 'function' + ) { + throw new TypeError('GPT later capability graph is invalid'); + } + const pageBidsEnabled = config.pageBidsEnabled; + return Object.freeze({ + activate: ({ onDispose }: IntegrationActivationContext) => { + const candidateView = runtime.document.defaultView; + if (!candidateView) throw new TypeError('GPT later document is unavailable'); + const view: Window = candidateView; + const history = view.history; + const previousPushState = Object.getOwnPropertyDescriptor(history, 'pushState'); + const previousReplaceState = Object.getOwnPropertyDescriptor(history, 'replaceState'); + const pushState = history.pushState; + const replaceState = history.replaceState; + let active = true; + let timer: number | undefined; + let lastCommittedPath = `${view.location.pathname}${view.location.search}`; + let observedPath = lastCommittedPath; + let pendingPath: string | undefined; + let invocationOrdinal = 0; + let latestInvocationOrdinal = 0; + let owner: ReturnType | undefined; + let wrappedPushState: History['pushState'] | undefined; + let wrappedReplaceState: History['replaceState'] | undefined; + const dispose = (): void => { + if (!active) return; + active = false; + if (timer !== undefined) view.clearTimeout(timer); + timer = undefined; + pendingPath = undefined; + view.removeEventListener('popstate', scheduleNavigation); + if (wrappedReplaceState) { + restoreHistoryMethod( + history, + 'replaceState', + previousReplaceState, + wrappedReplaceState + ); + } + if (wrappedPushState) { + restoreHistoryMethod(history, 'pushState', previousPushState, wrappedPushState); + } + const release = owner?.release; + owner = undefined; + release?.(); + }; + const flushNavigation = (): void => { + timer = undefined; + const path = pendingPath; + pendingPath = undefined; + if (!active || path === undefined || !owner) return; + invocationOrdinal += 1; + const invocation = invocationOrdinal; + latestInvocationOrdinal = invocation; + const rejectCurrentInvocation = (): void => { + if (!active || invocation !== latestInvocationOrdinal) return; + observedPath = lastCommittedPath; + }; + try { + void Promise.resolve(owner.navigate(path)).then((result) => { + if (!active || invocation !== latestInvocationOrdinal) return; + if (result.status === 'committed' && result.current) { + lastCommittedPath = path; + observedPath = path; + return; + } + if (result.status === 'rejected' && result.current) { + rejectCurrentInvocation(); + } + }, rejectCurrentInvocation); + } catch { + rejectCurrentInvocation(); + } + }; + function scheduleNavigation(): void { + if (!active) return; + let path: string; + try { + path = `${view.location.pathname}${view.location.search}`; + } catch { + return; + } + if (path === observedPath) return; + observedPath = path; + pendingPath = path; + if (timer === undefined) timer = view.setTimeout(flushNavigation, 0); + } + const wrap = (original: History['pushState']): History['pushState'] => + function wrappedHistoryState( + this: History, + data: unknown, + unused: string, + url?: string | URL | null + ): void { + Reflect.apply(original, this, [data, unused, url]); + scheduleNavigation(); + }; + onDispose(dispose); + try { + owner = gpt.activateLaterLifecycle(); + if ( + !owner || + !Object.isFrozen(owner) || + typeof owner.navigate !== 'function' || + typeof owner.release !== 'function' + ) { + throw new TypeError('GPT later lifecycle owner is invalid'); + } + if (!pageBidsEnabled) return; + wrappedPushState = wrap(pushState); + wrappedReplaceState = wrap(replaceState); + Object.defineProperty(history, 'pushState', { + configurable: true, + enumerable: previousPushState?.enumerable ?? false, + value: wrappedPushState, + writable: true, + }); + Object.defineProperty(history, 'replaceState', { + configurable: true, + enumerable: previousReplaceState?.enumerable ?? false, + value: wrappedReplaceState, + writable: true, + }); + view.addEventListener('popstate', scheduleNavigation); + } catch (error) { + dispose(); + throw error; + } + }, + }); + }, + }); +} + +if (typeof window !== 'undefined') { + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createGptLaterIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts new file mode 100644 index 000000000..0e57d186d --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -0,0 +1,2331 @@ +import { + persistentFirstDisplaySliceSelectedV1, + snapshotPersistentFirstDisplayAdoptionV1, + snapshotPersistentFirstDisplaySliceStateV1, + type FirstDisplayGptDiagnosticEventV1, + type FirstDisplayGptDiagnosticsV1, + type PersistentFirstDisplayAdoptionV1, +} from '../../shared/takeover'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, + PreparedIntegration, +} from '../../kernel/integration_registry'; +import { + createBrowserGoogletagAdapter, + type GoogletagAdapter, + type GoogletagFacade, +} from '../../adapters/googletag'; +import type { + BaselinePbsCacheSourceV1, + BrowserAuctionBidV1, + BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, +} from '../../core/types'; +import { isGptIntegrationConfigV1 } from '../../shared/integration_config_validators'; +import { log } from '../../core/log'; +import { DisposableStack } from '../../kernel/disposable'; +import type { RuntimeCapabilityV1 } from '../../kernel/runtime'; +import type { NavigationSession, RenderAttemptScope, RuntimeSession } from '../../kernel/sessions'; +import type { IdentityGenerationResult } from '../../kernel/identity'; +import type { + CommittedArtifactStore, + CommittedRenderArtifact, + RenderAttempt, + RenderAttemptCreationResult, + RenderFailureReason, + SlotOperationCreationResult, + SlotOperationOptions, +} from '../../services/render'; +import { resizeCollapsedPucShell } from '../../core/puc_shell'; +import { + createPucBridge, + type PucBridge, + type PucBridgeOptions, + type PucGamAttemptInput, +} from '../../services/puc_bridge'; +import type { ReservationService } from '../../services/reservations'; +import { + createBrowserSlotReconciliationBoundary, + createSlotService, + type GptSlotBinding, + type SlotRequestHandle, + type SlotRequestInput, + type SlotRequestOutcome, + type SlotService, + type TrustedServerRequestOpportunity, +} from '../../services/slots'; +import type { + TargetingBoundary, + TargetingOwnership, + TargetingService, +} from '../../services/targeting'; +import { createTargetingService } from '../../services/targeting'; + +import { + activateGptDiagnosticsEventListeners, + createTrustedServerOpportunityFact, + createGptDiagnosticsFactBuffer, + publishTrustedServerOpportunityFact, + projectGptTraceFact, +} from './diagnostics_facts'; +import { installGptGuard, resetGuardState } from './script_guard'; +import { createGptStartup } from './startup'; + +export const GPT_INTEGRATION_ID = 'gpt' as const; + +function frozenArray(candidate: unknown): readonly unknown[] | undefined { + return Array.isArray(candidate) && Object.isFrozen(candidate) ? candidate : undefined; +} + +function dataField(candidate: unknown, key: string): unknown { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) { + return undefined; + } + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + return descriptor?.enumerable && 'value' in descriptor ? descriptor.value : undefined; +} + +/** Hydrate exact GPT diagnostic tokens/cycles before adopted slots become observable. */ +export function adoptInitialGptDiagnosticsFromHandoff( + candidate: unknown, + adapter: Pick +): PersistentFirstDisplayAdoptionV1 | undefined { + const adoption = snapshotPersistentFirstDisplayAdoptionV1(candidate); + const adopt = adapter.adoptDiagnosticsState; + if (!adoption || typeof adopt !== 'function') return undefined; + const cycles = frozenArray(dataField(adoption.handoff, 'cycles')); + const artifacts = frozenArray(dataField(adoption.handoff, 'artifacts')); + const trace = dataField(adoption.handoff, 'trace'); + const nextTraceTokenOrdinal = dataField(trace, 'nextGlobalSlotOrdinal'); + if ( + !cycles || + !artifacts || + typeof nextTraceTokenOrdinal !== 'number' || + adoption.identities.length !== cycles.length + artifacts.length + ) { + return undefined; + } + const slots: Array<{ + nextCycleOrdinal: number; + physicalSlot: object; + records: readonly Readonly<{ + ordinal: number; + responseIdentifier: string | null; + seen: readonly FirstDisplayGptDiagnosticEventV1[]; + state: 'open' | 'completed' | 'retired'; + }>[]; + traceToken: string; + unknownPriorCycle: boolean; + }> = []; + for (let index = 0; index < cycles.length; index += 1) { + const cycle = cycles[index]; + const physicalSlot = adoption.identities[index]; + const traceToken = dataField(cycle, 'token'); + const nextCycleOrdinal = dataField(cycle, 'nextCycleOrdinal'); + const unknownPriorCycle = dataField(cycle, 'unknownPriorCycle'); + const records = frozenArray(dataField(cycle, 'records')); + if ( + !physicalSlot || + typeof traceToken !== 'string' || + typeof nextCycleOrdinal !== 'number' || + typeof unknownPriorCycle !== 'boolean' || + !records + ) { + return undefined; + } + const copiedRecords: Array<{ + ordinal: number; + responseIdentifier: string | null; + seen: readonly FirstDisplayGptDiagnosticEventV1[]; + state: 'open' | 'completed' | 'retired'; + }> = []; + for (const record of records) { + const ordinal = dataField(record, 'ordinal'); + const responseIdentifier = dataField(record, 'responseIdentifier'); + const seen = frozenArray(dataField(record, 'seen')); + const state = dataField(record, 'state'); + if ( + typeof ordinal !== 'number' || + (responseIdentifier !== null && typeof responseIdentifier !== 'string') || + !seen || + seen.some( + (event) => + event !== 'slotRequested' && + event !== 'slotResponseReceived' && + event !== 'slotRenderEnded' && + event !== 'slotOnload' && + event !== 'impressionViewable' && + event !== 'slotVisibilityChanged' + ) || + (state !== 'open' && state !== 'completed' && state !== 'retired') + ) { + return undefined; + } + copiedRecords.push({ + ordinal, + responseIdentifier, + seen: seen as readonly FirstDisplayGptDiagnosticEventV1[], + state, + }); + } + slots.push({ + nextCycleOrdinal, + physicalSlot, + records: copiedRecords, + traceToken, + unknownPriorCycle, + }); + } + try { + return Reflect.apply(adopt, adapter, [{ nextTraceTokenOrdinal, slots }]) === true + ? adoption + : undefined; + } catch { + return undefined; + } +} + +/** Transfer unexpired lifecycle-ticket tombstones into the sole persistent PUC owner. */ +export function adoptInitialPucTicketsFromHandoff( + candidate: unknown, + bridge: Pick +): PersistentFirstDisplayAdoptionV1 | undefined { + const adoption = snapshotPersistentFirstDisplayAdoptionV1(candidate); + if (!adoption) return undefined; + const highWater = dataField(adoption.handoff, 'highWater'); + const clockEpochMs = dataField(highWater, 'reservationClockEpochMs'); + const nextTicketOrdinal = dataField(highWater, 'nextTicketOrdinal'); + const tombstones = frozenArray(dataField(adoption.handoff, 'tombstones')); + if (typeof clockEpochMs !== 'number' || typeof nextTicketOrdinal !== 'number' || !tombstones) { + return undefined; + } + const tickets: Array<{ expiresAtMs: number; ticket: string }> = []; + for (const tombstone of tombstones) { + if (dataField(tombstone, 'kind') !== 'ticket') continue; + const ticket = dataField(tombstone, 'value'); + const expiresAtMs = dataField(tombstone, 'expiresAtMs'); + if (typeof ticket !== 'string' || typeof expiresAtMs !== 'number') return undefined; + tickets.push({ expiresAtMs, ticket }); + } + try { + return bridge.adoptFirstDisplayTickets({ + clockEpochMs, + nextTicketOrdinal, + tombstones: tickets, + }) + ? adoption + : undefined; + } catch { + return undefined; + } +} + +/** Restore the ordered bounded diagnostics buffer without replaying facts as new events. */ +export function adoptInitialGptFactsFromHandoff( + candidate: unknown, + buffer: Pick, 'adoptFirstDisplay'> | undefined, + adapter: Pick, + projection: Readonly | undefined +): PersistentFirstDisplayAdoptionV1 | undefined { + const adoption = snapshotPersistentFirstDisplayAdoptionV1(candidate); + if (!adoption) return undefined; + const diagnostics = dataField(adoption.handoff, 'gptDiagnostics'); + const facts = frozenArray(dataField(diagnostics, 'facts')); + const overflow = dataField(diagnostics, 'overflowCount'); + const drops = dataField(diagnostics, 'dropCount'); + if (!facts || typeof overflow !== 'number' || typeof drops !== 'number') return undefined; + if (!buffer) { + return facts.length === 0 && overflow === 0 && drops === 0 ? adoption : undefined; + } + const cycles = frozenArray(dataField(adoption.handoff, 'cycles')); + const artifacts = frozenArray(dataField(adoption.handoff, 'artifacts')); + const slots = frozenArray(dataField(adoption.handoff, 'slots')); + if ( + !cycles || + !artifacts || + !slots || + adoption.identities.length !== cycles.length + artifacts.length + ) { + return undefined; + } + const formatsBySlot = new Map(); + for (const slot of slots) { + const slotId = dataField(slot, 'id'); + const formats = frozenArray(dataField(slot, 'formats')); + if (typeof slotId !== 'string' || formatsBySlot.has(slotId) || !formats) return undefined; + const copied: Array = []; + for (let index = 0; index < formats.length && index < 16; index += 1) { + const format = formats[index]; + const dimensions = frozenArray(format); + if ( + !dimensions || + dimensions.length !== 2 || + !dimensions.every( + (dimension) => + typeof dimension === 'number' && + Number.isInteger(dimension) && + dimension >= 1 && + dimension <= 4096 + ) + ) { + return undefined; + } + copied.push(Object.freeze([dimensions[0] as number, dimensions[1] as number] as const)); + } + if (copied.length === 0) return undefined; + formatsBySlot.set(slotId, Object.freeze(copied)); + } + const identities = new Map>(); + const requestByToken = new Map< + string, + Readonly<{ formats: readonly (readonly [number, number])[]; slotId: string }> + >(); + for (let index = 0; index < cycles.length; index += 1) { + const token = dataField(cycles[index], 'token'); + const slotId = dataField(cycles[index], 'slotId'); + const physicalSlot = adoption.identities[index]; + const formats = typeof slotId === 'string' ? formatsBySlot.get(slotId) : undefined; + if (typeof token !== 'string' || typeof slotId !== 'string' || !formats || !physicalSlot) { + return undefined; + } + let identity: ReturnType; + try { + identity = adapter.diagnosticsIdentity(physicalSlot); + } catch { + return undefined; + } + if (!identity || identity.traceToken !== token || identities.has(token)) return undefined; + identities.set(token, identity); + requestByToken.set(token, Object.freeze({ formats, slotId })); + } + try { + return buffer.adoptFirstDisplay( + diagnostics as Readonly, + (traceToken) => identities.get(traceToken), + (traceToken, slot) => { + const request = requestByToken.get(traceToken); + const trustedServerAuctionId = projection?.auction.auctionId; + return request && + typeof trustedServerAuctionId === 'string' && + trustedServerAuctionId.length > 0 + ? createTrustedServerOpportunityFact({ + auctionSlotId: request.slotId, + opportunity: 'renderable_candidate', + requestedSlotSizes: request.formats, + slot, + trustedServerAuctionId, + }) + : undefined; + } + ) + ? adoption + : undefined; + } catch { + return undefined; + } +} + +/** Adopt the exact transferred GPT identities without defining, targeting, displaying, or refreshing. */ +export function adoptInitialGptSlotsFromHandoff( + candidate: unknown, + navigationGeneration: object, + service: Pick< + SlotService, + 'adoptCommittedArtifact' | 'adoptGptSlot' | 'adoptRegistrationHighWater' + >, + artifactStore: Pick, + targeting: Pick, + adapter: GoogletagAdapter +): PersistentFirstDisplayAdoptionV1 | undefined { + const adoption = snapshotPersistentFirstDisplayAdoptionV1(candidate); + if (!adoption) return undefined; + const slots = frozenArray(dataField(adoption.handoff, 'slots')); + const cycles = frozenArray(dataField(adoption.handoff, 'cycles')); + const artifacts = frozenArray(dataField(adoption.handoff, 'artifacts')); + const highWater = dataField(adoption.handoff, 'highWater'); + const nextSlotRegistrationOrdinal = dataField(highWater, 'nextSlotRegistrationOrdinal'); + if ( + !slots || + !cycles || + !artifacts || + typeof nextSlotRegistrationOrdinal !== 'number' || + adoption.identities.length !== cycles.length + artifacts.length + ) { + return undefined; + } + if (!service.adoptRegistrationHighWater(navigationGeneration, nextSlotRegistrationOrdinal)) { + return undefined; + } + const slotById = new Map(); + for (const slot of slots) { + const id = dataField(slot, 'id'); + if (typeof id !== 'string' || slotById.has(id)) return undefined; + slotById.set(id, slot); + } + const adopted = new Set(); + for (let index = 0; index < cycles.length; index += 1) { + const cycle = cycles[index]; + const slotId = dataField(cycle, 'slotId'); + const placement = typeof slotId === 'string' ? slotById.get(slotId) : undefined; + const identity = adoption.identities[index]; + const owner = dataField(placement, 'owner'); + const domId = dataField(placement, 'domId'); + const gamPath = dataField(placement, 'gamPath'); + const formats = frozenArray(dataField(placement, 'formats')); + const targetingOwnership = frozenArray(dataField(placement, 'targetingOwnership')); + if ( + typeof slotId !== 'string' || + adopted.has(slotId) || + (owner !== 'publisher' && owner !== 'trusted_server') || + typeof identity !== 'object' || + identity === null || + !targetingOwnership || + (owner === 'trusted_server' && + (typeof domId !== 'string' || typeof gamPath !== 'string' || !formats)) + ) { + return undefined; + } + const binding: GptSlotBinding = + owner === 'trusted_server' + ? { + definition: { + adUnitPath: gamPath as string, + elementId: domId as string, + sizes: formats as readonly unknown[], + }, + ownership: owner, + slot: identity, + } + : { ownership: owner, slot: identity }; + const result = service.adoptGptSlot(navigationGeneration, slotId, binding); + if (!result.ok) return undefined; + const artifact = artifactStore.current(slotId); + if (!artifact) return undefined; + const releases: TargetingOwnership[] = []; + let observation: ReturnType | undefined; + const releaseTargeting = (): void => { + for (let releaseIndex = releases.length - 1; releaseIndex >= 0; releaseIndex -= 1) { + try { + releases[releaseIndex]?.release(); + } catch { + // Exact artifact retirement contains hostile GPT targeting cleanup. + } + } + releases.length = 0; + try { + observation?.dispose(); + } catch { + // Adapter observation cleanup cannot restore retired ownership. + } + observation = undefined; + }; + try { + if (targetingOwnership.length > 0) { + observation = targeting.observePublisherMutations(identity, adapter); + if (observation.status !== 'present') { + releaseTargeting(); + return undefined; + } + const boundary = synchronousTargetingBoundary(adapter, identity); + for ( + let ownershipIndex = 0; + ownershipIndex < targetingOwnership.length; + ownershipIndex += 1 + ) { + const ownership = targetingOwnership[ownershipIndex]; + const key = dataField(ownership, 'key'); + const installed = dataField(ownership, 'installed'); + const prior = frozenArray(dataField(ownership, 'prior')); + if ( + typeof key !== 'string' || + typeof installed !== 'string' || + !prior || + prior.some((value) => typeof value !== 'string') + ) { + releaseTargeting(); + return undefined; + } + const adopted = targeting.adopt( + identity, + key, + installed, + prior as readonly string[], + artifact.attemptId, + boundary + ); + if (!adopted) { + releaseTargeting(); + return undefined; + } + releases.push(adopted); + } + } + } catch { + releaseTargeting(); + return undefined; + } + if (!service.adoptCommittedArtifact(navigationGeneration, slotId, artifact, releaseTargeting)) { + releaseTargeting(); + return undefined; + } + adopted.add(slotId); + } + return adoption; +} + +export type GptLaterNavigationResult = + | Readonly<{ + status: 'committed'; + navigationGeneration: object; + current: true; + }> + | Readonly<{ + status: 'rejected'; + navigationGeneration: object; + current: boolean; + }>; + +export interface GptCapabilityV1 { + readonly activateLaterLifecycle: () => Readonly<{ + readonly navigate: (path: string) => Promise; + readonly release: () => void; + }>; + readonly adapter: GoogletagAdapter; + readonly directAuctionUnitForSlot: (slot: object) => Readonly | undefined; + readonly installRefreshPolicy: ReturnType['installRefreshPolicy']; + readonly navigation: () => NavigationSession | undefined; + readonly slots: SlotService; +} + +const arrayIsArrayIntrinsic = Array.isArray; +const numberIsFiniteIntrinsic = Number.isFinite; +const objectGetOwnPropertyDescriptorIntrinsic = Object.getOwnPropertyDescriptor; +const objectGetOwnPropertyNamesIntrinsic = Object.getOwnPropertyNames; +const objectGetOwnPropertySymbolsIntrinsic = Object.getOwnPropertySymbols; +const objectIsFrozenIntrinsic = Object.isFrozen; +const jsonParseIntrinsic = JSON.parse; +const promiseThenIntrinsic = Promise.prototype.then; +const reflectApplyIntrinsic = Reflect.apply; +const stringIncludesIntrinsic = String.prototype.includes; +const stringJoinIntrinsic = Array.prototype.join; +const stringSplitIntrinsic = String.prototype.split; +const stringTrimIntrinsic = String.prototype.trim; +const stringCharCodeAtIntrinsic = String.prototype.charCodeAt; +const textEncoder = new TextEncoder(); +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; + +type OwnedBrowserAuctionBidV1 = Extract< + BrowserAuctionBidV1, + { readonly rendererReservationId: string } +>; +type PbsCacheBrowserAuctionBidV1 = Extract< + BrowserAuctionBidV1, + { readonly renderSource: BaselinePbsCacheSourceV1 } +>; + +function validGptTargetingValue(value: string): boolean { + if (value.length === 0) return false; + let scalars = 0; + for (let index = 0; index < value.length; index += 1) { + const code = reflectApplyIntrinsic(stringCharCodeAtIntrinsic, value, [index]) as number; + if (code <= 0x1f || code === 0x7f) return false; + if (code >= 0xd800 && code <= 0xdbff) { + const next = reflectApplyIntrinsic(stringCharCodeAtIntrinsic, value, [index + 1]) as number; + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + scalars += 1; + if (scalars > 40) return false; + } + return ( + (reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [value]) as Uint8Array) + .byteLength <= 160 + ); +} + +interface ProductionAuctionCapability { + readonly navigation: NavigationSession; + readonly projection: Readonly; + readonly session: RuntimeSession; +} + +interface ProductionSlotsCapability { + readonly attachPhysicalService: (service: SlotService) => () => void; +} + +interface ProductionApsCapability { + readonly renderPuc: NonNullable; +} + +interface ProductionRenderCapability { + readonly attachPucGamAttemptRegistrar: ( + registrar: (input: PucGamAttemptInput) => boolean + ) => () => void; + readonly artifacts: Readonly<{ + current: (slot: string) => CommittedRenderArtifact | undefined; + release: (artifact: CommittedRenderArtifact) => boolean; + }>; + readonly bindArtifactRetirement: ( + artifact: CommittedRenderArtifact, + retire: () => void + ) => boolean; + readonly createAttempt: ( + owner: RenderAttemptScope, + parentAttemptId?: string + ) => RenderAttemptCreationResult; + readonly createSlotOperation: (options: SlotOperationOptions) => SlotOperationCreationResult; + readonly commitPageBids: ( + owner: NavigationSession, + slotRegistry: ReturnType, + candidate: unknown + ) => boolean; + readonly mintLifecycleTicket: () => IdentityGenerationResult; + readonly renderWinner: (attempt: RenderAttempt) => boolean; + readonly reservations: ReservationService; +} + +interface ProductionMessagesCapability { + readonly messaging: Parameters[0]['messaging']; +} + +interface ProductionTraceCapability { + readonly observations: Readonly<{ + publish: (observation: Readonly>) => boolean; + }>; +} + +interface InitialProjectionServices { + readonly googletag: GoogletagAdapter; + readonly projection: Readonly; + readonly navigation: NavigationSession; + readonly protect: RuntimeCapabilityV1['protectFirstDisplayAttemptBatch']; + readonly pucBridge: Pick; + readonly render: ProductionRenderCapability; + readonly slots: SlotService; + readonly targeting: TargetingService; + readonly requestClass?: string; +} + +export interface GptSlotOperationInput extends Omit { + readonly attempt: RenderAttempt; + readonly createFallback?: SlotOperationOptions['createFallback']; + readonly createSlotOperation: (options: SlotOperationOptions) => SlotOperationCreationResult; + readonly operation: 'display' | 'refresh'; + readonly pucBridge: Pick; + readonly requestClass: string; + readonly slots: Pick; + readonly trustedServerOpportunity?: TrustedServerRequestOpportunity; +} + +export type GptWinnerPublicationFailureReason = Extract< + RenderFailureReason, + | 'descriptor_invalid' + | 'gpt_request_failed' + | 'registry_full' + | 'reservation_collision' + | 'slot_unresolved' + | 'winner_not_renderable' +>; + +export type GptWinnerPublicationResult = + | Extract + | Readonly<{ ok: false; reason: GptWinnerPublicationFailureReason }>; + +export interface GptWinnerPublicationInput extends Omit< + GptSlotOperationInput, + 'artifact' | 'pucBridge' | 'reservationId' | 'slots' +> { + readonly artifact: CommittedRenderArtifact; + readonly bid: OwnedBrowserAuctionBidV1; + readonly googletag: GoogletagAdapter; + readonly navigation: NavigationSession; + readonly placement: BrowserAuctionSlotV1; + readonly pucBridge: Pick; + readonly reservations: Pick; + readonly slot: object; + readonly slots: Pick; + readonly targeting: Pick; +} + +function currentProjectedWinner( + input: GptWinnerPublicationInput, + projection: Readonly | undefined +): boolean { + try { + const bid = input.bid; + const placement = input.placement; + if ( + !projection || + !objectIsFrozenIntrinsic(projection) || + !objectIsFrozenIntrinsic(bid) || + !objectIsFrozenIntrinsic(bid.renderSource) || + !objectIsFrozenIntrinsic(bid.targeting) || + !objectIsFrozenIntrinsic(placement) || + !objectIsFrozenIntrinsic(placement.formats) || + !objectIsFrozenIntrinsic(placement.targeting) || + bid.slot !== input.attempt.slot || + placement.slot !== bid.slot || + input.attempt.navigationGeneration !== input.navigation.generation || + input.owner.id !== input.attempt.id || + input.owner.slot !== input.attempt.slot || + input.owner.generation !== input.attempt.generation || + input.owner.navigationGeneration !== input.navigation.generation || + input.artifact.kind !== 'puc' || + input.artifact.attemptId !== input.attempt.id || + input.artifact.slot !== input.attempt.slot || + input.artifact.navigationGeneration !== input.navigation.generation || + typeof input.artifact.dispose !== 'function' || + typeof input.slot !== 'object' || + input.slot === null || + !input.navigation.isCurrent() || + input.attempt.snapshot().outcome !== undefined + ) { + return false; + } + let exactBid = false; + for (let index = 0; index < projection.bids.length; index += 1) { + if (projection.bids[index] === bid) { + if (exactBid) return false; + exactBid = true; + } + } + if (!exactBid) return false; + let exactPlacement = false; + for (let index = 0; index < projection.slots.length; index += 1) { + if (projection.slots[index] === placement) { + if (exactPlacement) return false; + exactPlacement = true; + } + } + if (!exactPlacement) return false; + let exactWinner = false; + for (let index = 0; index < projection.auction.results.length; index += 1) { + const result = projection.auction.results[index]; + if ( + result?.outcome === 'winner' && + result.slot === bid.slot && + result.candidateId === bid.candidateId + ) { + if (exactWinner) return false; + exactWinner = true; + } + } + return exactWinner; + } catch { + return false; + } +} + +function targetingEntries( + bid: BrowserAuctionBidV1, + placement: BrowserAuctionSlotV1 +): readonly (readonly [string, string])[] | undefined { + try { + const bidNames = objectGetOwnPropertyNamesIntrinsic(bid.targeting); + const placementNames = objectGetOwnPropertyNamesIntrinsic(placement.targeting); + if ( + bidNames.length > 32 || + placementNames.length > 32 || + objectGetOwnPropertySymbolsIntrinsic(bid.targeting).length !== 0 || + objectGetOwnPropertySymbolsIntrinsic(placement.targeting).length !== 0 + ) { + return undefined; + } + const names: string[] = []; + const insertNames = (source: readonly string[]): boolean => { + for (let index = 0; index < source.length; index += 1) { + const name = source[index]; + if (!name || name === 'hb_adid' || name === 'hb_cache_host' || name === 'hb_cache_path') { + return false; + } + let insertion = 0; + while (insertion < names.length && (names[insertion] as string) < name) insertion += 1; + if (names[insertion] === name) continue; + for (let move = names.length; move > insertion; move -= 1) { + names[move] = names[move - 1] as string; + } + names[insertion] = name; + } + return true; + }; + if (!insertNames(placementNames) || !insertNames(bidNames)) return undefined; + const entries: Array = !('rendererReservationId' in bid) + ? [ + Object.freeze(['hb_adid', bid.renderSource.cacheId]), + Object.freeze(['hb_cache_host', bid.renderSource.cacheHost]), + Object.freeze(['hb_cache_path', bid.renderSource.cachePath]), + ] + : [Object.freeze(['hb_adid', bid.rendererReservationId])]; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (!entry || !validGptTargetingValue(entry[1])) return undefined; + } + for (let index = 0; index < names.length; index += 1) { + const key = names[index]; + if (!key) return undefined; + const bidDescriptor = objectGetOwnPropertyDescriptorIntrinsic(bid.targeting, key); + const placementDescriptor = objectGetOwnPropertyDescriptorIntrinsic(placement.targeting, key); + const descriptor = bidDescriptor ?? placementDescriptor; + if ( + !descriptor || + !descriptor.enumerable || + !('value' in descriptor) || + typeof descriptor.value !== 'string' + ) { + return undefined; + } + entries[entries.length] = Object.freeze([key, descriptor.value]); + } + return Object.freeze(entries); + } catch { + return undefined; + } +} + +function synchronousTargetingBoundary(adapter: GoogletagAdapter, slot: object): TargetingBoundary { + const invoke = (command: (gpt: Readonly) => Value): Value => { + let completed = false; + let failed = false; + let value: Value | undefined; + let failure: unknown; + const operation = adapter.run((gpt) => { + try { + value = command(gpt); + return value; + } catch (error) { + failed = true; + failure = error; + throw error; + } finally { + completed = true; + } + }); + void reflectApplyIntrinsic(promiseThenIntrinsic, operation.result, [ + () => undefined, + () => undefined, + ]); + if (!completed) { + operation.dispose(); + throw new Error('GPT targeting operation is not synchronously available'); + } + if (failed) throw failure; + return value as Value; + }; + return Object.freeze({ + clearTargeting: (key?: string) => invoke((gpt) => gpt.clearTargeting(slot, key)), + getTargeting: (key: string) => invoke((gpt) => gpt.getTargeting(slot, key)), + setTargeting: (key: string, value: string | readonly string[]) => + invoke((gpt) => gpt.setTargeting(slot, key, value)), + }); +} + +function reservationFailure(reason: string): GptWinnerPublicationFailureReason { + if (reason === 'reservation_collision') return 'reservation_collision'; + if (reason === 'registry_full') return 'registry_full'; + if (reason === 'invalid_render_source' || reason === 'invalid_reservation_id') { + return 'descriptor_invalid'; + } + return 'gpt_request_failed'; +} + +/** Publish one server-projected PUC winner without exposing capability state out of order. */ +export async function publishGptWinner( + input: GptWinnerPublicationInput +): Promise { + const failAttempt = (reason: GptWinnerPublicationFailureReason): GptWinnerPublicationResult => { + try { + input.attempt.fail(reason); + } catch { + // The attempt latch remains authoritative. + } + return Object.freeze({ ok: false, reason }); + }; + const disposeArtifact = (): void => { + try { + input.artifact.dispose(); + } catch { + // Rejected publication retains no artifact authority. + } + }; + const projection = input.navigation.currentAuctionProjection as + Readonly | undefined; + if (!projection || !currentProjectedWinner(input, projection)) { + disposeArtifact(); + return failAttempt('winner_not_renderable'); + } + const trustedServerOpportunity: TrustedServerRequestOpportunity = Object.freeze({ + requestedSlotSizes: Object.freeze( + input.placement.formats.map((size) => Object.freeze([size[0], size[1]] as const)) + ), + trustedServerAuctionId: projection.auction.auctionId, + }); + const isStillBound = (): boolean => { + try { + return input.slots.isBoundGptSlot(input.navigation.generation, input.bid.slot, input.slot); + } catch { + return false; + } + }; + if (!isStillBound()) { + disposeArtifact(); + return failAttempt('slot_unresolved'); + } + const entries = targetingEntries(input.bid, input.placement); + if (!entries) { + disposeArtifact(); + return failAttempt('descriptor_invalid'); + } + const winnerContext = Object.freeze({ selectedCpm: input.bid.cpm }); + const registration = (() => { + try { + return input.reservations.registerRender({ + reservationId: input.bid.rendererReservationId, + slot: input.bid.slot, + navigation: input.navigation, + attemptId: input.attempt.id, + renderSource: input.bid.renderSource, + winnerContext, + }); + } catch { + return Object.freeze({ ok: false as const, reason: 'service_disposed' as const }); + } + })(); + if (!registration.ok) { + disposeArtifact(); + return failAttempt(reservationFailure(registration.reason)); + } + + const owners: TargetingOwnership[] = []; + let observation: ReturnType | undefined; + let retirementAttempted = false; + let resourcesDisposed = false; + const tombstone = (): void => { + if (retirementAttempted) return; + retirementAttempted = true; + try { + input.reservations.tombstone( + { + reservationId: input.bid.rendererReservationId, + slot: input.bid.slot, + navigationGeneration: input.navigation.generation, + attemptId: input.attempt.id, + }, + 'disposed' + ); + } catch { + // Runtime disposal retains the last-resort retirement boundary. + } + }; + const disposeResources = (): void => { + if (resourcesDisposed) return; + resourcesDisposed = true; + tombstone(); + for (let index = owners.length - 1; index >= 0; index -= 1) { + try { + owners[index]?.release(); + } catch { + // One targeting cleanup cannot suppress the remaining rollback. + } + } + try { + observation?.dispose(); + } catch { + // The adapter owns final wrapper restoration. + } + disposeArtifact(); + }; + try { + observation = input.targeting.observePublisherMutations(input.slot, input.googletag); + await observation.result; + if (!input.navigation.isCurrent() || input.attempt.snapshot().outcome !== undefined) { + throw new Error('stale GPT publication'); + } + if (!isStillBound()) { + disposeResources(); + return failAttempt('slot_unresolved'); + } + const boundary = synchronousTargetingBoundary(input.googletag, input.slot); + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (!entry) throw new Error('targeting entry unavailable'); + const owner = input.targeting.own(input.slot, entry[0], entry[1], input.attempt.id, boundary); + if (!owner) throw new Error('targeting ownership unavailable'); + owners[owners.length] = owner; + } + if (!input.navigation.isCurrent() || input.attempt.snapshot().outcome !== undefined) { + throw new Error('stale GPT publication'); + } + if (!isStillBound()) { + disposeResources(); + return failAttempt('slot_unresolved'); + } + } catch { + disposeResources(); + return failAttempt('gpt_request_failed'); + } + + const publishedArtifact = Object.freeze({ + kind: 'puc' as const, + attemptId: input.artifact.attemptId, + slot: input.artifact.slot, + navigationGeneration: input.artifact.navigationGeneration, + dispose: disposeResources, + }); + let bridgeRegistered = false; + let requestStarted = false; + let operation: SlotOperationCreationResult; + try { + operation = startGptSlotOperation({ + artifact: publishedArtifact, + attempt: input.attempt, + ...(input.createFallback === undefined ? {} : { createFallback: input.createFallback }), + createSlotOperation: input.createSlotOperation, + operation: input.operation, + owner: input.owner, + pucBridge: { + registerGamAttempt: (bridgeInput) => { + bridgeRegistered = input.pucBridge.registerGamAttempt(bridgeInput) === true; + return bridgeRegistered; + }, + recordNonemptyGam: (bridgeInput) => input.pucBridge.recordNonemptyGam(bridgeInput), + }, + requestClass: input.requestClass, + trustedServerOpportunity, + reservationId: input.bid.rendererReservationId, + slots: { + request: (requestInput) => { + const handle = input.slots.request({ ...requestInput, expectedSlot: input.slot }); + requestStarted = true; + return handle; + }, + }, + }); + } catch { + disposeResources(); + return failAttempt('gpt_request_failed'); + } + if (!operation.ok || !bridgeRegistered || !requestStarted) { + disposeResources(); + return failAttempt('gpt_request_failed'); + } + return operation; +} + +interface PbsCacheGptPublicationInput { + readonly bid: PbsCacheBrowserAuctionBidV1; + readonly binding: Readonly<{ operation: 'display' | 'refresh'; slot: object }>; + readonly googletag: GoogletagAdapter; + readonly navigation: NavigationSession; + readonly placement: BrowserAuctionSlotV1; + readonly requestClass: string; + readonly slots: Pick; + readonly targeting: Pick; +} + +async function publishPbsCacheGptWinner(input: PbsCacheGptPublicationInput): Promise { + const projection = input.navigation.currentAuctionProjection as + Readonly | undefined; + if (!projection) return; + const trustedServerOpportunity: TrustedServerRequestOpportunity = Object.freeze({ + requestedSlotSizes: Object.freeze( + input.placement.formats.map((size) => Object.freeze([size[0], size[1]] as const)) + ), + trustedServerAuctionId: projection.auction.auctionId, + }); + const current = (): boolean => { + try { + if ( + !input.navigation.isCurrent() || + input.navigation.currentAuctionProjection !== projection + ) { + return false; + } + let bids = 0; + let slots = 0; + let winners = 0; + for (let index = 0; index < projection.bids.length; index += 1) { + if (projection.bids[index] === input.bid) bids += 1; + } + for (let index = 0; index < projection.slots.length; index += 1) { + if (projection.slots[index] === input.placement) slots += 1; + } + for (let index = 0; index < projection.auction.results.length; index += 1) { + const decision = projection.auction.results[index]; + if ( + decision?.outcome === 'winner' && + decision.slot === input.bid.slot && + decision.candidateId === input.bid.candidateId + ) { + winners += 1; + } + } + return ( + bids === 1 && + slots === 1 && + winners === 1 && + input.slots.isBoundGptSlot(input.navigation.generation, input.bid.slot, input.binding.slot) + ); + } catch { + return false; + } + }; + if (!current()) return; + const entries = targetingEntries(input.bid, input.placement); + if (!entries) return; + + const ownerId = `pbs-cache:${projection.auction.auctionId}:${input.bid.candidateId}`; + const owners: TargetingOwnership[] = []; + let observation: ReturnType | undefined; + let handle: ReturnType | undefined; + try { + observation = input.targeting.observePublisherMutations(input.binding.slot, input.googletag); + await observation.result; + if (!current()) return; + const boundary = synchronousTargetingBoundary(input.googletag, input.binding.slot); + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (!entry) return; + const ownership = input.targeting.own( + input.binding.slot, + entry[0], + entry[1], + ownerId, + boundary + ); + if (!ownership) return; + owners.push(ownership); + } + if (!current()) return; + handle = input.slots.request({ + expectedSlot: input.binding.slot, + intentId: ownerId, + navigationGeneration: input.navigation.generation, + operation: input.binding.operation, + registeredSlotId: input.bid.slot, + requestClass: input.requestClass, + trustedServerOpportunity, + }); + await handle.result; + } catch (error) { + if (input.navigation.isCurrent()) log.warn('GPT PBS Cache publication failed', error); + } finally { + try { + handle?.dispose(); + } catch { + // Request settlement remains authoritative. + } + for (let index = owners.length - 1; index >= 0; index -= 1) { + try { + owners[index]?.release(); + } catch { + // One targeting cleanup cannot suppress the remaining rollback. + } + } + try { + observation?.dispose(); + } catch { + // The adapter owns final wrapper restoration. + } + } +} + +function settleFromSlotOutcome( + attempt: RenderAttempt, + bridge: GptSlotOperationInput['pucBridge'], + bridgeInput: PucGamAttemptInput, + outcome: SlotRequestOutcome +): void { + try { + if (outcome.status === 'empty') { + attempt.fail('gam_empty'); + return; + } + if (outcome.status === 'rendered') { + if (!bridge.recordNonemptyGam(bridgeInput)) attempt.fail('cycle_unattributable'); + return; + } + if (outcome.status === 'failed') { + attempt.fail(outcome.reason); + return; + } + if (outcome.status === 'cancelled') attempt.cancel(outcome.reason); + } catch { + try { + attempt.fail('internal_error'); + } catch { + // The attempt latch remains the terminal authority. + } + } +} + +/** + * Join one TS-owned physical GPT cycle to its primary render attempt. + * + * Only the slot service may identify an attributable empty cycle. The resulting + * `gam_empty` transition is therefore the sole path that can activate the + * optional `SlotOperation` fallback child. + */ +export function startGptSlotOperation(input: GptSlotOperationInput): SlotOperationCreationResult { + const operation = input.createSlotOperation({ + primary: input.attempt, + ...(input.createFallback === undefined ? {} : { createFallback: input.createFallback }), + }); + if (!operation.ok) return operation; + + const bridgeInput = Object.freeze({ + artifact: input.artifact, + attempt: input.attempt, + owner: input.owner, + reservationId: input.reservationId, + }); + const registered = (() => { + try { + return input.pucBridge.registerGamAttempt(bridgeInput); + } catch { + return false; + } + })(); + if (!registered) { + try { + input.attempt.fail('gpt_request_failed'); + } catch { + // The operation still observes any terminal result already committed by the bridge. + } + return operation; + } + + let handle: ReturnType; + try { + handle = input.slots.request({ + intentId: input.attempt.id, + navigationGeneration: input.attempt.navigationGeneration, + operation: input.operation, + registeredSlotId: input.attempt.slot, + requestClass: input.requestClass, + ...(input.trustedServerOpportunity === undefined + ? {} + : { trustedServerOpportunity: input.trustedServerOpportunity }), + }); + } catch { + input.attempt.fail('gpt_request_failed'); + return operation; + } + + let handleDisposed = false; + const disposeHandle = (): void => { + if (handleDisposed) return; + handleDisposed = true; + try { + handle.dispose(); + } catch { + // Attempt settlement remains authoritative when request cleanup throws. + } + }; + const observing = (() => { + try { + return input.attempt.onSettled(disposeHandle); + } catch { + return false; + } + })(); + if (!observing) { + disposeHandle(); + try { + input.attempt.fail('internal_error'); + } catch { + // A concurrently terminal attempt cannot be overwritten. + } + return operation; + } + + void handle.result.then( + (outcome) => settleFromSlotOutcome(input.attempt, input.pucBridge, bridgeInput, outcome), + () => { + try { + input.attempt.fail('gpt_request_failed'); + } catch { + // A late rejected request cannot overwrite an existing terminal outcome. + } + } + ); + return operation; +} + +function exactCapability( + interfaces: Readonly>, + key: string +): Value | undefined { + const candidate = interfaces[key]; + return typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as Value) + : undefined; +} + +function gptDiagnosticsActive(runtime: RuntimeCapabilityV1): boolean { + try { + const boot = runtime.boot(); + if (!boot) return false; + const diagnostics = Object.getOwnPropertyDescriptor(boot, 'diagnostics'); + if (!diagnostics || !('value' in diagnostics)) return false; + const gpt = Object.getOwnPropertyDescriptor(diagnostics.value, 'gpt'); + if (!gpt || !('value' in gpt)) return false; + const active = Object.getOwnPropertyDescriptor(gpt.value, 'active'); + return Boolean(active && 'value' in active && active.value === true); + } catch { + return false; + } +} + +function resolveProjectedSlotElement( + document: Document, + placement: Readonly +): HTMLElement | undefined { + try { + const exact = document.getElementById(placement.divId); + if (exact instanceof HTMLElement) return exact; + const matches = [...document.querySelectorAll('[id]')].filter( + (element) => element.id.startsWith(placement.divId) && !element.id.endsWith('-container') + ); + return matches.length === 1 ? matches[0] : undefined; + } catch { + return undefined; + } +} + +const TS_DISPLAY_RENDERER = + '(function(){window.render=function(d,h,w){' + + 'var f=h.mkFrame(w.document,{width:d.width||"100%",height:d.height||"100%"});' + + 'if(d.adUrl&&!d.ad){f.src=d.adUrl;}else{f.srcdoc=d.ad;}' + + 'w.document.body.appendChild(f);};})();'; + +const AUCTION_PRICE_MACRO = '${AUCTION_PRICE}'; + +interface CachedPbsBid { + readonly adm: string; + readonly width?: number; + readonly height?: number; + readonly price?: number; +} + +function expandCachedAuctionPriceMacro(markup: string, cpm: number): string { + if (!(reflectApplyIntrinsic(stringIncludesIntrinsic, markup, [AUCTION_PRICE_MACRO]) as boolean)) { + return markup; + } + const pieces = reflectApplyIntrinsic(stringSplitIntrinsic, markup, [ + AUCTION_PRICE_MACRO, + ]) as string[]; + return reflectApplyIntrinsic(stringJoinIntrinsic, pieces, [String(cpm)]) as string; +} + +/** Preserve current-main PBS Cache decoding inside GPT without publishing a cache service. */ +function parseCachedPbsBid(body: string): CachedPbsBid | undefined { + let parsed: unknown; + try { + parsed = reflectApplyIntrinsic(jsonParseIntrinsic, JSON, [body]) as unknown; + } catch { + const trimmed = reflectApplyIntrinsic(stringTrimIntrinsic, body, []) as string; + return trimmed.length > 0 ? Object.freeze({ adm: body }) : undefined; + } + if (!parsed || typeof parsed !== 'object' || arrayIsArrayIntrinsic(parsed)) return undefined; + const record = parsed as Record; + if (typeof record.adm !== 'string' || record.adm.length === 0) return undefined; + const finite = (value: unknown): number | undefined => + typeof value === 'number' && numberIsFiniteIntrinsic(value) ? value : undefined; + const dimension = (value: unknown): number | undefined => { + const numeric = finite(value); + return numeric !== undefined && numeric > 0 ? numeric : undefined; + }; + const width = dimension(record.w) ?? dimension(record.width); + const height = dimension(record.h) ?? dimension(record.height); + const price = finite(record.price); + return Object.freeze({ + adm: record.adm, + ...(width === undefined ? {} : { width }), + ...(height === undefined ? {} : { height }), + ...(price === undefined ? {} : { price }), + }); +} + +type PbsCacheBridgeFailure = + 'cache_fetch_failed' | 'invalid_cache_payload' | 'response_post_failed'; + +/** Install the activation-scoped current-main PBS Cache bridge owned only by GPT. */ +export function installPbsCacheBridge( + document: Document, + auction: ProductionAuctionCapability, + isActive: () => boolean, + observe: (observation: Readonly>) => boolean +): () => void { + const view = document.defaultView; + if (!view) throw new TypeError('GPT PBS Cache bridge requires a browser window'); + const inFlight = new WeakMap>(); + let listening = true; + + const publishFailure = (slot: string, reason: PbsCacheBridgeFailure): void => { + try { + observe(Object.freeze({ kind: 'pbs_cache_bridge', slotId: slot, reason })); + } catch { + // Diagnostics cannot alter cache bridge ownership. + } + }; + const parseRequest = (candidate: unknown): Readonly<{ adId: string }> | undefined => { + try { + const value = + typeof candidate === 'string' + ? (reflectApplyIntrinsic(jsonParseIntrinsic, JSON, [candidate]) as unknown) + : candidate; + if (typeof value !== 'object' || value === null || arrayIsArrayIntrinsic(value)) { + return undefined; + } + const record = value as Record; + return record.message === 'Prebid Request' && + typeof record.adId === 'string' && + record.adId !== '' + ? Object.freeze({ adId: record.adId }) + : undefined; + } catch { + return undefined; + } + }; + const sourceOwnsPlacement = ( + source: MessageEventSource | null, + placement: Readonly + ): boolean => { + if (!source) return false; + try { + const exact = document.getElementById(placement.divId); + const configuredContainer = document.getElementById(`${placement.divId}-container`); + const owns = (root: HTMLElement): boolean => { + const frames = root.querySelectorAll('iframe'); + for (let index = 0; index < frames.length; index += 1) { + if (frames.item(index)?.contentWindow === source) return true; + } + return false; + }; + return ( + (exact instanceof HTMLElement && owns(exact)) || + (configuredContainer instanceof HTMLElement && + configuredContainer !== exact && + owns(configuredContainer)) + ); + } catch { + return false; + } + }; + const listener = (event: MessageEvent): void => { + if (!listening || !isActive()) return; + const request = parseRequest(event.data); + const port = event.ports?.[0]; + if (!request || !port || typeof port.postMessage !== 'function') return; + const navigation = auction.session.currentNavigation; + const projection = navigation?.currentAuctionProjection as + Readonly | undefined; + if (!navigation?.isCurrent() || !projection) return; + let placement: BrowserAuctionSlotV1 | undefined; + for (let index = 0; index < projection.slots.length; index += 1) { + const candidate = projection.slots[index]; + if (!candidate || !sourceOwnsPlacement(event.source, candidate)) continue; + if (placement) return; + placement = candidate; + } + if (!placement) return; + let bid: PbsCacheBrowserAuctionBidV1 | undefined; + for (let index = 0; index < projection.bids.length; index += 1) { + const candidate = projection.bids[index]; + if ( + !candidate || + candidate.slot !== placement.slot || + candidate.renderSource.type !== 'pbs_cache' || + candidate.renderSource.cacheId !== request.adId + ) { + continue; + } + if (bid) return; + bid = candidate as PbsCacheBrowserAuctionBidV1; + } + if (!bid) return; + + event.stopImmediatePropagation(); + const key = `${bid.slot}\u0000${request.adId}`; + let generationFlights = inFlight.get(navigation.generation); + if (!generationFlights) { + generationFlights = new Set(); + inFlight.set(navigation.generation, generationFlights); + } + if (generationFlights.has(key)) return; + generationFlights.add(key); + + const remainsCurrent = (): boolean => + listening && + isActive() && + navigation.isCurrent() && + auction.session.currentNavigation === navigation && + navigation.currentAuctionProjection === projection && + (() => { + for (let index = 0; index < projection.bids.length; index += 1) { + if (projection.bids[index] === bid) return true; + } + return false; + })(); + const cacheUrl = `https://${bid.renderSource.cacheHost}${bid.renderSource.cachePath}?uuid=${encodeURIComponent(request.adId)}`; + const complete = async (): Promise => { + try { + const fetcher = globalThis.fetch; + if (typeof fetcher !== 'function') throw new Error('fetch unavailable'); + const response = await fetcher(cacheUrl, { mode: 'cors' }); + if (!response.ok) throw new Error(`cache HTTP ${response.status}`); + const body = await response.text(); + if (!remainsCurrent()) return; + const cached = parseCachedPbsBid(body); + if (!cached) { + publishFailure(bid.slot, 'invalid_cache_payload'); + return; + } + const width = cached.width ?? (bid.renderSource.width || placement.formats[0]?.[0] || 728); + const height = + cached.height ?? (bid.renderSource.height || placement.formats[0]?.[1] || 90); + const adm = + cached.price === undefined + ? cached.adm + : expandCachedAuctionPriceMacro(cached.adm, cached.price); + try { + port.postMessage( + JSON.stringify({ + message: 'Prebid Response', + adId: request.adId, + ad: adm, + renderer: TS_DISPLAY_RENDERER, + width, + height, + }) + ); + } catch { + publishFailure(bid.slot, 'response_post_failed'); + return; + } + if (!remainsCurrent()) return; + resizeCollapsedPucShell({ source: event.source as object, width, height }); + } catch { + if (remainsCurrent()) publishFailure(bid.slot, 'cache_fetch_failed'); + } finally { + generationFlights?.delete(key); + } + }; + void complete(); + }; + + view.addEventListener('message', listener); + return (): void => { + if (!listening) return; + listening = false; + view.removeEventListener('message', listener); + }; +} + +function terminalLatch(attempt: RenderAttempt): Promise { + return new Promise((resolve) => { + if (!attempt.onSettled(resolve)) resolve(attempt.snapshot().outcome); + }); +} + +interface PreparedInitialGptWinner { + readonly attempt: RenderAttempt; + readonly bid: OwnedBrowserAuctionBidV1; + readonly binding: Readonly<{ operation: 'display' | 'refresh'; slot: object }> | undefined; + readonly decision: Readonly<{ slot: string; outcome: 'winner'; candidateId: string }>; + readonly owner: RenderAttemptScope; + readonly placement: BrowserAuctionSlotV1; + readonly terminal: Promise; +} + +interface PreparedInitialPbsCacheWinner { + readonly bid: PbsCacheBrowserAuctionBidV1; + readonly binding: Readonly<{ operation: 'display' | 'refresh'; slot: object }>; + readonly placement: BrowserAuctionSlotV1; +} + +interface InitialGptRequestParticipant { + readonly complete: () => void; + readonly request: (input: SlotRequestInput) => SlotRequestHandle; +} + +function failedBatchRequestHandle(): SlotRequestHandle { + const outcome = Object.freeze({ + reason: 'gpt_request_failed' as const, + status: 'failed' as const, + }); + return Object.freeze({ + status: 'terminal' as const, + result: Promise.resolve(outcome), + dispose: () => undefined, + }); +} + +function createInitialGptRequestBatch( + slots: Pick, + participantCount: number +): Readonly<{ participant: () => InitialGptRequestParticipant }> { + interface PendingRequest { + readonly bind: (handle: SlotRequestHandle) => void; + readonly fail: () => void; + readonly input: SlotRequestInput; + readonly isDisposed: () => boolean; + } + + const pending: PendingRequest[] = []; + let claimedParticipants = 0; + let readyParticipants = 0; + let flushed = false; + const flush = (): void => { + if (flushed || readyParticipants !== participantCount) return; + flushed = true; + const active = pending.filter((request) => !request.isDisposed()); + if (active.length === 0) return; + let handles: readonly SlotRequestHandle[] = Object.freeze([]); + try { + const inputs = Object.freeze(active.map(({ input }) => input)); + handles = slots.requestBatch(inputs); + if (!Array.isArray(handles) || handles.length !== active.length) { + throw new Error('GPT SRA batch admission failed'); + } + for (let index = 0; index < active.length; index += 1) { + const request = active[index]; + const handle = handles[index]; + if (!request || !handle) throw new Error('GPT SRA batch result is incomplete'); + request.bind(handle); + } + } catch { + for (let index = 0; index < handles.length; index += 1) { + try { + handles[index]?.dispose(); + } catch { + // Continue rolling back the other partially returned handles. + } + } + for (let index = 0; index < active.length; index += 1) active[index]?.fail(); + } + }; + + return Object.freeze({ + participant: (): InitialGptRequestParticipant => { + if (claimedParticipants >= participantCount) { + return Object.freeze({ + complete: () => undefined, + request: () => failedBatchRequestHandle(), + }); + } + claimedParticipants += 1; + let ready = false; + let requested = false; + const markReady = (): void => { + if (ready) return; + ready = true; + readyParticipants += 1; + flush(); + }; + return Object.freeze({ + complete: markReady, + request: (input: SlotRequestInput): SlotRequestHandle => { + if (requested || ready) return failedBatchRequestHandle(); + requested = true; + let disposed = false; + let terminal = false; + let activeHandle: SlotRequestHandle | undefined; + let resolve!: (outcome: SlotRequestOutcome) => void; + const result = new Promise((resolveResult) => { + resolve = resolveResult; + }); + const settleFailed = (): void => { + if (terminal) return; + terminal = true; + resolve(Object.freeze({ reason: 'gpt_request_failed', status: 'failed' })); + }; + const settleDisposed = (): void => { + if (terminal) return; + terminal = true; + resolve(Object.freeze({ reason: 'superseded', status: 'cancelled' })); + }; + const pendingRequest: PendingRequest = { + bind: (handle): void => { + if (terminal) { + try { + handle.dispose(); + } catch { + // The placeholder outcome already owns terminal settlement. + } + return; + } + activeHandle = handle; + void handle.result.then((outcome) => { + if (terminal) return; + terminal = true; + resolve(outcome); + }, settleFailed); + if (disposed) { + try { + handle.dispose(); + } catch { + settleDisposed(); + } + } + }, + fail: settleFailed, + input, + isDisposed: () => disposed, + }; + pending[pending.length] = pendingRequest; + const placeholder = Object.freeze({ + get status(): 'active' | 'queued' | 'terminal' { + return terminal ? 'terminal' : (activeHandle?.status ?? 'active'); + }, + result, + dispose: (): void => { + if (disposed) return; + disposed = true; + if (activeHandle) { + try { + activeHandle.dispose(); + } catch { + settleDisposed(); + } + } else { + settleDisposed(); + } + }, + }); + markReady(); + return placeholder; + }, + }); + }, + }); +} + +/** Start one immutable initial GPT winner batch and protect every terminal latch together. */ +export async function publishInitialGptProjection( + document: Document, + input: InitialProjectionServices +): Promise { + const { googletag, navigation, projection, render, slots } = input; + if (!navigation.isCurrent() || projection.slots.length === 0) return; + const physicalBySlot = new Map< + string, + Readonly<{ operation: 'display' | 'refresh'; slot: object }> + >(); + const operation = googletag.run( + (gpt) => { + for (let index = 0; index < projection.slots.length; index += 1) { + const placement = projection.slots[index]; + if (!placement || !navigation.isCurrent()) break; + const element = resolveProjectedSlotElement(document, placement); + if (!element) continue; + const definition = Object.freeze({ + adUnitPath: placement.gamUnitPath, + elementId: element.id, + sizes: placement.formats, + }); + const existing = gpt.slots().filter((slot) => gpt.slotElementId?.(slot) === element.id); + if (existing.length > 1) continue; + const publisherSlot = existing[0]; + if (publisherSlot) { + const adopted = slots.adoptGptSlot(navigation.generation, placement.slot, { + definition, + elementIdPrefix: placement.divId, + ownership: 'publisher', + slot: publisherSlot, + }); + if (adopted.ok) { + physicalBySlot.set( + placement.slot, + Object.freeze({ operation: 'refresh', slot: publisherSlot }) + ); + } + continue; + } + const defined = gpt.transactionalDefine( + definition, + () => navigation.isCurrent(), + (candidate) => { + let committed = false; + return Object.freeze({ + commit: (): boolean => { + const adopted = slots.adoptGptSlot(navigation.generation, placement.slot, { + definition, + elementIdPrefix: placement.divId, + ownership: 'trusted_server', + slot: candidate, + }); + committed = adopted.ok; + return committed; + }, + rollback: (): void => { + if (!committed) return; + committed = false; + slots.recordPublisherDestruction(candidate); + }, + }); + } + ); + if (defined.status === 'defined') { + physicalBySlot.set( + placement.slot, + Object.freeze({ operation: 'display', slot: defined.slot }) + ); + } + } + }, + { signal: navigation.signal } + ); + try { + await operation.result; + } catch (error) { + if (navigation.isCurrent()) log.warn('GPT projection slot binding failed', error); + } + if (!navigation.isCurrent()) return; + + const batch = navigation.createAuctionBatch(`gpt:${projection.auction.auctionId}`); + if (!batch) return; + const prepared: PreparedInitialGptWinner[] = []; + const preparedCache: PreparedInitialPbsCacheWinner[] = []; + let winnerIndex = 0; + for (let index = 0; index < projection.auction.results.length; index += 1) { + const decision = projection.auction.results[index]; + const placement = projection.slots[index]; + if (!decision || !placement || decision.outcome !== 'winner') continue; + const bid = projection.bids[winnerIndex]; + winnerIndex += 1; + if (!bid || !navigation.isCurrent()) continue; + const binding = physicalBySlot.get(decision.slot); + if (!('rendererReservationId' in bid)) { + if (binding) { + preparedCache.push(Object.freeze({ bid, binding, placement })); + } + continue; + } + const owner = batch.createRenderAttempt(decision.slot); + if (!owner.ok) continue; + const created = render.createAttempt(owner.value); + if (!created.ok) continue; + prepared.push( + Object.freeze({ + attempt: created.value, + bid, + binding, + decision, + owner: owner.value, + placement, + terminal: terminalLatch(created.value), + }) + ); + } + if (prepared.length === 0 && preparedCache.length === 0) return; + const requestBatch = createInitialGptRequestBatch(slots, prepared.length + preparedCache.length); + const cachePublications = preparedCache.map(({ bid, binding, placement }) => { + const participant = requestBatch.participant(); + return Promise.resolve().then(async () => { + try { + await publishPbsCacheGptWinner({ + bid, + binding, + googletag, + navigation, + placement, + requestClass: input.requestClass ?? 'initial', + slots: Object.freeze({ + isBoundGptSlot: slots.isBoundGptSlot, + request: participant.request, + }), + targeting: input.targeting, + }); + } finally { + participant.complete(); + } + }); + }); + input.protect(Object.freeze([...prepared.map(({ terminal }) => terminal), ...cachePublications])); + + await Promise.all([ + ...cachePublications, + ...prepared.map(async ({ attempt, bid, binding, decision, owner, placement }) => { + const participant = requestBatch.participant(); + try { + if (!binding) { + attempt.fail('slot_unresolved'); + return; + } + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: attempt.navigationGeneration, + dispose: () => undefined, + }); + const published = await publishGptWinner({ + artifact, + attempt, + bid, + createSlotOperation: render.createSlotOperation, + googletag, + navigation, + operation: binding.operation, + owner, + placement, + pucBridge: input.pucBridge, + requestClass: input.requestClass ?? 'initial', + reservations: render.reservations, + slot: binding.slot, + slots: Object.freeze({ + isBoundGptSlot: slots.isBoundGptSlot, + request: participant.request, + }), + targeting: input.targeting, + createFallback: (parentAttemptId) => { + const fallbackOwner = batch.createRenderAttempt(decision.slot); + if (!fallbackOwner.ok) { + return Object.freeze({ + ok: false as const, + reason: + fallbackOwner.reason === 'identity_generation_failed' + ? ('identity_generation_failed' as const) + : fallbackOwner.reason === 'stale_owner' + ? ('stale_owner' as const) + : ('invalid_attempt' as const), + }); + } + const fallback = render.createAttempt(fallbackOwner.value, parentAttemptId); + if (!fallback.ok) return fallback; + if ( + !fallback.value.admitDirectWinner( + bid.renderSource, + Object.freeze({ selectedCpm: bid.cpm }) + ) + ) { + fallback.value.fail('winner_not_renderable'); + return fallback; + } + if (!render.renderWinner(fallback.value)) fallback.value.fail('winner_not_renderable'); + return fallback; + }, + }); + if (!published.ok && navigation.isCurrent()) { + log.warn('GPT projection winner publication failed', published.reason); + } + } finally { + participant.complete(); + } + }), + ]); +} + +function prepareProductionGpt(context: IntegrationPrepareContext): PreparedIntegration { + const runtime = exactCapability(context.interfaces, 'runtime.v1'); + if (!runtime) throw new TypeError('GPT requires runtime.v1'); + if (!isGptIntegrationConfigV1(context.config)) { + throw new TypeError('GPT integration config is invalid'); + } + const config = context.config; + const auction = exactCapability(context.interfaces, 'auction.v1'); + const slotCapability = exactCapability(context.interfaces, 'slots.v1'); + const aps = exactCapability(context.interfaces, 'aps.v1'); + const render = exactCapability(context.interfaces, 'render.v1'); + const messages = exactCapability(context.interfaces, 'messages.v1'); + const trace = exactCapability(context.interfaces, 'trace.v1'); + const document = runtime.document; + if ( + !auction || + !slotCapability || + !render || + !messages || + !trace || + !document?.defaultView || + typeof auction.session?.replaceNavigation !== 'function' || + typeof runtime.protectFirstDisplayAttemptBatch !== 'function' || + typeof slotCapability.attachPhysicalService !== 'function' || + typeof render.attachPucGamAttemptRegistrar !== 'function' || + typeof render.bindArtifactRetirement !== 'function' || + typeof render.createAttempt !== 'function' || + typeof render.createSlotOperation !== 'function' || + typeof render.commitPageBids !== 'function' || + typeof render.mintLifecycleTicket !== 'function' || + typeof render.renderWinner !== 'function' || + (aps !== undefined && typeof aps.renderPuc !== 'function') || + typeof trace.observations?.publish !== 'function' + ) { + throw new TypeError('GPT capability graph is malformed'); + } + + const scope = new DisposableStack((error) => log.warn('GPT preparation disposal failed', error)); + context.onDispose(() => scope.dispose()); + let active = false; + scope.onDispose(() => { + active = false; + }); + const googletag = createBrowserGoogletagAdapter( + document.defaultView as unknown as Parameters[0], + { + reportDiagnosticsFailure: (code) => log.warn('GPT diagnostics identity unavailable', code), + } + ); + scope.onDispose(() => googletag.dispose()); + const diagnosticsEnabled = gptDiagnosticsActive(runtime); + const diagnosticsFacts = diagnosticsEnabled + ? createGptDiagnosticsFactBuffer({ + onOverflow: (droppedFacts) => + log.warn('GPT diagnostics fact buffer overflow', droppedFacts), + }) + : undefined; + if (diagnosticsFacts) scope.onDispose(diagnosticsFacts.dispose); + const reconciliation = createBrowserSlotReconciliationBoundary( + document, + document.defaultView.MutationObserver + ); + const slots = createSlotService({ + bindCommittedArtifactRetirement: render.bindArtifactRetirement, + disposeCommittedArtifact: (navigationGeneration, registeredSlotId, expectedArtifact) => { + const artifact = render.artifacts.current(registeredSlotId); + if (artifact === expectedArtifact && artifact.navigationGeneration === navigationGeneration) + render.artifacts.release(artifact); + }, + googletag, + ...(diagnosticsFacts + ? { + onTrustedServerRequest: ({ opportunity, registeredSlotId, slot }) => { + if (!opportunity) return; + publishTrustedServerOpportunityFact({ + adapter: googletag, + buffer: diagnosticsFacts, + physicalSlot: slot, + registeredSlotId, + opportunity, + }); + }, + } + : {}), + ...(reconciliation ? { reconciliation } : {}), + }); + scope.onDispose(() => slots.dispose()); + const targeting = createTargetingService(); + scope.onDispose(() => targeting.dispose()); + const startup = createGptStartup({ googletag, slots: () => slots }); + let pucBridge: PucBridge | undefined; + let takeoverReconciliationRelease: (() => void) | undefined; + let laterLifecycleActive = false; + let laterLifecycleRelease: (() => void) | undefined; + const gptCapability: GptCapabilityV1 = Object.freeze({ + activateLaterLifecycle: () => { + if (!active || laterLifecycleActive) { + throw new TypeError('GPT later lifecycle is unavailable'); + } + const currentBridge = pucBridge; + if (!currentBridge) throw new TypeError('GPT later bridge is unavailable'); + const releaseReconciliation = takeoverReconciliationRelease; + if (!releaseReconciliation) { + throw new TypeError('GPT takeover reconciliation owner is unavailable'); + } + takeoverReconciliationRelease = undefined; + const controllers = new Set(); + let ownerActive = true; + laterLifecycleActive = true; + const rejected = (navigation?: NavigationSession): GptLaterNavigationResult => { + const rejectedNavigation = + navigation ?? auction.session.currentNavigation ?? auction.navigation; + return Object.freeze({ + status: 'rejected', + navigationGeneration: rejectedNavigation.generation, + current: ownerActive && active && rejectedNavigation.isCurrent(), + }); + }; + const release = (): void => { + if (!ownerActive) return; + ownerActive = false; + laterLifecycleActive = false; + if (laterLifecycleRelease === release) laterLifecycleRelease = undefined; + for (const controller of controllers) controller.abort(); + controllers.clear(); + releaseReconciliation(); + }; + laterLifecycleRelease = release; + return Object.freeze({ + navigate: async (path: string): Promise => { + if ( + !ownerActive || + !active || + typeof path !== 'string' || + path.length === 0 || + path.length > 4_096 || + !path.startsWith('/') + ) { + return rejected(); + } + const replacement = auction.session.replaceNavigation(); + if (!replacement.ok || !ownerActive || !active) return rejected(); + const navigation = replacement.value; + const controller = new AbortController(); + controllers.add(controller); + const abortForNavigation = (): void => controller.abort(); + navigation.signal.addEventListener('abort', abortForNavigation, { once: true }); + try { + const fetcher = globalThis.fetch; + if (typeof fetcher !== 'function') return rejected(navigation); + const response = await fetcher(`/_ts/page-bids?path=${encodeURIComponent(path)}`, { + credentials: 'include', + headers: { 'X-TSJS-Page-Bids': '1' }, + signal: controller.signal, + }); + if (!ownerActive || !active || !navigation.isCurrent() || !response.ok) { + return rejected(navigation); + } + const candidate = await response.json(); + if (!ownerActive || !active || !navigation.isCurrent()) return rejected(navigation); + if ( + !render.commitPageBids(navigation, slots.projectionRegistry(navigation), candidate) + ) { + return rejected(navigation); + } + const projection = navigation.currentAuctionProjection as + Readonly | undefined; + if (!projection || !ownerActive || !active || !navigation.isCurrent()) { + return rejected(navigation); + } + await publishInitialGptProjection(document, { + googletag, + navigation, + projection, + protect: () => true, + pucBridge: currentBridge, + render, + requestClass: 'page-bids', + slots, + targeting, + }); + if (!ownerActive || !active || !navigation.isCurrent()) return rejected(navigation); + return Object.freeze({ + status: 'committed', + navigationGeneration: navigation.generation, + current: true, + }); + } catch (error) { + if (!controller.signal.aborted && ownerActive && navigation.isCurrent()) { + log.warn('GPT page-bids navigation failed', error); + } + return rejected(navigation); + } finally { + navigation.signal.removeEventListener('abort', abortForNavigation); + controllers.delete(controller); + } + }, + release, + }); + }, + adapter: googletag, + directAuctionUnitForSlot: (slot: object): Readonly | undefined => { + const navigation = auction.session.currentNavigation; + if (!active || !navigation?.isCurrent()) return undefined; + const records = slots.snapshotRegisteredSlots(navigation) ?? Object.freeze([]); + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + if ( + record?.directAuctionUnit && + slots.isBoundGptSlot(navigation.generation, record.registeredSlotId, slot) + ) { + return record.directAuctionUnit; + } + } + return undefined; + }, + installRefreshPolicy: startup.installRefreshPolicy, + navigation: () => { + const navigation = auction.session.currentNavigation; + return active && navigation?.isCurrent() ? navigation : undefined; + }, + slots, + }); + const eventsCapability = Object.freeze({ + subscribe: (listener: (fact: Readonly>) => void): (() => void) => { + const release = + active && diagnosticsFacts + ? diagnosticsFacts.activate(listener as Parameters[0]) + : undefined; + if (!release) { + throw new TypeError('GPT event subscription is unavailable'); + } + return release; + }, + }); + const cacheCapability = Object.freeze({}); + + return Object.freeze({ + activate: (activation: IntegrationActivationContext) => { + const { afterCommit, onDispose } = activation; + const adoptionCandidate = activation.adoption; + if (active) throw new Error('GPT already activated'); + if (adoptionCandidate !== undefined) { + const selected = persistentFirstDisplaySliceSelectedV1(adoptionCandidate, 'gpt_initial'); + const initialState = selected + ? snapshotPersistentFirstDisplaySliceStateV1(adoptionCandidate, 'gpt_initial') + : undefined; + if ( + selected === undefined || + (config.gamAttributionEnabled && !selected) || + (selected && + (!initialState || + initialState.values.length !== 2 || + initialState.values[0]?.[0] !== 'gam' || + initialState.values[0][1] !== config.gamAttributionEnabled || + initialState.values[1]?.[0] !== 'v' || + initialState.values[1][1] !== 1)) + ) { + throw new TypeError('GPT first-display parser state is invalid'); + } + } else if (config.gamAttributionEnabled && !googletag.enqueueGamAttribution()) { + throw new TypeError('GPT GAM attribution is unavailable'); + } + const diagnosticsEventRelease: { current?: () => void } = {}; + const diagnosticsRelease: { current?: () => void } = {}; + const pbsCacheBridgeRelease: { current?: () => void } = {}; + const publisherRelease: { current?: () => void } = {}; + const slotServiceRelease: { current?: () => void } = {}; + const bridgeRelease: { current?: () => void } = {}; + const pucRegistrarRelease: { current?: () => void } = {}; + onDispose(resetGuardState); + onDispose(() => diagnosticsEventRelease.current?.()); + onDispose(() => diagnosticsRelease.current?.()); + onDispose(() => pbsCacheBridgeRelease.current?.()); + onDispose(() => publisherRelease.current?.()); + onDispose(() => slotServiceRelease.current?.()); + onDispose(() => bridgeRelease.current?.()); + onDispose(() => pucRegistrarRelease.current?.()); + onDispose(() => { + active = false; + const release = laterLifecycleRelease; + laterLifecycleRelease = undefined; + release?.(); + const releaseTakeoverReconciliation = takeoverReconciliationRelease; + takeoverReconciliationRelease = undefined; + releaseTakeoverReconciliation?.(); + }); + + slotServiceRelease.current = slotCapability.attachPhysicalService(slots); + const diagnosticsAdoption = + adoptionCandidate === undefined + ? undefined + : adoptInitialGptDiagnosticsFromHandoff(adoptionCandidate, googletag); + const adoption = + adoptionCandidate === undefined + ? undefined + : adoptInitialGptSlotsFromHandoff( + adoptionCandidate, + auction.navigation.generation, + slots, + render.artifacts, + targeting, + googletag + ); + if (adoptionCandidate !== undefined && (!diagnosticsAdoption || !adoption)) { + throw new TypeError('GPT first-display adoption is invalid'); + } + const factAdoption = + adoptionCandidate === undefined + ? undefined + : adoptInitialGptFactsFromHandoff( + adoptionCandidate, + diagnosticsFacts, + googletag, + auction.navigation.currentAuctionProjection as + Readonly | undefined + ); + if (adoptionCandidate !== undefined && !factAdoption) { + throw new TypeError('GPT first-display diagnostics facts are invalid'); + } + slots.start(); + takeoverReconciliationRelease = slots.activateReconciliation(); + const bridge = createPucBridge({ + messaging: messages.messaging, + mintLifecycleTicket: render.mintLifecycleTicket, + ...(aps ? { mountAps: aps.renderPuc } : {}), + now: () => document.defaultView!.performance.now(), + reservations: render.reservations, + resizeCollapsedShell: resizeCollapsedPucShell, + slots, + }); + const ticketAdoption = + adoptionCandidate === undefined + ? undefined + : adoptInitialPucTicketsFromHandoff(adoptionCandidate, bridge); + if (adoptionCandidate !== undefined && !ticketAdoption) { + throw new TypeError('GPT first-display ticket adoption is invalid'); + } + pucBridge = bridge; + bridgeRelease.current = () => { + bridge.dispose(); + if (pucBridge === bridge) pucBridge = undefined; + }; + pucRegistrarRelease.current = render.attachPucGamAttemptRegistrar((input) => + bridge.registerGamAttempt(input) + ); + const releaseDiagnostics = googletag.observeDiagnostics((fact) => { + const observation = projectGptTraceFact(fact); + if (observation) trace.observations.publish(observation); + diagnosticsFacts?.publish(fact); + }); + if (!releaseDiagnostics) throw new Error('GPT diagnostics event boundary is unavailable'); + diagnosticsRelease.current = releaseDiagnostics; + if (diagnosticsFacts) { + const releaseDiagnosticEvents = activateGptDiagnosticsEventListeners(googletag); + if (!releaseDiagnosticEvents) { + throw new Error('GPT diagnostics-only event listeners are unavailable'); + } + diagnosticsEventRelease.current = releaseDiagnosticEvents; + } + pbsCacheBridgeRelease.current = installPbsCacheBridge( + document, + auction, + () => active, + trace.observations.publish + ); + publisherRelease.current = startup.activate(); + installGptGuard(); + active = true; + afterCommit(() => { + startup.start(context.config); + if (adoption) return; + const currentBridge = pucBridge; + if (!active || currentBridge !== bridge) return; + void publishInitialGptProjection(document, { + googletag, + navigation: auction.navigation, + projection: auction.projection, + protect: runtime.protectFirstDisplayAttemptBatch, + pucBridge: currentBridge, + render, + slots, + targeting, + }).catch((error) => { + if (auction.navigation.isCurrent()) log.warn('GPT initial projection failed', error); + }); + }); + }, + interfaces: Object.freeze({ + 'gpt.v1': gptCapability, + 'gpt.events.v1': eventsCapability, + 'pbs_cache.baseline.v1': cacheCapability, + }), + }); +} + +/** Build the release-bound GPT module registered by the coordinated runtime. */ +export function createGptIntegrationRegistration(releaseId: string): IntegrationRegistration { + const prepare = (context: IntegrationPrepareContext) => prepareProductionGpt(context); + return Object.freeze({ + abi: 1, + id: GPT_INTEGRATION_ID, + phase: 'takeover', + releaseId, + prepareSync: prepare, + prepare, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/script_guard.ts b/crates/trusted-server-js/lib/src/integrations/gpt/script_guard.ts index c1bc89945..ab78834f1 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/script_guard.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/script_guard.ts @@ -1,75 +1,14 @@ -import { log } from '../../core/log'; -import { - DEFAULT_DOM_INSERTION_HANDLER_PRIORITY, - type DomInsertionCandidate, - registerDomInsertionHandler, -} from '../../shared/dom_insertion_dispatcher'; +import { createScriptGuard } from '../../shared/script_guard'; -/** - * GPT Script Interception Guard - * - * Intercepts script elements whose URLs point at Google's ad-serving domains - * and synchronously rewrites them to the first-party proxy, preserving the - * original path. This guard performs a *host swap*: - * - * securepubads.g.doubleclick.net/pagead/managed/js/gpt/…/pubads_impl.js - * → publisher.com/integrations/gpt/pagead/managed/js/gpt/…/pubads_impl.js - * - * The server-side proxy serves script bodies verbatim, so this guard is - * the sole mechanism that routes GPT's cascaded script loads (pubads_impl, - * sub-modules, viewability, etc.) back through the first-party proxy. - * - * ## Interception layers - * - * 1. **`document.write` / `document.writeln`** — GPT's primary loading - * mechanism. When gpt.js loads synchronously it uses `document.write` - * to inject ` +`; + +const OUTER_TEMPLATE = ` + + + + + +`; + +function exactGenerationInput(candidate: unknown): ApsDataDocumentInputV1 | undefined { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype + ) { + return undefined; + } + const expected = ['renderer', 'publisherOrigin', 'bootstrapNonce', 'rendererNonce']; + const keys = Reflect.ownKeys(candidate); + if ( + keys.length !== expected.length || + keys.some((key) => typeof key !== 'string' || !expected.includes(key)) + ) { + return undefined; + } + const values: Record = {}; + for (const key of expected) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + return undefined; + } + values[key] = descriptor.value; + } + if ( + typeof values['publisherOrigin'] !== 'string' || + typeof values['bootstrapNonce'] !== 'string' || + typeof values['rendererNonce'] !== 'string' + ) { + return undefined; + } + return { + renderer: values['renderer'], + publisherOrigin: values['publisherOrigin'], + bootstrapNonce: values['bootstrapNonce'], + rendererNonce: values['rendererNonce'], + }; + } catch { + return undefined; + } +} + +function hasForbiddenOriginCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if ( + code <= 0x20 || + code === 0x7f || + value[index] === "'" || + value[index] === '"' || + value[index] === ';' + ) { + return true; + } + } + return false; +} + +function trustedServerOrigin(value: string): string | undefined { + if (encoder.encode(value).byteLength > 2_048 || hasForbiddenOriginCharacter(value)) { + return undefined; + } + try { + const origin = new URL(value); + const loopbackHttp = + origin.protocol === 'http:' && + (origin.hostname === 'localhost' || + origin.hostname === '[::1]' || + loopbackIpv4Pattern.test(origin.hostname)); + return origin.origin === value && + origin.username === '' && + origin.password === '' && + origin.pathname === '/' && + origin.search === '' && + origin.hash === '' && + (origin.protocol === 'https:' || loopbackHttp) + ? origin.origin + : undefined; + } catch { + return undefined; + } +} + +function validatedCreativeOrigin( + renderer: Readonly, + publisherOrigin: string +): string | undefined { + try { + const creative = new URL(renderer.creativeUrl); + return creative.protocol === 'https:' && + creative.username === '' && + creative.password === '' && + creative.origin !== publisherOrigin && + !hasForbiddenOriginCharacter(creative.origin) + ? creative.origin + : undefined; + } catch { + return undefined; + } +} + +function replaceSentinel( + template: string, + sentinel: string, + replacement: string +): string | undefined { + const first = template.indexOf(sentinel); + if (first < 0 || template.indexOf(sentinel, first + sentinel.length) >= 0) return undefined; + return `${template.slice(0, first)}${replacement}${template.slice(first + sentinel.length)}`; +} + +function substitute( + template: string, + replacements: Readonly> +): string | undefined { + let output = template; + for (const [sentinel, replacement] of Object.entries(replacements)) { + const next = replaceSentinel(output, sentinel, replacement); + if (next === undefined) return undefined; + output = next; + } + return sentinelPattern.test(output) ? undefined : output; +} + +function htmlAttribute(value: string): string | undefined { + if (value.includes('&') || value.includes('"') || value.includes('<') || value.includes('>')) { + return undefined; + } + return value; +} + +function scriptString(value: string): string | undefined { + if (!validUnicodeScalars(value)) return undefined; + return JSON.stringify(value) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + +function hasOneClosingScript(documentSource: string): boolean { + const token = ''; + const first = documentSource.indexOf(token); + return first >= 0 && first === documentSource.lastIndexOf(token); +} + +function validUnicodeScalars(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return false; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function dataUrl(documentSource: string, nonce: string): string { + return `${APS_DATA_URL_PREFIX}${encodeURIComponent(documentSource)}#${nonce}`; +} + +/** Generate one detached outer container and inner renderer before any DOM mutation. */ +export function generateApsDataDocumentsV1( + candidate: unknown +): Readonly | undefined { + try { + const input = exactGenerationInput(candidate); + if ( + !input || + !bootstrapNoncePattern.test(input.bootstrapNonce) || + !rendererNoncePattern.test(input.rendererNonce) + ) { + return undefined; + } + const trustedOrigin = trustedServerOrigin(input.publisherOrigin); + if (!trustedOrigin) return undefined; + const renderer = validateApsRenderer(input.renderer, trustedOrigin); + if (!renderer) return undefined; + const creativeOrigin = validatedCreativeOrigin(renderer, trustedOrigin); + if (!creativeOrigin) return undefined; + const scriptCreative = renderer.tagType === 'script'; + const selectedOuterCsp = outerCsp(trustedOrigin, creativeOrigin, scriptCreative); + const selectedInnerCsp = innerCsp(trustedOrigin, creativeOrigin, scriptCreative); + const runnerUrl = new URL('/integrations/aps/runner.js', trustedOrigin).href; + const innerCspAttribute = htmlAttribute(selectedInnerCsp); + const rendererNonceJson = scriptString(input.rendererNonce); + const publisherOriginJson = scriptString(trustedOrigin); + const creativeOriginJson = scriptString(creativeOrigin); + const tagTypeJson = scriptString(renderer.tagType); + const runnerUrlJson = scriptString(runnerUrl); + if ( + !innerCspAttribute || + !rendererNonceJson || + !publisherOriginJson || + !creativeOriginJson || + !tagTypeJson || + !runnerUrlJson + ) { + return undefined; + } + const innerDocument = substitute(INNER_TEMPLATE, { + __TS_INNER_CSP__: innerCspAttribute, + __TS_RENDERER_VALIDATOR__: APS_RENDERER_VALIDATOR_ES5_V1, + __TS_RENDERER_NONCE_JSON__: rendererNonceJson, + __TS_PUBLISHER_ORIGIN_JSON__: publisherOriginJson, + __TS_CREATIVE_ORIGIN_JSON__: creativeOriginJson, + __TS_TAG_TYPE_JSON__: tagTypeJson, + __TS_RUNNER_URL_JSON__: runnerUrlJson, + }); + if ( + !innerDocument || + !hasOneClosingScript(innerDocument) || + encoder.encode(innerDocument).byteLength > MAX_APS_INNER_DOCUMENT_BYTES + ) { + return undefined; + } + const innerUrl = dataUrl(innerDocument, input.rendererNonce); + const outerCspAttribute = htmlAttribute(selectedOuterCsp); + const bootstrapNonceJson = scriptString(input.bootstrapNonce); + const innerUrlJson = scriptString(innerUrl); + const permanentSandboxJson = scriptString(APS_PERMANENT_SANDBOX); + if (!outerCspAttribute || !bootstrapNonceJson || !innerUrlJson || !permanentSandboxJson) { + return undefined; + } + const outerDocument = substitute(OUTER_TEMPLATE, { + __TS_OUTER_CSP__: outerCspAttribute, + __TS_BOOTSTRAP_NONCE_JSON__: bootstrapNonceJson, + __TS_RENDERER_NONCE_JSON__: rendererNonceJson, + __TS_INNER_URL_JSON__: innerUrlJson, + __TS_PERMANENT_SANDBOX_JSON__: permanentSandboxJson, + }); + if ( + !outerDocument || + !hasOneClosingScript(outerDocument) || + encoder.encode(outerDocument).byteLength > MAX_APS_CONTAINER_DOCUMENT_BYTES + ) { + return undefined; + } + const outerUrl = dataUrl(outerDocument, input.bootstrapNonce); + if (encoder.encode(outerUrl).byteLength > MAX_APS_CONTAINER_URL_BYTES) return undefined; + return Object.freeze({ + version: 1, + bootstrapNonce: input.bootstrapNonce, + rendererNonce: input.rendererNonce, + trustedServerOrigin: trustedOrigin, + creativeOrigin, + sandbox: APS_PERMANENT_SANDBOX, + outerCsp: selectedOuterCsp, + innerCsp: selectedInnerCsp, + outerDocument, + innerDocument, + outerUrl, + innerUrl, + }); + } catch { + return undefined; + } +} diff --git a/crates/trusted-server-js/lib/src/shared/beacon_guard.ts b/crates/trusted-server-js/lib/src/shared/beacon_guard.ts index 94618dc30..a5a99552f 100644 --- a/crates/trusted-server-js/lib/src/shared/beacon_guard.ts +++ b/crates/trusted-server-js/lib/src/shared/beacon_guard.ts @@ -50,13 +50,53 @@ function extractUrl(input: RequestInfo | URL): string | null { return null; } +function sameDescriptor( + left: PropertyDescriptor | undefined, + right: PropertyDescriptor | undefined +): boolean { + if (!left || !right) return left === right; + if (left.configurable !== right.configurable || left.enumerable !== right.enumerable) { + return false; + } + if ('value' in left || 'value' in right) { + return ( + 'value' in left && + 'value' in right && + left.value === right.value && + left.writable === right.writable + ); + } + return left.get === right.get && left.set === right.set; +} + +function restoreOwnedDescriptor( + target: object, + property: PropertyKey, + installed: PropertyDescriptor | undefined, + original: PropertyDescriptor | undefined +): void { + try { + if (!sameDescriptor(Object.getOwnPropertyDescriptor(target, property), installed)) return; + if (original) Object.defineProperty(target, property, original); + else Reflect.deleteProperty(target, property); + } catch { + // Publisher replacement or a hostile descriptor cannot block independent cleanup. + } +} + /** * Create an independent beacon guard for a specific integration. */ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { let installed = false; - let originalSendBeacon: typeof navigator.sendBeacon | null = null; - let originalFetch: typeof window.fetch | null = null; + let originalSendBeacon: typeof navigator.sendBeacon | undefined; + let originalSendBeaconDescriptor: PropertyDescriptor | undefined; + let installedSendBeaconDescriptor: PropertyDescriptor | undefined; + let originalFetch: typeof window.fetch | undefined; + let originalFetchDescriptor: PropertyDescriptor | undefined; + let installedFetchDescriptor: PropertyDescriptor | undefined; + let sendBeaconPatched = false; + let fetchPatched = false; const prefix = `${config.name} beacon guard`; function install(): void { @@ -74,23 +114,37 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { // --- Patch navigator.sendBeacon --- if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { - originalSendBeacon = navigator.sendBeacon.bind(navigator); + originalSendBeacon = navigator.sendBeacon; + originalSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + sendBeaconPatched = true; - navigator.sendBeacon = function (url: string, data?: BodyInit | null): boolean { + const wrapper = function (url: string, data?: BodyInit | null): boolean { + const sendBeacon = originalSendBeacon; + if (!sendBeacon) return false; if (config.isTargetUrl(url)) { const rewritten = config.rewriteUrl(url); log.info(`${prefix}: rewriting sendBeacon`, { original: url, rewritten }); - return originalSendBeacon!(rewritten, data); + return Reflect.apply(sendBeacon, navigator, [rewritten, data]); } - return originalSendBeacon!(url, data); + return Reflect.apply(sendBeacon, navigator, [url, data]); }; + navigator.sendBeacon = wrapper; + installedSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + sendBeaconPatched = sameDescriptor(installedSendBeaconDescriptor, { + ...installedSendBeaconDescriptor, + value: wrapper, + }); } // --- Patch window.fetch --- if (typeof window.fetch === 'function') { - originalFetch = window.fetch.bind(window); + originalFetch = window.fetch; + originalFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + fetchPatched = true; - window.fetch = function (input: RequestInfo | URL, init?: RequestInit): Promise { + const wrapper = function (input: RequestInfo | URL, init?: RequestInit): Promise { + const fetch = originalFetch; + if (!fetch) return Promise.reject(new TypeError('fetch is unavailable')); const url = extractUrl(input); if (url && config.isTargetUrl(url)) { @@ -100,13 +154,19 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { // If the input was a Request, create a new one with the rewritten URL if (input instanceof Request) { const newRequest = new Request(rewritten, input); - return originalFetch!(newRequest, init); + return Reflect.apply(fetch, window, [newRequest, init]); } - return originalFetch!(rewritten, init); + return Reflect.apply(fetch, window, [rewritten, init]); } - return originalFetch!(input, init); + return Reflect.apply(fetch, window, [input, init]); }; + window.fetch = wrapper; + installedFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + fetchPatched = sameDescriptor(installedFetchDescriptor, { + ...installedFetchDescriptor, + value: wrapper, + }); } installed = true; @@ -118,14 +178,25 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { } function reset(): void { - if (originalSendBeacon && typeof navigator !== 'undefined') { - navigator.sendBeacon = originalSendBeacon; - originalSendBeacon = null; + if (sendBeaconPatched && typeof navigator !== 'undefined') { + restoreOwnedDescriptor( + navigator, + 'sendBeacon', + installedSendBeaconDescriptor, + originalSendBeaconDescriptor + ); } - if (originalFetch && typeof window !== 'undefined') { - window.fetch = originalFetch; - originalFetch = null; + if (fetchPatched && typeof window !== 'undefined') { + restoreOwnedDescriptor(window, 'fetch', installedFetchDescriptor, originalFetchDescriptor); } + originalSendBeacon = undefined; + originalSendBeaconDescriptor = undefined; + installedSendBeaconDescriptor = undefined; + originalFetch = undefined; + originalFetchDescriptor = undefined; + installedFetchDescriptor = undefined; + sendBeaconPatched = false; + fetchPatched = false; installed = false; log.debug(`${prefix}: reset and uninstalled`); } diff --git a/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts b/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts index 5fb9283be..1046d951d 100644 --- a/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts +++ b/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts @@ -42,11 +42,11 @@ interface RegisteredDomInsertionHandler extends DomInsertionHandler { } interface DomInsertionDispatcherState { - appendChildWrapper?: AppendChildMethod; - baselineAppendChild?: AppendChildMethod; - baselineInsertBefore?: InsertBeforeMethod; + appendChildWrapper?: AppendChildMethod | undefined; + baselineAppendChild?: AppendChildMethod | undefined; + baselineInsertBefore?: InsertBeforeMethod | undefined; handlers: Map; - insertBeforeWrapper?: InsertBeforeMethod; + insertBeforeWrapper?: InsertBeforeMethod | undefined; nextSequence: number; orderedHandlers: RegisteredDomInsertionHandler[]; version: number; diff --git a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts new file mode 100644 index 000000000..7a7ec162c --- /dev/null +++ b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts @@ -0,0 +1,1610 @@ +import type { FirstDisplaySliceId } from '../kernel/release_catalog'; +import type { FirstDisplayGptDiagnosticsV1, FirstDisplayGptFactV1 } from '../shared/takeover'; + +export const FIRST_DISPLAY_CONTRACT_IDS: readonly FirstDisplaySliceId[] = Object.freeze([ + 'first_display', + 'render_owner_initial', + 'aps_initial', + 'creative_initial', + 'datadome_initial', + 'didomi_initial', + 'google_tag_manager_initial', + 'gpt_initial', + 'lockr_initial', + 'osano_initial', + 'permutive_initial', + 'sourcepoint_initial', + 'prebid_initial', + 'testlight_initial', +]); + +export const MAX_FIRST_DISPLAY_SLOTS = 256; +export const MAX_FIRST_DISPLAY_NON_DIAGNOSTICS_BYTES = 8 * 1024 * 1024; +export const MAX_GPT_FACT_BYTES = 512 * 1024; +export const MAX_SINGLE_GPT_FACT_BYTES = 1_000; +export const MAX_FIRST_DISPLAY_HANDOFF_BYTES = 8.5 * 1024 * 1024; + +const MAX_U32 = 4_294_967_295; +const MAX_STRING_BYTES = 4096; +const MAX_PROPERTY_BYTES = 128; +const MAX_TARGETING = 32; +const MAX_FORMATS = 32; +const MAX_ALIASES = 32; +const MAX_FACTS = 512; +const HASH = /^[0-9a-f]{64}$/; +const FIRST_DISPLAY_ORDER = new Map( + FIRST_DISPLAY_CONTRACT_IDS.map((id, index) => [id, index + 1] as const) +); + +export type TerminalAttemptState = 'accepted' | 'no_bid' | 'failed' | 'cancelled'; +export type CommittedArtifactKind = 'none' | 'gpt_adm' | 'aps'; + +export interface TakeoverOutlineV1 { + readonly version: 1; + readonly releaseId: string; + readonly generation: number; + readonly projectionDigest: string; + readonly integrationConfigDigest: string; + readonly slices: readonly FirstDisplaySliceId[]; + readonly slotCount: number; + readonly outcomeCount: number; + readonly capabilities: readonly string[]; + readonly objectKinds: readonly ('gpt_slot' | 'dom_artifact')[]; +} + +export interface FirstDisplayHandoffV1 { + readonly version: 1; + readonly releaseId: string; + readonly generation: number; + readonly projectionDigest: string; + readonly integrationConfigDigest: string; + readonly slices: readonly FirstDisplaySliceId[]; + readonly slots: readonly Readonly>[]; + readonly attempts: readonly Readonly>[]; + readonly tombstones: readonly Readonly>[]; + readonly artifacts: readonly Readonly>[]; + readonly parserState: readonly Readonly>[]; + readonly gptDiagnostics: Readonly; + readonly timing: Readonly>; + readonly highWater: Readonly>; + readonly cycles: readonly Readonly>[]; + readonly trace: Readonly>; + readonly mutationRevision: number; +} + +export interface FirstDisplayOwnershipCapsuleV1 { + readonly releaseId: string; + readonly generation: number; + readonly consume: (releaseId: string, generation: number) => readonly T[] | undefined; + readonly clear: () => void; +} + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function isU32(value: unknown, allowZero = true): value is number { + return ( + typeof value === 'number' && + Number.isInteger(value) && + value >= (allowZero ? 0 : 1) && + value <= MAX_U32 + ); +} + +function finiteNonnegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isCapability(value: string): boolean { + if (value.length === 0 || value.length > MAX_PROPERTY_BYTES) return false; + const first = value.charCodeAt(0); + if (first < 0x61 || first > 0x7a) return false; + let previousWasDot = false; + for (let index = 1; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code === 0x2e) { + if (previousWasDot || index === value.length - 1) return false; + previousWasDot = true; + continue; + } + const lowercase = code >= 0x61 && code <= 0x7a; + const digit = code >= 0x30 && code <= 0x39; + if (!lowercase && !digit && code !== 0x5f) return false; + previousWasDot = false; + } + return true; +} + +function boundedString( + value: unknown, + maximum = MAX_STRING_BYTES, + allowEmpty = false +): value is string { + return ( + typeof value === 'string' && + (allowEmpty || value.length > 0) && + utf8Length(value) <= maximum && + !value.split('').some((character) => { + const code = character.charCodeAt(0); + return code <= 0x1f || code === 0x7f; + }) + ); +} + +function exactRecord( + value: unknown, + keys: readonly string[] +): Readonly> | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + !ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) + ) { + return undefined; + } + const result: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result[key] = descriptor.value; + } + return result; +} + +function exactArray(value: unknown, maximum: number): readonly unknown[] | undefined { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (!lengthDescriptor || !('value' in lengthDescriptor)) return undefined; + const length = lengthDescriptor.value; + if (!Number.isSafeInteger(length) || length < 0 || length > maximum) return undefined; + const keys = Reflect.ownKeys(value); + if (keys.length !== length + 1) return undefined; + const result: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result.push(descriptor.value); + } + return result; +} + +function uniqueStrings( + value: unknown, + maximum: number, + validate: (value: string) => boolean = (candidate) => boundedString(candidate) +): readonly string[] | undefined { + const values = exactArray(value, maximum); + if (!values) return undefined; + const seen = new Set(); + const result: string[] = []; + for (const candidate of values) { + if (typeof candidate !== 'string' || !validate(candidate) || seen.has(candidate)) + return undefined; + seen.add(candidate); + result.push(candidate); + } + return Object.freeze(result); +} + +function snapshotSlices(value: unknown): readonly FirstDisplaySliceId[] | undefined { + const slices = uniqueStrings(value, FIRST_DISPLAY_CONTRACT_IDS.length, (candidate) => + FIRST_DISPLAY_ORDER.has(candidate as FirstDisplaySliceId) + ); + if (!slices || slices[0] !== 'first_display') return undefined; + let previous = 0; + for (const slice of slices) { + const order = FIRST_DISPLAY_ORDER.get(slice as FirstDisplaySliceId); + if (!order || order <= previous) return undefined; + previous = order; + } + const renderOwner = slices.includes('render_owner_initial'); + if ( + (slices.includes('aps_initial') && !renderOwner) || + (renderOwner && !slices.includes('gpt_initial')) + ) { + return undefined; + } + return slices as readonly FirstDisplaySliceId[]; +} + +function freezeRecord>(value: T): Readonly { + return Object.freeze(value); +} + +function canonicalBytes(value: unknown): number | undefined { + try { + return utf8Length(JSON.stringify(value)); + } catch { + return undefined; + } +} + +export function snapshotTakeoverOutlineV1(candidate: unknown): TakeoverOutlineV1 | undefined { + try { + const fields = exactRecord(candidate, [ + 'version', + 'releaseId', + 'generation', + 'projectionDigest', + 'integrationConfigDigest', + 'slices', + 'slotCount', + 'outcomeCount', + 'capabilities', + 'objectKinds', + ]); + if (!fields) return undefined; + const slices = snapshotSlices(fields.slices); + const capabilities = uniqueStrings(fields.capabilities, 32, isCapability); + const objectKinds = uniqueStrings( + fields.objectKinds, + 2, + (value) => value === 'gpt_slot' || value === 'dom_artifact' + ) as readonly ('gpt_slot' | 'dom_artifact')[] | undefined; + if ( + fields.version !== 1 || + typeof fields.releaseId !== 'string' || + !HASH.test(fields.releaseId) || + !isU32(fields.generation, false) || + typeof fields.projectionDigest !== 'string' || + !HASH.test(fields.projectionDigest) || + typeof fields.integrationConfigDigest !== 'string' || + !HASH.test(fields.integrationConfigDigest) || + !slices || + !isU32(fields.slotCount, false) || + fields.slotCount > MAX_FIRST_DISPLAY_SLOTS || + fields.outcomeCount !== fields.slotCount || + !capabilities || + !objectKinds + ) { + return undefined; + } + return Object.freeze({ + version: 1, + releaseId: fields.releaseId, + generation: fields.generation, + projectionDigest: fields.projectionDigest, + integrationConfigDigest: fields.integrationConfigDigest, + slices, + slotCount: fields.slotCount, + outcomeCount: fields.outcomeCount, + capabilities, + objectKinds, + }); + } catch { + return undefined; + } +} + +function snapshotStringPairs( + value: unknown, + maximum: number +): readonly (readonly [string, string])[] | undefined { + const entries = exactArray(value, maximum); + if (!entries) return undefined; + const seen = new Set(); + const result: Array = []; + for (const entry of entries) { + const pair = exactArray(entry, 2); + if ( + !pair || + pair.length !== 2 || + !boundedString(pair[0], MAX_PROPERTY_BYTES) || + !boundedString(pair[1], MAX_STRING_BYTES, true) || + seen.has(pair[0]) + ) { + return undefined; + } + seen.add(pair[0]); + result.push(Object.freeze([pair[0], pair[1]])); + } + return Object.freeze(result); +} + +function snapshotTargetingOwnership( + value: unknown +): readonly Readonly<{ installed: string; key: string; prior: readonly string[] }>[] | undefined { + const entries = exactArray(value, MAX_TARGETING); + if (!entries) return undefined; + const seen = new Set(); + const result: Array> = []; + for (const entry of entries) { + const fields = exactRecord(entry, ['installed', 'key', 'prior']); + const prior = fields ? exactArray(fields.prior, MAX_TARGETING) : undefined; + if ( + !fields || + !boundedString(fields.key, MAX_PROPERTY_BYTES) || + !boundedString(fields.installed, MAX_STRING_BYTES) || + !prior || + prior.some((value) => !boundedString(value, MAX_STRING_BYTES, true)) || + seen.has(fields.key) + ) { + return undefined; + } + seen.add(fields.key); + result.push( + Object.freeze({ + installed: fields.installed, + key: fields.key, + prior: Object.freeze([...prior] as string[]), + }) + ); + } + return Object.freeze(result); +} + +function snapshotSlot(value: unknown): Readonly> | undefined { + const fields = exactRecord(value, [ + 'id', + 'aliases', + 'domId', + 'gamPath', + 'formats', + 'owner', + 'outcome', + 'targeting', + 'targetingOwnership', + 'committedArtifact', + 'gptToken', + ]); + if (!fields) return undefined; + const aliases = uniqueStrings(fields.aliases, MAX_ALIASES); + const formatValues = exactArray(fields.formats, MAX_FORMATS); + const formats: Array = []; + if (!formatValues) return undefined; + for (const format of formatValues) { + const dimensions = exactArray(format, 2); + if ( + !dimensions || + dimensions.length !== 2 || + !isU32(dimensions[0], false) || + !isU32(dimensions[1], false) || + dimensions[0] > 4096 || + dimensions[1] > 4096 + ) { + return undefined; + } + formats.push(Object.freeze([dimensions[0], dimensions[1]])); + } + const targeting = snapshotStringPairs(fields.targeting, MAX_TARGETING); + const targetingOwnership = snapshotTargetingOwnership(fields.targetingOwnership); + if ( + !boundedString(fields.id) || + !aliases || + !boundedString(fields.domId) || + !boundedString(fields.gamPath) || + (fields.owner !== 'trusted_server' && fields.owner !== 'publisher') || + !['accepted', 'no_bid', 'failed', 'cancelled'].includes(fields.outcome as string) || + !targeting || + !targetingOwnership || + !['none', 'gpt_adm', 'aps'].includes(fields.committedArtifact as string) || + (fields.gptToken !== null && + (!boundedString(fields.gptToken) || !/^gt1_[1-9a-z][0-9a-z]{0,6}$/.test(fields.gptToken))) + ) { + return undefined; + } + return freezeRecord({ + id: fields.id, + aliases, + domId: fields.domId, + gamPath: fields.gamPath, + formats: Object.freeze(formats), + owner: fields.owner, + outcome: fields.outcome, + targeting, + targetingOwnership, + committedArtifact: fields.committedArtifact, + gptToken: fields.gptToken, + }); +} + +function snapshotAttempt(value: unknown): Readonly> | undefined { + const fields = exactRecord(value, ['id', 'slotId', 'ordinal', 'state', 'reason']); + if ( + !fields || + typeof fields.id !== 'string' || + !/^a1_[A-Za-z0-9_-]{22}$/.test(fields.id) || + !boundedString(fields.slotId) || + !isU32(fields.ordinal, false) || + !['accepted', 'no_bid', 'failed', 'cancelled'].includes(fields.state as string) || + (fields.state === 'accepted' || fields.state === 'no_bid' + ? fields.reason !== null + : !boundedString(fields.reason)) + ) { + return undefined; + } + return freezeRecord({ ...fields }); +} + +function snapshotTombstone(value: unknown): Readonly> | undefined { + const fields = exactRecord(value, ['kind', 'value', 'expiresAtMs', 'ordinal']); + if ( + !fields || + (fields.kind !== 'reservation' && fields.kind !== 'ticket') || + typeof fields.value !== 'string' || + (fields.kind === 'reservation' + ? !/^r1_[A-Za-z0-9_-]{22}$/.test(fields.value) + : !/^t1_[A-Za-z0-9_-]{22}$/.test(fields.value)) || + !finiteNonnegative(fields.expiresAtMs) || + !isU32(fields.ordinal, false) + ) { + return undefined; + } + return freezeRecord({ ...fields }); +} + +function snapshotArtifact(value: unknown): Readonly> | undefined { + const fields = exactRecord(value, [ + 'slotId', + 'kind', + 'owner', + 'token', + 'hostPosition', + 'hostPositionPriority', + ]); + if ( + !fields || + !boundedString(fields.slotId) || + (fields.kind !== 'gpt_adm' && fields.kind !== 'aps') || + (fields.owner !== 'trusted_server' && fields.owner !== 'publisher') || + typeof fields.token !== 'string' || + !/^r1_[A-Za-z0-9_-]{22}$/.test(fields.token) || + (fields.hostPosition !== null && !boundedString(fields.hostPosition, MAX_STRING_BYTES, true)) || + (fields.hostPositionPriority !== null && + fields.hostPositionPriority !== '' && + fields.hostPositionPriority !== 'important') || + (fields.hostPosition === null) !== (fields.hostPositionPriority === null) || + (fields.kind === 'gpt_adm' && fields.hostPosition !== null) + ) { + return undefined; + } + return freezeRecord({ ...fields }); +} + +function validParserValues( + sliceId: unknown, + values: readonly (readonly [string, string | number | boolean | null])[] +): boolean { + const single = ( + key: string, + validate: (value: string | number | boolean | null) => boolean + ): boolean => values.length === 1 && values[0]?.[0] === key && validate(values[0][1]); + const integer = (value: string | number | boolean | null): value is number => + typeof value === 'number' && Number.isInteger(value) && value >= 0; + switch (sliceId) { + case 'render_owner_initial': + case 'aps_initial': + case 'prebid_initial': + return single('protocol_version', (value) => value === 1); + case 'gpt_initial': + return ( + values.length === 2 && + values[0]?.[0] === 'gam' && + typeof values[0][1] === 'boolean' && + values[1]?.[0] === 'v' && + values[1][1] === 1 + ); + case 'creative_initial': + return single('guard_count', (value) => integer(value) && value >= 1 && value <= 3); + case 'datadome_initial': + return single('route_guard', (value) => value === 'datadome'); + case 'didomi_initial': + return single('sdk_path', (value) => typeof value === 'string' && value.length > 0); + case 'google_tag_manager_initial': + return single('route_guard', (value) => value === 'google_tag_manager'); + case 'lockr_initial': + return ( + values[0]?.[0] === 'route_guard' && + values[0][1] === 'lockr' && + (values.length === 1 || + (values.length === 2 && + (values[1]?.[0] === 'sdk_host' + ? typeof values[1][1] === 'string' && values[1][1].length > 0 + : values[1]?.[0] === 'readiness_timeout' && values[1][1] === 50))) + ); + case 'osano_initial': + return values.length === 0 || single('consent_snapshot', integer); + case 'permutive_initial': + return ( + values.length === 0 || + single('sdk_config', (value) => typeof value === 'string' && value.length > 0) || + single('readiness_timeout', (value) => value === 50) + ); + case 'sourcepoint_initial': + return single('gpp_snapshot', integer); + case 'testlight_initial': + return single('callback_count', integer); + default: + return false; + } +} + +function snapshotParserState(value: unknown): Readonly> | undefined { + const fields = exactRecord(value, ['sliceId', 'observations', 'values']); + const observations = uniqueStrings(fields?.observations, 256); + const entries = exactArray(fields?.values, 256); + if ( + !fields || + typeof fields.sliceId !== 'string' || + fields.sliceId === 'first_display' || + !FIRST_DISPLAY_ORDER.has(fields.sliceId as FirstDisplaySliceId) || + !observations || + !entries || + observations.length !== entries.length + ) { + return undefined; + } + const seen = new Set(); + const values: Array = []; + for (const entry of entries) { + const pair = exactArray(entry, 2); + if ( + !pair || + pair.length !== 2 || + !boundedString(pair[0], MAX_PROPERTY_BYTES) || + pair[0] !== observations[values.length] || + seen.has(pair[0]) + ) { + return undefined; + } + const scalar = pair[1]; + if ( + scalar !== null && + typeof scalar !== 'string' && + typeof scalar !== 'boolean' && + !(typeof scalar === 'number' && Number.isFinite(scalar)) + ) { + return undefined; + } + if (typeof scalar === 'string' && !boundedString(scalar, MAX_STRING_BYTES, true)) + return undefined; + seen.add(pair[0]); + values.push(Object.freeze([pair[0], scalar])); + } + if (!validParserValues(fields.sliceId, values)) return undefined; + return freezeRecord({ + sliceId: fields.sliceId, + observations, + values: Object.freeze(values), + }); +} + +function snapshotTiming(value: unknown): Readonly> | undefined { + const fields = exactRecord(value, ['bidsScriptMs', 'firstDisplayMs', 'terminalMs', 'paintMs']); + if (!fields) return undefined; + for (const key of ['bidsScriptMs', 'terminalMs', 'paintMs'] as const) { + if (!finiteNonnegative(fields[key])) return undefined; + } + if (fields.firstDisplayMs !== null && !finiteNonnegative(fields.firstDisplayMs)) return undefined; + if ( + (fields.bidsScriptMs as number) > (fields.terminalMs as number) || + (fields.terminalMs as number) > (fields.paintMs as number) || + (fields.firstDisplayMs !== null && + ((fields.bidsScriptMs as number) > (fields.firstDisplayMs as number) || + (fields.firstDisplayMs as number) > (fields.terminalMs as number))) + ) { + return undefined; + } + return freezeRecord({ + bidsScriptMs: fields.bidsScriptMs as number, + firstDisplayMs: fields.firstDisplayMs as number | null, + terminalMs: fields.terminalMs as number, + paintMs: fields.paintMs as number, + }); +} + +function snapshotHighWater(value: unknown): Readonly> | undefined { + const fields = exactRecord(value, [ + 'navigationAttemptPrefix', + 'nextNavigationAttemptOrdinal', + 'nextAttemptOrdinal', + 'nextSlotRegistrationOrdinal', + 'reservationClockEpochMs', + 'nextReservationOrdinal', + 'nextTicketOrdinal', + ]); + if ( + !fields || + typeof fields.navigationAttemptPrefix !== 'string' || + !/^[A-Za-z0-9_-]{11}$/.test(fields.navigationAttemptPrefix) + ) { + return undefined; + } + for (const key of [ + 'nextNavigationAttemptOrdinal', + 'nextAttemptOrdinal', + 'nextSlotRegistrationOrdinal', + 'nextReservationOrdinal', + 'nextTicketOrdinal', + ] as const) { + if (!isU32(fields[key], false)) return undefined; + } + if (!finiteNonnegative(fields.reservationClockEpochMs)) return undefined; + return freezeRecord({ ...fields }) as Readonly>; +} + +const GPT_DIAGNOSTIC_EVENTS = Object.freeze([ + 'slotRequested', + 'slotResponseReceived', + 'slotRenderEnded', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', +] as const); + +function snapshotCycle(value: unknown): Readonly> | undefined { + const fields = exactRecord(value, [ + 'slotId', + 'token', + 'nextCycleOrdinal', + 'unknownPriorCycle', + 'records', + 'quarantines', + ]); + const records = exactArray(fields?.records, 10); + const quarantines = uniqueStrings(fields?.quarantines, 10); + if ( + !fields || + !boundedString(fields.slotId) || + !boundedString(fields.token) || + !/^gt1_[1-9a-z][0-9a-z]{0,6}$/.test(fields.token) || + !isU32(fields.nextCycleOrdinal, false) || + typeof fields.unknownPriorCycle !== 'boolean' || + !records || + !quarantines + ) { + return undefined; + } + const normalized: Array>> = []; + let maximum = 0; + for (const record of records) { + const recordFields = exactRecord(record, ['ordinal', 'responseIdentifier', 'seen', 'state']); + const seen = uniqueStrings(recordFields?.seen, GPT_DIAGNOSTIC_EVENTS.length, (event) => + GPT_DIAGNOSTIC_EVENTS.includes(event as (typeof GPT_DIAGNOSTIC_EVENTS)[number]) + ); + if ( + !recordFields || + !isU32(recordFields.ordinal, false) || + (recordFields.responseIdentifier !== null && + !boundedString(recordFields.responseIdentifier, 256)) || + !seen || + !seen.includes('slotRequested') || + !['open', 'completed', 'retired'].includes(recordFields.state as string) || + (recordFields.state === 'open' && seen.includes('slotRenderEnded')) || + (recordFields.state === 'completed' && !seen.includes('slotRenderEnded')) || + recordFields.ordinal <= maximum + ) { + return undefined; + } + maximum = Math.max(maximum, recordFields.ordinal); + normalized.push(freezeRecord({ ...recordFields, seen })); + } + if (fields.nextCycleOrdinal <= maximum) return undefined; + return freezeRecord({ + slotId: fields.slotId, + token: fields.token, + nextCycleOrdinal: fields.nextCycleOrdinal, + unknownPriorCycle: fields.unknownPriorCycle, + records: Object.freeze(normalized), + quarantines, + }); +} + +function snapshotTrace(value: unknown): Readonly> | undefined { + const fields = exactRecord(value, ['nextSequence', 'nextGlobalSlotOrdinal', 'slots']); + const slots = exactArray(fields?.slots, MAX_FIRST_DISPLAY_SLOTS); + if ( + !fields || + !isU32(fields.nextSequence, false) || + !isU32(fields.nextGlobalSlotOrdinal, false) || + !slots + ) { + return undefined; + } + const normalized: Array>> = []; + for (const slot of slots) { + const slotFields = exactRecord(slot, ['slotId', 'impressions', 'bindings']); + const rawBindings = exactArray(slotFields?.bindings, 10); + if ( + !slotFields || + !boundedString(slotFields.slotId) || + !isU32(slotFields.impressions) || + !rawBindings + ) { + return undefined; + } + const bindings: Array>> = []; + const compoundKeys = new Set(); + const historySequences = new Set(); + for (const binding of rawBindings) { + const bindingFields = exactRecord(binding, [ + 'atMs', + 'cycleOrdinal', + 'historySequence', + 'state', + 'token', + ]); + if ( + !bindingFields || + !finiteNonnegative(bindingFields.atMs) || + !isU32(bindingFields.cycleOrdinal, false) || + !isU32(bindingFields.historySequence, false) || + bindingFields.state !== 'completed' || + typeof bindingFields.token !== 'string' || + !/^gt1_[1-9a-z][0-9a-z]{0,6}$/.test(bindingFields.token) + ) { + return undefined; + } + const compoundKey = `${bindingFields.token}:${bindingFields.cycleOrdinal}`; + if (compoundKeys.has(compoundKey) || historySequences.has(bindingFields.historySequence)) { + return undefined; + } + compoundKeys.add(compoundKey); + historySequences.add(bindingFields.historySequence); + bindings.push(freezeRecord({ ...bindingFields })); + } + normalized.push(freezeRecord({ ...slotFields, bindings: Object.freeze(bindings) })); + } + return freezeRecord({ + nextSequence: fields.nextSequence, + nextGlobalSlotOrdinal: fields.nextGlobalSlotOrdinal, + slots: Object.freeze(normalized), + }); +} + +const GPT_DIAGNOSTIC_DISPOSITIONS = Object.freeze(['matched', 'unmatched', 'ambiguous'] as const); +const GPT_DIAGNOSTIC_ISSUES = Object.freeze([ + 'no_request_cycle', + 'overlapping_request_cycles', + 'unknown_prior_cycle', + 'invalid_event_order', +] as const); + +function snapshotGptFact(value: unknown): Readonly | undefined { + const fields = exactRecord(value, [ + 'version', + 'event', + 'token', + 'runtimeSlotNumber', + 'cycleOrdinal', + 'disposition', + 'issueReason', + 'capturedAtMs', + 'elementId', + 'adUnitPath', + 'requestedSlotSizes', + 'isEmpty', + 'renderedSize', + 'isBackfill', + 'slotContentChanged', + 'visibilityPercent', + ]); + if (!fields) return undefined; + const event = GPT_DIAGNOSTIC_EVENTS.includes( + fields.event as (typeof GPT_DIAGNOSTIC_EVENTS)[number] + ) + ? (fields.event as FirstDisplayGptFactV1['event']) + : undefined; + const disposition = GPT_DIAGNOSTIC_DISPOSITIONS.includes( + fields.disposition as (typeof GPT_DIAGNOSTIC_DISPOSITIONS)[number] + ) + ? (fields.disposition as FirstDisplayGptFactV1['disposition']) + : undefined; + const issueReason = + fields.issueReason === null || + GPT_DIAGNOSTIC_ISSUES.includes(fields.issueReason as (typeof GPT_DIAGNOSTIC_ISSUES)[number]) + ? (fields.issueReason as FirstDisplayGptFactV1['issueReason']) + : undefined; + const renderedSize = (() => { + const dimensions = exactArray(fields.renderedSize, 2); + if ( + !dimensions || + dimensions.length !== 2 || + !dimensions.every( + (dimension) => + typeof dimension === 'number' && + Number.isInteger(dimension) && + dimension >= 1 && + dimension <= 4096 + ) + ) { + return undefined; + } + return Object.freeze([dimensions[0] as number, dimensions[1] as number] as const); + })(); + const requestedSlotSizes = (() => { + if (fields.requestedSlotSizes === null) return null; + const values = exactArray(fields.requestedSlotSizes, 16); + if (!values || values.length === 0) return undefined; + const sizes: Array = []; + for (const value of values) { + const dimensions = exactArray(value, 2); + if ( + !dimensions || + dimensions.length !== 2 || + !isU32(dimensions[0], false) || + !isU32(dimensions[1], false) || + dimensions[0] > 4096 || + dimensions[1] > 4096 + ) { + return undefined; + } + sizes.push(Object.freeze([dimensions[0], dimensions[1]])); + } + return Object.freeze(sizes); + })(); + const tokenOrdinal = + typeof fields.token === 'string' && /^gt1_[1-9a-z][0-9a-z]{0,6}$/.test(fields.token) + ? Number.parseInt(fields.token.slice(4), 36) + : undefined; + if ( + fields.version !== 1 || + !event || + tokenOrdinal === undefined || + tokenOrdinal > MAX_U32 || + !isU32(fields.runtimeSlotNumber, false) || + fields.runtimeSlotNumber !== tokenOrdinal || + (fields.cycleOrdinal !== null && !isU32(fields.cycleOrdinal, false)) || + !disposition || + issueReason === undefined || + !finiteNonnegative(fields.capturedAtMs) || + (fields.elementId !== null && !boundedString(fields.elementId, 256)) || + (fields.adUnitPath !== null && !boundedString(fields.adUnitPath, 256)) || + requestedSlotSizes === undefined || + (requestedSlotSizes !== null && (event !== 'slotRequested' || disposition !== 'matched')) || + (fields.isEmpty !== null && typeof fields.isEmpty !== 'boolean') || + (fields.renderedSize !== null && !renderedSize) || + (fields.isBackfill !== null && typeof fields.isBackfill !== 'boolean') || + (fields.slotContentChanged !== null && typeof fields.slotContentChanged !== 'boolean') || + (fields.visibilityPercent !== null && + (typeof fields.visibilityPercent !== 'number' || + !Number.isFinite(fields.visibilityPercent) || + fields.visibilityPercent < 0 || + fields.visibilityPercent > 100)) || + (event !== 'slotRenderEnded' && + (fields.isEmpty !== null || + fields.renderedSize !== null || + fields.isBackfill !== null || + fields.slotContentChanged !== null)) || + (event !== 'slotVisibilityChanged' && fields.visibilityPercent !== null) || + (issueReason === 'invalid_event_order' && disposition !== 'matched') || + (issueReason === 'overlapping_request_cycles' && disposition !== 'ambiguous') || + ((issueReason === 'no_request_cycle' || issueReason === 'unknown_prior_cycle') && + disposition !== 'unmatched') || + (disposition === 'matched' && event !== 'slotVisibilityChanged' && fields.cycleOrdinal === null) + ) { + return undefined; + } + const result = Object.freeze({ + version: 1 as const, + event, + token: fields.token as string, + runtimeSlotNumber: fields.runtimeSlotNumber, + cycleOrdinal: fields.cycleOrdinal as number | null, + disposition, + issueReason, + capturedAtMs: fields.capturedAtMs as number, + elementId: fields.elementId as string | null, + adUnitPath: fields.adUnitPath as string | null, + requestedSlotSizes, + isEmpty: fields.isEmpty as boolean | null, + renderedSize: fields.renderedSize === null ? null : renderedSize!, + isBackfill: fields.isBackfill as boolean | null, + slotContentChanged: fields.slotContentChanged as boolean | null, + visibilityPercent: fields.visibilityPercent as number | null, + }); + const bytes = canonicalBytes(result); + return bytes !== undefined && bytes <= MAX_SINGLE_GPT_FACT_BYTES ? result : undefined; +} + +function snapshotGptDiagnostics( + value: unknown +): Readonly | undefined { + const fields = exactRecord(value, ['facts', 'overflowCount', 'dropCount']); + if (!fields) return undefined; + const values = exactArray(fields.facts, MAX_FACTS); + if (!values || !isU32(fields.overflowCount) || !isU32(fields.dropCount)) { + return undefined; + } + const facts: Array> = []; + for (const value of values) { + const fact = snapshotGptFact(value); + if (!fact) return undefined; + facts.push(fact); + } + const result = Object.freeze({ + facts: Object.freeze(facts), + overflowCount: fields.overflowCount, + dropCount: fields.dropCount, + }); + const bytes = canonicalBytes(result); + return bytes !== undefined && bytes <= MAX_GPT_FACT_BYTES ? result : undefined; +} + +function snapshotList( + value: unknown, + maximum: number, + snapshot: (value: unknown) => Readonly> | undefined +): readonly Readonly>[] | undefined { + const values = exactArray(value, maximum); + if (!values) return undefined; + const result: Array>> = []; + for (const entry of values) { + const accepted = snapshot(entry); + if (!accepted) return undefined; + result.push(accepted); + } + return Object.freeze(result); +} + +export function snapshotFirstDisplayHandoffV1( + candidate: unknown +): FirstDisplayHandoffV1 | undefined { + try { + const fields = exactRecord(candidate, [ + 'version', + 'releaseId', + 'generation', + 'projectionDigest', + 'integrationConfigDigest', + 'slices', + 'slots', + 'attempts', + 'tombstones', + 'artifacts', + 'parserState', + 'gptDiagnostics', + 'timing', + 'highWater', + 'cycles', + 'trace', + 'mutationRevision', + ]); + if (!fields) return undefined; + const slices = snapshotSlices(fields.slices); + const slots = snapshotList(fields.slots, MAX_FIRST_DISPLAY_SLOTS, snapshotSlot); + const attempts = snapshotList(fields.attempts, MAX_FIRST_DISPLAY_SLOTS, snapshotAttempt); + const tombstones = snapshotList(fields.tombstones, 512, snapshotTombstone); + const artifacts = snapshotList(fields.artifacts, MAX_FIRST_DISPLAY_SLOTS, snapshotArtifact); + const parserState = snapshotList( + fields.parserState, + FIRST_DISPLAY_CONTRACT_IDS.length, + snapshotParserState + ); + const gptDiagnostics = snapshotGptDiagnostics(fields.gptDiagnostics); + const timing = snapshotTiming(fields.timing); + const highWater = snapshotHighWater(fields.highWater); + const cycles = snapshotList(fields.cycles, MAX_FIRST_DISPLAY_SLOTS, snapshotCycle); + const trace = snapshotTrace(fields.trace); + if ( + fields.version !== 1 || + typeof fields.releaseId !== 'string' || + !HASH.test(fields.releaseId) || + !isU32(fields.generation, false) || + typeof fields.projectionDigest !== 'string' || + !HASH.test(fields.projectionDigest) || + typeof fields.integrationConfigDigest !== 'string' || + !HASH.test(fields.integrationConfigDigest) || + !slices || + !slots || + !attempts || + !tombstones || + !artifacts || + !parserState || + !gptDiagnostics || + !timing || + !highWater || + !cycles || + !trace || + !isU32(fields.mutationRevision) + ) { + return undefined; + } + const slotIds = new Set(slots.map((slot) => slot.id as string)); + const attemptIds = new Set(attempts.map((attempt) => attempt.id as string)); + const attemptOrdinals = new Set(attempts.map((attempt) => attempt.ordinal as number)); + const attemptSlotIds = new Set(attempts.map((attempt) => attempt.slotId as string)); + const tombstoneKeys = new Set( + tombstones.map((tombstone) => `${String(tombstone.kind)}:${String(tombstone.value)}`) + ); + const tombstoneOrdinals = new Set( + tombstones.map((tombstone) => `${String(tombstone.kind)}:${String(tombstone.ordinal)}`) + ); + if ( + parserState.length !== slices.length - 1 || + parserState.some((state, index) => state.sliceId !== slices[index + 1]) + ) { + return undefined; + } + const cycleSlotIds = new Set(cycles.map((cycle) => cycle.slotId as string)); + const artifactSlotIds = new Set(artifacts.map((artifact) => artifact.slotId as string)); + const traceSlots = trace.slots as readonly Readonly>[]; + const traceSlotIds = new Set(traceSlots.map((slot) => slot.slotId as string)); + const slotById = new Map(slots.map((slot) => [slot.id as string, slot])); + const traceBySlot = new Map(traceSlots.map((slot) => [slot.slotId as string, slot])); + const artifactBySlot = new Map( + artifacts.map((artifact) => [artifact.slotId as string, artifact]) + ); + const cycleBySlot = new Map(cycles.map((cycle) => [cycle.slotId as string, cycle])); + if ( + ((cycles.length > 0 || + gptDiagnostics.facts.length > 0 || + slots.some((slot) => slot.gptToken !== null)) && + !slices.includes('gpt_initial')) || + slotIds.size !== slots.length || + attempts.length !== slots.length || + attemptIds.size !== attempts.length || + attemptOrdinals.size !== attempts.length || + attemptSlotIds.size !== attempts.length || + tombstoneKeys.size !== tombstones.length || + tombstoneOrdinals.size !== tombstones.length || + tombstones.some( + (tombstone) => + (tombstone.expiresAtMs as number) <= (highWater.reservationClockEpochMs as number) + ) || + cycleSlotIds.size !== cycles.length || + artifactSlotIds.size !== artifacts.length || + traceSlotIds.size !== traceSlots.length || + traceSlots.length !== slots.length || + traceSlots.some((traceSlot, index) => { + const slot = slots[index]; + if (!slot || traceSlot.slotId !== slot.id) return true; + const accepted = slot.outcome === 'accepted'; + const bindings = traceSlot.bindings as readonly Readonly>[]; + return ( + traceSlot.impressions !== (accepted ? 1 : 0) || + (accepted + ? bindings.length !== 1 || bindings[0]?.token !== slot.gptToken + : bindings.length !== 0) + ); + }) || + attempts.some((attempt, index) => { + const slot = slots[index]; + return ( + !slot || + attempt.slotId !== slot.id || + attempt.state !== slot.outcome || + attempt.ordinal !== index + 1 || + !(attempt.id as string).startsWith(`a1_${highWater.navigationAttemptPrefix as string}`) + ); + }) || + slots.some((slot) => { + const artifact = artifactBySlot.get(slot.id as string); + const cycle = cycleBySlot.get(slot.id as string); + const targetingOwnership = slot.targetingOwnership as readonly Readonly<{ + installed: string; + key: string; + prior: readonly string[]; + }>[]; + if (slot.outcome !== 'accepted') { + return ( + slot.committedArtifact !== 'none' || + slot.gptToken !== null || + targetingOwnership.length !== 0 || + artifact || + cycle + ); + } + const targeting = slot.targeting as readonly (readonly [string, string])[]; + const reservation = targeting.find(([key]) => key === 'hb_adid')?.[1]; + return ( + slot.committedArtifact === 'none' || + typeof slot.gptToken !== 'string' || + !artifact || + artifact.kind !== slot.committedArtifact || + artifact.token !== reservation || + !cycle || + cycle.token !== slot.gptToken || + (slot.owner !== 'publisher' && targetingOwnership.length !== 0) || + targetingOwnership.some( + (ownership) => + targeting.find(([key]) => key === ownership.key)?.[1] !== ownership.installed + ) + ); + }) || + cycles.some((cycle) => { + const slot = slotById.get(cycle.slotId as string); + const traceSlot = traceBySlot.get(cycle.slotId as string); + const bindings = traceSlot?.bindings as + readonly Readonly>[] | undefined; + const cycleRecords = cycle.records as readonly Readonly>[]; + return ( + !slot || + slot.gptToken !== cycle.token || + !bindings || + bindings.length !== 1 || + bindings[0]?.token !== cycle.token || + !cycleRecords.some( + (record) => + record.ordinal === bindings[0]?.cycleOrdinal && record.state === bindings[0]?.state + ) + ); + }) + ) { + return undefined; + } + const maximumGlobalSlotOrdinal = [ + ...cycles.map((cycle) => Number.parseInt((cycle.token as string).slice(4), 36)), + ...gptDiagnostics.facts.map((fact) => fact.runtimeSlotNumber), + ].reduce((maximum, ordinal) => Math.max(maximum, ordinal), 0); + if ((trace.nextGlobalSlotOrdinal as number) <= maximumGlobalSlotOrdinal) return undefined; + const transferredImpressions = traceSlots.reduce( + (count, slot) => count + (slot.impressions as number), + 0 + ); + const traceBindings = traceSlots.flatMap( + (slot) => slot.bindings as readonly Readonly>[] + ); + const traceHistorySequences = new Set( + traceBindings.map((binding) => binding.historySequence as number) + ); + const maximumTraceSequence = traceBindings.reduce( + (maximum, binding) => Math.max(maximum, binding.historySequence as number), + 0 + ); + if ( + traceBindings.length !== transferredImpressions || + traceHistorySequences.size !== traceBindings.length || + traceBindings.some( + (binding) => + (binding.atMs as number) < (timing.bidsScriptMs as number) || + (binding.atMs as number) > (timing.terminalMs as number) + ) || + (trace.nextSequence as number) <= maximumTraceSequence + ) { + return undefined; + } + const maximumAttempt = attempts.reduce( + (maximum, attempt) => Math.max(maximum, attempt.ordinal as number), + 0 + ); + const maximumReservation = tombstones.reduce( + (maximum, tombstone) => + tombstone.kind === 'reservation' ? Math.max(maximum, tombstone.ordinal as number) : maximum, + 0 + ); + const maximumTicket = tombstones.reduce( + (maximum, tombstone) => + tombstone.kind === 'ticket' ? Math.max(maximum, tombstone.ordinal as number) : maximum, + 0 + ); + if ( + (highWater.nextNavigationAttemptOrdinal as number) <= maximumAttempt || + (highWater.nextAttemptOrdinal as number) <= maximumAttempt || + (highWater.nextSlotRegistrationOrdinal as number) <= slots.length || + (highWater.nextReservationOrdinal as number) <= maximumReservation || + (highWater.nextTicketOrdinal as number) <= maximumTicket + ) { + return undefined; + } + const result = Object.freeze({ + version: 1 as const, + releaseId: fields.releaseId, + generation: fields.generation, + projectionDigest: fields.projectionDigest, + integrationConfigDigest: fields.integrationConfigDigest, + slices, + slots, + attempts, + tombstones, + artifacts, + parserState, + gptDiagnostics, + timing, + highWater, + cycles, + trace, + mutationRevision: fields.mutationRevision, + }); + const factBytes = canonicalBytes(gptDiagnostics); + const nonDiagnostics = Object.freeze({ + ...result, + gptDiagnostics: Object.freeze({ + facts: Object.freeze([]), + overflowCount: 0, + dropCount: 0, + }), + }); + const nonDiagnosticsBytes = canonicalBytes(nonDiagnostics); + const totalBytes = canonicalBytes(result); + if ( + factBytes === undefined || + nonDiagnosticsBytes === undefined || + totalBytes === undefined || + factBytes > MAX_GPT_FACT_BYTES || + nonDiagnosticsBytes > MAX_FIRST_DISPLAY_NON_DIAGNOSTICS_BYTES || + totalBytes > MAX_FIRST_DISPLAY_HANDOFF_BYTES + ) { + return undefined; + } + return result; + } catch { + return undefined; + } +} + +/** Validate one complete handoff against the server-authored takeover outline. */ +export function snapshotOutlinedFirstDisplayHandoffV1( + candidate: unknown, + outlineCandidate: unknown +): FirstDisplayHandoffV1 | undefined { + const handoff = snapshotFirstDisplayHandoffV1(candidate); + const outline = snapshotTakeoverOutlineV1(outlineCandidate); + const requiredObjectKinds = [ + ...(handoff?.cycles.length === 0 ? [] : (['gpt_slot'] as const)), + ...(handoff?.artifacts.length === 0 ? [] : (['dom_artifact'] as const)), + ]; + if ( + !handoff || + !outline || + outline.releaseId !== handoff.releaseId || + outline.generation !== handoff.generation || + outline.projectionDigest !== handoff.projectionDigest || + outline.integrationConfigDigest !== handoff.integrationConfigDigest || + outline.slotCount !== handoff.slots.length || + outline.outcomeCount !== handoff.slots.length || + outline.slices.length !== handoff.slices.length || + outline.slices.some((id, index) => id !== handoff.slices[index]) || + requiredObjectKinds.some((kind) => !outline.objectKinds.includes(kind)) + ) { + return undefined; + } + return handoff; +} + +/** + * Expand the compact old-owner capture after the persistent bundle is available, + * then run the unchanged canonical handoff validator before either owner detaches. + */ +export function snapshotOutlinedFirstDisplayCaptureV1( + candidate: unknown, + outlineCandidate: unknown, + bootCandidate: unknown, + identityIssuer?: Readonly<{ + mintAttemptId: () => Readonly<{ ok: boolean; value?: string }>; + snapshotPrefix: () => string; + }> +): FirstDisplayHandoffV1 | undefined { + try { + const capture = exactRecord(candidate, [ + 'captureVersion', + 'releaseId', + 'generation', + 'data', + 'mutationRevision', + 'identityCount', + ]); + const data = exactArray(capture?.data, 14); + const boot = exactRecord(bootCandidate, [ + 'abi', + 'releaseId', + 'manifest', + 'auctionProjection', + 'integrations', + 'creative', + 'diagnostics', + ]); + const projection = exactRecord(boot?.auctionProjection, [ + 'version', + 'auction', + 'slots', + 'bids', + ]); + const auction = exactRecord(projection?.auction, ['version', 'auctionId', 'results']); + const placements = exactArray(projection?.slots, MAX_FIRST_DISPLAY_SLOTS); + const bids = exactArray(projection?.bids, MAX_FIRST_DISPLAY_SLOTS); + const decisions = exactArray(auction?.results, MAX_FIRST_DISPLAY_SLOTS); + const results = exactArray(data?.[3], MAX_FIRST_DISPLAY_SLOTS); + const bindings = exactArray(data?.[4], MAX_FIRST_DISPLAY_SLOTS); + const rawTombstones = exactArray(data?.[5], 512); + const rawArtifacts = exactArray(data?.[6], MAX_FIRST_DISPLAY_SLOTS); + const rawParserState = exactArray(data?.[7], FIRST_DISPLAY_CONTRACT_IDS.length); + const diagnostics = exactArray(data?.[8], 3); + const timing = exactArray(data?.[9], 4); + const highWater = exactArray(data?.[10], 4); + const cycles = exactArray(data?.[11], MAX_FIRST_DISPLAY_SLOTS); + if ( + !capture || + capture.captureVersion !== 1 || + !data || + data.length !== 14 || + !boot || + boot.abi !== 1 || + !projection || + projection.version !== 1 || + !auction || + auction.version !== 1 || + !placements || + !bids || + !decisions || + !results || + results.length !== placements.length || + decisions.length !== placements.length || + !bindings || + !rawTombstones || + !rawArtifacts || + !rawParserState || + !diagnostics || + diagnostics.length !== 3 || + !timing || + timing.length !== 4 || + !highWater || + highWater.length !== 4 || + !cycles || + !Number.isInteger(capture.identityCount) || + !identityIssuer + ) { + return undefined; + } + const bidBySlot = new Map>>(); + for (const value of bids) { + const bid = exactRecord( + value, + Object.prototype.hasOwnProperty.call(value, 'creativeId') + ? [ + 'candidateId', + 'slot', + 'provider', + 'upstreamBidId', + 'cpm', + 'currency', + 'targeting', + 'rendererReservationId', + 'renderSource', + 'creativeId', + ] + : [ + 'candidateId', + 'slot', + 'provider', + 'upstreamBidId', + 'cpm', + 'currency', + 'targeting', + 'rendererReservationId', + 'renderSource', + ] + ); + if (!bid || typeof bid.slot !== 'string' || bidBySlot.has(bid.slot)) return undefined; + bidBySlot.set(bid.slot, bid); + } + const bindingBySlot = new Map>>(); + for (const value of bindings) { + const fields = exactArray(value, 5); + if (!fields || fields.length !== 5 || typeof fields[0] !== 'string') return undefined; + const binding = { + slotId: fields[0], + domId: fields[1], + owner: fields[2], + targetingOwnership: fields[3], + gptToken: fields[4], + }; + if (bindingBySlot.has(binding.slotId)) { + return undefined; + } + bindingBySlot.set(binding.slotId, binding); + } + const tombstones: Readonly>[] = []; + for (const value of rawTombstones) { + const fields = exactArray(value, 4); + if (!fields || fields.length !== 4) return undefined; + tombstones.push({ + kind: fields[0], + value: fields[1], + expiresAtMs: fields[2], + ordinal: fields[3], + }); + } + const artifacts: Readonly>[] = []; + for (const value of rawArtifacts) { + const fields = exactArray(value, 6); + if (!fields || fields.length !== 6) return undefined; + artifacts.push({ + hostPosition: fields[0], + hostPositionPriority: fields[1], + slotId: fields[2], + kind: fields[3], + owner: fields[4], + token: fields[5], + }); + } + const parserState: Readonly>[] = []; + for (const value of rawParserState) { + const fields = exactArray(value, 2); + const values = exactArray(fields?.[1], 256); + if (!fields || fields.length !== 2 || !values) return undefined; + const observations: unknown[] = []; + for (const value of values) { + const pair = exactArray(value, 2); + if (!pair || pair.length !== 2) return undefined; + observations.push(pair[0]); + } + parserState.push({ sliceId: fields[0], observations, values }); + } + let acceptedBindings = 0; + const slots: Readonly>[] = []; + const attempts: Readonly>[] = []; + const traceSlots: Readonly>[] = []; + for (let index = 0; index < placements.length; index += 1) { + const placement = exactRecord(placements[index], [ + 'slot', + 'gamUnitPath', + 'divId', + 'formats', + 'targeting', + ]); + const decision = exactRecord( + decisions[index], + Object.prototype.hasOwnProperty.call(decisions[index], 'candidateId') + ? ['slot', 'outcome', 'candidateId'] + : Object.prototype.hasOwnProperty.call(decisions[index], 'reason') + ? ['slot', 'outcome', 'reason'] + : ['slot', 'outcome'] + ); + const result = exactArray(results[index], 4); + const slotId = placement?.slot; + if ( + !placement || + typeof slotId !== 'string' || + !decision || + decision.slot !== slotId || + !result || + result.length !== 4 + ) { + return undefined; + } + const bid = bidBySlot.get(slotId); + const renderSource = bid?.renderSource as Readonly> | undefined; + const projectedKind = + decision.outcome === 'no_bid' + ? 'no_bid' + : decision.outcome === 'failed' + ? 'failed' + : renderSource?.type === 'aps' + ? 'aps' + : renderSource?.type === 'adm' + ? 'gpt_adm' + : undefined; + if (!projectedKind) return undefined; + const binding = bindingBySlot.get(slotId); + const accepted = result[0] === 'accepted'; + if (accepted !== Boolean(binding) || (accepted && !bid)) return undefined; + if (!accepted && (result[2] !== null || result[3] !== null)) return undefined; + if (binding) acceptedBindings += 1; + const identity = identityIssuer.mintAttemptId(); + if (!identity.ok || typeof identity.value !== 'string') return undefined; + attempts.push({ + id: identity.value, + slotId, + ordinal: index + 1, + state: result[0], + reason: result[1], + }); + const targeting = { + ...(placement.targeting as Record), + ...(bid?.targeting as Record | undefined), + }; + if (bid) targeting['hb_adid'] = bid.rendererReservationId as string; + slots.push({ + id: slotId, + aliases: [], + domId: binding?.domId ?? placement.divId, + gamPath: placement.gamUnitPath, + formats: placement.formats, + owner: binding?.owner ?? 'trusted_server', + outcome: result[0], + targeting: Object.keys(targeting) + .sort() + .map((key) => [key, targeting[key]]), + targetingOwnership: binding?.targetingOwnership ?? [], + committedArtifact: + accepted && (projectedKind === 'gpt_adm' || projectedKind === 'aps') + ? projectedKind + : 'none', + gptToken: binding?.gptToken ?? null, + }); + traceSlots.push({ + slotId, + impressions: accepted ? 1 : 0, + bindings: accepted + ? [ + { + atMs: result[2], + cycleOrdinal: 1, + historySequence: result[3], + state: 'completed', + token: binding?.gptToken, + }, + ] + : [], + }); + } + if (bindingBySlot.size !== acceptedBindings) return undefined; + const expanded = { + version: 1, + releaseId: capture.releaseId, + generation: capture.generation, + projectionDigest: data[0], + integrationConfigDigest: data[1], + slices: data[2], + slots, + attempts, + tombstones, + artifacts, + parserState, + gptDiagnostics: { + facts: diagnostics[0], + overflowCount: diagnostics[1], + dropCount: diagnostics[2], + }, + timing: { + bidsScriptMs: timing[0], + firstDisplayMs: timing[1], + terminalMs: timing[2], + paintMs: timing[3], + }, + highWater: { + navigationAttemptPrefix: identityIssuer.snapshotPrefix(), + nextNavigationAttemptOrdinal: attempts.length + 1, + nextAttemptOrdinal: attempts.length + 1, + nextSlotRegistrationOrdinal: highWater[0], + reservationClockEpochMs: highWater[1], + nextReservationOrdinal: highWater[2], + nextTicketOrdinal: highWater[3], + }, + cycles, + trace: { + nextSequence: data[12], + nextGlobalSlotOrdinal: data[13], + slots: traceSlots, + }, + mutationRevision: capture.mutationRevision, + }; + const accepted = snapshotOutlinedFirstDisplayHandoffV1(expanded, outlineCandidate); + return accepted && capture.identityCount === accepted.cycles.length + accepted.artifacts.length + ? accepted + : undefined; + } catch { + return undefined; + } +} + +/** Mint a closure-private, release/generation-bound one-use object-identity capsule. */ +export function createFirstDisplayOwnershipCapsuleV1( + releaseId: string, + generation: number, + identities: readonly T[] +): FirstDisplayOwnershipCapsuleV1 | undefined { + if ( + !HASH.test(releaseId) || + !isU32(generation, false) || + identities.length > MAX_FIRST_DISPLAY_SLOTS * 2 + ) { + return undefined; + } + const accepted = [...identities]; + if ( + accepted.some((identity) => typeof identity !== 'object' || identity === null) || + new Set(accepted).size !== accepted.length + ) + return undefined; + let live: T[] | undefined = accepted; + return Object.freeze({ + releaseId, + generation, + consume: (candidateReleaseId: string, candidateGeneration: number) => { + if (!live || candidateReleaseId !== releaseId || candidateGeneration !== generation) { + return undefined; + } + const result = Object.freeze(live); + live = undefined; + return result; + }, + clear: () => { + live = undefined; + }, + }); +} diff --git a/crates/trusted-server-js/lib/src/shared/first_display_handoff.ts b/crates/trusted-server-js/lib/src/shared/first_display_handoff.ts new file mode 100644 index 000000000..24f98367b --- /dev/null +++ b/crates/trusted-server-js/lib/src/shared/first_display_handoff.ts @@ -0,0 +1,498 @@ +import type { BootFailureReason } from '../kernel/fallback_surface'; +import type { PreparedKernelTakeover } from '../kernel/integration_registry'; +import type { FirstDisplaySliceId } from '../kernel/release_catalog'; +import type { FirstDisplayRenderCaptureV1 } from '../first_display/driver'; +import type { + FirstDisplayGptCaptureCycleV1, + FirstDisplayGptDiagnosticsCaptureV1, +} from '../first_display/leaf/gpt_protocol'; +import type { FirstDisplayProjectedKind } from '../first_display/leaf/projection'; + +import type { + FirstDisplayHandoffV1, + FirstDisplayOwnershipCapsuleV1, +} from './first_display_contracts'; + +const HASH = /^[0-9a-f]{64}$/; +const MAX_U32 = 4_294_967_295; +/** Seal inert ordinary data before the runtime download; semantic authority stays at takeover. */ +export function snapshotFirstDisplayHandoffEnvelopeV1( + candidate: unknown +): Readonly> | undefined { + try { + const serialized = JSON.stringify(candidate); + if (typeof serialized !== 'string' || serialized.length > 9 * 1024 * 1024) return undefined; + const handoff = JSON.parse(serialized) as Record; + return handoff && + !Array.isArray(handoff) && + handoff['captureVersion'] === 1 && + typeof handoff['releaseId'] === 'string' && + HASH.test(handoff['releaseId']) && + Number.isInteger(handoff['generation']) && + (handoff['generation'] as number) >= 1 && + (handoff['generation'] as number) <= MAX_U32 && + Number.isInteger(handoff['mutationRevision']) && + (handoff['mutationRevision'] as number) >= 0 && + (handoff['mutationRevision'] as number) <= MAX_U32 && + Number.isInteger(handoff['identityCount']) && + (handoff['identityCount'] as number) >= 0 && + (handoff['identityCount'] as number) <= 512 + ? Object.freeze(handoff) + : undefined; + } catch { + return undefined; + } +} + +export type FirstDisplayHandoffOwnerState = + 'observing' | 'sealing' | 'finalized' | 'failed' | 'disposed'; + +export interface FirstDisplayHandoffOwnerOptions { + readonly releaseId: string; + readonly generation: number; + readonly initialMutationRevision?: number; + readonly isCurrentGeneration: () => boolean; + readonly isTerminal: () => boolean; + readonly isPainted: () => boolean; + /** Closes all old-epoch work ingress synchronously before the final snapshot. */ + readonly closeIngress: () => void; + readonly onFailure: (reason: BootFailureReason) => void; +} + +export interface FinalizedFirstDisplayHandoffV1 { + readonly handoff: Readonly>; + readonly capsule: FirstDisplayOwnershipCapsuleV1; +} + +export interface FirstDisplayHandoffOwner { + readonly state: FirstDisplayHandoffOwnerState; + readonly mutationRevision: number; + readonly observeMutation: () => boolean; + readonly finalize: ( + capture: () => Readonly<{ candidate: unknown; identities: readonly object[] }> | undefined + ) => FinalizedFirstDisplayHandoffV1 | undefined; + readonly dispose: () => void; +} + +export interface FirstDisplayTakeoverOptions { + readonly finalized: FinalizedFirstDisplayHandoffV1; + readonly outline: unknown; + readonly boot?: unknown; + readonly isCurrentGeneration: () => boolean; + readonly authenticateRuntimeScript: () => boolean; + readonly currentMutationRevision: () => number; + readonly quiesceAgent: () => void; + readonly detachCommittedArtifacts: () => void; + readonly disposeAgent: () => void; + /** Persistent-core validator; executes before either owner mutates state. */ + readonly validateHandoff: ( + handoff: unknown, + outline: unknown, + boot?: unknown + ) => FirstDisplayHandoffV1 | undefined; + readonly activatePersistent: ( + handoff: FirstDisplayHandoffV1, + identities: readonly object[], + own: (dispose: () => void) => void + ) => void; + readonly commitPersistent: () => void; + readonly onFailure: (reason: BootFailureReason) => void; +} + +export interface PreparedFirstDisplayTakeoverOptions extends Omit< + FirstDisplayTakeoverOptions, + 'activatePersistent' | 'commitPersistent' | 'validateHandoff' +> { + readonly prepared: PreparedKernelTakeover; +} + +/** Stack-local old-owner state accepted only by the release-matched persistent finalizer. */ +export type FirstDisplayAgentCaptureSourceV1 = readonly [ + handoff: Readonly<{ + releaseId: string; + generation: number; + integrationConfigDigest: string; + slices: readonly FirstDisplaySliceId[]; + }>, + batch: readonly [ + projectionDigest: string, + outcomes: readonly (readonly [slotId: string, kind: FirstDisplayProjectedKind])[], + ], + slotResults: ReadonlyMap, + reasons: ReadonlyMap, + acceptedTrace: ReadonlyMap>, + parserState: readonly (readonly [ + string, + readonly (readonly [string, string | number | boolean | null])[], + ])[], + gptCycles: readonly FirstDisplayGptCaptureCycleV1[] | undefined, + diagnostics: FirstDisplayGptDiagnosticsCaptureV1 | undefined, + render: FirstDisplayRenderCaptureV1 | undefined, + timing: readonly [ + startedAtMs: number, + firstActionAtMs: number | null, + terminalAtMs: number, + paintAtMs: number, + currentTimeMs: number, + ], + nextTraceSequence: number, + mutationRevision: number, +]; + +export type FirstDisplayAgentCaptureFinalizerV1 = ( + source: FirstDisplayAgentCaptureSourceV1 +) => FinalizedFirstDisplayHandoffV1 | undefined; + +export function createFirstDisplayOwnershipCapsuleV1( + releaseId: string, + generation: number, + identities: readonly T[] +): FirstDisplayOwnershipCapsuleV1 | undefined { + if ( + !HASH.test(releaseId) || + !Number.isInteger(generation) || + generation < 1 || + generation > MAX_U32 || + identities.length > 512 + ) { + return undefined; + } + const accepted = [...identities]; + if ( + accepted.some((identity) => typeof identity !== 'object' || identity === null) || + new Set(accepted).size !== accepted.length + ) { + return undefined; + } + let live: T[] | undefined = accepted; + return Object.freeze({ + releaseId, + generation, + consume: (candidateReleaseId: string, candidateGeneration: number) => { + if (!live || candidateReleaseId !== releaseId || candidateGeneration !== generation) { + return undefined; + } + const result = Object.freeze(live); + live = undefined; + return result; + }, + clear: () => { + live = undefined; + }, + }); +} + +/** Materialize the compact data envelope and one-use identities in the takeover task. */ +export function finalizeFirstDisplayAgentCaptureV1( + source: FirstDisplayAgentCaptureSourceV1 +): FinalizedFirstDisplayHandoffV1 | undefined { + try { + const [ + handoffSource, + batch, + slotResults, + reasons, + acceptedTrace, + parserState, + gptCycles, + diagnosticsSource, + renderSource, + timing, + nextTraceSequence, + mutationRevision, + ] = source; + const cycles = gptCycles ?? Object.freeze([]); + const diagnostics = + diagnosticsSource ?? Object.freeze([Object.freeze([]), Object.freeze([]), 1, 0, 0] as const); + const render = + renderSource ?? + Object.freeze([Object.freeze([]), Object.freeze([]), timing[4], 1, 1] as const); + if ( + diagnostics[0].length !== cycles.length || + diagnostics[0].some((cycle) => { + const physical = cycles.find((candidate) => candidate[0] === cycle[0]); + return !physical || physical[4] !== cycle[1]; + }) + ) { + return undefined; + } + const identities = [ + ...cycles.map((cycle) => cycle[5]), + ...render[0].map((artifact) => artifact[2]), + ]; + const capsule = createFirstDisplayOwnershipCapsuleV1( + handoffSource.releaseId, + handoffSource.generation, + identities + ); + if (!capsule) return undefined; + const results = batch[1].map(([slotId]) => { + const accepted = acceptedTrace.get(slotId); + return [ + slotResults.get(slotId) ?? 'failed', + reasons.get(slotId) ?? null, + accepted?.atMs ?? null, + accepted?.historySequence ?? null, + ]; + }); + const handoff = Object.freeze({ + captureVersion: 1, + releaseId: handoffSource.releaseId, + generation: handoffSource.generation, + data: [ + batch[0], + handoffSource.integrationConfigDigest, + handoffSource.slices, + results, + cycles.map((cycle) => cycle.slice(0, 5)), + render[1], + render[0].map((artifact) => [ + artifact[0], + artifact[1], + artifact[5], + artifact[3], + artifact[4], + artifact[6], + ]), + parserState, + [diagnostics[1], diagnostics[3], diagnostics[4]], + timing.slice(0, 4), + [results.length + 1, render[2], render[3], render[4]], + diagnostics[0].map((cycle) => ({ + slotId: cycle[0], + token: cycle[1], + nextCycleOrdinal: cycle[2], + unknownPriorCycle: cycle[3], + quarantines: cycle[4], + records: cycle[5].map((record) => ({ + ordinal: record[0], + responseIdentifier: record[1], + seen: record[2], + state: record[3], + })), + })), + nextTraceSequence, + diagnostics[2], + ], + mutationRevision, + identityCount: identities.length, + }); + return Object.freeze({ handoff, capsule }); + } catch { + return undefined; + } +} + +/** + * Own the final old-epoch revision and one-use object capsule. `finalize` is + * deliberately synchronous: after ingress closes, no browser task can interleave + * before the immutable snapshot and capsule are bound to the same revision. + */ +export function createFirstDisplayHandoffOwner( + options: FirstDisplayHandoffOwnerOptions +): FirstDisplayHandoffOwner { + let state: FirstDisplayHandoffOwnerState = 'observing'; + let revision = options.initialMutationRevision ?? 0; + let liveCapsule: FirstDisplayOwnershipCapsuleV1 | undefined; + let failurePublished = false; + + const publishFailure = (): undefined => { + liveCapsule?.clear(); + liveCapsule = undefined; + state = 'failed'; + if (!failurePublished) { + failurePublished = true; + try { + options.onFailure('bundle_partial'); + } catch { + // Failure reporting cannot preserve transferable object authority. + } + } + return undefined; + }; + + if ( + !HASH.test(options.releaseId) || + !Number.isInteger(options.generation) || + options.generation < 1 || + options.generation > MAX_U32 || + !Number.isInteger(revision) || + revision < 0 || + revision > MAX_U32 + ) { + publishFailure(); + } + + return Object.freeze({ + get state() { + return state; + }, + get mutationRevision() { + return revision; + }, + observeMutation: (): boolean => { + if (state !== 'observing' && state !== 'sealing') return false; + if (revision >= MAX_U32) { + publishFailure(); + return false; + } + revision += 1; + return true; + }, + finalize: ( + capture: () => Readonly<{ candidate: unknown; identities: readonly object[] }> | undefined + ): FinalizedFirstDisplayHandoffV1 | undefined => { + if (state !== 'observing') return publishFailure(); + try { + if ( + typeof capture !== 'function' || + !options.isCurrentGeneration() || + !options.isTerminal() || + !options.isPainted() + ) { + return publishFailure(); + } + state = 'sealing'; + options.closeIngress(); + const captured = capture(); + if (!captured) return publishFailure(); + const handoff = snapshotFirstDisplayHandoffEnvelopeV1(captured.candidate); + if ( + !handoff || + handoff['releaseId'] !== options.releaseId || + handoff['generation'] !== options.generation || + handoff['mutationRevision'] !== revision || + captured.identities.length !== handoff['identityCount'] || + new Set(captured.identities).size !== captured.identities.length + ) { + return publishFailure(); + } + const capsule = createFirstDisplayOwnershipCapsuleV1( + options.releaseId, + options.generation, + captured.identities + ); + if (!capsule) return publishFailure(); + liveCapsule = capsule; + state = 'finalized'; + return Object.freeze({ handoff, capsule }); + } catch { + return publishFailure(); + } + }, + dispose: (): void => { + if (state === 'disposed') return; + liveCapsule?.clear(); + liveCapsule = undefined; + state = 'disposed'; + }, + }); +} + +/** Execute the non-yielding old-owner to persistent-owner transfer in one call stack. */ +export function performFirstDisplayTakeoverV1(options: FirstDisplayTakeoverOptions): boolean { + const persistentDisposers: Array<() => void> = []; + let ownershipOpen = true; + let failurePublished = false; + const fail = (): false => { + ownershipOpen = false; + for (let index = persistentDisposers.length - 1; index >= 0; index -= 1) { + try { + persistentDisposers[index]?.(); + } catch { + // Continue unwinding every independently activated persistent effect. + } + } + persistentDisposers.length = 0; + options.finalized.capsule.clear(); + if (!failurePublished) { + failurePublished = true; + try { + options.onFailure('bundle_partial'); + } catch { + // Failure publication cannot restore either ownership epoch. + } + } + return false; + }; + + try { + const handoff = options.validateHandoff( + options.finalized.handoff, + options.outline, + options.boot + ); + const { capsule } = options.finalized; + if ( + !handoff || + !options.isCurrentGeneration() || + !options.authenticateRuntimeScript() || + options.currentMutationRevision() !== handoff.mutationRevision + ) { + return fail(); + } + + options.quiesceAgent(); + if ( + !options.isCurrentGeneration() || + options.currentMutationRevision() !== handoff.mutationRevision + ) { + return fail(); + } + const identities = capsule.consume(handoff.releaseId, handoff.generation); + if (!identities) return fail(); + + options.detachCommittedArtifacts(); + options.disposeAgent(); + options.activatePersistent(handoff, identities, (dispose) => { + if (!ownershipOpen || typeof dispose !== 'function') { + throw new TypeError('tsjs'); + } + persistentDisposers.push(dispose); + }); + ownershipOpen = false; + if ( + !options.isCurrentGeneration() || + !options.authenticateRuntimeScript() || + options.currentMutationRevision() !== handoff.mutationRevision + ) { + return fail(); + } + options.commitPersistent(); + persistentDisposers.length = 0; + return true; + } catch { + return fail(); + } +} + +/** Bind the validated old-epoch snapshot to one prepared persistent activation transaction. */ +export function coordinatePreparedFirstDisplayTakeoverV1( + options: PreparedFirstDisplayTakeoverOptions +): boolean { + return performFirstDisplayTakeoverV1({ + finalized: options.finalized, + outline: options.outline, + ...(options.boot === undefined ? {} : { boot: options.boot }), + validateHandoff: options.prepared.validateHandoff, + isCurrentGeneration: options.isCurrentGeneration, + authenticateRuntimeScript: options.authenticateRuntimeScript, + currentMutationRevision: options.currentMutationRevision, + quiesceAgent: options.quiesceAgent, + detachCommittedArtifacts: options.detachCommittedArtifacts, + disposeAgent: options.disposeAgent, + activatePersistent: (handoff, identities, own) => { + own(options.prepared.rollback); + options.prepared.activate( + Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff, + identities, + }) + ); + }, + commitPersistent: options.prepared.commit, + onFailure: options.onFailure, + }); +} diff --git a/crates/trusted-server-js/lib/src/shared/first_display_registration.ts b/crates/trusted-server-js/lib/src/shared/first_display_registration.ts new file mode 100644 index 000000000..d6c73aa0e --- /dev/null +++ b/crates/trusted-server-js/lib/src/shared/first_display_registration.ts @@ -0,0 +1,313 @@ +/** Private registration protocol shared by the bootstrap and provisional artifact. */ +declare const __TSJS_EMBEDDED_RELEASE_ID_V1__: string; + +const HASH = /^[0-9a-f]{64}$/; +const COMPONENT_ID = /^[a-z][a-z0-9_]{0,63}$/; +const FIRST_DISPLAY_SRC = + /^\/static\/tsjs=tsjs-first-display\.min\.js\?m=[0-9a-f]{4}&v=[0-9a-f]{64}$/; +const PARSER_SLICE_ORDER = Object.freeze([ + 'render_owner_initial', + 'aps_initial', + 'creative_initial', + 'datadome_initial', + 'didomi_initial', + 'google_tag_manager_initial', + 'gpt_initial', + 'lockr_initial', + 'osano_initial', + 'permutive_initial', + 'sourcepoint_initial', + 'prebid_initial', + 'testlight_initial', +]); + +export const FIRST_DISPLAY_REGISTRATION_FIELD = '_registerFirstDisplay' as const; + +export interface FirstDisplayComponentRegistrationV1 { + readonly abi: 1; + readonly id: string; + readonly releaseId: string; + /** Absolute catalog order, not the component's position in one selected mask. */ + readonly order: number; + readonly install: ( + bindings: unknown, + own: (dispose: () => void) => void, + config: unknown + ) => unknown; +} + +type FirstDisplayBootstrapTarget = object & { + readonly [FIRST_DISPLAY_REGISTRATION_FIELD]?: unknown; +}; + +export interface FirstDisplayParserStateCollector { + readonly register: (sliceId: string) => boolean; + readonly observe: (sliceId: string, key: unknown, value: unknown) => boolean; + readonly snapshot: () => readonly Readonly<{ + sliceId: string; + observations: readonly string[]; + values: readonly (readonly [string, string | number | boolean | null])[]; + }>[]; +} + +/** Retain only bounded ordinary-data observations needed by persistent slice owners. */ +export function createFirstDisplayParserStateCollector(): FirstDisplayParserStateCollector { + const states = new Map< + string, + { observations: string[]; values: Map } + >(); + const encoder = new TextEncoder(); + const register = (sliceId: string): boolean => { + if (!PARSER_SLICE_ORDER.includes(sliceId)) return false; + if (!states.has(sliceId)) states.set(sliceId, { observations: [], values: new Map() }); + return true; + }; + const bounded = (value: string, bytes: number, allowEmpty = false): boolean => { + if ((!allowEmpty && value.length === 0) || encoder.encode(value).byteLength > bytes) + return false; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return false; + } + return true; + }; + return Object.freeze({ + register, + observe: (sliceId: string, key: unknown, value: unknown): boolean => { + if ( + !register(sliceId) || + typeof key !== 'string' || + !bounded(key, 128) || + (value !== null && + typeof value !== 'string' && + typeof value !== 'boolean' && + !(typeof value === 'number' && Number.isFinite(value))) || + (typeof value === 'string' && !bounded(value, 4096, true)) + ) { + return false; + } + const state = states.get(sliceId)!; + if (!state.values.has(key)) { + if (state.values.size >= 256) return false; + state.observations.push(key); + } + state.values.set(key, value as string | number | boolean | null); + return true; + }, + snapshot: () => + Object.freeze( + PARSER_SLICE_ORDER.flatMap((sliceId) => { + const state = states.get(sliceId); + if (!state) return []; + return [ + Object.freeze({ + sliceId, + observations: Object.freeze([...state.observations]), + values: Object.freeze( + state.observations.map((key) => + Object.freeze([key, state.values.get(key)!] as const) + ) + ), + }), + ]; + }) + ), + }); +} + +/** Wrap one exact frozen slice observation channel with the final-revision ledger. */ +export function captureMutationObservedBindings( + candidate: unknown, + observeMutation: () => boolean, + captureObservation?: (key: unknown, value: unknown) => void +): unknown { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + !Object.isFrozen(candidate) || + typeof observeMutation !== 'function' + ) { + return candidate; + } + const descriptors = Object.getOwnPropertyDescriptors(candidate); + const observe = descriptors.observe; + if (!observe?.enumerable || !('value' in observe) || typeof observe.value !== 'function') { + return candidate; + } + const original = observe.value as (...arguments_: unknown[]) => unknown; + const captured = Object.create(Object.prototype) as Record; + Object.defineProperties(captured, { + ...descriptors, + observe: { + ...observe, + value: (...arguments_: unknown[]): unknown => { + const result = Reflect.apply(original, candidate, arguments_); + captureObservation?.(arguments_[0], arguments_[1]); + observeMutation(); + return result; + }, + }, + }); + return Object.freeze(captured); + } catch { + return candidate; + } +} + +/** Copy one untrusted component record without invoking accessors or inherited hooks. */ +export function snapshotFirstDisplayComponentRegistration( + candidate: unknown +): FirstDisplayComponentRegistrationV1 | undefined { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + !Object.isFrozen(candidate) + ) { + return undefined; + } + const keys = Reflect.ownKeys(candidate); + if ( + keys.length !== 5 || + !keys.every( + (key) => + typeof key === 'string' && ['abi', 'id', 'releaseId', 'order', 'install'].includes(key) + ) + ) { + return undefined; + } + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + } + const registration = candidate as unknown as FirstDisplayComponentRegistrationV1; + if ( + registration.abi !== 1 || + !COMPONENT_ID.test(registration.id) || + !HASH.test(registration.releaseId) || + !Number.isInteger(registration.order) || + registration.order < 1 || + registration.order > 14 || + typeof registration.install !== 'function' + ) { + return undefined; + } + return Object.freeze({ + abi: 1, + id: registration.id, + releaseId: registration.releaseId, + order: registration.order, + install: registration.install, + }); + } catch { + return undefined; + } +} + +function bootstrapTarget(browser: Window): FirstDisplayBootstrapTarget | undefined { + try { + const descriptor = Object.getOwnPropertyDescriptor(browser, 'tsjs'); + if (!descriptor || !('value' in descriptor)) return undefined; + const target = descriptor.value; + return (typeof target === 'object' || typeof target === 'function') && target !== null + ? (target as FirstDisplayBootstrapTarget) + : undefined; + } catch { + return undefined; + } +} + +function authenticatedCurrentScript(browser: Window): HTMLScriptElement | undefined { + try { + const { document } = browser; + const candidate = document.currentScript; + const Script = document.defaultView?.HTMLScriptElement; + if (!Script || !(candidate instanceof Script)) { + return undefined; + } + const script = candidate as HTMLScriptElement; + if ( + script.id !== 'trustedserver-js' || + !script.isConnected || + script.ownerDocument !== document + ) { + return undefined; + } + const origin = document.location.origin; + if (!/^https?:\/\//.test(origin)) return undefined; + const source = new URL(script.src, origin); + if ( + source.origin !== origin || + source.hash !== '' || + !FIRST_DISPLAY_SRC.test(`${source.pathname}${source.search}`) + ) { + return undefined; + } + return script; + } catch { + return undefined; + } +} + +/** + * Register one independently built component through the bootstrap's ephemeral sink. + * The sink receives the authenticated current script so its closure can compare the + * exact parser-inserted owner without consulting `document.currentScript` later. + */ +export function registerFirstDisplayComponent( + browser: Window, + registration: FirstDisplayComponentRegistrationV1 +): boolean { + if (!snapshotFirstDisplayComponentRegistration(registration)) return false; + const script = authenticatedCurrentScript(browser); + const target = script ? bootstrapTarget(browser) : undefined; + if (!script || !target) return false; + try { + const descriptor = Object.getOwnPropertyDescriptor(target, FIRST_DISPLAY_REGISTRATION_FIELD); + if ( + !descriptor || + !('value' in descriptor) || + typeof descriptor.value !== 'function' || + descriptor.enumerable || + !descriptor.configurable || + descriptor.writable + ) { + return false; + } + return Reflect.apply(descriptor.value, target, [registration, script]) === true; + } catch { + return false; + } +} + +/** Create one immutable release-bound registration shared by the build entries. */ +export function firstDisplayComponentRegistration( + id: string, + order: number, + install: FirstDisplayComponentRegistrationV1['install'] +): FirstDisplayComponentRegistrationV1 { + return Object.freeze({ + abi: 1, + id, + releaseId: __TSJS_EMBEDDED_RELEASE_ID_V1__, + order, + install, + }); +} + +/** Execute only in a browser build; unit imports without a current script remain inert. */ +export function registerCurrentFirstDisplayComponent( + registration: FirstDisplayComponentRegistrationV1 +): boolean { + try { + const browser = (globalThis as unknown as { window?: Window }).window; + return browser ? registerFirstDisplayComponent(browser, registration) : false; + } catch { + return false; + } +} diff --git a/crates/trusted-server-js/lib/src/shared/first_display_transaction.ts b/crates/trusted-server-js/lib/src/shared/first_display_transaction.ts new file mode 100644 index 000000000..fac8e0a28 --- /dev/null +++ b/crates/trusted-server-js/lib/src/shared/first_display_transaction.ts @@ -0,0 +1,314 @@ +import type { FirstDisplaySliceId } from '../kernel/release_catalog'; + +/** Closure-private activation transaction shared by bootstrap and provisional base. */ + +const HASH = /^[0-9a-f]{64}$/; +const FIRST_DISPLAY_SRC = + /^\/static\/tsjs=tsjs-first-display\.min\.js\?m=[0-9a-f]{4}&v=[0-9a-f]{64}$/; +const FIRST_DISPLAY_ORDER = new Map( + [ + 'first_display', + 'render_owner_initial', + 'aps_initial', + 'creative_initial', + 'datadome_initial', + 'didomi_initial', + 'google_tag_manager_initial', + 'gpt_initial', + 'lockr_initial', + 'osano_initial', + 'permutive_initial', + 'sourcepoint_initial', + 'prebid_initial', + 'testlight_initial', + ].map((id, index) => [id, index + 1] as const) +); + +export type FirstDisplayTransactionState = + 'collecting' | 'preparing' | 'activating' | 'active' | 'failed' | 'disposed'; + +export interface FirstDisplaySlicePrepareContext { + readonly releaseId: string; + readonly generation: number; + readonly sliceId: FirstDisplaySliceId; +} + +export interface FirstDisplaySliceActivationContext { + readonly own: (dispose: () => void) => void; + /** Base-only action that runs synchronously after every selected slice activates. */ + readonly afterActivate: (callback: () => void) => void; +} + +export interface PreparedFirstDisplaySliceV1 { + readonly activate: (context: FirstDisplaySliceActivationContext) => void; +} + +export interface FirstDisplaySliceRegistrationV1 { + readonly abi: 1; + readonly id: FirstDisplaySliceId; + readonly releaseId: string; + readonly generation: number; + readonly order: number; + readonly prepare: (context: FirstDisplaySlicePrepareContext) => PreparedFirstDisplaySliceV1; +} + +export interface FirstDisplayTransactionOptions { + readonly document: Document; + readonly script: HTMLScriptElement; + readonly releaseId: string; + readonly generation: number; + readonly expectedSliceIds: readonly FirstDisplaySliceId[]; + readonly isCurrentGeneration: () => boolean; + readonly onDisposalError?: (error: unknown) => void; +} + +export interface FirstDisplayTransaction { + readonly state: FirstDisplayTransactionState; + readonly register: (candidate: unknown) => boolean; + readonly activate: () => boolean; + readonly dispose: () => void; +} + +function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return undefined; + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + !ownKeys.every((key) => typeof key === 'string' && keys.includes(key)) + ) { + return undefined; + } + const result: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result[key] = descriptor.value; + } + return result; +} + +function snapshotRegistration(candidate: unknown): FirstDisplaySliceRegistrationV1 | undefined { + try { + const fields = exactRecord(candidate, [ + 'abi', + 'id', + 'releaseId', + 'generation', + 'order', + 'prepare', + ]); + if ( + !fields || + fields.abi !== 1 || + typeof fields.id !== 'string' || + typeof fields.releaseId !== 'string' || + !HASH.test(fields.releaseId) || + !Number.isInteger(fields.generation) || + (fields.generation as number) < 1 || + (fields.generation as number) > 4_294_967_295 || + !Number.isInteger(fields.order) || + (fields.order as number) < 1 || + typeof fields.prepare !== 'function' + ) { + return undefined; + } + return Object.freeze({ + abi: 1, + id: fields.id as FirstDisplaySliceId, + releaseId: fields.releaseId, + generation: fields.generation as number, + order: fields.order as number, + prepare: fields.prepare as FirstDisplaySliceRegistrationV1['prepare'], + }); + } catch { + return undefined; + } +} + +class FirstDisplayTransactionOwner implements FirstDisplayTransaction { + private stateValue: FirstDisplayTransactionState = 'collecting'; + private readonly registrations: FirstDisplaySliceRegistrationV1[] = []; + private readonly disposers: Array<() => void> = []; + + public constructor(private readonly options: FirstDisplayTransactionOptions) {} + + public get state(): FirstDisplayTransactionState { + return this.stateValue; + } + + public register(candidate: unknown): boolean { + if (this.stateValue !== 'collecting' || !this.authenticated()) return this.reject(); + const registration = snapshotRegistration(candidate); + const expectedIndex = this.registrations.length; + if ( + !registration || + registration.releaseId !== this.options.releaseId || + registration.generation !== this.options.generation || + registration.order !== FIRST_DISPLAY_ORDER.get(registration.id) || + registration.id !== this.options.expectedSliceIds[expectedIndex] + ) { + return this.reject(); + } + this.registrations.push(registration); + return true; + } + + public activate(): boolean { + if ( + this.stateValue !== 'collecting' || + this.registrations.length !== this.options.expectedSliceIds.length || + !this.authenticated() + ) { + return this.reject(); + } + if (this.stateValue !== 'collecting') return false; + const prepared: PreparedFirstDisplaySliceV1[] = []; + this.stateValue = 'preparing'; + try { + for (const registration of this.registrations) { + const candidate = registration.prepare( + Object.freeze({ + releaseId: this.options.releaseId, + generation: this.options.generation, + sliceId: registration.id, + }) + ); + if (this.stateValue !== 'preparing') throw new TypeError('stale slice preparation'); + const fields = exactRecord(candidate, ['activate']); + if (!fields || typeof fields.activate !== 'function') + throw new TypeError('invalid prepared slice'); + prepared.push( + Object.freeze({ activate: fields.activate as PreparedFirstDisplaySliceV1['activate'] }) + ); + } + if (!this.authenticated() || this.stateValue !== 'preparing') { + throw new TypeError('stale first-display owner'); + } + this.stateValue = 'activating'; + let ownershipOpen = true; + let afterActivation: (() => void) | undefined; + try { + for (let index = 0; index < prepared.length; index += 1) { + const slice = prepared[index]; + if (!slice) throw new TypeError('missing prepared first-display slice'); + slice.activate( + Object.freeze({ + own: (dispose: () => void): void => { + if (!ownershipOpen || typeof dispose !== 'function') { + throw new TypeError('first-display disposer registration is closed'); + } + this.disposers.push(dispose); + }, + afterActivate: (callback: () => void): void => { + if ( + !ownershipOpen || + index !== 0 || + afterActivation !== undefined || + typeof callback !== 'function' + ) { + throw new TypeError('invalid first-display post-activation callback'); + } + afterActivation = callback; + }, + }) + ); + if (this.stateValue !== 'activating') { + throw new TypeError('stale first-display activation'); + } + } + } finally { + ownershipOpen = false; + } + afterActivation?.(); + if ( + !this.stateCurrent('activating') || + !this.authenticated() || + !this.stateCurrent('activating') + ) { + throw new TypeError('stale first-display activation'); + } + this.stateValue = 'active'; + return true; + } catch { + if (!this.stateCurrent('disposed')) this.stateValue = 'failed'; + this.unwind(); + return false; + } + } + + public dispose(): void { + if (this.stateValue === 'disposed') return; + this.stateValue = 'disposed'; + this.unwind(); + this.registrations.length = 0; + } + + private stateCurrent(expected: FirstDisplayTransactionState): boolean { + return this.stateValue === expected; + } + + private authenticated(): boolean { + try { + const { document, script, releaseId, generation, isCurrentGeneration } = this.options; + const origin = document.defaultView?.location.origin; + if (!origin || !HASH.test(releaseId) || generation < 1 || !isCurrentGeneration()) + return false; + const source = new URL(script.src, origin); + return ( + script.id === 'trustedserver-js' && + script.isConnected && + document.currentScript === script && + source.origin === origin && + source.hash === '' && + FIRST_DISPLAY_SRC.test(`${source.pathname}${source.search}`) + ); + } catch { + return false; + } + } + + private reject(): false { + if (this.stateValue !== 'active' && this.stateValue !== 'disposed') { + this.stateValue = 'failed'; + this.unwind(); + } + return false; + } + + private unwind(): void { + while (this.disposers.length > 0) { + const dispose = this.disposers.pop(); + try { + dispose?.(); + } catch (error) { + try { + this.options.onDisposalError?.(error); + } catch { + // Continue releasing every independent provisional effect. + } + } + } + } +} + +/** Create the closure-private collector for exactly one authenticated agent artifact. */ +export function createFirstDisplayTransaction( + options: FirstDisplayTransactionOptions +): FirstDisplayTransaction { + const owner = new FirstDisplayTransactionOwner(options); + return Object.freeze({ + get state() { + return owner.state; + }, + register: (candidate: unknown) => owner.register(candidate), + activate: () => owner.activate(), + dispose: () => owner.dispose(), + }); +} diff --git a/crates/trusted-server-js/lib/src/shared/globals.ts b/crates/trusted-server-js/lib/src/shared/globals.ts index cbacb590f..8785ecfb5 100644 --- a/crates/trusted-server-js/lib/src/shared/globals.ts +++ b/crates/trusted-server-js/lib/src/shared/globals.ts @@ -1,43 +1,4 @@ -// Cross-runtime helpers for resolving windows/globals in creatives and pbjs shims. -import type { TsjsApi } from '../core/types'; - -export interface TsCreativeApi { - installGuards(): void; - setConfig?(cfg: TsCreativeConfig): void; - getConfig?(): TsCreativeConfig; -} - -export interface TsCreativeConfig { - /** Enable click guard runtime. Defaults to true. */ - clickGuard?: boolean; - /** Enable render guard (dynamic image/iframe src proxies). Defaults to false. */ - renderGuard?: boolean; -} - -export type CreativeWindow = Window & { - __ts_creative_installed?: boolean; - tsCreativeConfig?: TsCreativeConfig; -}; - -export type CreativeGlobal = typeof globalThis & { +/** Minimal cross-runtime access to optional browser storage. */ +export const creativeGlobal = globalThis as typeof globalThis & { localStorage?: Storage; - tscreative?: TsCreativeApi; - tsCreativeConfig?: TsCreativeConfig; }; - -export const creativeGlobal = globalThis as CreativeGlobal; - -// Support SSR/unit tests where window may live on globalThis or be undefined. -export function resolveWindow(): Window | undefined { - if (typeof window !== 'undefined') return window; - const maybeWindow = (globalThis as { window?: Window }).window; - return maybeWindow; -} - -export type PrebidWindow = Window & { tsjs?: TsjsApi; pbjs?: TsjsApi }; - -// Always hand back an object so shims can safely assign tsjs/pbjs globals. -export function resolvePrebidWindow(): PrebidWindow { - const maybeWindow = resolveWindow(); - return (maybeWindow as PrebidWindow) ?? ({} as PrebidWindow); -} diff --git a/crates/trusted-server-js/lib/src/shared/gpt_diagnostics.ts b/crates/trusted-server-js/lib/src/shared/gpt_diagnostics.ts new file mode 100644 index 000000000..bca9887e0 --- /dev/null +++ b/crates/trusted-server-js/lib/src/shared/gpt_diagnostics.ts @@ -0,0 +1,96 @@ +import type { GptDiagnosticsTrustedServerOpportunity, Size } from '../core/types'; + +/** Opaque GPT slot identity safe to pass through the data-only diagnostics capability. */ +export interface GptDiagnosticsSlotIdentityV1 { + readonly token: object; + readonly traceToken?: string | undefined; + readonly runtimeSlotNumber?: number | undefined; + readonly cycleOrdinal?: number | undefined; + readonly elementId?: string | undefined; + readonly adUnitPath?: string | undefined; +} + +/** Release-private Trusted Server request intent consumed only by GPT diagnostics. */ +export interface GptDiagnosticsOpportunityFact { + readonly kind: 'trustedServerOpportunity'; + readonly auctionSlotId: string; + readonly opportunity: GptDiagnosticsTrustedServerOpportunity; + readonly requestedSlotSizes: readonly Readonly[]; + readonly slot: Readonly; + readonly trustedServerAuctionId?: string | undefined; +} + +const opportunityFacts = new WeakSet(); + +/** Copy one immutable request-intent fact before its exact GPT request starts. */ +export function createTrustedServerOpportunityFact(input: { + readonly auctionSlotId: string; + readonly opportunity: GptDiagnosticsTrustedServerOpportunity; + readonly requestedSlotSizes: readonly Readonly[]; + readonly slot: Readonly; + readonly trustedServerAuctionId?: string | undefined; +}): Readonly | undefined { + try { + if ( + typeof input.auctionSlotId !== 'string' || + input.auctionSlotId.length === 0 || + input.auctionSlotId.length > 256 || + (input.opportunity !== 'renderable_candidate' && + input.opportunity !== 'unrenderable_candidate' && + input.opportunity !== 'no_candidate') || + typeof input.slot !== 'object' || + input.slot === null || + !Object.isFrozen(input.slot) || + !Array.isArray(input.requestedSlotSizes) + ) { + return undefined; + } + const requestedSlotSizes: Readonly[] = []; + for (let index = 0; index < input.requestedSlotSizes.length && index < 16; index += 1) { + const size = input.requestedSlotSizes[index]; + if ( + !Array.isArray(size) || + size.length !== 2 || + !Number.isInteger(size[0]) || + !Number.isInteger(size[1]) || + (size[0] ?? 0) < 1 || + (size[0] ?? 0) > 4_096 || + (size[1] ?? 0) < 1 || + (size[1] ?? 0) > 4_096 + ) { + continue; + } + requestedSlotSizes.push(Object.freeze([size[0], size[1]] as Size)); + } + if (requestedSlotSizes.length === 0) return undefined; + if ( + input.trustedServerAuctionId !== undefined && + (typeof input.trustedServerAuctionId !== 'string' || + input.trustedServerAuctionId.length === 0 || + input.trustedServerAuctionId.length > 256) + ) { + return undefined; + } + const fact = Object.freeze({ + kind: 'trustedServerOpportunity' as const, + auctionSlotId: input.auctionSlotId, + opportunity: input.opportunity, + requestedSlotSizes: Object.freeze(requestedSlotSizes), + slot: input.slot, + ...(input.trustedServerAuctionId === undefined + ? {} + : { trustedServerAuctionId: input.trustedServerAuctionId }), + }); + opportunityFacts.add(fact); + return fact; + } catch { + return undefined; + } +} + +/** Verify that an opportunity fact was minted by this release's private producer. */ +export function isTrustedServerOpportunityFact( + candidate: unknown +): candidate is Readonly { + return typeof candidate === 'object' && candidate !== null && opportunityFacts.has(candidate); +} diff --git a/crates/trusted-server-js/lib/src/shared/integration_config_validators.ts b/crates/trusted-server-js/lib/src/shared/integration_config_validators.ts new file mode 100644 index 000000000..4ae83f69a --- /dev/null +++ b/crates/trusted-server-js/lib/src/shared/integration_config_validators.ts @@ -0,0 +1,141 @@ +/** Exact validator used by products whose browser projection is deliberately empty. */ +export function isEmptyIntegrationConfigV1(candidate: unknown): boolean { + try { + return ( + typeof candidate === 'object' && + candidate !== null && + !Array.isArray(candidate) && + Object.getPrototypeOf(candidate) === Object.prototype && + Object.isFrozen(candidate) && + Reflect.ownKeys(candidate).length === 0 + ); + } catch { + return false; + } +} + +function exactFrozenRecord( + candidate: unknown, + required: readonly string[], + optional: readonly string[] = [] +): Readonly> | undefined { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + !Object.isFrozen(candidate) + ) { + return undefined; + } + const keys = Reflect.ownKeys(candidate); + if ( + keys.some((key) => typeof key !== 'string') || + required.some((key) => !keys.includes(key)) || + keys.some( + (key) => typeof key !== 'string' || (!required.includes(key) && !optional.includes(key)) + ) + ) { + return undefined; + } + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + } + return candidate as Readonly>; + } catch { + return undefined; + } +} + +function frozenStringArray(candidate: unknown): candidate is readonly string[] { + try { + if ( + !Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Array.prototype || + !Object.isFrozen(candidate) || + Reflect.ownKeys(candidate).length !== candidate.length + 1 + ) { + return false; + } + for (let index = 0; index < candidate.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index)); + if ( + !descriptor?.enumerable || + !('value' in descriptor) || + typeof descriptor.value !== 'string' + ) { + return false; + } + } + return true; + } catch { + return false; + } +} + +export interface GptIntegrationConfigV1 { + readonly gamAttributionEnabled: boolean; + readonly pageBidsEnabled: boolean; +} + +export function isGptIntegrationConfigV1( + candidate: unknown +): candidate is Readonly { + const config = exactFrozenRecord(candidate, ['gamAttributionEnabled', 'pageBidsEnabled']); + return ( + typeof config?.gamAttributionEnabled === 'boolean' && + typeof config.pageBidsEnabled === 'boolean' + ); +} + +export function isDidomiIntegrationConfigV1(candidate: unknown): boolean { + const config = exactFrozenRecord(candidate, ['proxyPath']); + if (typeof config?.proxyPath !== 'string') return false; + return ( + config.proxyPath.startsWith('/') && + !config.proxyPath.startsWith('//') && + !config.proxyPath.startsWith('/\\') && + !config.proxyPath.includes('?') && + !config.proxyPath.includes('#') + ); +} + +export function isSourcepointIntegrationConfigV1(candidate: unknown): boolean { + const config = exactFrozenRecord(candidate, ['rewriteSdk']); + return typeof config?.rewriteSdk === 'boolean'; +} + +export function isPrebidIntegrationConfigV1(candidate: unknown): boolean { + const config = exactFrozenRecord( + candidate, + ['accountId', 'timeout', 'debug', 'bidders'], + ['clientSideBidders', 'excludedGamAdUnitPathSuffixes'] + ); + return Boolean( + config && + typeof config.accountId === 'string' && + typeof config.timeout === 'number' && + Number.isInteger(config.timeout) && + config.timeout >= 0 && + config.timeout <= 4_294_967_295 && + typeof config.debug === 'boolean' && + frozenStringArray(config.bidders) && + (config.clientSideBidders === undefined || frozenStringArray(config.clientSideBidders)) && + (config.excludedGamAdUnitPathSuffixes === undefined || + frozenStringArray(config.excludedGamAdUnitPathSuffixes)) + ); +} + +export function isCreativeBootConfigV1(candidate: unknown): boolean { + const config = exactFrozenRecord(candidate, ['version', 'enabled', 'clickGuard', 'renderGuard']); + return Boolean( + config && + config.version === 1 && + typeof config.enabled === 'boolean' && + typeof config.clickGuard === 'boolean' && + typeof config.renderGuard === 'boolean' && + (config.enabled || (!config.clickGuard && !config.renderGuard)) + ); +} diff --git a/crates/trusted-server-js/lib/src/shared/origin.ts b/crates/trusted-server-js/lib/src/shared/origin.ts index e08431ae4..ae1a0ce9b 100644 --- a/crates/trusted-server-js/lib/src/shared/origin.ts +++ b/crates/trusted-server-js/lib/src/shared/origin.ts @@ -78,3 +78,26 @@ export const TRUSTED_BASE_URL: string = (() => { } return ''; })(); + +// Exact first-party origin for protocol messages and root-owned endpoints. +// `TRUSTED_BASE_URL` may be a full inherited base URI in the final fallback, +// so normalize it to its origin while retaining the same HTTP(S)-only and +// credential-free trust boundary. +export function trustedHttpOrigin(baseUrl: string = TRUSTED_BASE_URL): string { + if (!baseUrl) return ''; + try { + const parsed = new URL(baseUrl); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; + if (parsed.username !== '' || parsed.password !== '') return ''; + return parsed.origin; + } catch { + return ''; + } +} + +export function trustedDocumentHttpOrigin( + documentOrigin: string, + trustedBaseUrl: string = TRUSTED_BASE_URL +): string { + return trustedHttpOrigin(documentOrigin === 'null' ? trustedBaseUrl : documentOrigin); +} diff --git a/crates/trusted-server-js/lib/src/shared/realm.ts b/crates/trusted-server-js/lib/src/shared/realm.ts new file mode 100644 index 000000000..58923e653 --- /dev/null +++ b/crates/trusted-server-js/lib/src/shared/realm.ts @@ -0,0 +1,49 @@ +function realmOwnedInstance( + candidate: unknown, + targetRealm: unknown, + constructorName: 'Document' | 'Element' | 'HTMLElement' +): object | undefined { + try { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) { + return undefined; + } + if ( + (typeof targetRealm !== 'object' && typeof targetRealm !== 'function') || + targetRealm === null + ) { + return undefined; + } + const constructor = (targetRealm as Readonly>)[constructorName]; + return typeof constructor === 'function' && candidate instanceof constructor + ? candidate + : undefined; + } catch { + return undefined; + } +} + +/** Return a Document only when its own browsing-context realm authenticates it. */ +export function realmOwnedDocument(candidate: unknown): Document | undefined { + try { + const targetWindow = (candidate as { readonly defaultView?: unknown } | null)?.defaultView; + const document = realmOwnedInstance(candidate, targetWindow, 'Document'); + if (!document) return undefined; + const realmDocument = (targetWindow as { readonly document?: unknown }).document; + return realmDocument === candidate ? (document as Document) : undefined; + } catch { + return undefined; + } +} + +/** Return an Element only when the supplied target realm authenticates it. */ +export function realmOwnedElement(candidate: unknown, targetRealm: unknown): Element | undefined { + return realmOwnedInstance(candidate, targetRealm, 'Element') as Element | undefined; +} + +/** Return an HTMLElement only when the supplied target realm authenticates it. */ +export function realmOwnedHtmlElement( + candidate: unknown, + targetRealm: unknown +): HTMLElement | undefined { + return realmOwnedInstance(candidate, targetRealm, 'HTMLElement') as HTMLElement | undefined; +} diff --git a/crates/trusted-server-js/lib/src/shared/scheduler.ts b/crates/trusted-server-js/lib/src/shared/scheduler.ts index 32664f635..088eadfe5 100644 --- a/crates/trusted-server-js/lib/src/shared/scheduler.ts +++ b/crates/trusted-server-js/lib/src/shared/scheduler.ts @@ -1,15 +1,34 @@ // Mutation observer helper that batches callbacks onto the microtask queue. import { queueTask } from './async'; +export interface MutationScheduler { + (target: T): void; + readonly dispose: () => void; +} + // Coalesce repeated mutation callbacks on the same element into a single microtask run. -export function createMutationScheduler(perform: (target: T) => void) { +export function createMutationScheduler( + perform: (target: T) => void +): MutationScheduler { const queued = new WeakSet(); - return (target: T) => { + let active = true; + const schedule = ((target: T): void => { + if (!active) return; if (queued.has(target)) return; queued.add(target); queueTask(() => { queued.delete(target); + if (!active) return; perform(target); }); - }; + }) as MutationScheduler; + Object.defineProperty(schedule, 'dispose', { + configurable: false, + enumerable: true, + value: (): void => { + active = false; + }, + writable: false, + }); + return Object.freeze(schedule); } diff --git a/crates/trusted-server-js/lib/src/shared/script_guard.ts b/crates/trusted-server-js/lib/src/shared/script_guard.ts index b34d696bf..c70d87ae7 100644 --- a/crates/trusted-server-js/lib/src/shared/script_guard.ts +++ b/crates/trusted-server-js/lib/src/shared/script_guard.ts @@ -6,165 +6,441 @@ import { type DomInsertionCandidate, } from './dom_insertion_dispatcher'; -/** - * Shared Script Guard Factory - * - * Creates a DOM interception guard that registers with the shared DOM insertion - * dispatcher. Matching dynamically inserted script (and preload/prefetch link) - * elements are rewritten to a first-party proxy endpoint before insertion. - * - * Each call to createScriptGuard() produces an independent guard with its own - * installation state, so multiple integrations can coexist without interference. - */ +/** Maximum fallback instance descriptors retained until one guard is reset. */ +const MAX_TRACKED_INSTANCE_PATCHES = 256; + +/** Optional interception layers needed by SDKs that load child scripts themselves. */ +export interface DeepScriptInterceptionConfig { + /** Cheap fail-closed hint used before parsing HTML passed to document.write/writeln. */ + readonly documentWriteUrlHint: string; +} -/** - * Base configuration shared by all guard types. - */ interface ScriptGuardConfigBase { - /** Integration ID used for deterministic ordering and internal identity. */ + /** Install source setters, document-write parsing, and a mutation observer. */ + deepInterception?: DeepScriptInterceptionConfig; + /** Integration ID used for deterministic dispatcher ordering. */ id: string; - /** Optional human-readable label used in log messages (e.g. "GTM"). */ + /** Return true only for a URL owned by this integration. */ + isTargetUrl: (url: string) => boolean; + /** Optional human-readable log label. */ displayName?: string; - /** Lower values run earlier when multiple handlers match the same node. */ + /** Lower values run earlier when multiple insertion handlers match. */ priority?: number; - /** Return true if the URL belongs to this integration's SDK. */ - isTargetUrl: (url: string) => boolean; } -/** - * Config using a fixed proxy path (original behavior). - * The entire URL is replaced with `{origin}{proxyPath}`. - */ interface ScriptGuardConfigWithProxyPath extends ScriptGuardConfigBase { - /** First-party proxy path to rewrite to (e.g. "/integrations/lockr/sdk"). */ proxyPath: string; rewriteUrl?: never; } -/** - * Config using a custom URL rewriter function. - * Allows integrations like DataDome to preserve the original path. - */ interface ScriptGuardConfigWithRewriter extends ScriptGuardConfigBase { proxyPath?: never; - /** Custom function to rewrite the original URL to a first-party URL. */ rewriteUrl: (originalUrl: string) => string; } export type ScriptGuardConfig = ScriptGuardConfigWithProxyPath | ScriptGuardConfigWithRewriter; export interface ScriptGuard { - /** Install a shared DOM insertion handler for matching scripts and links. */ install: () => void; - /** Whether the guard has already been installed. */ isInstalled: () => boolean; - /** Reset installation state (primarily for testing). */ reset: () => void; } -/** - * Build a first-party URL from the current page origin and the configured proxy path. - */ +interface InstancePatch { + readonly element: HTMLScriptElement; + readonly previous: PropertyDescriptor | undefined; + readonly setter: (this: HTMLScriptElement, value: string) => void; +} + function rewriteToFirstParty(proxyPath: string): string { return `${window.location.origin}${proxyPath}`; } -/** - * Get the rewritten URL using either the custom rewriter or the proxy path. - */ -function getRewrittenUrl(originalUrl: string, config: ScriptGuardConfig): string { - if (config.rewriteUrl) { - return config.rewriteUrl(originalUrl); - } - return rewriteToFirstParty(config.proxyPath); +function rewrittenUrl(originalUrl: string, config: ScriptGuardConfig): string { + return config.rewriteUrl ? config.rewriteUrl(originalUrl) : rewriteToFirstParty(config.proxyPath); } /** - * Rewrite the URL attribute on a matched element to the first-party proxy. + * Create one reversible guard. Basic guards share only the insertion dispatcher; + * deep guards additionally own the browser patches required by self-loading SDKs. */ -function rewriteElement(candidate: DomInsertionCandidate, config: ScriptGuardConfig): void { +export function createScriptGuard(config: ScriptGuardConfig): ScriptGuard { const prefix = `${config.displayName ?? config.id} guard`; + let installed = false; + let unregister: (() => void) | undefined; + let mutationObserver: MutationObserver | undefined; + let nativeDocWrite: typeof document.write | undefined; + let nativeDocWriteln: typeof document.writeln | undefined; + let documentWriteWrapper: typeof document.write | undefined; + let documentWritelnWrapper: typeof document.writeln | undefined; + let nativeCreateElement: typeof document.createElement | undefined; + let createElementWrapper: typeof document.createElement | undefined; + let nativeSetAttribute: typeof HTMLScriptElement.prototype.setAttribute | undefined; + let setAttributeWrapper: typeof HTMLScriptElement.prototype.setAttribute | undefined; + let nativeSrcDescriptor: PropertyDescriptor | undefined; + let nativeSrcGet: ((this: HTMLScriptElement) => string) | undefined; + let nativeSrcSet: ((this: HTMLScriptElement, value: string) => void) | undefined; + let installedSrcSetter: ((this: HTMLScriptElement, value: string) => void) | undefined; + let srcDescriptorInstalled = false; + let rewritten = new WeakMap(); + let patchedInstances = new Map(); - if (candidate.kind === 'script') { - const rewritten = getRewrittenUrl(candidate.url, config); + const isTarget = (url: string): boolean => { + try { + return config.isTargetUrl(url); + } catch { + return false; + } + }; - log.info(`${prefix}: rewriting dynamically inserted SDK script`, { - original: candidate.url, - rewritten, - framework: candidate.element.getAttribute('data-nscript') || 'generic', - }); + const rewrite = (url: string): string => { + try { + return rewrittenUrl(url, config); + } catch { + return url; + } + }; - candidate.element.src = rewritten; - candidate.element.setAttribute('src', rewritten); - } else { - const rewritten = getRewrittenUrl(candidate.url, config); + const applyScriptSource = (element: HTMLScriptElement, value: string): void => { + if (nativeSrcSet) { + nativeSrcSet.call(element, value); + return; + } + if (nativeSetAttribute) { + nativeSetAttribute.call(element, 'src', value); + return; + } + element.setAttribute('src', value); + }; + const rewriteScriptSource = (element: HTMLScriptElement, rawUrl: string): boolean => { + if (!isTarget(rawUrl)) return false; + const finalUrl = rewrite(rawUrl); + if (finalUrl === rawUrl || rewritten.get(element) === finalUrl) return false; + rewritten.set(element, finalUrl); + log.info(`${prefix}: rewriting script src`, { original: rawUrl, rewritten: finalUrl }); + applyScriptSource(element, finalUrl); + return true; + }; + + const rewriteLinkSource = ( + element: HTMLLinkElement, + rawUrl: string, + rel: string | null + ): boolean => { + if ( + (rel !== 'preload' && rel !== 'prefetch') || + element.getAttribute('as') !== 'script' || + !isTarget(rawUrl) + ) { + return false; + } + const finalUrl = rewrite(rawUrl); + if (finalUrl === rawUrl || rewritten.get(element) === finalUrl) return false; + rewritten.set(element, finalUrl); + log.info(`${prefix}: rewriting SDK ${rel} link`, { original: rawUrl, rewritten: finalUrl }); + element.href = finalUrl; + element.setAttribute('href', finalUrl); + return true; + }; + + const rewriteCandidate = (candidate: DomInsertionCandidate): boolean => { + if (!isTarget(candidate.url)) return false; + if (candidate.kind === 'script') { + if (config.deepInterception) return rewriteScriptSource(candidate.element, candidate.url); + const finalUrl = rewrite(candidate.url); + candidate.element.src = finalUrl; + candidate.element.setAttribute('src', finalUrl); + log.info(`${prefix}: rewriting dynamically inserted SDK script`, { + framework: candidate.element.getAttribute('data-nscript') || 'generic', + original: candidate.url, + rewritten: finalUrl, + }); + return true; + } + if (config.deepInterception) { + return rewriteLinkSource(candidate.element, candidate.url, candidate.rel); + } + const finalUrl = rewrite(candidate.url); + candidate.element.href = finalUrl; + candidate.element.setAttribute('href', finalUrl); log.info(`${prefix}: rewriting SDK ${candidate.rel} link`, { + as: candidate.element.getAttribute('as'), original: candidate.url, - rewritten, rel: candidate.rel, - as: candidate.element.getAttribute('as'), + rewritten: finalUrl, }); + return true; + }; - candidate.element.href = rewritten; - candidate.element.setAttribute('href', rewritten); - } -} + const rewriteDocumentHtml = (html: string): string => { + const hint = config.deepInterception?.documentWriteUrlHint; + if (!hint || !html.includes(hint)) return html; + if (typeof DOMParser === 'undefined') { + log.warn(`${prefix}: DOMParser unavailable, blocking matching document.write HTML`); + return ''; + } + try { + const parsed = new DOMParser().parseFromString(html, 'text/html'); + const scripts = parsed.querySelectorAll('script[src]'); + let changed = false; + for (let index = 0; index < scripts.length; index += 1) { + const script = scripts.item(index); + const rawUrl = script.getAttribute('src') ?? ''; + if (!isTarget(rawUrl)) continue; + const finalUrl = rewrite(rawUrl); + if (finalUrl === rawUrl) continue; + script.setAttribute('src', finalUrl); + changed = true; + } + return changed ? (parsed.head?.innerHTML ?? '') + (parsed.body?.innerHTML ?? '') : html; + } catch (error) { + log.warn(`${prefix}: failed to parse matching document.write HTML, blocking`, error); + return ''; + } + }; -/** - * Create an independent script guard for a specific integration. - */ -export function createScriptGuard(config: ScriptGuardConfig): ScriptGuard { - let installed = false; - let unregister: (() => void) | undefined; - const prefix = `${config.displayName ?? config.id} guard`; + const installDocumentWritePatch = (): void => { + if (typeof document === 'undefined') return; + nativeDocWrite = document.write; + nativeDocWriteln = document.writeln; + documentWriteWrapper = function (this: Document, ...args: string[]): void { + nativeDocWrite?.apply( + this, + args.map((value) => (typeof value === 'string' ? rewriteDocumentHtml(value) : value)) + ); + }; + documentWritelnWrapper = function (this: Document, ...args: string[]): void { + nativeDocWriteln?.apply( + this, + args.map((value) => (typeof value === 'string' ? rewriteDocumentHtml(value) : value)) + ); + }; + document.write = documentWriteWrapper; + document.writeln = documentWritelnWrapper; + }; - function install(): void { - if (installed) { - log.debug(`${prefix}: already installed, skipping`); + const installSrcDescriptor = (): void => { + if (typeof HTMLScriptElement === 'undefined') return; + const descriptor = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src'); + if (!descriptor || typeof descriptor.set !== 'function' || descriptor.configurable === false) { return; } + nativeSrcDescriptor = descriptor; + nativeSrcGet = typeof descriptor.get === 'function' ? descriptor.get : undefined; + nativeSrcSet = descriptor.set; + installedSrcSetter = function (this: HTMLScriptElement, value: string): void { + const rawUrl = String(value ?? ''); + if (!rewriteScriptSource(this, rawUrl)) applyScriptSource(this, rawUrl); + }; + try { + Object.defineProperty(HTMLScriptElement.prototype, 'src', { + configurable: true, + enumerable: descriptor.enumerable ?? true, + get(this: HTMLScriptElement): string { + return nativeSrcGet ? nativeSrcGet.call(this) : (this.getAttribute('src') ?? ''); + }, + set: installedSrcSetter, + }); + srcDescriptorInstalled = true; + } catch { + nativeSrcDescriptor = undefined; + nativeSrcGet = undefined; + nativeSrcSet = undefined; + installedSrcSetter = undefined; + } + }; - if (typeof window === 'undefined' || typeof Element === 'undefined') { - log.debug(`${prefix}: not in browser environment, skipping`); - return; + const installSetAttributePatch = (): void => { + if (typeof HTMLScriptElement === 'undefined') return; + nativeSetAttribute = HTMLScriptElement.prototype.setAttribute; + setAttributeWrapper = function (this: HTMLScriptElement, name: string, value: string): void { + if (typeof name === 'string' && name.toLowerCase() === 'src') { + const rawUrl = String(value ?? ''); + if (isTarget(rawUrl)) { + const finalUrl = rewrite(rawUrl); + if (finalUrl !== rawUrl && rewritten.get(this) !== finalUrl) { + rewritten.set(this, finalUrl); + nativeSetAttribute?.call(this, name, finalUrl); + return; + } + } + } + nativeSetAttribute?.call(this, name, value); + }; + HTMLScriptElement.prototype.setAttribute = setAttributeWrapper; + }; + + const ensureInstancePatched = (element: HTMLScriptElement): void => { + if (srcDescriptorInstalled || patchedInstances.has(element)) return; + if (patchedInstances.size >= MAX_TRACKED_INSTANCE_PATCHES) return; + const previous = Object.getOwnPropertyDescriptor(element, 'src'); + const setter = function (this: HTMLScriptElement, value: string): void { + const rawUrl = String(value ?? ''); + if (!rewriteScriptSource(this, rawUrl)) applyScriptSource(this, rawUrl); + }; + try { + Object.defineProperty(element, 'src', { + configurable: true, + enumerable: true, + get(this: HTMLScriptElement): string { + return nativeSrcGet ? nativeSrcGet.call(this) : (this.getAttribute('src') ?? ''); + }, + set: setter, + }); + patchedInstances.set(element, { element, previous, setter }); + } catch { + // The insertion dispatcher and observer remain fail-safe fallbacks. } + }; - log.info(`${prefix}: installing DOM interception for SDK`); + const installCreateElementPatch = (): void => { + if (typeof document === 'undefined') return; + nativeCreateElement = document.createElement; + createElementWrapper = function ( + this: Document, + tagName: string, + options?: ElementCreationOptions + ): HTMLElement { + const element = nativeCreateElement!.call(this, tagName, options); + if (typeof tagName === 'string' && tagName.toLowerCase() === 'script') { + ensureInstancePatched(element as HTMLScriptElement); + } + return element; + } as typeof document.createElement; + document.createElement = createElementWrapper; + }; - unregister = registerDomInsertionHandler({ - handle(candidate): boolean { - if (!config.isTargetUrl(candidate.url)) { - return false; + const inspectMutationNode = (node: Node): void => { + if (node instanceof HTMLScriptElement) { + const rawUrl = node.src || node.getAttribute('src') || ''; + if (rawUrl) rewriteScriptSource(node, rawUrl); + return; + } + if (node instanceof HTMLLinkElement) { + rewriteLinkSource( + node, + node.href || node.getAttribute('href') || '', + node.getAttribute('rel') + ); + return; + } + if (!(node instanceof Element)) return; + const descendants = node.querySelectorAll( + 'script[src],link[rel="preload"][as="script"],link[rel="prefetch"][as="script"]' + ); + for (let index = 0; index < descendants.length; index += 1) { + inspectMutationNode(descendants.item(index)); + } + }; + + const installMutationObserver = (): void => { + if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return; + mutationObserver = new MutationObserver((records) => { + for (let recordIndex = 0; recordIndex < records.length; recordIndex += 1) { + const record = records[recordIndex]; + if (!record) continue; + if (record.type === 'attributes' && record.attributeName === 'src') { + inspectMutationNode(record.target); + continue; } + if (record.type !== 'childList') continue; + for (let nodeIndex = 0; nodeIndex < record.addedNodes.length; nodeIndex += 1) { + const node = record.addedNodes.item(nodeIndex); + if (node) inspectMutationNode(node); + } + } + }); + mutationObserver.observe(document, { + attributeFilter: ['src'], + attributes: true, + childList: true, + subtree: true, + }); + }; - rewriteElement(candidate, config); - return true; - }, + const install = (): void => { + if (installed) return; + if ( + typeof window === 'undefined' || + (!config.deepInterception && typeof Element === 'undefined') + ) { + return; + } + if (config.deepInterception) { + installDocumentWritePatch(); + installSrcDescriptor(); + installSetAttributePatch(); + installCreateElementPatch(); + installMutationObserver(); + } + unregister = registerDomInsertionHandler({ + handle: rewriteCandidate, id: config.id, priority: config.priority ?? DEFAULT_DOM_INSERTION_HANDLER_PRIORITY, }); - installed = true; - log.info(`${prefix}: DOM interception installed successfully`); - } + }; - function isInstalled(): boolean { - return installed; - } + const reset = (): void => { + mutationObserver?.disconnect(); + mutationObserver = undefined; + unregister?.(); + unregister = undefined; - function reset(): void { - if (unregister) { - unregister(); - unregister = undefined; + for (const patch of patchedInstances.values()) { + try { + const current = Object.getOwnPropertyDescriptor(patch.element, 'src'); + if (current?.set !== patch.setter) continue; + if (patch.previous) Object.defineProperty(patch.element, 'src', patch.previous); + else Reflect.deleteProperty(patch.element, 'src'); + } catch { + // External replacement wins; do not overwrite it during cleanup. + } } + patchedInstances.clear(); - if (installed) { - log.debug(`${prefix}: reset and uninstalled`); + if (typeof document !== 'undefined') { + if (document.write === documentWriteWrapper && nativeDocWrite) + document.write = nativeDocWrite; + if (document.writeln === documentWritelnWrapper && nativeDocWriteln) { + document.writeln = nativeDocWriteln; + } + if (document.createElement === createElementWrapper && nativeCreateElement) { + document.createElement = nativeCreateElement; + } + } + if (typeof HTMLScriptElement !== 'undefined') { + if (HTMLScriptElement.prototype.setAttribute === setAttributeWrapper && nativeSetAttribute) { + HTMLScriptElement.prototype.setAttribute = nativeSetAttribute; + } + const current = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src'); + if (current?.set === installedSrcSetter && nativeSrcDescriptor) { + try { + Object.defineProperty(HTMLScriptElement.prototype, 'src', nativeSrcDescriptor); + } catch { + // External hardening can make restoration impossible; keep cleanup contained. + } + } } + nativeDocWrite = undefined; + nativeDocWriteln = undefined; + documentWriteWrapper = undefined; + documentWritelnWrapper = undefined; + nativeCreateElement = undefined; + createElementWrapper = undefined; + nativeSetAttribute = undefined; + setAttributeWrapper = undefined; + nativeSrcDescriptor = undefined; + nativeSrcGet = undefined; + nativeSrcSet = undefined; + installedSrcSetter = undefined; + srcDescriptorInstalled = false; + rewritten = new WeakMap(); + patchedInstances = new Map(); installed = false; - } + }; - return { install, isInstalled, reset }; + return { install, isInstalled: () => installed, reset }; } diff --git a/crates/trusted-server-js/lib/src/shared/takeover.ts b/crates/trusted-server-js/lib/src/shared/takeover.ts new file mode 100644 index 000000000..e5d8fe289 --- /dev/null +++ b/crates/trusted-server-js/lib/src/shared/takeover.ts @@ -0,0 +1,361 @@ +import type { BootFailureReason } from '../kernel/fallback_surface'; + +import type { + FinalizedFirstDisplayHandoffV1, + FirstDisplayAgentCaptureFinalizerV1, +} from './first_display_handoff'; + +export type FirstDisplayGptDiagnosticEventV1 = + | 'slotRequested' + | 'slotResponseReceived' + | 'slotRenderEnded' + | 'slotOnload' + | 'impressionViewable' + | 'slotVisibilityChanged'; + +export type FirstDisplayGptDiagnosticDispositionV1 = 'matched' | 'unmatched' | 'ambiguous'; + +export type FirstDisplayGptDiagnosticIssueReasonV1 = + 'no_request_cycle' | 'overlapping_request_cycles' | 'unknown_prior_cycle' | 'invalid_event_order'; + +/** Exact ordinary-data GPT observation permitted to cross the first-display handoff. */ +export interface FirstDisplayGptFactV1 { + readonly version: 1; + readonly event: FirstDisplayGptDiagnosticEventV1; + readonly token: string; + readonly runtimeSlotNumber: number; + readonly cycleOrdinal: number | null; + readonly disposition: FirstDisplayGptDiagnosticDispositionV1; + readonly issueReason: FirstDisplayGptDiagnosticIssueReasonV1 | null; + readonly capturedAtMs: number; + readonly elementId: string | null; + readonly adUnitPath: string | null; + readonly requestedSlotSizes: readonly (readonly [number, number])[] | null; + readonly isEmpty: boolean | null; + readonly renderedSize: readonly [number, number] | null; + readonly isBackfill: boolean | null; + readonly slotContentChanged: boolean | null; + readonly visibilityPercent: number | null; +} + +export interface FirstDisplayGptDiagnosticsV1 { + readonly facts: readonly Readonly[]; + readonly overflowCount: number; + readonly dropCount: number; +} + +export interface PersistentFirstDisplayAdoptionV1 { + readonly version: 1; + readonly adoptInitialDisplay: true; + readonly handoff: Readonly>; + readonly identities: readonly object[]; +} + +export interface PersistentFirstDisplaySliceStateV1 { + readonly sliceId: string; + readonly observations: readonly string[]; + readonly values: readonly (readonly [string, string | number | boolean | null])[]; +} + +function exactDataRecord( + candidate: unknown, + expected: readonly string[] +): Readonly> | undefined { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype + ) { + return undefined; + } + const keys = Reflect.ownKeys(candidate); + if ( + keys.length !== expected.length || + !keys.every((key) => typeof key === 'string' && expected.includes(key)) + ) { + return undefined; + } + const fields: Record = {}; + for (const key of expected) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + fields[key] = descriptor.value; + } + return fields; +} + +function exactFrozenIdentities(candidate: unknown): candidate is readonly object[] { + if (!Array.isArray(candidate) || !Object.isFrozen(candidate) || candidate.length > 512) { + return false; + } + const expectedKeys = Array.from({ length: candidate.length }, (_, index) => String(index)); + expectedKeys.push('length'); + const actualKeys = Reflect.ownKeys(candidate); + if ( + actualKeys.length !== expectedKeys.length || + !actualKeys.every((key) => typeof key === 'string' && expectedKeys.includes(key)) + ) { + return false; + } + for (let index = 0; index < candidate.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index)); + const identity = descriptor && 'value' in descriptor ? descriptor.value : undefined; + if ( + !descriptor?.enumerable || + (typeof identity !== 'object' && typeof identity !== 'function') || + identity === null + ) { + return false; + } + } + return true; +} + +/** Validate only the closure-private outer adoption carrier without cloning object identities. */ +export function snapshotPersistentFirstDisplayAdoptionV1( + candidate: unknown +): PersistentFirstDisplayAdoptionV1 | undefined { + try { + if (!Object.isFrozen(candidate)) return undefined; + const fields = exactDataRecord(candidate, [ + 'version', + 'adoptInitialDisplay', + 'handoff', + 'identities', + ]); + if ( + !fields || + fields.version !== 1 || + fields.adoptInitialDisplay !== true || + typeof fields.handoff !== 'object' || + fields.handoff === null || + Array.isArray(fields.handoff) || + Object.getPrototypeOf(fields.handoff) !== Object.prototype || + !Object.isFrozen(fields.handoff) || + !exactFrozenIdentities(fields.identities) + ) { + return undefined; + } + return candidate as PersistentFirstDisplayAdoptionV1; + } catch { + return undefined; + } +} + +/** Select one exact parser-time slice snapshot from a validated takeover carrier. */ +export function snapshotPersistentFirstDisplaySliceStateV1( + candidate: unknown, + sliceId: string +): Readonly | undefined { + try { + const adoption = snapshotPersistentFirstDisplayAdoptionV1(candidate); + if (!adoption || typeof sliceId !== 'string' || sliceId.length === 0) return undefined; + const slicesDescriptor = Object.getOwnPropertyDescriptor(adoption.handoff, 'slices'); + const parserDescriptor = Object.getOwnPropertyDescriptor(adoption.handoff, 'parserState'); + const slices = slicesDescriptor && 'value' in slicesDescriptor ? slicesDescriptor.value : null; + const parserState = + parserDescriptor && 'value' in parserDescriptor ? parserDescriptor.value : null; + if ( + !Array.isArray(slices) || + !Object.isFrozen(slices) || + !slices.includes(sliceId) || + !Array.isArray(parserState) || + !Object.isFrozen(parserState) + ) { + return undefined; + } + const matches = parserState.filter((entry) => { + const descriptor = + typeof entry === 'object' && entry !== null + ? Object.getOwnPropertyDescriptor(entry, 'sliceId') + : undefined; + return descriptor && 'value' in descriptor && descriptor.value === sliceId; + }); + if (matches.length !== 1) return undefined; + const row = matches[0]; + if (!Object.isFrozen(row)) return undefined; + const fields = exactDataRecord(row, ['sliceId', 'observations', 'values']); + if (!fields || !Array.isArray(fields.observations) || !Array.isArray(fields.values)) { + return undefined; + } + if ( + !Object.isFrozen(fields.observations) || + !Object.isFrozen(fields.values) || + fields.observations.length > 256 || + fields.values.length !== fields.observations.length + ) { + return undefined; + } + const observations: string[] = []; + const values: Array = []; + for (let index = 0; index < fields.observations.length; index += 1) { + const key = fields.observations[index]; + const pair = fields.values[index]; + if ( + typeof key !== 'string' || + key.length === 0 || + observations.includes(key) || + !Array.isArray(pair) || + !Object.isFrozen(pair) || + pair.length !== 2 || + pair[0] !== key + ) { + return undefined; + } + const value = pair[1]; + if ( + value !== null && + typeof value !== 'string' && + typeof value !== 'boolean' && + !(typeof value === 'number' && Number.isFinite(value)) + ) { + return undefined; + } + observations.push(key); + values.push(Object.freeze([key, value] as const)); + } + return Object.freeze({ + sliceId, + observations: Object.freeze(observations), + values: Object.freeze(values), + }); + } catch { + return undefined; + } +} + +/** Report whether one validated takeover selected an exact parser-time slice. */ +export function persistentFirstDisplaySliceSelectedV1( + candidate: unknown, + sliceId: string +): boolean | undefined { + try { + const adoption = snapshotPersistentFirstDisplayAdoptionV1(candidate); + if (!adoption || typeof sliceId !== 'string' || sliceId.length === 0) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(adoption.handoff, 'slices'); + const slices = descriptor && 'value' in descriptor ? descriptor.value : undefined; + if ( + !Array.isArray(slices) || + !Object.isFrozen(slices) || + slices.length === 0 || + slices[0] !== 'first_display' || + slices.some((value) => typeof value !== 'string') || + new Set(slices).size !== slices.length + ) { + return undefined; + } + return slices.includes(sliceId); + } catch { + return undefined; + } +} + +/** Validate one selected parser row while allowing an integration absent from the initial batch. */ +export function validatePersistentFirstDisplaySliceAdoptionV1( + candidate: unknown, + sliceId: string, + validate?: (state: Readonly) => boolean +): boolean { + const selected = persistentFirstDisplaySliceSelectedV1(candidate, sliceId); + if (selected === undefined) return false; + if (!selected) return true; + const state = snapshotPersistentFirstDisplaySliceStateV1(candidate, sliceId); + if (!state) return false; + try { + return validate?.(state) ?? true; + } catch { + return false; + } +} + +export const FIRST_DISPLAY_TAKEOVER_FIELD = '_firstDisplayTakeover' as const; + +/** One-use old-owner lease claimed only after persistent preparation is complete. */ +export type ClaimedFirstDisplayTakeoverV1 = readonly [ + finalized: FinalizedFirstDisplayHandoffV1, + outline: unknown, + isCurrentGeneration: () => boolean, + authenticateRuntimeScript: () => boolean, + currentMutationRevision: () => number, + detachCommittedArtifacts: () => boolean, + disposeAgent: () => void, + onFailure: (reason: BootFailureReason) => void, + onCommit: () => void, + trustedScriptUrl: (value: string) => unknown, + currentScript: () => HTMLScriptElement | null, +]; + +export type FirstDisplayTakeoverClaim = ( + source: unknown, + finalize: FirstDisplayAgentCaptureFinalizerV1 +) => ClaimedFirstDisplayTakeoverV1 | undefined; + +export type FirstDisplayTakeoverTransportResult = + | Readonly<{ status: 'absent' }> + | Readonly<{ status: 'invalid' }> + | Readonly<{ status: 'accepted'; claim: FirstDisplayTakeoverClaim }>; + +/** Install one non-enumerable, one-use handoff sink shared only by bootstrap and core IIFEs. */ +export function installFirstDisplayTakeoverTransport( + target: object, + claim: FirstDisplayTakeoverClaim +): (() => void) | undefined { + if ( + typeof claim !== 'function' || + Object.getOwnPropertyDescriptor(target, FIRST_DISPLAY_TAKEOVER_FIELD) + ) { + return undefined; + } + let live = true; + const installed: FirstDisplayTakeoverClaim = (source, finalize) => { + if (!live) return undefined; + try { + const result = claim(source, finalize); + if (result !== undefined) live = false; + return result; + } catch (error) { + live = false; + throw error; + } + }; + try { + Object.defineProperty(target, FIRST_DISPLAY_TAKEOVER_FIELD, { + configurable: false, + enumerable: false, + value: installed, + writable: false, + }); + } catch { + return undefined; + } + return (): void => { + if (!live) return; + live = false; + }; +} + +/** Consume the exact bootstrap-owned sink before persistent preparation starts. */ +export function consumeFirstDisplayTakeoverTransport( + target: object +): FirstDisplayTakeoverTransportResult { + try { + const descriptor = Object.getOwnPropertyDescriptor(target, FIRST_DISPLAY_TAKEOVER_FIELD); + if (!descriptor) return Object.freeze({ status: 'absent' }); + if ( + descriptor.configurable || + descriptor.enumerable || + descriptor.writable || + !('value' in descriptor) || + typeof descriptor.value !== 'function' + ) { + return Object.freeze({ status: 'invalid' }); + } + return Object.freeze({ + status: 'accepted', + claim: descriptor.value as FirstDisplayTakeoverClaim, + }); + } catch { + return Object.freeze({ status: 'invalid' }); + } +} diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts new file mode 100644 index 000000000..75a1437bf --- /dev/null +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -0,0 +1,3547 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createBrowserGoogletagAdapter, + createGoogletagTraceCycleHandle, + type GoogletagDiagnosticsFact, +} from '../../src/adapters/googletag'; + +type Command = () => void; + +function createReadyGoogletag( + options: { + readonly deferCommands?: boolean; + readonly initialLoadDisabled?: boolean; + readonly servicesEnabled?: boolean; + } = {} +) { + const commands: Command[] = []; + const display = vi.fn(); + const initialLoad = { disabled: options.initialLoadDisabled === true }; + const listeners = new Map void>>(); + const pubads = { + addEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + const registered = listeners.get(type) ?? new Set(); + registered.add(listener); + listeners.set(type, registered); + }), + disableInitialLoad: vi.fn(() => { + initialLoad.disabled = true; + return 'legacy-result'; + }), + enableSingleRequest: vi.fn(() => true), + getSlots: vi.fn<() => object[]>(() => []), + refresh: vi.fn(), + removeEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + listeners.get(type)?.delete(listener); + }), + }; + const googletag = { + apiReady: true, + pubadsReady: options.servicesEnabled !== false, + cmd: { + push: vi.fn((command: Command): number => { + if (options.deferCommands) commands.push(command); + else command(); + return commands.length; + }), + }, + defineSlot: vi.fn(), + destroySlots: vi.fn(), + display, + enableServices: vi.fn(() => { + googletag.pubadsReady = true; + }), + getConfig: vi.fn((key: string) => + key === 'disableInitialLoad' ? { disableInitialLoad: initialLoad.disabled } : {} + ), + pubads: vi.fn(() => pubads), + setConfig: vi.fn((config: { readonly disableInitialLoad?: boolean | null }) => { + if (Object.prototype.hasOwnProperty.call(config, 'disableInitialLoad')) { + initialLoad.disabled = config.disableInitialLoad === true; + } + return 'config-result'; + }), + }; + return { commands, display, googletag, initialLoad, listeners, pubads }; +} + +describe('browser googletag adapter readiness', () => { + afterEach(() => vi.useRealTimers()); + + it('enqueues GAM attribution once before later publisher commands', () => { + const ready = createReadyGoogletag({ deferCommands: true }); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const order: string[] = []; + ready.googletag.setConfig.mockImplementation((config) => { + order.push('trusted-server'); + return JSON.stringify(config); + }); + + expect(adapter.enqueueGamAttribution()).toBe(true); + expect(adapter.enqueueGamAttribution()).toBe(true); + ready.googletag.cmd.push(() => order.push('publisher')); + + expect(ready.commands).toHaveLength(2); + ready.commands.splice(0).forEach((command) => command()); + expect(order).toEqual(['trusted-server', 'publisher']); + expect(ready.googletag.setConfig).toHaveBeenCalledExactlyOnceWith({ + targeting: { ts: 'true' }, + }); + }); + + it('creates an absent GPT queue and isolates a missing or throwing targeting API', () => { + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + + expect(adapter.enqueueGamAttribution()).toBe(true); + const created = target.googletag as { cmd: Array<() => void> }; + expect(created.cmd).toHaveLength(1); + expect(() => created.cmd[0]?.()).not.toThrow(); + + const publisher = vi.fn(); + const throwing = createReadyGoogletag(); + throwing.googletag.setConfig.mockImplementation(() => { + throw new Error('targeting unavailable'); + }); + const throwingAdapter = createBrowserGoogletagAdapter({ googletag: throwing.googletag }); + expect(throwingAdapter.enqueueGamAttribution()).toBe(true); + expect(() => throwing.googletag.cmd.push(publisher)).not.toThrow(); + expect(publisher).toHaveBeenCalledOnce(); + }); + + it('reports present and gives the command only a frozen narrow facade', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const operation = adapter.run((gpt) => { + expect(Object.isFrozen(gpt)).toBe(true); + expect('cmd' in gpt).toBe(false); + expect('apiReady' in gpt).toBe(false); + gpt.display('slot-a'); + return 'completed'; + }); + + expect(operation.status).toBe('present'); + await expect(operation.result).resolves.toBe('completed'); + expect(ready.display).toHaveBeenCalledWith('slot-a'); + }); + + it('defines and adopts one GPT slot as a synchronous rollback-capable transaction', async () => { + const ready = createReadyGoogletag(); + const slot = { addService: vi.fn() }; + ready.googletag.defineSlot.mockReturnValue(slot); + ready.googletag.destroySlots.mockReturnValue(true); + const commit = vi.fn(() => true); + const rollback = vi.fn(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + + const operation = adapter.run((gpt) => + gpt.transactionalDefine( + { + adUnitPath: '/123/slot-a', + elementId: 'slot-a', + sizes: [[300, 250]], + }, + () => true, + (candidate) => { + expect(candidate).toBe(slot); + return Object.freeze({ commit, rollback }); + } + ) + ); + + await expect(operation.result).resolves.toEqual({ status: 'defined', slot }); + expect(ready.googletag.defineSlot).toHaveBeenCalledExactlyOnceWith( + '/123/slot-a', + [[300, 250]], + 'slot-a' + ); + expect(slot.addService).toHaveBeenCalledExactlyOnceWith(ready.pubads); + expect(commit).toHaveBeenCalledOnce(); + expect(rollback).not.toHaveBeenCalled(); + expect(ready.googletag.destroySlots).not.toHaveBeenCalled(); + expect(slot.addService.mock.invocationCallOrder[0]).toBeLessThan( + commit.mock.invocationCallOrder[0]! + ); + }); + + it('destroys a newly defined GPT slot when its navigation becomes stale before adoption', async () => { + const ready = createReadyGoogletag(); + const slot = { addService: vi.fn() }; + ready.googletag.defineSlot.mockReturnValue(slot); + ready.googletag.destroySlots.mockReturnValue(true); + const isGenerationCurrent = vi.fn().mockReturnValueOnce(true).mockReturnValue(false); + const prepareCommit = vi.fn(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + + const operation = adapter.run((gpt) => + gpt.transactionalDefine( + { + adUnitPath: '/123/slot-a', + elementId: 'slot-a', + sizes: [[300, 250]], + }, + isGenerationCurrent, + prepareCommit + ) + ); + + await expect(operation.result).resolves.toEqual({ status: 'discarded' }); + expect(prepareCommit).not.toHaveBeenCalled(); + expect(slot.addService).not.toHaveBeenCalled(); + expect(ready.googletag.destroySlots).toHaveBeenCalledExactlyOnceWith([slot]); + }); + + it('marks and measures only the first TS-authoritative display', async () => { + const ready = createReadyGoogletag(); + const performance = { + mark: vi.fn(), + measure: vi.fn(), + }; + const target = { googletag: ready.googletag, performance }; + const adapter = createBrowserGoogletagAdapter(target); + + ready.googletag.display('publisher-slot'); + expect(performance.mark).not.toHaveBeenCalled(); + + const first = adapter.run((gpt) => { + gpt.display('trusted-slot-one'); + gpt.display('trusted-slot-two'); + }); + await expect(first.result).resolves.toBeUndefined(); + const replay = adapter.run((gpt) => gpt.display('trusted-slot-three')); + await expect(replay.result).resolves.toBeUndefined(); + + expect(performance.mark).toHaveBeenCalledExactlyOnceWith('tsjs:first-display'); + expect(performance.measure).toHaveBeenCalledExactlyOnceWith( + 'tsjs:boot-to-first-display', + 'tsjs:bids-script', + 'tsjs:first-display' + ); + expect(performance.mark.mock.invocationCallOrder[0]).toBeLessThan( + ready.display.mock.invocationCallOrder[1]! + ); + }); + + it('contains missing and throwing Performance APIs at the first display boundary', async () => { + const missing = createReadyGoogletag(); + const missingAdapter = createBrowserGoogletagAdapter({ googletag: missing.googletag }); + await expect(missingAdapter.run((gpt) => gpt.display('slot-missing')).result).resolves.toBe( + undefined + ); + expect(missing.display).toHaveBeenCalledExactlyOnceWith('slot-missing'); + + const throwing = createReadyGoogletag(); + const mark = vi.fn(() => { + throw new Error('performance unavailable'); + }); + const measure = vi.fn(); + const target = { googletag: throwing.googletag, performance: { mark, measure } }; + const throwingAdapter = createBrowserGoogletagAdapter(target); + await expect( + throwingAdapter.run((gpt) => { + gpt.display('slot-throwing'); + gpt.display('slot-replay'); + }).result + ).resolves.toBeUndefined(); + + expect(mark).toHaveBeenCalledExactlyOnceWith('tsjs:first-display'); + expect(measure).not.toHaveBeenCalled(); + expect(throwing.display).toHaveBeenCalledTimes(2); + + const markOnly = createReadyGoogletag(); + const markWithoutMeasure = vi.fn(); + const markOnlyAdapter = createBrowserGoogletagAdapter({ + googletag: markOnly.googletag, + performance: { mark: markWithoutMeasure }, + }); + await expect(markOnlyAdapter.run((gpt) => gpt.display('slot-mark-only')).result).resolves.toBe( + undefined + ); + expect(markWithoutMeasure).toHaveBeenCalledExactlyOnceWith('tsjs:first-display'); + expect(markOnly.display).toHaveBeenCalledExactlyOnceWith('slot-mark-only'); + }); + + it('does not mark a display rejected from a stale GPT generation', async () => { + const first = createReadyGoogletag(); + const replacement = createReadyGoogletag(); + const performance = { mark: vi.fn(), measure: vi.fn() }; + const target = { googletag: first.googletag, performance }; + const adapter = createBrowserGoogletagAdapter(target); + const operation = adapter.run((gpt) => { + target.googletag = replacement.googletag; + gpt.display('stale-slot'); + }); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(performance.mark).not.toHaveBeenCalled(); + expect(performance.measure).not.toHaveBeenCalled(); + expect(first.display).not.toHaveBeenCalled(); + expect(replacement.display).not.toHaveBeenCalled(); + + await expect(adapter.run((gpt) => gpt.display('current-slot')).result).resolves.toBeUndefined(); + expect(performance.mark).toHaveBeenCalledExactlyOnceWith('tsjs:first-display'); + expect(replacement.display).toHaveBeenCalledExactlyOnceWith('current-slot'); + }); + + it('reports pending and drains live operations FIFO through a real GPT command notification', async () => { + const readinessCommands: Command[] = []; + const target: { googletag?: unknown } = { + googletag: { cmd: readinessCommands }, + }; + const adapter = createBrowserGoogletagAdapter(target); + const order: number[] = []; + const first = adapter.run(() => order.push(1)); + const second = adapter.run(() => order.push(2)); + + expect(first.status).toBe('pending'); + expect(second.status).toBe('pending'); + expect(readinessCommands).toHaveLength(1); + + target.googletag = createReadyGoogletag().googletag; + readinessCommands[0]?.(); + + await expect(first.result).resolves.toBe(1); + await expect(second.result).resolves.toBe(2); + expect(order).toEqual([1, 2]); + expect(first.status).toBe('present'); + expect(second.status).toBe('present'); + }); + + it.each(['before', 'after'] as const)( + 'recovers GPT notification arming when WeakSet.add throws %s insertion', + async (failure) => { + const readinessCommands: Command[] = []; + const target: { googletag?: unknown } = { googletag: { cmd: readinessCommands } }; + const adapter = createBrowserGoogletagAdapter(target); + const originalWeakSetAdd = WeakSet.prototype.add; + WeakSet.prototype.add = function (this: WeakSet, value: object): WeakSet { + if (failure === 'after') Reflect.apply(originalWeakSetAdd, this, [value]); + throw new Error(`GPT arming failed ${failure} insertion`); + } as typeof WeakSet.prototype.add; + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(() => 'ready'); + } catch (error) { + thrown = error; + } finally { + WeakSet.prototype.add = originalWeakSetAdd; + } + + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published GPT operation'); + expect(readinessCommands).toHaveLength(0); + adapter.notifyReady(); + expect(readinessCommands).toHaveLength(1); + target.googletag = createReadyGoogletag().googletag; + readinessCommands[0]?.(); + await expect(operation.result).resolves.toBe('ready'); + adapter.dispose(); + } + ); + + it('recovers GPT notification arming when WeakSet.has throws after publication', async () => { + vi.useFakeTimers(); + const readinessCommands: Command[] = []; + const target: { googletag?: unknown } = { googletag: { cmd: readinessCommands } }; + const adapter = createBrowserGoogletagAdapter(target); + const order: number[] = []; + const originalWeakSetHas = WeakSet.prototype.has; + WeakSet.prototype.has = function (): boolean { + throw new Error('GPT armed lookup failed'); + } as typeof WeakSet.prototype.has; + + let first: ReturnType | undefined; + let thrown: unknown; + try { + first = adapter.run(() => order.push(1)); + } catch (error) { + thrown = error; + } finally { + WeakSet.prototype.has = originalWeakSetHas; + } + + expect(thrown).toBeUndefined(); + if (!first) throw new Error('Expected a published GPT operation'); + expect(readinessCommands).toHaveLength(1); + const second = adapter.run(() => order.push(2)); + expect(readinessCommands).toHaveLength(1); + target.googletag = createReadyGoogletag().googletag; + readinessCommands[0]?.(); + await expect(Promise.all([first.result, second.result])).resolves.toEqual([1, 2]); + expect(order).toEqual([1, 2]); + expect(vi.getTimerCount()).toBe(0); + + target.googletag = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + const recoveredResults = recovered.map(({ result }) => result.catch((error) => error)); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + for (const operation of recovered) operation.dispose(); + await Promise.all(recoveredResults); + expect(vi.getTimerCount()).toBe(0); + adapter.dispose(); + }); + + it.each([ + { pushFailure: 'before', deleteFailure: 'throw' }, + { pushFailure: 'after', deleteFailure: 'retain' }, + ] as const)( + 'retries GPT notification registration after $pushFailure enqueue failure and $deleteFailure rollback', + async ({ pushFailure, deleteFailure }) => { + vi.useFakeTimers(); + const readinessCommands: Command[] = []; + let queueBroken = true; + const push = vi.fn((command: Command): number => { + if (queueBroken) { + if (pushFailure === 'after') readinessCommands.push(command); + throw new Error(`GPT queue failed ${pushFailure} enqueue`); + } + readinessCommands.push(command); + return readinessCommands.length; + }); + const target: { googletag?: unknown } = { googletag: { cmd: { push } } }; + const adapter = createBrowserGoogletagAdapter(target); + const order: number[] = []; + const originalWeakSetDelete = WeakSet.prototype.delete; + WeakSet.prototype.delete = function (): boolean { + if (deleteFailure === 'throw') throw new Error('GPT arming rollback failed'); + return false; + } as typeof WeakSet.prototype.delete; + + let first: ReturnType | undefined; + let thrown: unknown; + try { + first = adapter.run(() => order.push(1)); + } catch (error) { + thrown = error; + } finally { + WeakSet.prototype.delete = originalWeakSetDelete; + } + + expect(thrown).toBeUndefined(); + if (!first) throw new Error('Expected a published GPT operation'); + expect(readinessCommands).toHaveLength(pushFailure === 'after' ? 1 : 0); + queueBroken = false; + const second = adapter.run(() => order.push(2)); + expect(readinessCommands).toHaveLength(pushFailure === 'after' ? 2 : 1); + + target.googletag = createReadyGoogletag().googletag; + if (pushFailure === 'after') { + readinessCommands[0]?.(); + expect(order).toEqual([]); + } + readinessCommands[readinessCommands.length - 1]?.(); + await expect(Promise.all([first.result, second.result])).resolves.toEqual([1, 2]); + expect(order).toEqual([1, 2]); + for (const notify of readinessCommands) notify(); + expect(order).toEqual([1, 2]); + expect(vi.getTimerCount()).toBe(0); + + target.googletag = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + const recoveredResults = recovered.map(({ result }) => result.catch((error) => error)); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + for (const operation of recovered) operation.dispose(); + await Promise.all(recoveredResults); + expect(vi.getTimerCount()).toBe(0); + adapter.dispose(); + } + ); + + it('rejects a queued operation when its pending GPT stub becomes incompatible', async () => { + const readinessCommands: Command[] = []; + const ready = createReadyGoogletag(); + const binding: Record = { + ...ready.googletag, + apiReady: false, + cmd: readinessCommands, + }; + const adapter = createBrowserGoogletagAdapter({ googletag: binding }); + const command = vi.fn(); + const operation = adapter.run(command); + + binding['apiReady'] = true; + delete binding['display']; + readinessCommands[0]?.(); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(operation.status).toBe('incompatible'); + expect(command).not.toHaveBeenCalled(); + }); + + it('requires the captured GPT queue and root methods to remain compatible', async () => { + const mutations = [ + (binding: Record): void => { + binding['apiReady'] = false; + }, + (binding: Record): void => { + binding['display'] = vi.fn(); + }, + (binding: Record): void => { + binding['pubads'] = vi.fn(); + }, + (binding: Record): void => { + binding['cmd'] = { push: vi.fn() }; + }, + ]; + for (const mutate of mutations) { + const ready = createReadyGoogletag({ deferCommands: true }); + const binding = ready.googletag as unknown as Record; + const adapter = createBrowserGoogletagAdapter({ googletag: binding }); + const command = vi.fn(); + const operation = adapter.run(command); + + mutate(binding); + ready.commands[0]?.(); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(operation.status).toBe('incompatible'); + expect(command).not.toHaveBeenCalled(); + } + }); + + it('rechecks GPT compatibility after an in-place getter mutation', async () => { + const ready = createReadyGoogletag({ deferCommands: true }); + const binding = ready.googletag as unknown as Record; + const originalDisplay = ready.googletag.display; + const adapter = createBrowserGoogletagAdapter({ googletag: binding }); + const command = vi.fn(); + const operation = adapter.run(command); + Object.defineProperty(binding, 'display', { + configurable: true, + get: () => { + binding['apiReady'] = false; + return originalDisplay; + }, + }); + + ready.commands[0]?.(); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(command).not.toHaveBeenCalled(); + }); + + it('ignores a stale GPT notification and lets the replacement notification decide', async () => { + const oldNotifications: Command[] = []; + const replacementNotifications: Command[] = []; + const oldBinding = { cmd: oldNotifications }; + const replacement: Record = { cmd: replacementNotifications }; + const target: { googletag?: unknown } = { googletag: oldBinding }; + const adapter = createBrowserGoogletagAdapter(target); + const first = adapter.run(() => 'first'); + target.googletag = replacement; + const second = adapter.run(() => 'second'); + + replacement['apiReady'] = true; + oldNotifications[0]?.(); + expect(first.status).toBe('pending'); + expect(second.status).toBe('pending'); + + replacementNotifications[0]?.(); + await expect(first.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + await expect(second.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + }); + + it('does not let a stale GPT notification condemn a primitive replacement', async () => { + const oldNotifications: Command[] = []; + const target: { googletag?: unknown } = { googletag: { cmd: oldNotifications } }; + const adapter = createBrowserGoogletagAdapter(target); + const operation = adapter.run(vi.fn()); + const result = operation.result.catch((error: unknown) => error); + target.googletag = 1; + + oldNotifications[0]?.(); + expect(operation.status).toBe('pending'); + adapter.dispose(); + await expect(result).resolves.toMatchObject({ code: 'operation_disposed' }); + }); + + it('marks only the current operation incompatible and permits a later replacement', async () => { + const target = { googletag: { apiReady: true, cmd: {} } }; + const adapter = createBrowserGoogletagAdapter(target); + const incompatible = adapter.run(vi.fn()); + + expect(incompatible.status).toBe('incompatible'); + await expect(incompatible.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + + target.googletag = createReadyGoogletag().googletag; + const replacement = adapter.run(() => 'replacement'); + expect(replacement.status).toBe('present'); + await expect(replacement.result).resolves.toBe('replacement'); + }); + + it('holds 64 pending operations and fails only overflow synchronously', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const operations = Array.from({ length: 64 }, () => adapter.run(() => undefined)); + + expect(operations.every(({ status }) => status === 'pending')).toBe(true); + expect(() => adapter.run(() => undefined)).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + await expect(Promise.all(operations.map(({ result }) => result))).resolves.toHaveLength(64); + }); + + it('reserves pending GPT capacity before hostile signal registration reenters', async () => { + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const accepted: Array> = []; + const overflows: unknown[] = []; + const order: number[] = []; + const signal = { + aborted: false, + addEventListener: vi.fn(() => { + for (let index = 1; index <= 64; index += 1) { + try { + accepted.push(adapter.run(() => order.push(index))); + } catch (error) { + overflows.push(error); + } + } + }), + removeEventListener: vi.fn(), + } as unknown as AbortSignal; + + const outer = adapter.run(() => order.push(0), { signal }); + + expect(accepted).toHaveLength(63); + expect(overflows).toHaveLength(1); + expect(overflows[0]).toMatchObject({ code: 'external_queue_full' }); + + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + await expect( + Promise.all([outer.result, ...accepted.map(({ result }) => result)]) + ).resolves.toHaveLength(64); + expect(order).toEqual(Array.from({ length: 64 }, (_, index) => index)); + }); + + it('reserves pending GPT capacity before poisoned Set.add reenters', async () => { + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const accepted: Array> = []; + const overflows: unknown[] = []; + const order: number[] = []; + const originalSetAdd = Set.prototype.add; + let reentered = false; + Set.prototype.add = function (this: Set, value: unknown): Set { + if (!reentered) { + reentered = true; + Set.prototype.add = originalSetAdd; + for (let index = 1; index <= 64; index += 1) { + try { + accepted.push(adapter.run(() => order.push(index))); + } catch (error) { + overflows.push(error); + } + } + } + return Reflect.apply(originalSetAdd, this, [value]) as Set; + } as typeof Set.prototype.add; + + let outer: ReturnType | undefined; + try { + outer = adapter.run(() => order.push(0)); + } finally { + Set.prototype.add = originalSetAdd; + } + if (!outer) throw new Error('Expected a published GPT operation'); + + expect(accepted).toHaveLength(63); + expect(overflows).toHaveLength(1); + expect(overflows[0]).toMatchObject({ code: 'external_queue_full' }); + + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + await expect( + Promise.all([outer.result, ...accepted.map(({ result }) => result)]) + ).resolves.toHaveLength(64); + expect(order).toEqual([...Array.from({ length: 63 }, (_, index) => index + 1), 0]); + + target.googletag = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + await expect(Promise.all(recovered.map(({ result }) => result))).resolves.toHaveLength(64); + }); + + it('rolls back pending GPT publication when poisoned Set.add throws', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const publicationError = new Error('GPT publication failed'); + const command = vi.fn(); + const signalGetter = vi.fn(() => undefined); + const options = Object.defineProperty({}, 'signal', { + get: signalGetter, + }) as { readonly signal?: AbortSignal }; + const originalSetAdd = Set.prototype.add; + const originalSetDelete = Set.prototype.delete; + const poisonedDelete = function (): boolean { + throw new Error('GPT publication rollback delete failed'); + } as typeof Set.prototype.delete; + let poisonNextAdd = true; + Set.prototype.add = function (this: Set, value: unknown): Set { + if (poisonNextAdd) { + poisonNextAdd = false; + Set.prototype.add = originalSetAdd; + Reflect.apply(originalSetAdd, this, [value]); + throw publicationError; + } + return Reflect.apply(originalSetAdd, this, [value]) as Set; + } as typeof Set.prototype.add; + Set.prototype.delete = poisonedDelete; + + let thrown: unknown; + try { + adapter.run(command, options); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + Set.prototype.delete = originalSetDelete; + } + + expect(thrown).toBe(publicationError); + expect(command).not.toHaveBeenCalled(); + expect(signalGetter).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + await expect(Promise.all(recovered.map(({ result }) => result))).resolves.toHaveLength(64); + expect(vi.getTimerCount()).toBe(0); + Set.prototype.delete = poisonedDelete; + try { + expect(() => adapter.dispose()).not.toThrow(); + } finally { + Set.prototype.delete = originalSetDelete; + } + await Promise.resolve(); + }); + + it.each(['signal-getter', 'aborted-getter', 'add-throw', 'abort-remove-throw'] as const)( + 'contains hostile GPT AbortSignal ownership for %s', + async (failure) => { + const adapter = createBrowserGoogletagAdapter({}); + const signalError = new Error(`signal failure: ${failure}`); + const listeners = new Set<() => void>(); + const removeEventListener = vi.fn((_type: string, listener: () => void) => { + if (failure === 'abort-remove-throw') throw signalError; + listeners.delete(listener); + }); + const signal = Object.defineProperties( + {}, + { + aborted: { + get: () => { + if (failure === 'aborted-getter') throw signalError; + return false; + }, + }, + addEventListener: { + value: vi.fn((_type: string, listener: () => void) => { + listeners.add(listener); + if (failure === 'add-throw') throw signalError; + if (failure === 'abort-remove-throw') listener(); + }), + }, + removeEventListener: { value: removeEventListener }, + } + ) as AbortSignal; + const options = + failure === 'signal-getter' + ? (Object.defineProperty({}, 'signal', { + get: () => { + throw signalError; + }, + }) as { readonly signal?: AbortSignal }) + : { signal }; + let operation: ReturnType | undefined; + + expect(() => { + operation = adapter.run(vi.fn(), options); + }).not.toThrow(); + if (!operation) throw new Error('Expected a published GPT operation'); + if (failure === 'abort-remove-throw') { + await expect(operation.result).rejects.toMatchObject({ code: 'caller_aborted' }); + } else { + await expect(operation.result).rejects.toBe(signalError); + } + if (failure === 'add-throw' || failure === 'abort-remove-throw') { + expect(removeEventListener).toHaveBeenCalledTimes(1); + } + + const fillers: Array> = []; + for (let index = 0; index < 64; index += 1) fillers.push(adapter.run(vi.fn())); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + adapter.dispose(); + await Promise.all(fillers.map(({ result }) => result.catch((error: unknown) => error))); + } + ); + + it.each([ + 'add-getter', + 'before-install', + 'after-install', + 'reentrant-callback', + 'post-check-throw', + ] as const)( + 'settles GPT abort transitions during listener registration for %s', + async (transition) => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const signalError = new Error(`abort transition failure: ${transition}`); + const listeners = new Set<() => void>(); + let aborted = false; + let abortedReads = 0; + const addEventListener = vi.fn((_type: string, listener: () => void) => { + if (transition === 'before-install') aborted = true; + listeners.add(listener); + if (transition === 'after-install') aborted = true; + if (transition === 'reentrant-callback') { + aborted = true; + listener(); + } + }); + const removeEventListener = vi.fn((_type: string, listener: () => void) => { + listeners.delete(listener); + }); + const signal = Object.defineProperties( + {}, + { + aborted: { + get: () => { + abortedReads += 1; + if (transition === 'post-check-throw' && abortedReads === 2) throw signalError; + return aborted; + }, + }, + addEventListener: { + get: () => { + if (transition === 'add-getter') aborted = true; + return addEventListener; + }, + }, + removeEventListener: { value: removeEventListener }, + } + ) as AbortSignal; + const command = vi.fn(); + const operation = adapter.run(command, { signal }); + const result = operation.result.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(10_000); + if (transition === 'post-check-throw') await expect(result).resolves.toBe(signalError); + else await expect(result).resolves.toMatchObject({ code: 'caller_aborted' }); + expect(addEventListener).toHaveBeenCalledTimes(1); + expect(removeEventListener).toHaveBeenCalledTimes(1); + expect(listeners).toHaveLength(0); + + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + expect(command).not.toHaveBeenCalled(); + + target.googletag = undefined; + const fillers = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + adapter.dispose(); + await Promise.all( + fillers.map(({ result: filler }) => filler.catch((error: unknown) => error)) + ); + } + ); + + it('uses one exact independent ten-second deadline per enqueued operation', async () => { + vi.useFakeTimers(); + const adapter = createBrowserGoogletagAdapter({}); + const first = adapter.run(vi.fn()); + const firstResult = first.result.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(5_000); + const second = adapter.run(vi.fn()); + const secondResult = second.result.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(4_999); + expect(first.status).toBe('pending'); + expect(second.status).toBe('pending'); + + await vi.advanceTimersByTimeAsync(1); + expect(first.status).toBe('timed_out'); + await expect(firstResult).resolves.toMatchObject({ code: 'external_ready_timeout' }); + expect(second.status).toBe('pending'); + + await vi.advanceTimersByTimeAsync(5_000); + expect(second.status).toBe('timed_out'); + await expect(secondResult).resolves.toMatchObject({ code: 'external_ready_timeout' }); + }); + + it('lets readiness immediately before the deadline win the operation latch', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const operation = adapter.run(() => 'ready'); + + await vi.advanceTimersByTimeAsync(9_999); + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + await vi.advanceTimersByTimeAsync(1); + + expect(operation.status).toBe('present'); + await expect(operation.result).resolves.toBe('ready'); + }); + + it('lets the first callback at the exact deadline win and keeps the loser inert', async () => { + vi.useFakeTimers(); + const ready = createReadyGoogletag(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + vi.setSystemTime(0); + const operation = adapter.run(() => 'ready'); + + vi.setSystemTime(10_000); + target.googletag = ready.googletag; + adapter.notifyReady(); + await vi.runOnlyPendingTimersAsync(); + + expect(operation.status).toBe('present'); + await expect(operation.result).resolves.toBe('ready'); + expect(ready.googletag.cmd.push).toHaveBeenCalledTimes(1); + }); + + it('lets timeout at or after the deadline win and ignores late readiness', async () => { + vi.useFakeTimers(); + const command = vi.fn(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const operation = adapter.run(command); + const result = operation.result.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(10_000); + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + + expect(operation.status).toBe('timed_out'); + await expect(result).resolves.toMatchObject({ code: 'external_ready_timeout' }); + expect(command).not.toHaveBeenCalled(); + }); + + it('removes aborted and disposed operations immediately', async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const abortedCommand = vi.fn(); + const disposedCommand = vi.fn(); + const aborted = adapter.run(abortedCommand, { signal: controller.signal }); + const disposed = adapter.run(disposedCommand); + const abortedResult = aborted.result.catch((error: unknown) => error); + const disposedResult = disposed.result.catch((error: unknown) => error); + + controller.abort(); + disposed.dispose(); + const replacements = Array.from({ length: 64 }, () => adapter.run(() => undefined)); + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + + await expect(abortedResult).resolves.toMatchObject({ code: 'caller_aborted' }); + await expect(disposedResult).resolves.toMatchObject({ code: 'operation_disposed' }); + expect(abortedCommand).not.toHaveBeenCalled(); + expect(disposedCommand).not.toHaveBeenCalled(); + await expect(Promise.all(replacements.map(({ result }) => result))).resolves.toHaveLength(64); + }); + + it('disposes the adapter by removing every pending operation', async () => { + const command = vi.fn(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const operation = adapter.run(command); + const result = operation.result.catch((error: unknown) => error); + + adapter.dispose(); + target.googletag = createReadyGoogletag().googletag; + adapter.notifyReady(); + + await expect(result).resolves.toMatchObject({ code: 'operation_disposed' }); + expect(command).not.toHaveBeenCalled(); + }); + + it('invalidates an entered command immediately when the adapter is disposed', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const operation = adapter.run((gpt) => { + adapter.dispose(); + gpt.display('must-not-display'); + }); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(operation.status).toBe('present'); + expect(ready.display).not.toHaveBeenCalled(); + expect(ready.listeners.size).toBe(0); + }); + + it('throws when GPT inspection disposes the adapter before an operation is published', () => { + const ready = createReadyGoogletag(); + const holder: { adapter?: ReturnType } = {}; + const target = Object.defineProperty({}, 'googletag', { + get: () => { + holder.adapter?.dispose(); + return ready.googletag; + }, + }); + const adapter = createBrowserGoogletagAdapter(target); + holder.adapter = adapter; + const command = vi.fn(); + + expect(() => adapter.run(command)).toThrowError( + expect.objectContaining({ code: 'operation_disposed' }) + ); + expect(command).not.toHaveBeenCalled(); + expect(ready.commands).toHaveLength(0); + }); + + it('rejects without enqueueing when GPT inspection disposes a published operation', async () => { + const ready = createReadyGoogletag({ deferCommands: true }); + let reads = 0; + const holder: { adapter?: ReturnType } = {}; + const target = Object.defineProperty({}, 'googletag', { + get: () => { + reads += 1; + if (reads === 3) holder.adapter?.dispose(); + return ready.googletag; + }, + }); + const adapter = createBrowserGoogletagAdapter(target); + holder.adapter = adapter; + const command = vi.fn(); + const operation = adapter.run(command); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(command).not.toHaveBeenCalled(); + expect(ready.commands).toHaveLength(0); + }); + + it('contains disposal reentrant from GPT member reads and external calls', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const staleSet = vi.fn(); + const slot = Object.defineProperty({}, 'setTargeting', { + get: () => { + adapter.dispose(); + return staleSet; + }, + }); + const memberOperation = adapter.run((gpt) => gpt.setTargeting(slot, 'key', 'value')); + + await expect(memberOperation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(staleSet).not.toHaveBeenCalled(); + + const externalReady = createReadyGoogletag(); + const externalAdapter = createBrowserGoogletagAdapter({ + googletag: externalReady.googletag, + }); + externalReady.display.mockImplementation(() => externalAdapter.dispose()); + const externalOperation = externalAdapter.run((gpt) => { + gpt.display('first'); + gpt.display('must-not-display'); + }); + + await expect(externalOperation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(externalReady.display).toHaveBeenCalledTimes(1); + }); + + it('does not enqueue after disposal reentrant from GPT configuration reads', async () => { + const ready = createReadyGoogletag({ deferCommands: true }); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisableInitialLoad = ready.pubads.disableInitialLoad; + ready.googletag.getConfig.mockImplementation(() => { + adapter.dispose(); + return { disableInitialLoad: false }; + }); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const command = vi.fn(); + const operation = adapter.run(command); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(ready.commands).toHaveLength(0); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisableInitialLoad); + + ready.googletag.getConfig.mockImplementation((key: string) => + key === 'disableInitialLoad' ? { disableInitialLoad: ready.initialLoad.disabled } : {} + ); + const laterAdapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const laterOperation = laterAdapter.run((gpt) => gpt.serviceState()); + ready.commands[0]?.(); + await laterOperation.result; + laterAdapter.dispose(); + + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisableInitialLoad); + }); + + it.each(['root', 'service'] as const)( + 'rolls back a GPT %s wrapper when post-install currentness inspection disposes', + async (wrapperKind) => { + const ready = createReadyGoogletag({ deferCommands: true }); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisableInitialLoad = ready.pubads.disableInitialLoad; + let wrappedReads = 0; + const holder: { adapter?: ReturnType } = {}; + const target = Object.defineProperty({}, 'googletag', { + get: () => { + const wrapped = + wrapperKind === 'root' + ? ready.googletag.setConfig !== nativeSetConfig + : ready.pubads.disableInitialLoad !== nativeDisableInitialLoad; + if (wrapped) { + wrappedReads += 1; + if (wrappedReads === 7) holder.adapter?.dispose(); + } + return ready.googletag; + }, + }); + const adapter = createBrowserGoogletagAdapter(target); + holder.adapter = adapter; + const operation = adapter.run(vi.fn()); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisableInitialLoad); + expect(ready.commands).toHaveLength(0); + } + ); + + it('keeps abort ownership while a ready GPT command callback is deferred', async () => { + const deferred = createReadyGoogletag({ deferCommands: true }); + const controller = new AbortController(); + const command = vi.fn(); + const adapter = createBrowserGoogletagAdapter({ googletag: deferred.googletag }); + const operation = adapter.run(command, { signal: controller.signal }); + const result = operation.result.catch((error: unknown) => error); + + expect(operation.status).toBe('present'); + controller.abort(); + expect(() => deferred.commands[0]?.()).not.toThrow(); + + await expect(result).resolves.toMatchObject({ code: 'caller_aborted' }); + expect(command).not.toHaveBeenCalled(); + }); + + it('rejects a deferred command against a replaced GPT object and accepts later work', async () => { + const first = createReadyGoogletag({ deferCommands: true }); + const replacement = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + const command = vi.fn(); + const operation = adapter.run(command); + const result = operation.result.catch((error: unknown) => error); + + target.googletag = replacement.googletag; + expect(() => first.commands[0]?.()).not.toThrow(); + + await expect(result).resolves.toMatchObject({ code: 'external_artifact_incompatible' }); + expect(operation.status).toBe('incompatible'); + expect(command).not.toHaveBeenCalled(); + await expect(adapter.run(() => 'replacement').result).resolves.toBe('replacement'); + }); + + it('rechecks GPT identity around hostile member reads and external calls', async () => { + const first = createReadyGoogletag(); + const replacement = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + const staleSet = vi.fn(); + const slot = Object.defineProperty({}, 'setTargeting', { + get: () => { + target.googletag = replacement.googletag; + return staleSet; + }, + }); + const operation = adapter.run((gpt) => gpt.setTargeting(slot, 'key', 'value')); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(operation.status).toBe('incompatible'); + expect(staleSet).not.toHaveBeenCalled(); + }); + + it('makes an old GPT listener inert after whole-object replacement', async () => { + const first = createReadyGoogletag(); + const replacement = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + const listener = vi.fn(); + let unsubscribe = (): void => undefined; + await adapter.run((gpt) => { + unsubscribe = gpt.subscribe('slotRequested', listener); + }).result; + const oldListener = [...(first.listeners.get('slotRequested') ?? [])][0]; + + target.googletag = replacement.googletag; + expect(() => oldListener?.({ slot: {} })).not.toThrow(); + unsubscribe(); + + expect(listener).not.toHaveBeenCalled(); + expect(first.pubads.removeEventListener).toHaveBeenCalledTimes(1); + }); + + it('publishes frozen diagnostics facts after the sole adapter listener completes', async () => { + const ready = createReadyGoogletag(); + const performance = { now: vi.fn(() => 42.25) }; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag, performance }); + const order: string[] = []; + const facts: unknown[] = []; + const releaseDiagnostics = adapter.observeDiagnostics?.((fact) => { + order.push('diagnostics'); + facts.push(fact); + throw new Error('fictional diagnostics failure'); + }); + expect(releaseDiagnostics).toEqual(expect.any(Function)); + expect(ready.pubads.addEventListener).not.toHaveBeenCalled(); + + await adapter.run((gpt) => + gpt.subscribe('slotRenderEnded', () => { + order.push('correctness'); + }) + ).result; + expect(ready.pubads.addEventListener).toHaveBeenCalledOnce(); + const slot = Object.freeze({ + getSlotElementId: () => 'fictional-slot', + getAdUnitPath: () => '/example/fictional-slot', + setTargeting: vi.fn(), + }); + const emit = (event: unknown): void => { + for (const listener of ready.listeners.get('slotRenderEnded') ?? []) listener(event); + }; + expect(() => + emit({ + slot, + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: false, + }) + ).not.toThrow(); + + expect(order).toEqual(['correctness', 'diagnostics']); + expect(facts).toEqual([ + { + kind: 'slotRenderEnded', + observedAtMs: 42.25, + slot: { + token: expect.any(Object), + traceToken: 'gt1_1', + runtimeSlotNumber: 1, + elementId: 'fictional-slot', + adUnitPath: '/example/fictional-slot', + }, + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: false, + }, + ]); + expect(Object.isFrozen(facts[0])).toBe(true); + expect(Object.isFrozen((facts[0] as { size: unknown }).size)).toBe(true); + const safeSlot = (facts[0] as { slot: Record }).slot; + expect(Object.isFrozen(safeSlot)).toBe(true); + expect(Object.isFrozen(safeSlot['token'])).toBe(true); + expect(Reflect.ownKeys(safeSlot).sort()).toEqual([ + 'adUnitPath', + 'elementId', + 'runtimeSlotNumber', + 'token', + 'traceToken', + ]); + expect(Object.values(safeSlot).some((value) => typeof value === 'function')).toBe(false); + expect(safeSlot).not.toBe(slot); + + releaseDiagnostics?.(); + emit({ slot, isEmpty: true }); + expect(facts).toHaveLength(1); + }); + + it.each([false, true])( + 'publishes correctness facts from the sole listeners and conditions four diagnostics-only listeners (active=%s)', + async (diagnosticsActive) => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const facts: GoogletagDiagnosticsFact[] = []; + const accepted = createGoogletagTraceCycleHandle(() => false); + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + await adapter.run((gpt) => { + gpt.subscribe('slotRequested', () => accepted); + gpt.subscribe('slotRenderEnded', () => accepted); + if (diagnosticsActive) { + for (const eventType of [ + 'slotResponseReceived', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', + ]) { + gpt.subscribe(eventType, () => undefined, true); + } + } + }).result; + const slot = { getSlotElementId: () => 'sole-listener-slot' }; + const emit = (eventType: string, fields: Readonly> = {}): void => { + const event = { slot, ...fields }; + for (const listener of ready.listeners.get(eventType) ?? []) listener(event); + }; + + emit('slotRequested'); + if (diagnosticsActive) emit('slotResponseReceived'); + emit('slotRenderEnded', { isEmpty: false }); + if (diagnosticsActive) { + emit('slotOnload'); + emit('impressionViewable'); + emit('slotVisibilityChanged', { inViewPercentage: 75 }); + } + + expect(ready.pubads.addEventListener).toHaveBeenCalledTimes(diagnosticsActive ? 6 : 2); + expect(facts.map(({ kind }) => kind)).toEqual( + diagnosticsActive + ? [ + 'slotRequested', + 'slotResponseReceived', + 'slotRenderEnded', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', + ] + : ['slotRequested', 'slotRenderEnded'] + ); + expect(facts.every(({ slot: snapshot }) => snapshot.cycleOrdinal === 1)).toBe(true); + } + ); + + it('opens trace cycles only from accepted lifecycle handles and publishes each raw event once', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const facts: GoogletagDiagnosticsFact[] = []; + const lifecycle = { retired: false }; + const accepted = createGoogletagTraceCycleHandle(() => lifecycle.retired); + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + await adapter.run((gpt) => { + gpt.subscribe('slotRequested', () => accepted); + gpt.subscribe('slotRenderEnded', () => accepted); + }).result; + const slot = { getSlotElementId: () => 'accepted-cycle-slot' }; + const emit = (eventType: string, fields: Readonly> = {}): void => { + const event = { slot, ...fields }; + for (const listener of ready.listeners.get(eventType) ?? []) listener(event); + }; + + emit('slotRequested'); + emit('slotRenderEnded', { isEmpty: false, responseIdentifier: 'accepted-response' }); + lifecycle.retired = true; + emit('slotRequested'); + + expect(facts.map(({ kind, slot: snapshot }) => [kind, snapshot.cycleOrdinal])).toEqual([ + ['slotRequested', 1], + ['slotRenderEnded', 1], + ['slotRequested', undefined], + ]); + }); + + it('publishes a correctness fact only after lifecycle attribution completes', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const facts: GoogletagDiagnosticsFact[] = []; + const order: string[] = []; + const accepted = createGoogletagTraceCycleHandle(() => false); + adapter.observeDiagnostics?.((fact) => { + order.push('diagnostics'); + facts.push(fact); + }); + await adapter.run((gpt) => { + gpt.subscribe('slotRequested', () => { + order.push('correctness'); + return accepted; + }); + }).result; + const slot = { getSlotElementId: () => 'attributed-cycle-slot' }; + + const event = { slot }; + for (const listener of ready.listeners.get('slotRequested') ?? []) listener(event); + + expect(facts).toHaveLength(1); + expect(facts[0]?.slot.cycleOrdinal).toBe(1); + expect(order).toEqual(['correctness', 'diagnostics']); + }); + + it('reports duplicate, reused, and already-open cycle handles once while preserving raw facts', async () => { + const ready = createReadyGoogletag(); + const reports = vi.fn(); + const adapter = createBrowserGoogletagAdapter( + { googletag: ready.googletag }, + { reportDiagnosticsFailure: reports } + ); + const facts: GoogletagDiagnosticsFact[] = []; + const first = createGoogletagTraceCycleHandle(() => false); + const second = createGoogletagTraceCycleHandle(() => false); + let selected = first; + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + await adapter.run((gpt) => { + gpt.subscribe('slotRequested', () => selected); + }).result; + const firstSlot = { getSlotElementId: () => 'cycle-collision-one' }; + const secondSlot = { getSlotElementId: () => 'cycle-collision-two' }; + const emit = (slot: object): void => { + const event = { slot }; + for (const listener of ready.listeners.get('slotRequested') ?? []) listener(event); + }; + + emit(firstSlot); + emit(firstSlot); + selected = second; + emit(firstSlot); + selected = first; + emit(secondSlot); + + expect(facts).toHaveLength(4); + expect(facts.map(({ slot }) => slot.cycleOrdinal)).toEqual([ + 1, + undefined, + undefined, + undefined, + ]); + expect(reports).toHaveBeenCalledOnce(); + expect(reports).toHaveBeenCalledWith('trace_cycle_collision'); + }); + + it('reports nonunique callbacks once without suppressing raw facts', async () => { + const ready = createReadyGoogletag(); + const reports = vi.fn(); + const adapter = createBrowserGoogletagAdapter( + { googletag: ready.googletag }, + { reportDiagnosticsFailure: reports } + ); + const facts: GoogletagDiagnosticsFact[] = []; + const first = createGoogletagTraceCycleHandle(() => false); + const second = createGoogletagTraceCycleHandle(() => false); + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + const slot = { getSlotElementId: () => 'cycle-ambiguity' }; + const emit = (eventType: string, fields: Readonly> = {}): void => { + const event = { slot, ...fields }; + for (const listener of ready.listeners.get(eventType) ?? []) listener(event); + }; + + await adapter.run((gpt) => { + gpt.subscribe('slotRequested', (event) => + (event as { cycle: number }).cycle === 1 ? first : second + ); + gpt.subscribe('slotRenderEnded', (event) => + (event as { cycle: number }).cycle === 1 ? first : second + ); + gpt.subscribe('slotOnload', () => undefined, true); + }).result; + emit('slotRequested', { cycle: 1 }); + emit('slotRenderEnded', { cycle: 1, isEmpty: false }); + emit('slotRequested', { cycle: 2 }); + emit('slotRenderEnded', { cycle: 2, isEmpty: false }); + emit('slotOnload'); + + expect(facts).toHaveLength(5); + expect(facts.map(({ slot: snapshot }) => snapshot.cycleOrdinal)).toEqual([ + 1, + 1, + 2, + 2, + undefined, + ]); + expect(reports).toHaveBeenCalledOnce(); + expect(reports).toHaveBeenCalledWith('trace_cycle_ambiguity'); + }); + + it('never transfers a trace handle from a non-owner into a diagnostics-only fact', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const facts: GoogletagDiagnosticsFact[] = []; + const first = createGoogletagTraceCycleHandle(() => false); + const second = createGoogletagTraceCycleHandle(() => false); + let selected = first; + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + await adapter.run((gpt) => { + gpt.subscribe('slotRequested', () => selected); + gpt.subscribe('slotRenderEnded', () => selected); + gpt.subscribe('slotOnload', () => selected); + gpt.subscribe('slotOnload', () => undefined, true); + }).result; + const slot = { getSlotElementId: () => 'diagnostics-only-attribution-slot' }; + const emit = (eventType: string, fields: Readonly> = {}): void => { + const event = { slot, ...fields }; + for (const listener of ready.listeners.get(eventType) ?? []) listener(event); + }; + + emit('slotRequested'); + emit('slotRenderEnded', { isEmpty: false }); + selected = second; + emit('slotRequested'); + emit('slotRenderEnded', { isEmpty: false }); + selected = first; + emit('slotOnload'); + + expect(facts.map(({ kind, slot: snapshot }) => [kind, snapshot.cycleOrdinal])).toEqual([ + ['slotRequested', 1], + ['slotRenderEnded', 1], + ['slotRequested', 2], + ['slotRenderEnded', 2], + ['slotOnload', undefined], + ]); + }); + + it('accepts correctness attribution only from an adapter-branded trace handle', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const facts: GoogletagDiagnosticsFact[] = []; + const forged = Object.freeze({ isRetired: () => false }); + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + await adapter.run((gpt) => { + gpt.subscribe('slotRequested', () => forged as never); + }).result; + const slot = { getSlotElementId: () => 'forged-attribution-slot' }; + + for (const listener of ready.listeners.get('slotRequested') ?? []) listener({ slot }); + + expect(facts).toHaveLength(1); + expect(facts[0]?.slot.cycleOrdinal).toBeUndefined(); + }); + + it('admits only one diagnostics observer without adding GPT listeners', () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const release = adapter.observeDiagnostics?.(vi.fn()); + + expect(adapter.observeDiagnostics?.(vi.fn())).toBeUndefined(); + expect(ready.pubads.addEventListener).not.toHaveBeenCalled(); + release?.(); + expect(adapter.observeDiagnostics?.(vi.fn())).toEqual(expect.any(Function)); + expect(ready.pubads.addEventListener).not.toHaveBeenCalled(); + }); + + it('keeps distinct opaque and canonical trace tokens per physical Slot', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ + googletag: ready.googletag, + performance: { now: () => 7 }, + }); + const facts: GoogletagDiagnosticsFact[] = []; + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + await adapter.run((gpt) => gpt.subscribe('slotRequested', () => undefined)).result; + const first = { + getSlotElementId: () => 'same-id', + getAdUnitPath: () => '/example/first', + setTargeting: vi.fn(), + }; + const replacement = { + getSlotElementId: () => 'same-id', + getAdUnitPath: () => '/example/replacement', + setTargeting: vi.fn(), + }; + const emit = (slot: object): void => { + for (const listener of ready.listeners.get('slotRequested') ?? []) listener({ slot }); + }; + + emit(first); + emit(first); + emit(replacement); + + expect(facts).toHaveLength(3); + expect(facts[0]?.slot.token).toBe(facts[1]?.slot.token); + expect(facts[2]?.slot.token).not.toBe(facts[0]?.slot.token); + expect(facts[0]?.slot.traceToken).toBe('gt1_1'); + expect(facts[1]?.slot.traceToken).toBe('gt1_1'); + expect(facts[2]?.slot.traceToken).toBe('gt1_2'); + expect(facts[0]?.slot.cycleOrdinal).toBeUndefined(); + expect(facts[1]?.slot.cycleOrdinal).toBeUndefined(); + expect(facts[2]?.slot.cycleOrdinal).toBeUndefined(); + expect(facts[0]?.slot.token).not.toBe(facts[0]?.slot.traceToken); + expect(Object.isFrozen(first)).toBe(false); + expect(Object.isFrozen(replacement)).toBe(false); + expect(Reflect.ownKeys(facts[0]?.slot ?? {}).sort()).toEqual([ + 'adUnitPath', + 'elementId', + 'runtimeSlotNumber', + 'token', + 'traceToken', + ]); + }); + + it('mints canonical trace identity before events and keeps it stable for handoff', () => { + const adapter = createBrowserGoogletagAdapter({}); + const first = {}; + const replacement = {}; + + expect(adapter.traceToken(first)).toBe('gt1_1'); + expect(adapter.traceToken(first)).toBe('gt1_1'); + expect(adapter.traceToken(replacement)).toBe('gt1_2'); + expect(adapter.traceToken(first)).toMatch(/^gt1_[1-9a-z][0-9a-z]{0,6}$/); + adapter.dispose(); + expect(adapter.traceToken(first)).toBeUndefined(); + }); + + it('adopts transferred per-slot diagnostics cycles and the global token high-water value once', () => { + const adapter = createBrowserGoogletagAdapter({}); + const adopted = {}; + + expect( + adapter.adoptDiagnosticsState?.({ + nextTraceTokenOrdinal: 9, + slots: [ + { + nextCycleOrdinal: 4, + physicalSlot: adopted, + records: [ + { + ordinal: 1, + responseIdentifier: null, + seen: ['slotRequested', 'slotRenderEnded'], + state: 'completed', + }, + { + ordinal: 3, + responseIdentifier: null, + seen: ['slotRequested'], + state: 'retired', + }, + ], + traceToken: 'gt1_5', + unknownPriorCycle: true, + }, + ], + }) + ).toBe(true); + expect(adapter.traceToken(adopted)).toBe('gt1_5'); + const adoptedIdentity = adapter.diagnosticsIdentity(adopted); + expect(adoptedIdentity).toMatchObject({ + traceToken: 'gt1_5', + runtimeSlotNumber: 5, + cycleOrdinal: 3, + }); + expect(typeof adoptedIdentity?.token).toBe('object'); + expect(adapter.diagnosticsIdentity(adopted)?.token).toBe(adoptedIdentity?.token); + expect(adapter.traceToken({})).toBe('gt1_9'); + expect(adapter.adoptDiagnosticsState?.({ nextTraceTokenOrdinal: 10, slots: [] })).toBe(false); + }); + + it('preserves transferred cycle correlation for late diagnostics after takeover', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const adopted = {}; + const facts: GoogletagDiagnosticsFact[] = []; + + expect( + adapter.adoptDiagnosticsState?.({ + nextTraceTokenOrdinal: 2, + slots: [ + { + nextCycleOrdinal: 2, + physicalSlot: adopted, + records: [ + { + ordinal: 1, + responseIdentifier: 'response-one', + seen: ['slotRequested', 'slotRenderEnded'], + state: 'completed', + }, + ], + traceToken: 'gt1_1', + unknownPriorCycle: true, + }, + ], + } as never) + ).toBe(true); + adapter.observeDiagnostics((fact) => facts.push(fact)); + await adapter.run((gpt) => gpt.subscribe('slotOnload', () => undefined, true)).result; + + for (const listener of ready.listeners.get('slotOnload') ?? []) { + listener({ slot: adopted, responseIdentifier: 'response-one' }); + } + + expect(facts).toHaveLength(1); + expect(facts[0]?.slot).toMatchObject({ traceToken: 'gt1_1', cycleOrdinal: 1 }); + }); + + it('rejects malformed diagnostics adoption without consuming the token high-water value', () => { + const adapter = createBrowserGoogletagAdapter({}); + const physicalSlot = {}; + + expect( + adapter.adoptDiagnosticsState?.({ + nextTraceTokenOrdinal: 2, + slots: [ + { + nextCycleOrdinal: 1, + physicalSlot, + records: [ + { + ordinal: 1, + responseIdentifier: null, + seen: ['slotRequested', 'slotRenderEnded'], + state: 'completed', + }, + ], + traceToken: 'gt1_1', + unknownPriorCycle: false, + }, + ], + }) + ).toBe(false); + expect(adapter.traceToken(physicalSlot)).toBe('gt1_1'); + }); + + it('does not retain default canonical tokens in a collision ledger', () => { + const disposalClearCalls = (() => { + const clear = vi.spyOn(Set.prototype, 'clear'); + try { + const adapter = createBrowserGoogletagAdapter({}); + for (let ordinal = 1; ordinal <= 1_024; ordinal += 1) { + expect(adapter.traceToken({})).toBe(`gt1_${ordinal.toString(36)}`); + } + const callsBeforeDispose = clear.mock.calls.length; + + adapter.dispose(); + return clear.mock.calls.length - callsBeforeDispose; + } finally { + clear.mockRestore(); + } + })(); + + expect(disposalClearCalls).toBe(0); + }); + + it('clears the custom minted trace-token collision ledger once on adapter disposal', () => { + const disposalClearCalls = (() => { + const clear = vi.spyOn(Set.prototype, 'clear'); + try { + const adapter = createBrowserGoogletagAdapter( + {}, + { mintTraceToken: (ordinal) => `gt1_${ordinal.toString(36)}` } + ); + expect(adapter.traceToken({})).toBe('gt1_1'); + expect(adapter.traceToken({})).toBe('gt1_2'); + const callsBeforeDispose = clear.mock.calls.length; + + adapter.dispose(); + adapter.dispose(); + return clear.mock.calls.length - callsBeforeDispose; + } finally { + clear.mockRestore(); + } + })(); + + expect(disposalClearCalls).toBe(1); + }); + + it('mints trace-token boundary ordinals and reports exhaustion once without affecting older tokens', () => { + const boundaries = createBrowserGoogletagAdapter({}, { initialTraceTokenOrdinal: 35 }); + expect(boundaries.traceToken({})).toBe('gt1_z'); + expect(boundaries.traceToken({})).toBe('gt1_10'); + + const reports = vi.fn(); + const exhausted = createBrowserGoogletagAdapter( + {}, + { initialTraceTokenOrdinal: 4_294_967_295, reportDiagnosticsFailure: reports } + ); + const retained = {}; + expect(exhausted.traceToken(retained)).toBe('gt1_1z141z3'); + expect(exhausted.traceToken({})).toBeUndefined(); + expect(exhausted.traceToken({})).toBeUndefined(); + expect(exhausted.traceToken(retained)).toBe('gt1_1z141z3'); + expect(reports).toHaveBeenCalledTimes(1); + expect(reports).toHaveBeenCalledWith('trace_token_exhausted'); + }); + + it('contains one injected token collision and lets the next physical object reuse the unspent ordinal', () => { + const reports = vi.fn(() => { + throw new Error('fictional diagnostics reporter failure'); + }); + let calls = 0; + const adapter = createBrowserGoogletagAdapter( + {}, + { + mintTraceToken: (ordinal) => { + calls += 1; + return calls === 2 ? 'gt1_1' : `gt1_${ordinal.toString(36)}`; + }, + reportDiagnosticsFailure: reports, + } + ); + + expect(adapter.traceToken({})).toBe('gt1_1'); + expect(() => adapter.traceToken({})).not.toThrow(); + expect(adapter.traceToken({})).toBe('gt1_2'); + expect(reports).toHaveBeenCalledOnce(); + expect(reports).toHaveBeenCalledWith('trace_token_collision'); + }); + + it('contains per-object cycle exhaustion while preserving raw diagnostics delivery', async () => { + const ready = createReadyGoogletag(); + const reports = vi.fn(); + const adapter = createBrowserGoogletagAdapter( + { googletag: ready.googletag }, + { initialTraceCycleOrdinal: 4_294_967_295, reportDiagnosticsFailure: reports } + ); + const facts: GoogletagDiagnosticsFact[] = []; + let lifecycle = { retired: true }; + let accepted: ReturnType | undefined; + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + await adapter.run((gpt) => { + gpt.subscribe('slotRequested', () => { + lifecycle.retired = true; + const current = { retired: false }; + lifecycle = current; + accepted = createGoogletagTraceCycleHandle(() => current.retired); + return accepted; + }); + gpt.subscribe('slotRenderEnded', () => accepted); + }).result; + const slot = { getSlotElementId: () => 'cycle-exhaustion-slot' }; + const emit = (eventType: string, fields: Readonly> = {}): void => { + const event = { slot, ...fields }; + for (const listener of ready.listeners.get(eventType) ?? []) listener(event); + }; + + emit('slotRequested'); + emit('slotRenderEnded', { isEmpty: false }); + emit('slotRequested'); + + expect(facts.map((fact) => fact.slot.cycleOrdinal)).toEqual([ + 4_294_967_295, + 4_294_967_295, + undefined, + ]); + expect(reports).toHaveBeenCalledOnce(); + expect(reports).toHaveBeenCalledWith('trace_cycle_exhausted'); + }); + + it('assigns exact per-object cycle ordinals and omits ambiguous callbacks', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const facts: GoogletagDiagnosticsFact[] = []; + let lifecycle = { retired: true }; + let accepted: ReturnType | undefined; + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + await adapter.run((gpt) => { + gpt.subscribe('slotRequested', () => { + lifecycle.retired = true; + const current = { retired: false }; + lifecycle = current; + accepted = createGoogletagTraceCycleHandle(() => current.retired); + return accepted; + }); + gpt.subscribe('slotRenderEnded', () => accepted); + for (const eventType of ['slotResponseReceived', 'impressionViewable']) { + gpt.subscribe(eventType, () => undefined, true); + } + }).result; + const slot = { + getSlotElementId: () => 'cycle-slot', + getAdUnitPath: () => '/example/cycle-slot', + }; + const emit = (eventType: string, fields: Readonly> = {}): void => { + const event = { slot, ...fields }; + for (const listener of ready.listeners.get(eventType) ?? []) listener(event); + }; + + emit('slotRequested'); + emit('slotResponseReceived', { responseIdentifier: 'response-1' }); + emit('slotRenderEnded', { isEmpty: false, responseIdentifier: 'response-1' }); + emit('slotRequested'); + emit('impressionViewable'); + emit('slotRenderEnded', { isEmpty: false, responseIdentifier: 'response-2' }); + + expect(facts.map(({ kind, slot: snapshot }) => [kind, snapshot.cycleOrdinal])).toEqual([ + ['slotRequested', 1], + ['slotResponseReceived', 1], + ['slotRenderEnded', 1], + ['slotRequested', 2], + ['impressionViewable', undefined], + ['slotRenderEnded', 2], + ]); + expect(facts[1]?.responseIdentifier).toBe('response-1'); + expect(facts[2]?.responseIdentifier).toBe('response-1'); + expect(facts[5]?.responseIdentifier).toBe('response-2'); + }); + + it.each([ + [9, 1], + [10, 1], + [11, undefined], + ] as const)( + 'retains bounded cycle identity after %i completed requests', + async (cycleCount, expectedOldOrdinal) => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const facts: GoogletagDiagnosticsFact[] = []; + let lifecycle = { retired: true }; + let accepted: ReturnType | undefined; + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + await adapter.run((gpt) => { + gpt.subscribe('slotRequested', () => { + lifecycle.retired = true; + const current = { retired: false }; + lifecycle = current; + accepted = createGoogletagTraceCycleHandle(() => current.retired); + return accepted; + }); + gpt.subscribe('slotRenderEnded', () => accepted); + gpt.subscribe('slotOnload', () => undefined, true); + }).result; + const slot = { getSlotElementId: () => 'ledger-slot' }; + const emit = (eventType: string, fields: Readonly> = {}): void => { + const event = { slot, ...fields }; + for (const listener of ready.listeners.get(eventType) ?? []) listener(event); + }; + + for (let ordinal = 1; ordinal <= cycleCount; ordinal += 1) { + emit('slotRequested'); + emit('slotRenderEnded', { + isEmpty: false, + responseIdentifier: `response-${ordinal}`, + }); + } + emit('slotOnload', { responseIdentifier: 'response-1' }); + + const requested = facts.filter(({ kind }) => kind === 'slotRequested'); + expect(requested.map(({ slot: snapshot }) => snapshot.cycleOrdinal)).toEqual( + Array.from({ length: cycleCount }, (_, index) => index + 1) + ); + expect(facts[facts.length - 1]?.kind).toBe('slotOnload'); + expect(facts[facts.length - 1]?.slot.cycleOrdinal).toBe(expectedOldOrdinal); + } + ); + + it.each( + [ + 'slotResponseReceived', + 'slotRenderEnded', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', + ].flatMap((eventType) => + ['before-next-start', 'after-next-start', 'after-next-completion'].map( + (position) => [eventType, position] as const + ) + ) + )( + 'attributes an old %s callback %s only from exact retained evidence', + async (eventType, position) => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const facts: GoogletagDiagnosticsFact[] = []; + const firstState = { retired: false }; + const secondState = { retired: false }; + const first = createGoogletagTraceCycleHandle(() => firstState.retired); + const second = createGoogletagTraceCycleHandle(() => secondState.retired); + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + await adapter.run((gpt) => { + gpt.subscribe('slotRequested', (event) => + (event as { cycle: number }).cycle === 1 ? first : second + ); + gpt.subscribe('slotRenderEnded', (event) => + (event as { cycle: number }).cycle === 1 ? first : second + ); + for (const type of [ + 'slotResponseReceived', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', + ]) { + gpt.subscribe(type, () => undefined, true); + } + }).result; + const slot = { getSlotElementId: () => 'callback-order-slot' }; + let emitted = 0; + const emit = (type: string, fields: Readonly>): void => { + emitted += 1; + const event = { slot, ...fields }; + for (const listener of ready.listeners.get(type) ?? []) listener(event); + }; + const oldFields = { + cycle: 1, + responseIdentifier: 'response-1', + ...(eventType === 'slotRenderEnded' ? { isEmpty: false } : {}), + ...(eventType === 'slotVisibilityChanged' ? { inViewPercentage: 25 } : {}), + }; + + emit('slotRequested', { cycle: 1 }); + if (position !== 'before-next-start') { + emit('slotRenderEnded', { + cycle: 1, + isEmpty: false, + responseIdentifier: 'response-1', + }); + firstState.retired = true; + emit('slotRequested', { cycle: 2 }); + if (position === 'after-next-completion') { + emit('slotRenderEnded', { + cycle: 2, + isEmpty: false, + responseIdentifier: 'response-2', + }); + } + } + emit(eventType, oldFields); + + expect(facts).toHaveLength(emitted); + expect(facts[facts.length - 1]?.kind).toBe(eventType); + expect(facts[facts.length - 1]?.slot.cycleOrdinal).toBe( + eventType === 'slotRenderEnded' && position !== 'before-next-start' ? undefined : 1 + ); + } + ); + + it('rolls back an exact GPT listener when installation replaces the binding', async () => { + const first = createReadyGoogletag(); + const replacement = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + first.pubads.addEventListener.mockImplementation((type, listener) => { + const registered = first.listeners.get(type) ?? new Set(); + registered.add(listener); + first.listeners.set(type, registered); + target.googletag = replacement.googletag; + }); + const operation = adapter.run((gpt) => gpt.subscribe('slotRequested', vi.fn())); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + const installed = first.pubads.addEventListener.mock.calls[0]?.[1]; + expect(first.pubads.removeEventListener).toHaveBeenCalledWith('slotRequested', installed); + expect(first.listeners.get('slotRequested')?.size).toBe(0); + }); + + it('rolls back a GPT listener when installation disposes and cleanup throws', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + ready.pubads.addEventListener.mockImplementation((type, listener) => { + const registered = ready.listeners.get(type) ?? new Set(); + registered.add(listener); + ready.listeners.set(type, registered); + adapter.dispose(); + }); + ready.pubads.removeEventListener.mockImplementation((type, listener) => { + ready.listeners.get(type)?.delete(listener); + throw new Error('cleanup failed'); + }); + const operation = adapter.run((gpt) => gpt.subscribe('slotRequested', vi.fn())); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(ready.pubads.removeEventListener).toHaveBeenCalledTimes(1); + expect(ready.listeners.get('slotRequested')?.size).toBe(0); + }); + + it.each(['dispose', 'throw'] as const)( + 'rolls back GPT subscription ownership when effect registration must %s', + async (failure) => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const registryError = new Error('effect registry add failed'); + const originalDescriptor = Object.getOwnPropertyDescriptor(Set.prototype, 'add'); + const nativeAdd = Set.prototype.add; + const existingListeners = new Set<(event: unknown) => void>(); + const failedListeners = new Set<(event: unknown) => void>(); + const existingListener = vi.fn(); + const failedListener = vi.fn(); + ready.listeners.set('existing', existingListeners); + ready.listeners.set('failed', failedListeners); + let operation: ReturnType | undefined; + try { + operation = adapter.run((gpt) => { + gpt.subscribe('existing', existingListener); + Object.defineProperty(Set.prototype, 'add', { + configurable: true, + writable: true, + value: function (this: Set, value: unknown): Set { + if ( + typeof value === 'function' && + this !== existingListeners && + this !== failedListeners + ) { + if (failure === 'dispose') adapter.dispose(); + else throw registryError; + } + return Reflect.apply(nativeAdd, this, [value]) as Set; + }, + }); + return gpt.subscribe('failed', failedListener); + }); + } finally { + if (originalDescriptor) Object.defineProperty(Set.prototype, 'add', originalDescriptor); + } + + if (failure === 'dispose') { + await expect(operation?.result).rejects.toMatchObject({ code: 'operation_disposed' }); + } else { + await expect(operation?.result).rejects.toBe(registryError); + } + adapter.dispose(); + adapter.dispose(); + + expect(ready.listeners.get('existing')?.size).toBe(0); + expect(ready.listeners.get('failed')?.size).toBe(0); + expect(ready.pubads.removeEventListener).toHaveBeenCalledTimes(2); + } + ); + + it('settles a live GPT operation and restores exact effects when Set.delete is poisoned', async () => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const originalSetDelete = Set.prototype.delete; + ready.pubads.removeEventListener.mockImplementation((type, listener) => { + const registered = ready.listeners.get(type); + if (registered) Reflect.apply(originalSetDelete, registered, [listener]); + }); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const listener = vi.fn(); + const operation = adapter.run((gpt) => { + gpt.subscribe('slotRequested', listener); + return new Promise(() => undefined); + }); + expect(ready.googletag.setConfig).not.toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).not.toBe(nativeDisable); + expect(ready.listeners.get('slotRequested')).toHaveLength(1); + + Set.prototype.delete = function (): boolean { + throw new Error('GPT live cleanup delete failed'); + } as typeof Set.prototype.delete; + try { + expect(() => adapter.dispose()).not.toThrow(); + } finally { + Set.prototype.delete = originalSetDelete; + } + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(ready.listeners.get('slotRequested')).toHaveLength(0); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + expect(() => adapter.dispose()).not.toThrow(); + }); + + it.each(['keys', 'clear'] as const)( + 'restores every GPT effect when Map.%s is poisoned during disposal', + async (method) => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const listener = vi.fn(); + await adapter.run((gpt) => gpt.subscribe('slotRequested', listener)).result; + expect(ready.googletag.setConfig).not.toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).not.toBe(nativeDisable); + expect(ready.listeners.get('slotRequested')).toHaveLength(1); + + const originalMapKeys = Map.prototype.keys; + const originalMapClear = Map.prototype.clear; + if (method === 'keys') { + Map.prototype.keys = function (): never { + throw new Error('GPT initial-load keys failed'); + } as typeof Map.prototype.keys; + } else { + Map.prototype.clear = function (): never { + throw new Error('GPT initial-load clear failed'); + } as typeof Map.prototype.clear; + } + try { + expect(() => adapter.dispose()).not.toThrow(); + expect(() => adapter.dispose()).not.toThrow(); + } finally { + Map.prototype.keys = originalMapKeys; + Map.prototype.clear = originalMapClear; + } + + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + expect(ready.listeners.get('slotRequested')).toHaveLength(0); + expect(ready.pubads.removeEventListener).toHaveBeenCalledTimes(1); + + const ownershipProbe = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await ownershipProbe.run((gpt) => gpt.serviceState()).result; + expect(ready.googletag.setConfig).not.toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).not.toBe(nativeDisable); + ownershipProbe.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + } + ); + + it('settles GPT tracker publication failure and rolls back only its new owner', async () => { + const first = createReadyGoogletag(); + const second = createReadyGoogletag(); + const firstNativeSetConfig = first.googletag.setConfig; + const firstNativeDisable = first.pubads.disableInitialLoad; + const secondNativeSetConfig = second.googletag.setConfig; + const secondNativeDisable = second.pubads.disableInitialLoad; + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + const priorListener = vi.fn(); + await adapter.run((gpt) => gpt.subscribe('prior', priorListener)).result; + expect(first.listeners.get('prior')).toHaveLength(1); + + target.googletag = second.googletag; + const registryError = new Error('GPT tracker effect publication failed'); + const command = vi.fn(); + const originalSetAdd = Set.prototype.add; + let additions = 0; + Set.prototype.add = function (this: Set, value: unknown): Set { + additions += 1; + const added = Reflect.apply(originalSetAdd, this, [value]) as Set; + if (additions === 3) throw registryError; + return added; + } as typeof Set.prototype.add; + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(command); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + } + + expect(additions).toBe(3); + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(registryError); + expect(command).not.toHaveBeenCalled(); + expect(first.listeners.get('prior')).toHaveLength(1); + expect(first.googletag.setConfig).toBe(firstNativeSetConfig); + expect(first.pubads.disableInitialLoad).toBe(firstNativeDisable); + expect(second.googletag.setConfig).toBe(secondNativeSetConfig); + expect(second.pubads.disableInitialLoad).toBe(secondNativeDisable); + + const ownerProbe = createBrowserGoogletagAdapter({ googletag: second.googletag }); + await ownerProbe.run((gpt) => gpt.serviceState()).result; + expect(second.googletag.setConfig).not.toBe(secondNativeSetConfig); + expect(second.pubads.disableInitialLoad).not.toBe(secondNativeDisable); + ownerProbe.dispose(); + expect(second.googletag.setConfig).toBe(secondNativeSetConfig); + expect(second.pubads.disableInitialLoad).toBe(secondNativeDisable); + + target.googletag = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(() => 'recovered')); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + target.googletag = second.googletag; + adapter.notifyReady(); + await expect(Promise.all(recovered.map(({ result }) => result))).resolves.toEqual( + Array.from({ length: 64 }, () => 'recovered') + ); + expect(first.listeners.get('prior')).toHaveLength(1); + + adapter.dispose(); + expect(first.listeners.get('prior')).toHaveLength(0); + expect(first.pubads.removeEventListener).toHaveBeenCalledTimes(1); + expect(second.googletag.setConfig).toBe(secondNativeSetConfig); + expect(second.pubads.disableInitialLoad).toBe(secondNativeDisable); + }); + + it.each(['owner', 'release', 'service'] as const)( + 'settles and rolls back GPT tracking when the %s registry has lookup throws', + async (registry) => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const command = vi.fn(() => 'settled'); + const originalSetHas = Set.prototype.has; + const originalMapHas = Map.prototype.has; + const originalWeakMapHas = WeakMap.prototype.has; + if (registry === 'owner') { + Set.prototype.has = function (): boolean { + throw new Error('GPT owner lookup failed'); + } as typeof Set.prototype.has; + } else if (registry === 'release') { + Map.prototype.has = function (): boolean { + throw new Error('GPT release lookup failed'); + } as typeof Map.prototype.has; + } else { + WeakMap.prototype.has = function (): boolean { + throw new Error('GPT service lookup failed'); + } as typeof WeakMap.prototype.has; + } + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(command); + } catch (error) { + thrown = error; + } finally { + Set.prototype.has = originalSetHas; + Map.prototype.has = originalMapHas; + WeakMap.prototype.has = originalWeakMapHas; + } + + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).resolves.toBe('settled'); + expect(command).toHaveBeenCalledTimes(1); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + const ownerProbe = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await ownerProbe.run((gpt) => gpt.serviceState()).result; + ownerProbe.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + adapter.dispose(); + } + ); + + it.each(['before', 'after'] as const)( + 'rolls back shared GPT tracker publication when WeakMap.set throws %s insertion', + async (failure) => { + vi.useFakeTimers(); + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const target: { googletag?: unknown } = { googletag: ready.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + const publicationError = new Error(`shared tracker failed ${failure} insertion`); + const command = vi.fn(); + const originalWeakMapSet = WeakMap.prototype.set; + const originalWeakMapDelete = WeakMap.prototype.delete; + let publications = 0; + WeakMap.prototype.set = function ( + this: WeakMap, + key: object, + value: unknown + ): WeakMap { + publications += 1; + if (publications === 1) { + if (failure === 'after') Reflect.apply(originalWeakMapSet, this, [key, value]); + throw publicationError; + } + return Reflect.apply(originalWeakMapSet, this, [key, value]) as WeakMap; + } as typeof WeakMap.prototype.set; + WeakMap.prototype.delete = function (): boolean { + throw new Error('shared tracker rollback delete failed'); + } as typeof WeakMap.prototype.delete; + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(command); + } catch (error) { + thrown = error; + } finally { + WeakMap.prototype.set = originalWeakMapSet; + WeakMap.prototype.delete = originalWeakMapDelete; + } + + expect(thrown).toBeUndefined(); + expect(publications).toBe(1); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(publicationError); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + const ownerProbe = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + let recoveredPublications = 0; + WeakMap.prototype.set = function ( + this: WeakMap, + key: object, + value: unknown + ): WeakMap { + recoveredPublications += 1; + return Reflect.apply(originalWeakMapSet, this, [key, value]) as WeakMap; + } as typeof WeakMap.prototype.set; + try { + await ownerProbe.run((gpt) => gpt.serviceState()).result; + } finally { + WeakMap.prototype.set = originalWeakMapSet; + } + expect(recoveredPublications).toBe(2); + ownerProbe.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + target.googletag = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + const recoveredResults = recovered.map(({ result }) => result.catch((error) => error)); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + for (const recoveredOperation of recovered) recoveredOperation.dispose(); + await Promise.all(recoveredResults); + expect(vi.getTimerCount()).toBe(0); + adapter.dispose(); + } + ); + + it.each(['before', 'after'] as const)( + 'removes only the new GPT owner when its release Map.set throws %s insertion', + async (failure) => { + vi.useFakeTimers(); + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const first = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await first.run((gpt) => gpt.serviceState()).result; + const sharedSetConfig = ready.googletag.setConfig; + const sharedDisable = ready.pubads.disableInitialLoad; + const secondTarget: { googletag?: unknown } = { googletag: ready.googletag }; + const second = createBrowserGoogletagAdapter(secondTarget); + const publicationError = new Error(`release map failed ${failure} insertion`); + const command = vi.fn(); + const originalMapSet = Map.prototype.set; + const originalMapDelete = Map.prototype.delete; + let publications = 0; + Map.prototype.set = function ( + this: Map, + key: unknown, + value: unknown + ): Map { + publications += 1; + if (publications === 1) { + if (failure === 'after') Reflect.apply(originalMapSet, this, [key, value]); + throw publicationError; + } + return Reflect.apply(originalMapSet, this, [key, value]) as Map; + } as typeof Map.prototype.set; + Map.prototype.delete = function (): boolean { + throw new Error('release map rollback delete failed'); + } as typeof Map.prototype.delete; + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = second.run(command); + } catch (error) { + thrown = error; + } finally { + Map.prototype.set = originalMapSet; + Map.prototype.delete = originalMapDelete; + } + + expect(thrown).toBeUndefined(); + expect(publications).toBe(1); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(publicationError); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(sharedSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(sharedDisable); + + first.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + secondTarget.googletag = undefined; + const recovered = Array.from({ length: 64 }, () => second.run(vi.fn())); + const recoveredResults = recovered.map(({ result }) => result.catch((error) => error)); + expect(() => second.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + for (const recoveredOperation of recovered) recoveredOperation.dispose(); + await Promise.all(recoveredResults); + expect(vi.getTimerCount()).toBe(0); + second.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + } + ); + + it.each(['before', 'after'] as const)( + 'identity-restores GPT service publication when WeakMap.set throws %s insertion', + async (failure) => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const publicationError = new Error(`service map failed ${failure} insertion`); + const command = vi.fn(); + const originalWeakMapSet = WeakMap.prototype.set; + const originalWeakMapDelete = WeakMap.prototype.delete; + let publications = 0; + WeakMap.prototype.set = function ( + this: WeakMap, + key: object, + value: unknown + ): WeakMap { + publications += 1; + if (publications === 2) { + if (failure === 'after') Reflect.apply(originalWeakMapSet, this, [key, value]); + throw publicationError; + } + return Reflect.apply(originalWeakMapSet, this, [key, value]) as WeakMap; + } as typeof WeakMap.prototype.set; + WeakMap.prototype.delete = function (): boolean { + throw new Error('service map rollback delete failed'); + } as typeof WeakMap.prototype.delete; + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(command); + } catch (error) { + thrown = error; + } finally { + WeakMap.prototype.set = originalWeakMapSet; + WeakMap.prototype.delete = originalWeakMapDelete; + } + + expect(thrown).toBeUndefined(); + expect(publications).toBe(2); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(publicationError); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + const ownerProbe = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await ownerProbe.run((gpt) => gpt.serviceState()).result; + ownerProbe.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + adapter.dispose(); + } + ); + + it.each(['before', 'after'] as const)( + 'rolls back GPT tracker owner publication when Set.add throws %s insertion', + async (failure) => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const first = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await first.run((gpt) => gpt.serviceState()).result; + const sharedSetConfig = ready.googletag.setConfig; + const sharedDisable = ready.pubads.disableInitialLoad; + const second = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const registryError = new Error(`owner add failed ${failure} insertion`); + const command = vi.fn(); + const originalSetAdd = Set.prototype.add; + const originalSetDelete = Set.prototype.delete; + let additions = 0; + Set.prototype.add = function (this: Set, value: unknown): Set { + additions += 1; + if (additions === 2) { + if (failure === 'after') Reflect.apply(originalSetAdd, this, [value]); + throw registryError; + } + return Reflect.apply(originalSetAdd, this, [value]) as Set; + } as typeof Set.prototype.add; + Set.prototype.delete = function (): boolean { + throw new Error('owner rollback delete failed'); + } as typeof Set.prototype.delete; + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = second.run(command); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + Set.prototype.delete = originalSetDelete; + } + + expect(additions).toBe(2); + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(registryError); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(sharedSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(sharedDisable); + + second.dispose(); + expect(ready.googletag.setConfig).toBe(sharedSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(sharedDisable); + first.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + } + ); + + it.each(['before', 'after'] as const)( + 'identity-restores GPT root wrapper when restorer Set.add throws %s insertion', + async (failure) => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const registryError = new Error(`root restorer add failed ${failure} insertion`); + const command = vi.fn(); + const originalSetAdd = Set.prototype.add; + const originalSetDelete = Set.prototype.delete; + let additions = 0; + Set.prototype.add = function (this: Set, value: unknown): Set { + additions += 1; + if (additions === 4) { + if (failure === 'after') Reflect.apply(originalSetAdd, this, [value]); + throw registryError; + } + return Reflect.apply(originalSetAdd, this, [value]) as Set; + } as typeof Set.prototype.add; + Set.prototype.delete = function (): boolean { + throw new Error('root restorer cleanup delete failed'); + } as typeof Set.prototype.delete; + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(command); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + Set.prototype.delete = originalSetDelete; + } + + expect(additions).toBe(4); + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(registryError); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + const ownerProbe = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await ownerProbe.run((gpt) => gpt.serviceState()).result; + ownerProbe.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + adapter.dispose(); + } + ); + + it.each(['before', 'after'] as const)( + 'identity-restores GPT service wrapper when restorer Set.add throws %s insertion', + async (failure) => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisable = ready.pubads.disableInitialLoad; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const registryError = new Error(`service restorer add failed ${failure} insertion`); + const command = vi.fn(); + const originalSetAdd = Set.prototype.add; + const originalSetDelete = Set.prototype.delete; + let additions = 0; + Set.prototype.add = function (this: Set, value: unknown): Set { + additions += 1; + if (additions === 5) { + if (failure === 'after') Reflect.apply(originalSetAdd, this, [value]); + throw registryError; + } + return Reflect.apply(originalSetAdd, this, [value]) as Set; + } as typeof Set.prototype.add; + Set.prototype.delete = function (): boolean { + throw new Error('service restorer cleanup delete failed'); + } as typeof Set.prototype.delete; + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(command); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + Set.prototype.delete = originalSetDelete; + } + + expect(additions).toBe(5); + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published GPT operation'); + await expect(operation.result).rejects.toBe(registryError); + expect(command).not.toHaveBeenCalled(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + + const ownerProbe = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await ownerProbe.run((gpt) => gpt.serviceState()).result; + ownerProbe.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisable); + adapter.dispose(); + } + ); + + it('rolls back a failed GPT command subscription without touching prior global effects', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const priorListener = vi.fn(); + const failedListener = vi.fn(); + const commandError = new Error('command failed'); + await adapter.run((gpt) => gpt.subscribe('prior', priorListener)).result; + + const operation = adapter.run((gpt) => { + gpt.subscribe('failed', failedListener); + throw commandError; + }); + + await expect(operation.result).rejects.toBe(commandError); + expect(ready.listeners.get('prior')?.size).toBe(1); + expect(ready.listeners.get('failed')?.size).toBe(0); + expect(() => [...(ready.listeners.get('prior') ?? [])][0]?.({})).not.toThrow(); + expect(priorListener).toHaveBeenCalledTimes(1); + expect(failedListener).not.toHaveBeenCalled(); + + adapter.dispose(); + expect(ready.listeners.get('prior')?.size).toBe(0); + expect(ready.pubads.removeEventListener).toHaveBeenCalledTimes(2); + }); + + it('promotes fulfilled GPT command subscriptions and rolls back rejected ones', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const rejection = new Error('async command failed'); + const rejected = adapter.run((gpt) => { + gpt.subscribe('rejected', vi.fn()); + return Promise.reject(rejection); + }); + + await expect(rejected.result).rejects.toBe(rejection); + expect(ready.listeners.get('rejected')?.size).toBe(0); + + const fulfilled = adapter.run((gpt) => { + gpt.subscribe('fulfilled', vi.fn()); + return Promise.resolve('complete'); + }); + await expect(fulfilled.result).resolves.toBe('complete'); + expect(ready.listeners.get('fulfilled')?.size).toBe(1); + + adapter.dispose(); + expect(ready.listeners.get('fulfilled')?.size).toBe(0); + }); + + it.each(['dispose', 'replacement'] as const)( + 'rolls back a provisional GPT subscription after async %s', + async (failure) => { + const first = createReadyGoogletag(); + const replacement = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + let resolveCommand!: (value: string) => void; + const commandResult = new Promise((resolve) => { + resolveCommand = resolve; + }); + const operation = adapter.run((gpt) => { + gpt.subscribe('provisional', vi.fn()); + return commandResult; + }); + + if (failure === 'dispose') adapter.dispose(); + else target.googletag = replacement.googletag; + resolveCommand('late-success'); + + await expect(operation.result).rejects.toMatchObject({ + code: failure === 'dispose' ? 'operation_disposed' : 'external_artifact_incompatible', + }); + expect(first.listeners.get('provisional')?.size).toBe(0); + } + ); + + it('contains command-queue and command-callback throws', async () => { + const pushError = new Error('push failed'); + const callbackError = new Error('callback failed'); + const throwingPush = { + apiReady: true, + pubadsReady: true, + cmd: { + push: () => { + throw pushError; + }, + }, + display: vi.fn(), + pubads: () => createReadyGoogletag().pubads, + }; + const pushAdapter = createBrowserGoogletagAdapter({ googletag: throwingPush }); + + let pushOperation: ReturnType | undefined; + expect(() => { + pushOperation = pushAdapter.run(() => undefined); + }).not.toThrow(); + await expect(pushOperation?.result).rejects.toBe(pushError); + + const deferred = createReadyGoogletag({ deferCommands: true }); + const callbackAdapter = createBrowserGoogletagAdapter({ googletag: deferred.googletag }); + const callbackOperation = callbackAdapter.run(() => { + throw callbackError; + }); + expect(() => deferred.commands[0]?.()).not.toThrow(); + await expect(callbackOperation.result).rejects.toBe(callbackError); + }); + + it('owns GPT subscriptions, refresh, targeting, and service inspection behind the facade', async () => { + const ready = createReadyGoogletag({ initialLoadDisabled: true }); + const targeting = new Map(); + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) targeting.clear(); + else targeting.delete(key); + }), + getAdUnitPath: vi.fn(() => '/publisher/example'), + getTargeting: vi.fn((key: string) => targeting.get(key) ?? []), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + targeting.set(key, typeof value === 'string' ? [value] : [...value]); + }), + }; + ready.pubads.getSlots.mockReturnValue([slot]); + const listener = vi.fn(() => { + throw new Error('publisher callback failed'); + }); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const operation = adapter.run((gpt) => { + const unsubscribe = gpt.subscribe('slotRequested', listener); + gpt.setTargeting(slot, 'hb_adid', 'reservation'); + expect(gpt.getTargeting(slot, 'hb_adid')).toEqual(['reservation']); + expect(gpt.adUnitPath?.(slot)).toBe('/publisher/example'); + gpt.refresh([slot], { changeCorrelator: false }); + expect(gpt.slots()).toEqual([slot]); + expect(Object.isFrozen(gpt.slots())).toBe(true); + expect(gpt.serviceState()).toEqual({ + apiReady: true, + initialLoadDisabled: true, + pubadsReady: true, + }); + const installed = [...(ready.listeners.get('slotRequested') ?? [])][0]; + expect(() => installed?.({ slot })).not.toThrow(); + unsubscribe(); + gpt.clearTargeting(slot, 'hb_adid'); + }); + + await expect(operation.result).resolves.toBeUndefined(); + expect(listener).toHaveBeenCalledWith({ slot }); + expect(ready.pubads.refresh).toHaveBeenCalledWith([slot], { changeCorrelator: false }); + expect(ready.pubads.removeEventListener).toHaveBeenCalledTimes(1); + expect(targeting.has('hb_adid')).toBe(false); + }); + + it('enables SRA before GPT services and does not reconfigure an enabled publisher service', async () => { + const disabled = createReadyGoogletag({ servicesEnabled: false }); + const order: string[] = []; + disabled.pubads.enableSingleRequest.mockImplementation(() => { + order.push('sra'); + return true; + }); + disabled.googletag.enableServices.mockImplementation(() => { + order.push('services'); + disabled.googletag.pubadsReady = true; + }); + const adapter = createBrowserGoogletagAdapter({ googletag: disabled.googletag }); + + await expect(adapter.run((gpt) => gpt.enableServices()).result).resolves.toBeUndefined(); + await expect(adapter.run((gpt) => gpt.enableServices()).result).resolves.toBeUndefined(); + + expect(order).toEqual(['sra', 'services']); + }); + + it('exposes one reversible publisher-call observer without changing ordinary calls', () => { + const ready = createReadyGoogletag(); + const nativeDisplay = ready.googletag.display; + const nativeRefresh = ready.pubads.refresh; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const boundary = adapter as unknown as { + observePublisherCalls?: (observer: object) => () => void; + }; + + expect(boundary.observePublisherCalls).toBeTypeOf('function'); + if (!boundary.observePublisherCalls) return; + + const release = boundary.observePublisherCalls(Object.freeze({})); + expect(ready.googletag.display).not.toBe(nativeDisplay); + expect(ready.pubads.refresh).not.toBe(nativeRefresh); + + release(); + expect(ready.googletag.display).toBe(nativeDisplay); + expect(ready.pubads.refresh).toBe(nativeRefresh); + }); + + it('keeps an existing event subscription live while installing publisher-call wrappers', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const listener = vi.fn(); + let unsubscribe: (() => void) | undefined; + const subscription = adapter.run((gpt) => { + unsubscribe = gpt.subscribe('slotRequested', listener); + }); + await expect(subscription.result).resolves.toBeUndefined(); + const installed = [...(ready.listeners.get('slotRequested') ?? [])][0]; + expect(installed).toBeTypeOf('function'); + + const releasePublisherObserver = adapter.observePublisherCalls(Object.freeze({})); + installed?.({ slot: Object.freeze({ id: 'slot-a' }) }); + + expect(listener).toHaveBeenCalledOnce(); + unsubscribe?.(); + releasePublisherObserver(); + }); + + it('installs the publisher observer when an accepted command-queue stub becomes ready', () => { + const commands: Array<() => void> = []; + const pending = { + cmd: { + push: vi.fn((callback: () => void) => { + commands.push(callback); + return commands.length; + }), + }, + }; + const target = { googletag: pending as object }; + const adapter = createBrowserGoogletagAdapter(target); + const handoff = {}; + const release = adapter.observePublisherCalls({ + defineSlot: () => Object.freeze({ action: 'handoff', slot: handoff }), + }); + const ready = createReadyGoogletag(); + const nativeDefineSlot = vi.fn((_path: string, _sizes: unknown, _elementId: string) => ({})); + Object.assign(pending, { + apiReady: true, + defineSlot: nativeDefineSlot, + destroySlots: ready.googletag.destroySlots, + display: ready.googletag.display, + getConfig: ready.googletag.getConfig, + pubads: ready.googletag.pubads, + pubadsReady: true, + setConfig: ready.googletag.setConfig, + }); + + expect(commands).toHaveLength(1); + commands[0]?.(); + const defineSlot = (pending as typeof pending & { defineSlot: typeof nativeDefineSlot }) + .defineSlot; + expect(defineSlot).not.toBe(nativeDefineSlot); + expect(defineSlot('/publisher', [300, 250], 'slot')).toBe(handoff); + expect(nativeDefineSlot).not.toHaveBeenCalled(); + + release(); + expect((pending as typeof pending & { defineSlot: typeof nativeDefineSlot }).defineSlot).toBe( + nativeDefineSlot + ); + }); + + it('does not classify facade-driven GPT calls as publisher calls', async () => { + const ready = createReadyGoogletag(); + const nativeRefresh = ready.pubads.refresh; + const observer = { + display: vi.fn(() => Object.freeze({ action: 'suppress' as const })), + refresh: vi.fn(() => Object.freeze({ action: 'suppress' as const })), + }; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls(observer); + + await expect( + adapter.run((gpt) => { + gpt.display('trusted-slot'); + gpt.refresh([], { changeCorrelator: false }); + }).result + ).resolves.toBeUndefined(); + + expect(observer.display).not.toHaveBeenCalled(); + expect(observer.refresh).not.toHaveBeenCalled(); + expect(ready.display).toHaveBeenCalledExactlyOnceWith('trusted-slot'); + expect(nativeRefresh).toHaveBeenCalledExactlyOnceWith([], { + changeCorrelator: false, + }); + }); + + it('observes publisher GPT calls reentered by one facade-driven native display', async () => { + const ready = createReadyGoogletag(); + const slot = Object.freeze({ id: 'nested-publisher-slot' }); + ready.pubads.getSlots.mockReturnValue([slot]); + ready.googletag.defineSlot.mockReturnValue(slot); + ready.googletag.destroySlots.mockReturnValue(true); + ready.display.mockImplementation(() => { + ready.pubads.refresh([slot], { changeCorrelator: true }); + ready.googletag.defineSlot('/publisher', [300, 250], 'nested-slot'); + ready.googletag.destroySlots([slot]); + }); + const observer = { + defineSlot: vi.fn(() => Object.freeze({ action: 'forward' as const })), + destroySlots: vi.fn(), + display: vi.fn(() => Object.freeze({ action: 'forward' as const })), + refresh: vi.fn(() => Object.freeze({ action: 'forward' as const })), + }; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls(observer); + + await expect(adapter.run((gpt) => gpt.display('trusted-slot')).result).resolves.toBeUndefined(); + + expect(observer.display).not.toHaveBeenCalled(); + expect(observer.refresh).toHaveBeenCalledExactlyOnceWith({ + options: { changeCorrelator: true }, + requestedSlots: [slot], + slots: [slot], + }); + expect(observer.defineSlot).toHaveBeenCalledExactlyOnceWith({ + adUnitPath: '/publisher', + elementId: 'nested-slot', + initialLoadDisabled: false, + sizes: [300, 250], + }); + expect(observer.destroySlots).toHaveBeenCalledExactlyOnceWith({ slots: [slot] }); + }); + + it('observes a publisher wrapper call made inside a TS command but outside a facade invocation', async () => { + const ready = createReadyGoogletag(); + const observer = { + defineSlot: vi.fn(() => Object.freeze({ action: 'forward' as const })), + display: vi.fn(() => Object.freeze({ action: 'forward' as const })), + }; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls(observer); + + await expect( + adapter.run((gpt) => { + ready.googletag.defineSlot('/publisher', [300, 250], 'publisher-inside-command'); + gpt.display('trusted-slot'); + }).result + ).resolves.toBeUndefined(); + + expect(observer.defineSlot).toHaveBeenCalledExactlyOnceWith({ + adUnitPath: '/publisher', + elementId: 'publisher-inside-command', + initialLoadDisabled: false, + sizes: [300, 250], + }); + expect(observer.display).not.toHaveBeenCalled(); + }); + + it('commits publisher display and refresh admissions only after exact native returns', () => { + const ready = createReadyGoogletag(); + const slot = Object.freeze({ id: 'publisher-slot' }); + const receiver = Object.freeze({ publisher: true }); + const refreshOptions = Object.freeze({ changeCorrelator: false, publisher: 'exact' }); + const order: string[] = []; + const displayAdmission = Object.freeze({ + commit: vi.fn(() => order.push('commit:display')), + rollback: vi.fn(), + }); + const refreshAdmission = Object.freeze({ + commit: vi.fn(() => order.push('commit:refresh')), + rollback: vi.fn(), + }); + const nativeDisplay = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:display'); + return Object.freeze({ arguments_, receiver: this }); + }); + const nativeRefresh = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:refresh'); + return Object.freeze({ arguments_, receiver: this }); + }); + ready.googletag.display = nativeDisplay; + ready.pubads.refresh = nativeRefresh; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls({ + display: () => Object.freeze({ action: 'forward' as const, admission: displayAdmission }), + refresh: () => Object.freeze({ action: 'forward' as const, admission: refreshAdmission }), + }); + + const display = ready.googletag.display as (...arguments_: unknown[]) => unknown; + expect(Reflect.apply(display, receiver, ['slot'])).toEqual({ + arguments_: ['slot'], + receiver, + }); + const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; + expect(Reflect.apply(refresh, receiver, [[slot], refreshOptions])).toEqual({ + arguments_: [[slot], refreshOptions], + receiver, + }); + + expect(order).toEqual(['native:display', 'commit:display', 'native:refresh', 'commit:refresh']); + expect(displayAdmission.rollback).not.toHaveBeenCalled(); + expect(refreshAdmission.rollback).not.toHaveBeenCalled(); + }); + + it('defers one explicit refresh and forwards the complete snapshot with exact options once', async () => { + const ready = createReadyGoogletag(); + const first = Object.freeze({ id: 'first' }); + const second = Object.freeze({ id: 'second' }); + const options = Object.freeze({ changeCorrelator: false, publisher: 'exact-options' }); + const originalSlots = [first, second]; + let complete!: () => void; + const completion = new Promise((resolve) => { + complete = resolve; + }); + const admission = Object.freeze({ commit: vi.fn(), rollback: vi.fn() }); + const nativeRefresh = vi.fn(); + ready.pubads.refresh = nativeRefresh; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls({ + refresh: () => + Object.freeze({ + action: 'defer' as const, + admission, + completion, + slots: Object.freeze([first, second]), + }), + }); + + expect(ready.pubads.refresh(originalSlots, options)).toBeUndefined(); + originalSlots.length = 0; + expect(nativeRefresh).not.toHaveBeenCalled(); + + complete(); + await completion; + await Promise.resolve(); + expect(nativeRefresh).toHaveBeenCalledExactlyOnceWith([first, second], options); + expect(admission.commit).toHaveBeenCalledOnce(); + expect(admission.rollback).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(nativeRefresh).toHaveBeenCalledOnce(); + }); + + it('forwards a deferred global refresh exactly once when its observer is released', async () => { + const ready = createReadyGoogletag(); + const first = Object.freeze({ id: 'first' }); + const second = Object.freeze({ id: 'second' }); + const options = Object.freeze({ changeCorrelator: true }); + ready.pubads.getSlots.mockReturnValue([first, second]); + let complete!: () => void; + const completion = new Promise((resolve) => { + complete = resolve; + }); + const nativeRefresh = vi.fn(); + ready.pubads.refresh = nativeRefresh; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const release = adapter.observePublisherCalls({ + refresh: () => + Object.freeze({ + action: 'defer' as const, + completion, + slots: Object.freeze([first, second]), + }), + }); + + ready.pubads.refresh(undefined, options); + expect(nativeRefresh).not.toHaveBeenCalled(); + release(); + expect(nativeRefresh).toHaveBeenCalledExactlyOnceWith([first, second], options); + + complete(); + await completion; + await Promise.resolve(); + expect(nativeRefresh).toHaveBeenCalledOnce(); + }); + + it('rolls back each unconsumed publisher admission on native throw and rethrows the exact error', () => { + const ready = createReadyGoogletag(); + const displayError = new Error('exact display failure'); + const refreshError = new Error('exact refresh failure'); + const displayAdmissions = [0, 1].map(() => + Object.freeze({ commit: vi.fn(), rollback: vi.fn() }) + ); + const refreshAdmission = Object.freeze({ commit: vi.fn(), rollback: vi.fn() }); + ready.googletag.display = vi.fn(() => { + throw displayError; + }); + ready.pubads.refresh = vi.fn(() => { + throw refreshError; + }); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + let displayAttempt = 0; + adapter.observePublisherCalls({ + display: () => + Object.freeze({ + action: 'forward' as const, + admission: displayAdmissions[displayAttempt++]!, + }), + refresh: () => Object.freeze({ action: 'forward' as const, admission: refreshAdmission }), + }); + + const display = ready.googletag.display as (...arguments_: unknown[]) => unknown; + expect(() => display('slot')).toThrow(displayError); + expect(() => display('slot')).toThrow(displayError); + const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; + expect(() => refresh(undefined, { changeCorrelator: true })).toThrow(refreshError); + + for (const admission of displayAdmissions) { + expect(admission.rollback).toHaveBeenCalledOnce(); + expect(admission.commit).not.toHaveBeenCalled(); + } + expect(refreshAdmission.rollback).toHaveBeenCalledOnce(); + expect(refreshAdmission.commit).not.toHaveBeenCalled(); + }); + + it.each(['pubads', 'target_getter'] as const)( + 'fails open to captured publisher natives when %s identity probing throws', + (failure) => { + const ready = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: ready.googletag }; + const slot = Object.freeze({ id: 'publisher-slot' }); + const nativeDefine = vi.fn(() => 'defined'); + const nativeDisplay = vi.fn(() => 'displayed'); + const nativeRefresh = vi.fn(() => 'refreshed'); + const nativeDestroy = vi.fn(() => true); + ready.googletag.defineSlot = nativeDefine; + ready.googletag.display = nativeDisplay; + ready.googletag.destroySlots = nativeDestroy; + ready.pubads.refresh = nativeRefresh; + ready.pubads.getSlots.mockReturnValue([slot]); + const adapter = createBrowserGoogletagAdapter(target); + const observer = { + defineSlot: vi.fn(() => Object.freeze({ action: 'forward' as const })), + destroySlots: vi.fn(), + display: vi.fn(() => Object.freeze({ action: 'forward' as const })), + refresh: vi.fn(() => Object.freeze({ action: 'forward' as const })), + }; + adapter.observePublisherCalls(observer); + const define = ready.googletag.defineSlot as (...arguments_: unknown[]) => unknown; + const display = ready.googletag.display as (...arguments_: unknown[]) => unknown; + const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; + const destroy = ready.googletag.destroySlots as (...arguments_: unknown[]) => unknown; + const identityError = new Error(`throwing ${failure}`); + if (failure === 'pubads') { + ready.googletag.pubads.mockImplementation(() => { + throw identityError; + }); + } else { + Object.defineProperty(target, 'googletag', { + configurable: true, + get: () => { + throw identityError; + }, + }); + } + + expect(define('/publisher', [300, 250], 'slot')).toBe('defined'); + expect(display('slot')).toBe('displayed'); + expect(refresh([slot], { changeCorrelator: false })).toBe('refreshed'); + expect(destroy([slot])).toBe(true); + expect(nativeDefine).toHaveBeenCalledOnce(); + expect(nativeDisplay).toHaveBeenCalledOnce(); + expect(nativeRefresh).toHaveBeenCalledOnce(); + expect(nativeDestroy).toHaveBeenCalledOnce(); + expect(observer.defineSlot).not.toHaveBeenCalled(); + expect(observer.display).not.toHaveBeenCalled(); + expect(observer.refresh).not.toHaveBeenCalled(); + expect(observer.destroySlots).not.toHaveBeenCalled(); + } + ); + + it('mediates only explicit publisher decisions and preserves receiver, arguments, return, throw, and order', () => { + const ready = createReadyGoogletag({ initialLoadDisabled: true }); + const handoffSlot = Object.freeze({ id: 'handoff' }); + const ordinarySlot = Object.freeze({ id: 'ordinary' }); + const refreshOptions = Object.freeze({ changeCorrelator: true, publisher: 'kept' }); + const publisherError = new Error('publisher display failed'); + const defineReceiver = Object.freeze({ receiver: 'define' }); + const refreshReceiver = Object.freeze({ receiver: 'refresh' }); + const order: string[] = []; + const nativeDefineSlot = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:define'); + return Object.freeze({ arguments_, receiver: this }); + }); + const nativeDisplay = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:display'); + if (arguments_[0] === 'throw') throw publisherError; + return Object.freeze({ arguments_, receiver: this }); + }); + const nativeRefresh = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:refresh'); + return Object.freeze({ arguments_, receiver: this }); + }); + const nativeDestroy = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:destroy'); + return arguments_[0] === 'throw' + ? (() => { + throw new Error('publisher destroy failed'); + })() + : true; + }); + Object.assign(ready.googletag, { + defineSlot: nativeDefineSlot, + destroySlots: nativeDestroy, + display: nativeDisplay, + }); + ready.pubads.refresh = nativeRefresh; + ready.pubads.getSlots.mockReturnValue([handoffSlot, ordinarySlot]); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + let suppressDisplay = true; + const destroyed: Array = []; + const release = adapter.observePublisherCalls({ + defineSlot: (call) => { + order.push('observer:define'); + expect(call.initialLoadDisabled).toBe(true); + return call.elementId === 'handoff-id' + ? Object.freeze({ action: 'handoff' as const, slot: handoffSlot }) + : Object.freeze({ action: 'forward' as const }); + }, + destroySlots: (call) => { + order.push('observer:destroy'); + destroyed.push(call.slots); + }, + display: () => { + order.push('observer:display'); + if (!suppressDisplay) return Object.freeze({ action: 'forward' as const }); + suppressDisplay = false; + return Object.freeze({ action: 'suppress' as const }); + }, + refresh: (call) => { + order.push('observer:refresh'); + expect(call.requestedSlots).toBeUndefined(); + expect(call.slots).toEqual([handoffSlot, ordinarySlot]); + return Object.freeze({ action: 'replace' as const, slots: Object.freeze([ordinarySlot]) }); + }, + }); + + const defineSlot = ready.googletag.defineSlot as unknown as ( + ...arguments_: unknown[] + ) => unknown; + expect( + Reflect.apply(defineSlot, defineReceiver, ['/publisher', [300, 250], 'handoff-id']) + ).toBe(handoffSlot); + expect(nativeDefineSlot).not.toHaveBeenCalled(); + const forwarded = Reflect.apply(defineSlot, defineReceiver, [ + '/publisher', + [728, 90], + 'ordinary-id', + 'publisher-extra', + ]); + expect(forwarded).toEqual({ + arguments_: ['/publisher', [728, 90], 'ordinary-id', 'publisher-extra'], + receiver: defineReceiver, + }); + + const display = ready.googletag.display as (...arguments_: unknown[]) => unknown; + expect(Reflect.apply(display, defineReceiver, ['handoff-id'])).toBeUndefined(); + expect(Reflect.apply(display, defineReceiver, ['handoff-id', 'publisher-extra'])).toEqual({ + arguments_: ['handoff-id', 'publisher-extra'], + receiver: defineReceiver, + }); + expect(() => Reflect.apply(display, defineReceiver, ['throw', 'publisher-extra'])).toThrow( + publisherError + ); + + const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; + expect(Reflect.apply(refresh, refreshReceiver, [undefined, refreshOptions])).toEqual({ + arguments_: [[ordinarySlot], refreshOptions], + receiver: refreshReceiver, + }); + + const destroySlots = ready.googletag.destroySlots as unknown as ( + slots?: readonly object[] + ) => unknown; + expect(destroySlots([handoffSlot])).toBe(true); + expect(destroyed).toEqual([[handoffSlot]]); + expect(order).toEqual([ + 'observer:define', + 'native:define', + 'observer:display', + 'native:display', + 'native:display', + 'observer:refresh', + 'native:refresh', + 'native:destroy', + 'observer:destroy', + ]); + + release(); + }); + + it('tracks native GPT initial-load configuration without duplicate wrappers', async () => { + const ready = createReadyGoogletag({ initialLoadDisabled: true }); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisableInitialLoad = ready.pubads.disableInitialLoad; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + + await expect(adapter.run((gpt) => gpt.serviceState().initialLoadDisabled).result).resolves.toBe( + true + ); + const wrappedSetConfig = ready.googletag.setConfig; + const wrappedDisableInitialLoad = ready.pubads.disableInitialLoad; + expect(wrappedSetConfig).not.toBe(nativeSetConfig); + expect(wrappedDisableInitialLoad).not.toBe(nativeDisableInitialLoad); + expect(ready.googletag.getConfig).toHaveBeenCalledWith('disableInitialLoad'); + + await adapter.run((gpt) => gpt.serviceState()).result; + expect(ready.googletag.setConfig).toBe(wrappedSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(wrappedDisableInitialLoad); + + expect(ready.googletag.setConfig({ disableInitialLoad: false })).toBe('config-result'); + await expect(adapter.run((gpt) => gpt.serviceState().initialLoadDisabled).result).resolves.toBe( + false + ); + expect(ready.pubads.disableInitialLoad()).toBe('legacy-result'); + await expect(adapter.run((gpt) => gpt.serviceState().initialLoadDisabled).result).resolves.toBe( + true + ); + + adapter.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisableInitialLoad); + expect(Reflect.ownKeys(ready.googletag).some((key) => String(key).startsWith('__ts'))).toBe( + false + ); + }); + + it('preserves GPT configuration calls and falls back only when getConfig is unavailable', async () => { + const ready = createReadyGoogletag(); + const binding = ready.googletag as unknown as Record; + const nativeSetConfig = vi.fn(function ( + this: unknown, + config: { readonly disableInitialLoad?: boolean | null }, + marker: string + ) { + ready.initialLoad.disabled = config.disableInitialLoad === true; + return { marker, receiver: this }; + }); + binding['setConfig'] = nativeSetConfig; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await adapter.run((gpt) => gpt.serviceState()).result; + const receiver = { publisher: true }; + const config = { disableInitialLoad: true }; + + const wrappedSetConfig = binding['setConfig'] as (...arguments_: unknown[]) => unknown; + const returned = Reflect.apply(wrappedSetConfig, receiver, [config, 'exact']); + expect(nativeSetConfig).toHaveBeenCalledWith(config, 'exact'); + expect(returned).toEqual({ marker: 'exact', receiver }); + await expect(adapter.run((gpt) => gpt.serviceState().initialLoadDisabled).result).resolves.toBe( + true + ); + + binding['getConfig'] = undefined; + Reflect.apply(wrappedSetConfig, receiver, [{ disableInitialLoad: false }, 'fallback']); + await expect(adapter.run((gpt) => gpt.serviceState().initialLoadDisabled).result).resolves.toBe( + false + ); + }); + + it('shares one GPT wrapper across adapter instances until the last owner disposes', async () => { + const ready = createReadyGoogletag(); + const nativeSetConfig = ready.googletag.setConfig; + const nativeDisableInitialLoad = ready.pubads.disableInitialLoad; + const first = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const second = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await first.run((gpt) => gpt.serviceState()).result; + const sharedSetConfig = ready.googletag.setConfig; + const sharedDisableInitialLoad = ready.pubads.disableInitialLoad; + + await second.run((gpt) => gpt.serviceState()).result; + expect(ready.googletag.setConfig).toBe(sharedSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(sharedDisableInitialLoad); + + first.dispose(); + expect(ready.googletag.setConfig).toBe(sharedSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(sharedDisableInitialLoad); + ready.googletag.setConfig({ disableInitialLoad: true }); + await expect(second.run((gpt) => gpt.serviceState().initialLoadDisabled).result).resolves.toBe( + true + ); + + second.dispose(); + expect(ready.googletag.setConfig).toBe(nativeSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(nativeDisableInitialLoad); + }); + + it('releases historical GPT initial-load ownership across A to B to C', async () => { + const first = createReadyGoogletag(); + const second = createReadyGoogletag(); + const third = createReadyGoogletag(); + const firstNativeSetConfig = first.googletag.setConfig; + const firstNativeDisable = first.pubads.disableInitialLoad; + const secondPublisherSetConfig = vi.fn(); + const secondPublisherDisable = vi.fn(); + const thirdNativeSetConfig = third.googletag.setConfig; + const thirdNativeDisable = third.pubads.disableInitialLoad; + const target: { googletag?: unknown } = { googletag: first.googletag }; + const adapter = createBrowserGoogletagAdapter(target); + + await adapter.run((gpt) => gpt.serviceState()).result; + expect(first.googletag.setConfig).not.toBe(firstNativeSetConfig); + target.googletag = second.googletag; + await adapter.run((gpt) => gpt.serviceState()).result; + expect(first.googletag.setConfig).toBe(firstNativeSetConfig); + expect(first.pubads.disableInitialLoad).toBe(firstNativeDisable); + + second.googletag.setConfig = secondPublisherSetConfig; + second.pubads.disableInitialLoad = secondPublisherDisable; + target.googletag = third.googletag; + await adapter.run((gpt) => gpt.serviceState()).result; + expect(second.googletag.setConfig).toBe(secondPublisherSetConfig); + expect(second.pubads.disableInitialLoad).toBe(secondPublisherDisable); + expect(third.googletag.setConfig).not.toBe(thirdNativeSetConfig); + expect(third.pubads.disableInitialLoad).not.toBe(thirdNativeDisable); + + adapter.dispose(); + expect(third.googletag.setConfig).toBe(thirdNativeSetConfig); + expect(third.pubads.disableInitialLoad).toBe(thirdNativeDisable); + }); + + it('preserves shared GPT initial-load ownership when one adapter changes bindings', async () => { + const first = createReadyGoogletag(); + const second = createReadyGoogletag(); + const firstNativeSetConfig = first.googletag.setConfig; + const firstNativeDisable = first.pubads.disableInitialLoad; + const secondNativeSetConfig = second.googletag.setConfig; + const firstTarget: { googletag?: unknown } = { googletag: first.googletag }; + const secondTarget: { googletag?: unknown } = { googletag: first.googletag }; + const firstAdapter = createBrowserGoogletagAdapter(firstTarget); + const secondAdapter = createBrowserGoogletagAdapter(secondTarget); + await firstAdapter.run((gpt) => gpt.serviceState()).result; + await secondAdapter.run((gpt) => gpt.serviceState()).result; + const sharedSetConfig = first.googletag.setConfig; + + firstTarget.googletag = second.googletag; + await firstAdapter.run((gpt) => gpt.serviceState()).result; + expect(first.googletag.setConfig).toBe(sharedSetConfig); + expect(first.pubads.disableInitialLoad).not.toBe(firstNativeDisable); + expect(second.googletag.setConfig).not.toBe(secondNativeSetConfig); + + secondAdapter.dispose(); + expect(first.googletag.setConfig).toBe(firstNativeSetConfig); + expect(first.pubads.disableInitialLoad).toBe(firstNativeDisable); + expect(second.googletag.setConfig).not.toBe(secondNativeSetConfig); + + firstAdapter.dispose(); + expect(second.googletag.setConfig).toBe(secondNativeSetConfig); + }); + + it('does not overwrite publisher GPT method replacements during restoration', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + await adapter.run((gpt) => gpt.serviceState()).result; + const publisherSetConfig = vi.fn(); + const publisherDisableInitialLoad = vi.fn(); + ready.googletag.setConfig = publisherSetConfig; + ready.pubads.disableInitialLoad = publisherDisableInitialLoad; + + adapter.dispose(); + + expect(ready.googletag.setConfig).toBe(publisherSetConfig); + expect(ready.pubads.disableInitialLoad).toBe(publisherDisableInitialLoad); + }); +}); diff --git a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts new file mode 100644 index 000000000..88b72a384 --- /dev/null +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -0,0 +1,1635 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + PROTOCOL_MESSAGE_SCHEMAS_V1, + TSJS_MESSAGE_PROTOCOL_V1, + createBrowserMessagingAdapter, +} from '../../src/adapters/messaging'; + +function createTarget() { + return { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; +} + +function createPort() { + const listeners = new Set<(event: unknown) => void>(); + const messageErrorListeners = new Set<(event: unknown) => void>(); + return { + addEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + (type === 'messageerror' ? messageErrorListeners : listeners).add(listener); + }), + close: vi.fn(), + listeners, + messageErrorListeners, + postMessage: vi.fn(), + removeEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + (type === 'messageerror' ? messageErrorListeners : listeners).delete(listener); + }), + start: vi.fn(), + }; +} + +function createApsRenderer() { + return { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + }; +} + +describe('browser messaging adapter', () => { + it('creates one owned channel and transfers only its exact wrapped endpoint', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + if (!channel) throw new Error('Expected one channel'); + expect(Object.isFrozen(channel)).toBe(true); + expect(Object.isFrozen(channel.retained)).toBe(true); + expect(Object.isFrozen(channel.transferred)).toBe(true); + + const receiver = { postMessage: vi.fn() }; + const envelope = Object.freeze({ version: 1, nonce: 'n1_abcdefghijklmnopqrstuv' }); + expect(adapter.postWindow(receiver, envelope, '*', [channel.transferred])).toBe(true); + expect(receiver.postMessage).toHaveBeenCalledWith(envelope, '*', [transferredRaw]); + channel.transferred.close(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + expect(adapter.postWindow(receiver, envelope, '*', [channel.transferred])).toBe(false); + channel.retained.close(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + }); + + it('leaves an untransferred endpoint locally closeable when exact window posting fails', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + if (!channel) throw new Error('Expected one channel'); + const receiver = { + postMessage: vi.fn(() => { + throw new Error('window post failed'); + }), + }; + expect(adapter.postWindow(receiver, Object.freeze({}), '*', [channel.transferred])).toBe(false); + channel.transferred.close(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + channel.retained.close(); + }); + + it('reserves a transferred endpoint before a reentrant window post', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + if (!channel) throw new Error('Expected one channel'); + let nested: boolean | undefined; + const receiver = { + postMessage: vi.fn(() => { + nested = adapter.postWindow(receiver, Object.freeze({ nested: true }), '*', [ + channel.transferred, + ]); + }), + }; + expect( + adapter.postWindow(receiver, Object.freeze({ outer: true }), '*', [channel.transferred]) + ).toBe(true); + expect(nested).toBe(false); + expect(receiver.postMessage).toHaveBeenCalledOnce(); + channel.transferred.close(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + }); + + it('closes invalid channel endpoints without returning a partial facade', () => { + const duplicate = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = duplicate; + readonly port2 = duplicate; + }, + }); + expect(adapter.createChannel()).toBeUndefined(); + expect(duplicate.close).toHaveBeenCalledOnce(); + expect(createBrowserMessagingAdapter(createTarget()).createChannel()).toBeUndefined(); + }); + + it('transfers through descriptor snapshots when Array prototype operations are poisoned', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + if (!channel) throw new Error('Expected one channel'); + let posts = 0; + let receivedTransfer: unknown; + const receiver = { + postMessage(...parameters: unknown[]): void { + posts += 1; + receivedTransfer = parameters[2]; + }, + }; + const originalFilter = Object.getOwnPropertyDescriptor(Array.prototype, 'filter'); + const originalSort = Object.getOwnPropertyDescriptor(Array.prototype, 'sort'); + const originalPush = Object.getOwnPropertyDescriptor(Array.prototype, 'push'); + const originalIterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const poisoned = (): never => { + throw new Error('poisoned Array prototype operation'); + }; + let result: boolean | undefined; + let thrown: unknown; + try { + Object.defineProperty(Array.prototype, 'filter', { value: poisoned, configurable: true }); + Object.defineProperty(Array.prototype, 'sort', { value: poisoned, configurable: true }); + Object.defineProperty(Array.prototype, 'push', { value: poisoned, configurable: true }); + Object.defineProperty(Array.prototype, Symbol.iterator, { + value: poisoned, + configurable: true, + }); + result = adapter.postWindow(receiver, Object.freeze({}), '*', [channel.transferred]); + } catch (error) { + thrown = error; + } finally { + if (originalFilter) Object.defineProperty(Array.prototype, 'filter', originalFilter); + if (originalSort) Object.defineProperty(Array.prototype, 'sort', originalSort); + if (originalPush) Object.defineProperty(Array.prototype, 'push', originalPush); + if (originalIterator) { + Object.defineProperty(Array.prototype, Symbol.iterator, originalIterator); + } + } + + expect(thrown).toBeUndefined(); + expect(result).toBe(true); + expect(posts).toBe(1); + expect(receivedTransfer).toEqual([transferredRaw]); + channel.retained.close(); + }); + + it('drains listeners and closes the raw port when collection iterators are poisoned', () => { + let messageListener: unknown; + let messageErrorListener: unknown; + let removals = 0; + let closes = 0; + const raw = { + addEventListener(type: string, listener: unknown): void { + if (type === 'message') messageListener = listener; + else messageErrorListener = listener; + }, + close(): void { + closes += 1; + }, + postMessage(): void {}, + removeEventListener(type: string, listener: unknown): void { + if (type === 'message' && listener === messageListener) removals += 1; + if (type === 'messageerror' && listener === messageErrorListener) removals += 1; + }, + start(): void {}, + }; + const adapter = createBrowserMessagingAdapter(createTarget()); + const [port] = adapter.extractTransferredPorts({ ports: [raw] }, 1) ?? []; + if (!port) throw new Error('Expected one port'); + port.listen( + () => undefined, + () => undefined + ); + + const originalSetIterator = Object.getOwnPropertyDescriptor(Set.prototype, Symbol.iterator); + const originalSetValues = Object.getOwnPropertyDescriptor(Set.prototype, 'values'); + const originalArrayIterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const iteratorPrototype = Object.getPrototypeOf(new Set().values()) as object; + const originalNext = Object.getOwnPropertyDescriptor(iteratorPrototype, 'next'); + const poisoned = (): never => { + throw new Error('poisoned collection iterator'); + }; + let thrown: unknown; + try { + Object.defineProperty(Set.prototype, Symbol.iterator, { + value: poisoned, + configurable: true, + }); + Object.defineProperty(Set.prototype, 'values', { value: poisoned, configurable: true }); + Object.defineProperty(Array.prototype, Symbol.iterator, { + value: poisoned, + configurable: true, + }); + Object.defineProperty(iteratorPrototype, 'next', { value: poisoned, configurable: true }); + port.close(); + } catch (error) { + thrown = error; + } finally { + if (originalSetIterator) { + Object.defineProperty(Set.prototype, Symbol.iterator, originalSetIterator); + } + if (originalSetValues) Object.defineProperty(Set.prototype, 'values', originalSetValues); + if (originalArrayIterator) { + Object.defineProperty(Array.prototype, Symbol.iterator, originalArrayIterator); + } + if (originalNext) Object.defineProperty(iteratorPrototype, 'next', originalNext); + } + + expect(thrown).toBeUndefined(); + expect(removals).toBe(2); + expect(closes).toBe(1); + port.close(); + expect(closes).toBe(1); + }); + + it('posts through a wrapped port without dynamic transfer-array iteration', () => { + const raw = createPort(); + const adapter = createBrowserMessagingAdapter(createTarget()); + const [port] = adapter.extractTransferredPorts({ ports: [raw] }, 1) ?? []; + if (!port) throw new Error('Expected one port'); + const transferred: unknown[] = []; + const originalIterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const poisoned = (): never => { + throw new Error('poisoned Array iterator'); + }; + try { + Object.defineProperty(Array.prototype, Symbol.iterator, { + value: poisoned, + configurable: true, + }); + port.post(Object.freeze({ message: true }), transferred); + } finally { + if (originalIterator) { + Object.defineProperty(Array.prototype, Symbol.iterator, originalIterator); + } + } + + expect(raw.postMessage).toHaveBeenCalledWith({ message: true }, []); + port.close(); + }); + + it('unwraps and commits exact channel endpoints transferred through a retained port', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const controlRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + const [control] = adapter.extractTransferredPorts({ ports: [controlRaw] }, 1) ?? []; + if (!channel || !control) throw new Error('Expected channel and control port'); + const message = Object.freeze({ message: 'transfer' }); + + expect(control.post(message, [channel.transferred])).toBe(true); + expect(controlRaw.postMessage).toHaveBeenCalledWith(message, [transferredRaw]); + expect(control.post(message, [channel.transferred])).toBe(false); + channel.transferred.close(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + channel.retained.close(); + control.close(); + }); + + it('rolls back exact channel transfer ownership when retained-port posting throws', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const controlRaw = createPort(); + controlRaw.postMessage.mockImplementation(() => { + throw new Error('port post failed'); + }); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + const [control] = adapter.extractTransferredPorts({ ports: [controlRaw] }, 1) ?? []; + if (!channel || !control) throw new Error('Expected channel and control port'); + + expect(control.post(Object.freeze({}), [channel.transferred])).toBe(false); + channel.transferred.close(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + channel.retained.close(); + control.close(); + }); + + it('uses captured close authority when later channel validation fails', () => { + let closeReads = 0; + let closes = 0; + const first = { + addEventListener(): void {}, + get close(): () => void { + closeReads += 1; + if (closeReads > 1) throw new Error('close authority re-read'); + return () => { + closes += 1; + }; + }, + postMessage(): void {}, + removeEventListener(): void {}, + start(): void {}, + }; + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = first; + readonly port2 = Object.freeze({ invalid: true }); + }, + }); + + expect(adapter.createChannel()).toBeUndefined(); + expect(closeReads).toBe(1); + expect(closes).toBe(1); + }); + + it('preserves captured close authority when later raw-port method inspection throws', () => { + const first = createPort(); + let closeReads = 0; + let closes = 0; + const partial = { + addEventListener(): void {}, + get close(): () => void { + closeReads += 1; + if (closeReads > 1) throw new Error('close authority re-read'); + return () => { + closes += 1; + }; + }, + get postMessage(): never { + throw new Error('later port inspection failed'); + }, + removeEventListener(): void {}, + start(): void {}, + }; + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = first; + readonly port2 = partial; + }, + }); + + expect(adapter.createChannel()).toBeUndefined(); + expect(closeReads).toBe(1); + expect(closes).toBe(1); + expect(first.close).toHaveBeenCalledOnce(); + }); + + it('captures the first endpoint close before a hostile second-endpoint getter runs', () => { + let closeReads = 0; + let poisonedCloseReads = 0; + let closes = 0; + const first = { + addEventListener(): void {}, + get close(): () => void { + closeReads += 1; + if (closeReads > 1) throw new Error('first close authority re-read'); + return () => { + closes += 1; + }; + }, + postMessage(): void {}, + removeEventListener(): void {}, + start(): void {}, + }; + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = first; + + get port2(): never { + Object.defineProperty(first, 'close', { + configurable: true, + get: () => { + poisonedCloseReads += 1; + throw new Error('first close authority poisoned'); + }, + }); + throw new Error('second endpoint unavailable'); + } + }, + }); + + expect(adapter.createChannel()).toBeUndefined(); + expect(closeReads).toBe(1); + expect(poisonedCloseReads).toBe(0); + expect(closes).toBe(1); + }); + + it('uses captured WeakMap authority for channel registration and facade lookup', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const originalGet = WeakMap.prototype.get; + const originalSet = WeakMap.prototype.set; + let dynamicGets = 0; + let dynamicSets = 0; + WeakMap.prototype.set = function ( + this: WeakMap, + key: K, + value: V + ): WeakMap { + dynamicSets += 1; + Reflect.apply(originalSet, this, [key, value]); + throw new Error('registration intercepted'); + }; + WeakMap.prototype.get = function (this: WeakMap, _key: K): V { + dynamicGets += 1; + return { + raw: { binding: transferredRaw }, + transferable: true, + closed: false, + transferred: false, + transferring: false, + } as V; + }; + + let channel: ReturnType; + let forgedResult: boolean | undefined; + let thrown: unknown; + let posts = 0; + try { + channel = adapter.createChannel(); + forgedResult = adapter.postWindow( + { postMessage: () => (posts += 1) }, + Object.freeze({}), + '*', + [Object.freeze({}) as never] + ); + } catch (error) { + thrown = error; + } finally { + WeakMap.prototype.get = originalGet; + WeakMap.prototype.set = originalSet; + } + + expect(thrown).toBeUndefined(); + expect(channel).toBeDefined(); + expect(forgedResult).toBe(false); + expect(posts).toBe(0); + expect(dynamicGets).toBe(0); + expect(dynamicSets).toBe(0); + channel?.retained.close(); + channel?.transferred.close(); + }); + + it('rejects MessageChannel constructors that reuse an already-owned raw endpoint', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const first = adapter.createChannel(); + if (!first) throw new Error('Expected one channel'); + + expect(adapter.createChannel()).toBeUndefined(); + expect(retainedRaw.close).not.toHaveBeenCalled(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + first.retained.close(); + first.transferred.close(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + }); + + it('does not close a live owned endpoint when a later constructor returns it twice', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + let constructions = 0; + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1: unknown; + readonly port2: unknown; + + constructor() { + constructions += 1; + this.port1 = retainedRaw; + this.port2 = constructions === 1 ? transferredRaw : retainedRaw; + } + }, + }); + const first = adapter.createChannel(); + if (!first) throw new Error('Expected one channel'); + + expect(adapter.createChannel()).toBeUndefined(); + expect(retainedRaw.close).not.toHaveBeenCalled(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + first.retained.close(); + first.transferred.close(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + }); + + it('keeps failed channel bindings retired throughout reentrant close cleanup', () => { + let constructions = 0; + let closes = 0; + let nestedCloses = 0; + let nestedResult: ReturnType['createChannel']>; + const retainedRaw = { + addEventListener(): void {}, + close(): void { + closes += 1; + nestedResult = adapter.createChannel(); + }, + postMessage(): void {}, + removeEventListener(): void {}, + start(): void {}, + }; + const nestedRaw = { + addEventListener(): void {}, + close(): void { + nestedCloses += 1; + }, + postMessage(): void {}, + removeEventListener(): void {}, + start(): void {}, + }; + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1: unknown; + readonly port2: unknown; + + constructor() { + constructions += 1; + this.port1 = retainedRaw; + this.port2 = constructions === 1 ? Object.freeze({ invalid: true }) : nestedRaw; + } + }, + }); + + expect(adapter.createChannel()).toBeUndefined(); + expect(nestedResult).toBeUndefined(); + expect(closes).toBe(1); + expect(nestedCloses).toBe(1); + }); + + it('rejects channel-owned raw endpoints at transferred-port extraction without closing them', () => { + const retainedRaw = createPort(); + const transferredRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const channel = adapter.createChannel(); + if (!channel) throw new Error('Expected one channel'); + + expect(adapter.extractTransferredPorts({ ports: [retainedRaw] }, 1)).toBeUndefined(); + expect(adapter.extractTransferredPorts({ ports: [retainedRaw] }, 0)).toBeUndefined(); + expect(retainedRaw.close).not.toHaveBeenCalled(); + channel.retained.close(); + channel.transferred.close(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + }); + + it('rejects MessageChannel endpoints already owned by transferred-port extraction', () => { + const extractedRaw = createPort(); + const newRaw = createPort(); + const adapter = createBrowserMessagingAdapter({ + ...createTarget(), + MessageChannel: class { + readonly port1 = extractedRaw; + readonly port2 = newRaw; + }, + }); + const [extracted] = adapter.extractTransferredPorts({ ports: [extractedRaw] }, 1) ?? []; + if (!extracted) throw new Error('Expected one extracted port'); + + expect(adapter.createChannel()).toBeUndefined(); + expect(extractedRaw.close).not.toHaveBeenCalled(); + expect(newRaw.close).toHaveBeenCalledOnce(); + extracted.close(); + expect(extractedRaw.close).toHaveBeenCalledOnce(); + }); + + it('extracts and wraps transferred ports without dynamic Array operations or iteration', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const raw = createPort(); + const originalPush = Object.getOwnPropertyDescriptor(Array.prototype, 'push'); + const originalIterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const poisoned = (): never => { + throw new Error('poisoned Array operation'); + }; + let extracted: readonly unknown[] | undefined; + let thrown: unknown; + try { + Object.defineProperty(Array.prototype, 'push', { value: poisoned, configurable: true }); + Object.defineProperty(Array.prototype, Symbol.iterator, { + value: poisoned, + configurable: true, + }); + extracted = adapter.extractTransferredPorts({ ports: [raw] }, 1); + } catch (error) { + thrown = error; + } finally { + if (originalPush) Object.defineProperty(Array.prototype, 'push', originalPush); + if (originalIterator) { + Object.defineProperty(Array.prototype, Symbol.iterator, originalIterator); + } + } + + expect(thrown).toBeUndefined(); + expect(extracted).toHaveLength(1); + const port = extracted?.[0] as { close?: () => void } | undefined; + port?.close?.(); + expect(raw.close).toHaveBeenCalledOnce(); + }); + + it('centralizes every protocol literal and exact message shape as frozen data', () => { + expect(TSJS_MESSAGE_PROTOCOL_V1).toEqual({ + version: 1, + rendererVersion: '4', + message: { + prebidRequest: 'Prebid Request', + prebidResponse: 'Prebid Response', + ownerRegister: 'TS Render Owner Register', + ownerRegistered: 'TS Render Owner Registered', + ownerRefused: 'TS Render Owner Refused', + apsTopMountStarted: 'TS APS Top Mount Started', + admStart: 'TS ADM Start', + ownerInserted: 'TS Owner Inserted', + ownerSettled: 'TS Owner Settled', + admLoaded: 'TS ADM Loaded', + admFailed: 'TS ADM Failed', + apsBootstrapReady: 'TS APS Bootstrap Ready', + apsBootstrapConfigure: 'TS APS Bootstrap Configure', + apsInnerReady: 'TS APS Inner Ready', + apsInnerBind: 'TS APS Inner Bind', + apsContainerReady: 'TS APS Container Ready', + apsDocumentAccepted: 'TS APS Document Accepted', + apsRunnerLoaded: 'TS APS Runner Loaded', + apsRenderCompleted: 'TS APS Render Completed', + apsRenderFailed: 'TS APS Render Failed', + }, + status: { ready: 'ready', refused: 'refused' }, + kind: { aps: 'aps', adm: 'adm' }, + outcome: { accepted: 'accepted', failed: 'failed', cancelled: 'cancelled' }, + runnerFailure: { + descriptorInvalid: 'descriptor_invalid', + runnerNoLoad: 'runner_no_load', + runnerFailed: 'runner_failed', + }, + cancellation: { + callerAborted: 'caller_aborted', + superseded: 'superseded', + navigationDisposed: 'navigation_disposed', + }, + }); + expect(Object.isFrozen(TSJS_MESSAGE_PROTOCOL_V1)).toBe(true); + expect(Object.isFrozen(TSJS_MESSAGE_PROTOCOL_V1.message)).toBe(true); + expect(Object.isFrozen(PROTOCOL_MESSAGE_SCHEMAS_V1)).toBe(true); + expect(Object.isFrozen(PROTOCOL_MESSAGE_SCHEMAS_V1.apsTopMountStarted.keys)).toBe(true); + }); + + it('parses global JSON and structured messages through exact descriptor-safe schemas', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + expect( + adapter.parseProtocolMessage( + 'prebidRequest', + JSON.stringify({ + message: 'Prebid Request', + adId: 'r1_1234567890123456789012', + adServerDomain: 'ads.example.com', + }) + ) + ).toEqual({ + message: 'Prebid Request', + adId: 'r1_1234567890123456789012', + adServerDomain: 'ads.example.com', + }); + expect( + adapter.parseProtocolMessage('ownerInserted', { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }) + ).toEqual({ + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }); + + for (const candidate of [ + { message: 'TS Owner Inserted', version: 2, lifecycleTicket: 't1_abcdefghijklmnopqrstuv' }, + { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + extra: true, + }, + { message: 'wrong', version: 1, lifecycleTicket: 't1_abcdefghijklmnopqrstuv' }, + Object.assign(Object.create({ inherited: true }), { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }), + ]) { + expect(adapter.parseProtocolMessage('ownerInserted', candidate)).toBeUndefined(); + } + }); + + it('parses the exact bounded bootstrap, inner, and container window channels', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const bootstrapNonce = `b1_${'b'.repeat(22)}`; + const rendererNonce = `n1_${'n'.repeat(22)}`; + const creativeOrigin = 'https://creative.example'; + const messages = [ + ['apsBootstrapReady', { message: 'TS APS Bootstrap Ready', version: 1, bootstrapNonce }], + [ + 'apsBootstrapConfigure', + { + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce, + creativeOrigin, + tagType: 'iframe', + }, + ], + ['apsInnerReady', { message: 'TS APS Inner Ready', version: 1, rendererNonce }], + ['apsInnerBind', { message: 'TS APS Inner Bind', version: 1, rendererNonce }], + [ + 'apsContainerReady', + { + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, + }, + ], + ] as const; + for (const [kind, value] of messages) { + expect(adapter.parseProtocolMessage(kind, JSON.stringify(value))).toEqual(value); + expect( + adapter.parseProtocolMessage(kind, JSON.stringify({ ...value, extra: true })) + ).toBeUndefined(); + } + expect( + adapter.parseProtocolMessage( + 'apsBootstrapConfigure', + `{"message":"TS APS Bootstrap Configure","version":2,"bootstrapNonce":"${bootstrapNonce}","bootstrapNonce":"${bootstrapNonce}","rendererNonce":"${rendererNonce}","creativeOrigin":"${creativeOrigin}","tagType":"iframe"}` + ) + ).toBeUndefined(); + expect( + adapter.parseProtocolMessage( + 'apsBootstrapConfigure', + JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce, + creativeOrigin: 'http://creative.example', + tagType: 'iframe', + }) + ) + ).toBeUndefined(); + expect( + adapter.parseProtocolMessage( + 'apsBootstrapConfigure', + JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce, + creativeOrigin, + tagType: 'script', + }) + ) + ).toBeDefined(); + expect( + adapter.parseProtocolMessage( + 'apsBootstrapConfigure', + JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce, + creativeOrigin, + tagType: 'image', + }) + ) + ).toBeUndefined(); + }); + + it('inspects only own routing data before exact global-message parsing', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const json = JSON.stringify({ + message: 'Prebid Request', + adId: 'r1_abcdefghijklmnopqrstuv', + adServerDomain: 'ads.example.com', + ignored: { renderer: '' }, + }); + const object = Object.assign(Object.create(null), { + message: 'TS Render Owner Register', + adId: 'r1_abcdefghijklmnopqrstuv', + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + ignored: true, + }); + + const inspectedJson = adapter.inspectGlobalMessage(json); + const inspectedObject = adapter.inspectGlobalMessage(object); + + expect(inspectedJson).toEqual({ + message: 'Prebid Request', + adId: 'r1_abcdefghijklmnopqrstuv', + }); + expect(inspectedObject).toEqual({ + message: 'TS Render Owner Register', + adId: 'r1_abcdefghijklmnopqrstuv', + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }); + expect(Object.isFrozen(inspectedJson)).toBe(true); + expect(Object.isFrozen(inspectedObject)).toBe(true); + }); + + it('inspects global routing data without invoking accessors or inherited properties', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const getter = vi.fn(() => 'Prebid Request'); + const accessor = Object.create(null) as Record; + Object.defineProperty(accessor, 'message', { get: getter, enumerable: true }); + Object.defineProperty(accessor, 'adId', { + value: 'r1_abcdefghijklmnopqrstuv', + enumerable: true, + }); + const inherited = Object.assign(Object.create({ message: 'Prebid Request' }), { + adId: 'r1_abcdefghijklmnopqrstuv', + }); + const throwingProxy = new Proxy( + {}, + { + getPrototypeOf: () => { + throw new Error('prototype trap'); + }, + } + ); + + expect(adapter.inspectGlobalMessage(accessor)).toBeUndefined(); + expect(adapter.inspectGlobalMessage(inherited)).toBeUndefined(); + expect(adapter.inspectGlobalMessage(throwingProxy)).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + }); + + it('rejects malformed, duplicate-key, and oversized routing JSON during inspection', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const duplicate = '{"message":"Prebid Request","adId":"first","adId":"second","ignored":true}'; + const oversized = JSON.stringify({ + message: 'Prebid Request', + adId: 'r1_abcdefghijklmnopqrstuv', + ignored: 'é'.repeat(2_100), + }); + + expect(adapter.inspectGlobalMessage('{')).toBeUndefined(); + expect(adapter.inspectGlobalMessage(duplicate)).toBeUndefined(); + expect(adapter.inspectGlobalMessage(oversized)).toBeUndefined(); + expect(adapter.inspectGlobalMessage({ adId: 'r1_abcdefghijklmnopqrstuv' })).toBeUndefined(); + }); + + it('does not invoke accessors while rejecting an exact-shape candidate', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const getter = vi.fn(() => 'TS Owner Inserted'); + const candidate = { + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + } as Record; + Object.defineProperty(candidate, 'message', { get: getter, enumerable: true }); + + expect(adapter.parseProtocolMessage('ownerInserted', candidate)).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + }); + + it('rejects oversized UTF-8 and duplicate-key global JSON before stateful parsing', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const oversized = JSON.stringify({ + message: 'Prebid Request', + adId: 'r1_1234567890123456789012', + adServerDomain: 'é'.repeat(2_100), + }); + const duplicate = + '{"message":"Prebid Request","adId":"first","adId":"second","adServerDomain":"ads.example.com"}'; + + expect(adapter.parseProtocolMessage('prebidRequest', oversized)).toBeUndefined(); + expect(adapter.parseProtocolMessage('prebidRequest', duplicate)).toBeUndefined(); + expect( + adapter.parseProtocolMessage('prebidRequest', { message: 'Prebid Request' }) + ).toBeUndefined(); + }); + + it.each(['before', 'after'] as const)( + 'fails global JSON parsing closed when duplicate-key tracking throws %s insertion', + (failure) => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const originalSetAdd = Set.prototype.add; + Set.prototype.add = function (this: Set, value: unknown): Set { + if (failure === 'after') Reflect.apply(originalSetAdd, this, [value]); + throw new Error(`duplicate-key tracking failed ${failure} insertion`); + } as typeof Set.prototype.add; + + let parsed: unknown; + let thrown: unknown; + try { + parsed = adapter.parseProtocolMessage( + 'prebidRequest', + JSON.stringify({ + message: 'Prebid Request', + adId: 'r1_1234567890123456789012', + adServerDomain: 'ads.example.com', + }) + ); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + } + + expect(thrown).toBeUndefined(); + expect(parsed).toBeUndefined(); + } + ); + + it.each(['duplicate-key', 'reason'] as const)( + 'fails protocol %s membership checks closed when Set.has throws', + (lookup) => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const candidate = + lookup === 'duplicate-key' + ? JSON.stringify({ + message: 'Prebid Request', + adId: 'r1_1234567890123456789012', + adServerDomain: 'ads.example.com', + }) + : { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + outcome: 'failed', + reason: 'internal_error', + }; + const originalSetHas = Set.prototype.has; + Set.prototype.has = function (): boolean { + throw new Error(`${lookup} membership failed`); + } as typeof Set.prototype.has; + + let parsed: unknown; + let thrown: unknown; + try { + parsed = adapter.parseProtocolMessage( + lookup === 'duplicate-key' ? 'prebidRequest' : 'ownerSettledFailed', + candidate + ); + } catch (error) { + thrown = error; + } finally { + Set.prototype.has = originalSetHas; + } + + expect(thrown).toBeUndefined(); + expect(parsed).toBeUndefined(); + } + ); + + it('validates capability forms, field types, nested records, enums, and UTF-8 limits', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const request = (adId: unknown, adServerDomain: unknown) => + JSON.stringify({ message: 'Prebid Request', adId, adServerDomain }); + + expect( + adapter.parseProtocolMessage( + 'prebidRequest', + request('r1_abcdefghijklmnopqrstuv', 'é'.repeat(1_024)) + ) + ).toBeDefined(); + for (const candidate of [ + request('r1_too-short', 'ads.example.com'), + request('a1_abcdefghijklmnopqrstuv', 'ads.example.com'), + request('r1_abcdefghijklmnopqrstuv', ''), + request('r1_abcdefghijklmnopqrstuv', 'é'.repeat(1_025)), + request('r1_abcdefghijklmnopqrstuv', 1), + ]) { + expect(adapter.parseProtocolMessage('prebidRequest', candidate)).toBeUndefined(); + } + + expect( + adapter.parseProtocolMessage('tsOwnerReady', { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }) + ).toBeDefined(); + expect( + adapter.parseProtocolMessage('tsOwnerReady', { + version: 1, + status: 'ready', + kind: 'cache', + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }) + ).toBeUndefined(); + expect( + adapter.parseProtocolMessage('ownerSettledCancelled', { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + outcome: 'cancelled', + reason: 'external_ready_timeout', + }) + ).toBeUndefined(); + }); + + it('accepts only the exact data-free APS top-mount notification', () => { + const message = { + message: 'TS APS Top Mount Started', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }; + const adapter = createBrowserMessagingAdapter(createTarget()); + expect(adapter.parseProtocolMessage('apsTopMountStarted', message)).toEqual(message); + expect( + adapter.parseProtocolMessage('apsTopMountStarted', { + ...message, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', + }) + ).toBeUndefined(); + }); + + it('canonicalizes an exact APS renderer before invoking the semantic validator', () => { + const renderer = createApsRenderer(); + let canonical: unknown; + const validator = vi.fn((candidate: unknown) => { + canonical = candidate; + renderer.bidId = 'mutated-during-validation'; + return true; + }); + const adapter = createBrowserMessagingAdapter(createTarget(), { + expectedPublisherOrigin: 'https://publisher.example', + expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', + validateApsRenderer: validator, + }); + const parsed = adapter.parseProtocolMessage('apsEnvelope', { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer, + }); + + expect(validator).toHaveBeenCalledTimes(1); + expect(canonical).not.toBe(renderer); + expect(Object.getPrototypeOf(canonical)).toBeNull(); + expect(Object.isFrozen(canonical)).toBe(true); + expect((canonical as Record)['bidId']).toBe('bid-1'); + expect(parsed?.['renderer']).toBe(canonical); + }); + + it('rejects APS renderer accessors, proxies, and unknown keys before validation', () => { + const validator = vi.fn(() => true); + const adapter = createBrowserMessagingAdapter(createTarget(), { + expectedPublisherOrigin: 'https://publisher.example', + expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', + validateApsRenderer: validator, + }); + const parse = (renderer: unknown) => + adapter.parseProtocolMessage('apsEnvelope', { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer, + }); + const accessor = createApsRenderer(); + const getter = vi.fn(() => 'bid-from-getter'); + Object.defineProperty(accessor, 'bidId', { get: getter, enumerable: true }); + const proxy = new Proxy(createApsRenderer(), { + ownKeys: () => { + throw new Error('hostile renderer proxy'); + }, + }); + + expect(parse(accessor)).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + expect(() => parse(proxy)).not.toThrow(); + expect(parse(proxy)).toBeUndefined(); + expect(parse({ ...createApsRenderer(), unknown: true })).toBeUndefined(); + expect(validator).not.toHaveBeenCalled(); + }); + + it('returns canonical frozen nested records without invoking prototype serialization hooks', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const owner = { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }; + const toJSON = vi.fn(() => { + throw new Error('prototype hook called'); + }); + Object.defineProperty(Object.prototype, 'toJSON', { value: toJSON, configurable: true }); + try { + const parsed = adapter.parseProtocolMessage('prebidResponse', { + message: 'Prebid Response', + adId: 'r1_abcdefghijklmnopqrstuv', + renderer: 'renderer program', + rendererVersion: '4', + tsOwner: owner, + }); + expect(parsed).toBeDefined(); + expect(parsed?.['tsOwner']).not.toBe(owner); + expect(Object.isFrozen(parsed?.['tsOwner'])).toBe(true); + owner.kind = 'adm'; + expect(parsed?.['tsOwner']).toMatchObject({ kind: 'aps' }); + expect(toJSON).not.toHaveBeenCalled(); + } finally { + delete (Object.prototype as { toJSON?: unknown }).toJSON; + } + }); + + it('parses the renderer-free refused Prebid response as its own exact shape', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const refused = { + message: 'Prebid Response', + adId: 'r1_abcdefghijklmnopqrstuv', + rendererVersion: '4', + tsOwner: { version: 1, status: 'refused' }, + }; + expect(adapter.parseProtocolMessage('prebidResponseRefused', refused)).toBeDefined(); + expect( + adapter.parseProtocolMessage('prebidResponseRefused', { + ...refused, + renderer: 'must not be present', + }) + ).toBeUndefined(); + expect( + adapter.parseProtocolMessage('prebidResponseRefused', { + ...refused, + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }, + }) + ).toBeUndefined(); + }); + + it('returns undefined for an unknown runtime schema kind', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + expect(() => + adapter.parseProtocolMessage('unknown' as keyof typeof PROTOCOL_MESSAGE_SCHEMAS_V1, {}) + ).not.toThrow(); + expect( + adapter.parseProtocolMessage('unknown' as keyof typeof PROTOCOL_MESSAGE_SCHEMAS_V1, {}) + ).toBeUndefined(); + }); + + it('extracts exactly zero, one, or two transferred ports into frozen narrow facades', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const single = createPort(); + const pairFirst = createPort(); + const pairSecond = createPort(); + + const zero = adapter.extractTransferredPorts({ ports: [] }, 0); + const one = adapter.extractTransferredPorts({ ports: [single] }, 1); + const two = adapter.extractTransferredPorts({ ports: [pairFirst, pairSecond] }, 2); + + expect(zero).toEqual([]); + expect(one).toHaveLength(1); + expect(two).toHaveLength(2); + expect(Object.isFrozen(zero)).toBe(true); + expect(Object.isFrozen(one?.[0])).toBe(true); + expect(one?.[0]).not.toHaveProperty('postMessage'); + }); + + it('inspects every available refusal port without treating malformed counts as exact', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const first = createPort(); + const second = createPort(); + const third = createPort(); + const malformed = { close: vi.fn() }; + const laterUsable = createPort(); + + const overflow = adapter.inspectTransferredPorts({ ports: [first, second, third] }); + expect(overflow).toMatchObject({ exactShape: true, originalCount: 3 }); + expect(overflow?.ports).toHaveLength(3); + expect(Object.isFrozen(overflow)).toBe(true); + expect(Object.isFrozen(overflow?.ports)).toBe(true); + overflow?.ports.forEach((port) => port.close()); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).toHaveBeenCalledOnce(); + expect(third.close).toHaveBeenCalledOnce(); + + const mixed = adapter.inspectTransferredPorts({ ports: [malformed, laterUsable] }); + expect(mixed).toMatchObject({ exactShape: true, originalCount: 2 }); + expect(mixed?.ports).toHaveLength(1); + expect(malformed.close).toHaveBeenCalledOnce(); + mixed?.ports[0]?.close(); + expect(laterUsable.close).toHaveBeenCalledOnce(); + }); + + it('closes every transferred port on count mismatch and contains hostile closure', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const first = createPort(); + const second = createPort(); + const partial = { close: vi.fn() }; + second.close.mockImplementation(() => { + throw new Error('close failed'); + }); + + expect(() => adapter.extractTransferredPorts({ ports: [first, second] }, 1)).not.toThrow(); + expect(first.close).toHaveBeenCalledTimes(1); + expect(second.close).toHaveBeenCalledTimes(1); + expect(() => adapter.extractTransferredPorts({ ports: [partial] }, 0)).not.toThrow(); + expect(partial.close).toHaveBeenCalledTimes(1); + expect( + adapter.extractTransferredPorts( + { + get ports() { + throw new Error('hostile'); + }, + }, + 0 + ) + ).toBeUndefined(); + }); + + it('snapshots hostile transferred-port arrays without accessors, iterators, or duplicate closes', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const first = createPort(); + const hidden = createPort(); + const getter = vi.fn(() => hidden); + const hostile = [first] as unknown[]; + Object.defineProperty(hostile, '1', { get: getter, enumerable: true }); + Object.defineProperty(hostile, Symbol.iterator, { + get: () => { + throw new Error('iterator read'); + }, + }); + + expect(() => adapter.extractTransferredPorts({ ports: hostile }, 2)).not.toThrow(); + expect(first.close).toHaveBeenCalledTimes(1); + expect(getter).not.toHaveBeenCalled(); + + const duplicate = createPort(); + expect(adapter.extractTransferredPorts({ ports: [duplicate, duplicate] }, 1)).toBeUndefined(); + expect(duplicate.close).toHaveBeenCalledTimes(1); + + const duplicatePair = createPort(); + expect( + adapter.extractTransferredPorts({ ports: [duplicatePair, duplicatePair] }, 2) + ).toBeUndefined(); + expect(duplicatePair.close).toHaveBeenCalledTimes(1); + + const mismatchFirst = createPort(); + const mismatchSecond = createPort(); + expect( + adapter.extractTransferredPorts({ ports: [mismatchFirst, mismatchSecond, mismatchSecond] }, 0) + ).toBeUndefined(); + expect(mismatchFirst.close).toHaveBeenCalledTimes(1); + expect(mismatchSecond.close).toHaveBeenCalledTimes(1); + }); + + it('bounds sparse hostile array inspection by present own keys rather than declared length', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const raw = createPort(); + const sparse = [raw]; + sparse.length = 0xffff_ffff; + let descriptorReads = 0; + const hostile = new Proxy(sparse, { + getOwnPropertyDescriptor(target, key) { + descriptorReads += 1; + if (descriptorReads > 8) throw new Error('unbounded descriptor scan'); + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); + + expect(adapter.extractTransferredPorts({ ports: hostile }, 1)).toBeUndefined(); + expect(descriptorReads).toBeLessThanOrEqual(3); + expect(raw.close).toHaveBeenCalledOnce(); + }); + + it('contains port listener throws and disposes listeners and ports exactly once', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const raw = createPort(); + const [port] = adapter.extractTransferredPorts({ ports: [raw] }, 1) ?? []; + if (!port) throw new Error('Expected one port'); + const listener = vi.fn(() => { + throw new Error('listener failed'); + }); + const messageErrorListener = vi.fn(() => { + throw new Error('messageerror listener failed'); + }); + const unsubscribe = port.listen(listener, messageErrorListener); + const installed = [...raw.listeners][0]; + const installedMessageError = [...raw.messageErrorListeners][0]; + + expect(() => installed?.({ data: { message: 'event' } })).not.toThrow(); + expect(() => installedMessageError?.({ data: 'uncloneable' })).not.toThrow(); + port.post({ message: 'response' }, []); + unsubscribe(); + unsubscribe(); + port.close(); + port.close(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(messageErrorListener).toHaveBeenCalledTimes(1); + expect(raw.postMessage).toHaveBeenCalledWith({ message: 'response' }, []); + expect(raw.removeEventListener).toHaveBeenCalledTimes(2); + expect(raw.removeEventListener).toHaveBeenCalledWith('message', installed); + expect(raw.removeEventListener).toHaveBeenCalledWith('messageerror', installedMessageError); + expect(raw.close).toHaveBeenCalledTimes(1); + }); + + it('rolls back every attempted port listener when message, messageerror, or start fails', () => { + for (const failure of ['message', 'messageerror', 'start'] as const) { + const adapter = createBrowserMessagingAdapter(createTarget()); + const raw = createPort(); + raw.addEventListener.mockImplementation((type, listener) => { + (type === 'messageerror' ? raw.messageErrorListeners : raw.listeners).add(listener); + if (failure === type) { + throw new Error(`${type} add failed`); + } + }); + if (failure === 'start') { + raw.start.mockImplementation(() => { + throw new Error('start failed'); + }); + } + const [port] = adapter.extractTransferredPorts({ ports: [raw] }, 1) ?? []; + if (!port) throw new Error('Expected one port'); + + let dispose = (): void => undefined; + expect(() => { + dispose = port.listen(vi.fn(), vi.fn()); + }).not.toThrow(); + expect(() => dispose()).not.toThrow(); + expect(raw.listeners.size).toBe(0); + expect(raw.messageErrorListeners.size).toBe(0); + expect(raw.removeEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + if (failure !== 'message') { + expect(raw.removeEventListener).toHaveBeenCalledWith('messageerror', expect.any(Function)); + } + expect(raw.removeEventListener).toHaveBeenCalledTimes(failure === 'message' ? 1 : 2); + } + }); + + it.each(['before', 'after'] as const)( + 'rolls back port listener ownership when its registry throws %s insertion', + (failure) => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const raw = createPort(); + const [port] = adapter.extractTransferredPorts({ ports: [raw] }, 1) ?? []; + if (!port) throw new Error('Expected one port'); + const messageListener = vi.fn(); + const messageErrorListener = vi.fn(); + const originalSetAdd = Set.prototype.add; + const originalSetDelete = Set.prototype.delete; + Set.prototype.add = function (this: Set, value: unknown): Set { + if (failure === 'after') Reflect.apply(originalSetAdd, this, [value]); + throw new Error(`listener registry failed ${failure} insertion`); + } as typeof Set.prototype.add; + Set.prototype.delete = function (): boolean { + throw new Error('listener publication rollback delete failed'); + } as typeof Set.prototype.delete; + + let dispose: (() => void) | undefined; + let thrown: unknown; + try { + dispose = port.listen(messageListener, messageErrorListener); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + Set.prototype.delete = originalSetDelete; + } + + expect(thrown).toBeUndefined(); + expect(raw.listeners.size).toBe(0); + expect(raw.messageErrorListeners.size).toBe(0); + expect(() => dispose?.()).not.toThrow(); + port.close(); + expect(raw.removeEventListener).not.toHaveBeenCalled(); + expect(raw.close).toHaveBeenCalledTimes(1); + } + ); + + it('removes port listeners when Set.delete is poisoned during unsubscribe and close', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const firstRaw = createPort(); + const secondRaw = createPort(); + const originalSetDelete = Set.prototype.delete; + for (const raw of [firstRaw, secondRaw]) { + raw.removeEventListener.mockImplementation((type, listener) => { + const registered = type === 'messageerror' ? raw.messageErrorListeners : raw.listeners; + Reflect.apply(originalSetDelete, registered, [listener]); + }); + } + const [first] = adapter.extractTransferredPorts({ ports: [firstRaw] }, 1) ?? []; + const [second] = adapter.extractTransferredPorts({ ports: [secondRaw] }, 1) ?? []; + if (!first || !second) throw new Error('Expected two ports'); + const unsubscribe = first.listen(vi.fn(), vi.fn()); + second.listen(vi.fn(), vi.fn()); + + Set.prototype.delete = function (): boolean { + throw new Error('port listener registry delete failed'); + } as typeof Set.prototype.delete; + try { + expect(() => unsubscribe()).not.toThrow(); + expect(() => unsubscribe()).not.toThrow(); + expect(() => second.close()).not.toThrow(); + expect(() => second.close()).not.toThrow(); + } finally { + Set.prototype.delete = originalSetDelete; + } + + expect(firstRaw.listeners.size).toBe(0); + expect(firstRaw.messageErrorListeners.size).toBe(0); + expect(secondRaw.listeners.size).toBe(0); + expect(secondRaw.messageErrorListeners.size).toBe(0); + expect(firstRaw.removeEventListener).toHaveBeenCalledTimes(2); + expect(secondRaw.removeEventListener).toHaveBeenCalledTimes(2); + expect(secondRaw.close).toHaveBeenCalledTimes(1); + first.close(); + }); + + it('rolls back both port listeners when setup and Set.delete fail together', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const raw = createPort(); + const originalSetDelete = Set.prototype.delete; + raw.removeEventListener.mockImplementation((type, listener) => { + const registered = type === 'messageerror' ? raw.messageErrorListeners : raw.listeners; + Reflect.apply(originalSetDelete, registered, [listener]); + }); + raw.addEventListener.mockImplementation((type, listener) => { + (type === 'messageerror' ? raw.messageErrorListeners : raw.listeners).add(listener); + if (type === 'messageerror') throw new Error('messageerror setup failed'); + }); + const [port] = adapter.extractTransferredPorts({ ports: [raw] }, 1) ?? []; + if (!port) throw new Error('Expected one port'); + Set.prototype.delete = function (): boolean { + throw new Error('setup rollback registry delete failed'); + } as typeof Set.prototype.delete; + + let unsubscribe: (() => void) | undefined; + try { + expect(() => { + unsubscribe = port.listen(vi.fn(), vi.fn()); + }).not.toThrow(); + expect(() => unsubscribe?.()).not.toThrow(); + } finally { + Set.prototype.delete = originalSetDelete; + } + + expect(raw.listeners.size).toBe(0); + expect(raw.messageErrorListeners.size).toBe(0); + expect(raw.removeEventListener).toHaveBeenCalledTimes(2); + expect(() => port.close()).not.toThrow(); + expect(raw.close).toHaveBeenCalledTimes(1); + }); + + it.each(['message', 'messageerror', 'start'] as const)( + 'lets reentrant close win during %s port setup', + (closeDuring) => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const raw = createPort(); + const [port] = adapter.extractTransferredPorts({ ports: [raw] }, 1) ?? []; + if (!port) throw new Error('Expected one port'); + raw.addEventListener.mockImplementation((type, listener) => { + (type === 'messageerror' ? raw.messageErrorListeners : raw.listeners).add(listener); + if (type === closeDuring) port.close(); + }); + raw.start.mockImplementation(() => { + if (closeDuring === 'start') port.close(); + }); + + const dispose = port.listen(vi.fn(), vi.fn()); + const removals = closeDuring === 'message' ? 1 : 2; + + expect(raw.listeners.size).toBe(0); + expect(raw.messageErrorListeners.size).toBe(0); + expect(raw.removeEventListener).toHaveBeenCalledTimes(removals); + expect(raw.removeEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + if (closeDuring !== 'message') { + expect(raw.removeEventListener).toHaveBeenCalledWith('messageerror', expect.any(Function)); + } + if (closeDuring === 'start') expect(raw.start).toHaveBeenCalledTimes(1); + else expect(raw.start).not.toHaveBeenCalled(); + expect(raw.close).toHaveBeenCalledTimes(1); + + dispose(); + dispose(); + port.close(); + expect(raw.removeEventListener).toHaveBeenCalledTimes(removals); + expect(raw.close).toHaveBeenCalledTimes(1); + } + ); + + it('contains hostile capture-target and captured port method throws', () => { + const installed: Array<(event: MessageEvent) => void> = []; + const target = { + addEventListener: vi.fn((_type: 'message', listener: (event: MessageEvent) => void) => { + installed.push(listener); + }), + removeEventListener: vi.fn(() => { + throw new Error('remove failed'); + }), + }; + const adapter = createBrowserMessagingAdapter(target); + const dispose = adapter.installCaptureListener(() => { + throw new Error('capture failed'); + }); + expect(dispose).toBeTypeOf('function'); + expect(() => installed[0]?.({} as MessageEvent)).not.toThrow(); + expect(() => dispose?.()).not.toThrow(); + + const raw = createPort(); + raw.postMessage.mockImplementation(() => { + throw new Error('post failed'); + }); + raw.start.mockImplementation(() => { + throw new Error('start failed'); + }); + raw.removeEventListener.mockImplementation(() => { + throw new Error('port remove failed'); + }); + const [port] = adapter.extractTransferredPorts({ ports: [raw] }, 1) ?? []; + if (!port) throw new Error('Expected one port'); + expect(() => port.post({}, [])).not.toThrow(); + let unsubscribe = (): void => undefined; + expect(() => { + unsubscribe = port.listen(vi.fn(), vi.fn()); + }).not.toThrow(); + expect(() => unsubscribe()).not.toThrow(); + + const throwingTarget = createBrowserMessagingAdapter({ + addEventListener: () => { + throw new Error('add failed'); + }, + removeEventListener: vi.fn(), + }); + expect(throwingTarget.installCaptureListener(vi.fn())).toBeUndefined(); + }); + + it('rolls back the exact capture listener when installation throws after adding it', () => { + const listeners = new Set<(event: MessageEvent) => void>(); + const removeEventListener = vi.fn( + (_type: 'message', listener: (event: MessageEvent) => void, _capture: true) => { + listeners.delete(listener); + } + ); + const target = { + addEventListener: vi.fn( + (_type: 'message', listener: (event: MessageEvent) => void, _capture: true) => { + listeners.add(listener); + throw new Error('add failed after installation'); + } + ), + removeEventListener, + }; + const dispose = createBrowserMessagingAdapter(target).installCaptureListener(vi.fn()); + const installed = target.addEventListener.mock.calls[0]?.[1]; + + expect(listeners.size).toBe(0); + expect(removeEventListener).toHaveBeenCalledTimes(1); + expect(removeEventListener).toHaveBeenCalledWith('message', installed, true); + expect(dispose).toBeUndefined(); + expect(removeEventListener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts new file mode 100644 index 000000000..ee185f582 --- /dev/null +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -0,0 +1,1826 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createBrowserPrebidAdapter, type PrebidEventFacade } from '../../src/adapters/prebid'; + +type Command = () => void; + +function wrapBids(bids: object[] = []): object[] & { bids: object[] } { + const response = [...bids] as object[] & { bids: object[] }; + response.bids = response; + return response; +} + +function recursivelyFreeze(value: T): T { + if (value && typeof value === 'object') { + for (const child of Object.values(value)) recursivelyFreeze(child); + Object.freeze(value); + } + return value; +} + +function createStamp(overrides: Record = {}) { + return recursivelyFreeze({ + abi: 1, + artifactReleaseId: 'a'.repeat(64), + prebidVersion: '10.26.0', + moduleStems: ['alphaBidAdapter', 'sharedIdSystem'], + bidderCodes: ['alpha', 'alphaAlias'], + bidderAliases: [{ code: 'alphaAlias', moduleStem: 'alphaBidAdapter' }], + userIdModules: [ + { + moduleName: 'sharedIdSystem', + configNames: ['sharedId'], + eidSources: ['sharedid.org'], + }, + ], + ...overrides, + }); +} + +function createReadyPrebid( + options: { + readonly deferCommands?: boolean; + readonly stamp?: object; + } = {} +) { + const commands: Command[] = []; + const listeners = new Map void>>(); + const pbjs = { + addAdUnits: vi.fn(), + getBidResponsesForAdUnitCode: vi.fn<() => object[] & { bids: object[] }>(() => wrapBids()), + getHighestCpmBids: vi.fn<() => object[]>(() => []), + offEvent: vi.fn((type: string, listener: (event: unknown) => void) => { + listeners.get(type)?.delete(listener); + }), + onEvent: vi.fn((type: string, listener: (event: unknown) => void) => { + const registered = listeners.get(type) ?? new Set(); + registered.add(listener); + listeners.set(type, registered); + }), + processQueue: vi.fn(), + registerBidAdapter: vi.fn(), + que: { + push: vi.fn((command: Command): number => { + if (options.deferCommands) commands.push(command); + else command(); + return commands.length; + }), + }, + renderAd: vi.fn(), + requestBids: vi.fn(), + setTargetingForGPTAsync: vi.fn(), + }; + const stamp = options.stamp ?? createStamp(); + Object.defineProperty(pbjs, '__trustedServerArtifactV1', { + value: stamp, + enumerable: false, + writable: false, + configurable: false, + }); + return { commands, listeners, pbjs, stamp }; +} + +describe('browser Prebid adapter readiness', () => { + afterEach(() => vi.useRealTimers()); + + it('binds an exact valid artifact and exposes a frozen narrow facade', async () => { + const ready = createReadyPrebid(); + const target: { pbjs: unknown } = { pbjs: ready.pbjs }; + const adapter = createBrowserPrebidAdapter(target); + const operation = adapter.run((prebid) => { + expect(Object.isFrozen(prebid)).toBe(true); + expect('que' in prebid).toBe(false); + expect('__trustedServerArtifactV1' in prebid).toBe(false); + prebid.addAdUnits([{ code: 'slot-a' }]); + prebid.registerBidAdapter(undefined, 'trustedServer', { code: 'trustedServer' }); + prebid.requestBids({ adUnitCodes: ['slot-a'] }); + prebid.setTargetingForGpt(['slot-a']); + prebid.renderAd({}, 'bid-a'); + return prebid.highestBids('slot-a'); + }); + + expect(operation.status).toBe('present'); + await expect(operation.result).resolves.toEqual([]); + expect(ready.pbjs.addAdUnits).toHaveBeenCalledTimes(1); + expect(ready.pbjs.registerBidAdapter).toHaveBeenCalledWith(undefined, 'trustedServer', { + code: 'trustedServer', + }); + expect(ready.pbjs.requestBids).toHaveBeenCalledTimes(1); + expect(ready.pbjs.setTargetingForGPTAsync).toHaveBeenCalledExactlyOnceWith(['slot-a']); + expect(ready.pbjs.renderAd).toHaveBeenCalledWith({}, 'bid-a'); + }); + + it('drains pending commands FIFO through the real Prebid queue notification', async () => { + const readinessCommands: Command[] = []; + const target: { pbjs?: unknown } = { pbjs: { que: readinessCommands } }; + const adapter = createBrowserPrebidAdapter(target); + const order: number[] = []; + const first = adapter.run(() => order.push(1)); + const second = adapter.run(() => order.push(2)); + + expect(first.status).toBe('pending'); + expect(second.status).toBe('pending'); + expect(readinessCommands).toHaveLength(1); + + target.pbjs = createReadyPrebid().pbjs; + readinessCommands[0]?.(); + + await expect(Promise.all([first.result, second.result])).resolves.toEqual([1, 2]); + expect(order).toEqual([1, 2]); + }); + + it.each(['before', 'after'] as const)( + 'recovers Prebid notification arming when WeakSet.add throws %s insertion', + async (failure) => { + const readinessCommands: Command[] = []; + const target: { pbjs?: unknown } = { pbjs: { que: readinessCommands } }; + const adapter = createBrowserPrebidAdapter(target); + const originalWeakSetAdd = WeakSet.prototype.add; + WeakSet.prototype.add = function (this: WeakSet, value: object): WeakSet { + if (failure === 'after') Reflect.apply(originalWeakSetAdd, this, [value]); + throw new Error(`Prebid arming failed ${failure} insertion`); + } as typeof WeakSet.prototype.add; + + let operation: ReturnType | undefined; + let thrown: unknown; + try { + operation = adapter.run(() => 'ready'); + } catch (error) { + thrown = error; + } finally { + WeakSet.prototype.add = originalWeakSetAdd; + } + + expect(thrown).toBeUndefined(); + if (!operation) throw new Error('Expected a published Prebid operation'); + expect(readinessCommands).toHaveLength(0); + adapter.notifyReady(); + expect(readinessCommands).toHaveLength(1); + target.pbjs = createReadyPrebid().pbjs; + readinessCommands[0]?.(); + await expect(operation.result).resolves.toBe('ready'); + adapter.dispose(); + } + ); + + it('recovers Prebid notification arming when WeakSet.has throws after publication', async () => { + vi.useFakeTimers(); + const readinessCommands: Command[] = []; + const target: { pbjs?: unknown } = { pbjs: { que: readinessCommands } }; + const adapter = createBrowserPrebidAdapter(target); + const order: number[] = []; + const originalWeakSetHas = WeakSet.prototype.has; + WeakSet.prototype.has = function (): boolean { + throw new Error('Prebid armed lookup failed'); + } as typeof WeakSet.prototype.has; + + let first: ReturnType | undefined; + let thrown: unknown; + try { + first = adapter.run(() => order.push(1)); + } catch (error) { + thrown = error; + } finally { + WeakSet.prototype.has = originalWeakSetHas; + } + + expect(thrown).toBeUndefined(); + if (!first) throw new Error('Expected a published Prebid operation'); + expect(readinessCommands).toHaveLength(1); + const second = adapter.run(() => order.push(2)); + expect(readinessCommands).toHaveLength(1); + target.pbjs = createReadyPrebid().pbjs; + readinessCommands[0]?.(); + await expect(Promise.all([first.result, second.result])).resolves.toEqual([1, 2]); + expect(order).toEqual([1, 2]); + expect(vi.getTimerCount()).toBe(0); + + target.pbjs = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + const recoveredResults = recovered.map(({ result }) => result.catch((error) => error)); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + for (const operation of recovered) operation.dispose(); + await Promise.all(recoveredResults); + expect(vi.getTimerCount()).toBe(0); + adapter.dispose(); + }); + + it.each([ + { pushFailure: 'before', deleteFailure: 'throw' }, + { pushFailure: 'after', deleteFailure: 'retain' }, + ] as const)( + 'retries Prebid notification registration after $pushFailure enqueue failure and $deleteFailure rollback', + async ({ pushFailure, deleteFailure }) => { + vi.useFakeTimers(); + const readinessCommands: Command[] = []; + let queueBroken = true; + const push = vi.fn((command: Command): number => { + if (queueBroken) { + if (pushFailure === 'after') readinessCommands.push(command); + throw new Error(`Prebid queue failed ${pushFailure} enqueue`); + } + readinessCommands.push(command); + return readinessCommands.length; + }); + const target: { pbjs?: unknown } = { pbjs: { que: { push } } }; + const adapter = createBrowserPrebidAdapter(target); + const order: number[] = []; + const originalWeakSetDelete = WeakSet.prototype.delete; + WeakSet.prototype.delete = function (): boolean { + if (deleteFailure === 'throw') throw new Error('Prebid arming rollback failed'); + return false; + } as typeof WeakSet.prototype.delete; + + let first: ReturnType | undefined; + let thrown: unknown; + try { + first = adapter.run(() => order.push(1)); + } catch (error) { + thrown = error; + } finally { + WeakSet.prototype.delete = originalWeakSetDelete; + } + + expect(thrown).toBeUndefined(); + if (!first) throw new Error('Expected a published Prebid operation'); + expect(readinessCommands).toHaveLength(pushFailure === 'after' ? 1 : 0); + queueBroken = false; + const second = adapter.run(() => order.push(2)); + expect(readinessCommands).toHaveLength(pushFailure === 'after' ? 2 : 1); + + target.pbjs = createReadyPrebid().pbjs; + if (pushFailure === 'after') { + readinessCommands[0]?.(); + expect(order).toEqual([]); + } + readinessCommands[readinessCommands.length - 1]?.(); + await expect(Promise.all([first.result, second.result])).resolves.toEqual([1, 2]); + expect(order).toEqual([1, 2]); + for (const notify of readinessCommands) notify(); + expect(order).toEqual([1, 2]); + expect(vi.getTimerCount()).toBe(0); + + target.pbjs = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + const recoveredResults = recovered.map(({ result }) => result.catch((error) => error)); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + for (const operation of recovered) operation.dispose(); + await Promise.all(recoveredResults); + expect(vi.getTimerCount()).toBe(0); + adapter.dispose(); + } + ); + + it('rejects a queued operation when its pending Prebid stub becomes incompatible', async () => { + const readinessCommands: Command[] = []; + const binding: Record = { que: readinessCommands }; + const adapter = createBrowserPrebidAdapter({ pbjs: binding }); + const command = vi.fn(); + const operation = adapter.run(command); + Object.defineProperty(binding, '__trustedServerArtifactV1', { + value: Object.freeze({}), + enumerable: false, + writable: false, + configurable: false, + }); + + readinessCommands[0]?.(); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(operation.status).toBe('incompatible'); + expect(command).not.toHaveBeenCalled(); + }); + + it('ignores a stale Prebid notification and lets the replacement notification decide', async () => { + const oldNotifications: Command[] = []; + const replacementNotifications: Command[] = []; + const oldBinding = { que: oldNotifications }; + const replacement: Record = { que: replacementNotifications }; + const target: { pbjs?: unknown } = { pbjs: oldBinding }; + const adapter = createBrowserPrebidAdapter(target); + const first = adapter.run(() => 'first'); + target.pbjs = replacement; + const second = adapter.run(() => 'second'); + Object.defineProperty(replacement, '__trustedServerArtifactV1', { + value: Object.freeze({}), + enumerable: false, + writable: false, + configurable: false, + }); + + oldNotifications[0]?.(); + expect(first.status).toBe('pending'); + expect(second.status).toBe('pending'); + + replacementNotifications[0]?.(); + await expect(first.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + await expect(second.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + }); + + it('does not let a stale Prebid notification condemn a primitive replacement', async () => { + const oldNotifications: Command[] = []; + const target: { pbjs?: unknown } = { pbjs: { que: oldNotifications } }; + const adapter = createBrowserPrebidAdapter(target); + const operation = adapter.run(vi.fn()); + const result = operation.result.catch((error: unknown) => error); + target.pbjs = 1; + + oldNotifications[0]?.(); + expect(operation.status).toBe('pending'); + adapter.dispose(); + await expect(result).resolves.toMatchObject({ code: 'operation_disposed' }); + }); + + it('requires an exact own artifact data descriptor', async () => { + const valid = createReadyPrebid(); + const inherited = Object.create(valid.pbjs) as Record; + const accessor = { ...valid.pbjs }; + Object.defineProperty(accessor, '__trustedServerArtifactV1', { get: () => valid.stamp }); + const enumerable = { ...valid.pbjs }; + Object.defineProperty(enumerable, '__trustedServerArtifactV1', { + value: valid.stamp, + enumerable: true, + writable: false, + configurable: false, + }); + + for (const pbjs of [{ ...valid.pbjs }, inherited, accessor, enumerable]) { + const operation = createBrowserPrebidAdapter({ pbjs }).run(vi.fn()); + expect(operation.status).toBe('incompatible'); + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + } + }); + + it('rejects stamp accessors and extra own keys without invoking them', async () => { + const getter = vi.fn(() => 'alpha'); + const bidderCodes: unknown[] = []; + Object.defineProperty(bidderCodes, '0', { + get: getter, + enumerable: true, + configurable: false, + }); + Object.defineProperty(bidderCodes, 'length', { writable: false }); + Object.freeze(bidderCodes); + const accessorStamp = Object.freeze({ ...createStamp(), bidderCodes }); + const extraStamp = recursivelyFreeze({ ...createStamp(), unexpected: true }); + + for (const stamp of [accessorStamp, extraStamp]) { + const operation = createBrowserPrebidAdapter({ + pbjs: createReadyPrebid({ stamp }).pbjs, + }).run(vi.fn()); + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + } + expect(getter).not.toHaveBeenCalled(); + }); + + it('validates ABI, version, frozen bounded metadata, and configured coverage', async () => { + const incompatibleStamps = [ + createStamp({ abi: 2 }), + createStamp({ prebidVersion: '10.25.0' }), + createStamp({ artifactReleaseId: 'A'.repeat(64) }), + createStamp({ bidderCodes: ['alpha', 'alpha'] }), + createStamp({ moduleStems: ['sharedIdSystem', 'alphaBidAdapter'] }), + createStamp({ bidderAliases: [{ code: 'missing', moduleStem: 'alphaBidAdapter' }] }), + createStamp({ + userIdModules: [ + { + moduleName: 'sharedIdSystem', + configNames: ['sharedId'], + eidSources: ['UPPER.example'], + }, + ], + }), + createStamp({ + moduleStems: Array.from( + { length: 257 }, + (_, index) => `module-${String(index).padStart(3, '0')}` + ), + }), + ]; + const mutable = Object.freeze({ + ...createStamp(), + bidderCodes: ['alpha', 'alphaAlias'], + }); + incompatibleStamps.push(mutable); + + for (const stamp of incompatibleStamps) { + const operation = createBrowserPrebidAdapter( + { pbjs: createReadyPrebid({ stamp }).pbjs }, + { + configuredClientSideBidders: ['alpha'], + requiredUserIdModules: [ + { + moduleName: 'sharedIdSystem', + configNames: ['sharedId'], + eidSources: ['sharedid.org'], + }, + ], + } + ).run(vi.fn()); + expect(operation.status).toBe('incompatible'); + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + } + + const uncoveredBidder = createBrowserPrebidAdapter( + { pbjs: createReadyPrebid().pbjs }, + { configuredClientSideBidders: ['unbundled'] } + ).run(vi.fn()); + await expect(uncoveredBidder.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + }); + + it('enforces every metadata count boundary', async () => { + const names = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => `${prefix}-${String(index).padStart(3, '0')}`); + const cases: Array<{ valid: object; invalid: object }> = []; + + cases.push({ + valid: createStamp({ + moduleStems: names('module', 256), + bidderAliases: [], + userIdModules: [], + }), + invalid: createStamp({ + moduleStems: names('module', 257), + bidderAliases: [], + userIdModules: [], + }), + }); + cases.push({ + valid: createStamp({ bidderCodes: names('bidder', 512), bidderAliases: [] }), + invalid: createStamp({ bidderCodes: names('bidder', 513), bidderAliases: [] }), + }); + const aliasCodes = names('alias', 512); + const aliasModules = names('adapter', 171); + const overflowingAliases = ['alias-a', 'alias-b', 'alias-c'].flatMap((code) => + aliasModules.map((moduleStem) => ({ code, moduleStem })) + ); + cases.push({ + valid: createStamp({ + moduleStems: ['adapter'], + bidderCodes: aliasCodes, + bidderAliases: aliasCodes.map((code) => ({ code, moduleStem: 'adapter' })), + userIdModules: [], + }), + invalid: createStamp({ + moduleStems: aliasModules, + bidderCodes: ['alias-a', 'alias-b', 'alias-c'], + bidderAliases: overflowingAliases, + userIdModules: [], + }), + }); + const moduleNames = names('user', 128); + cases.push({ + valid: createStamp({ + moduleStems: moduleNames, + bidderAliases: [], + userIdModules: moduleNames.map((moduleName) => ({ + moduleName, + configNames: [], + eidSources: [], + })), + }), + invalid: createStamp({ + moduleStems: [...moduleNames, 'user-overflow'].sort(), + bidderAliases: [], + userIdModules: [...moduleNames, 'user-overflow'].sort().map((moduleName) => ({ + moduleName, + configNames: [], + eidSources: [], + })), + }), + }); + const configNames = names('config', 64); + const eidSources = names('source', 64).map((source) => `${source}.example`); + cases.push({ + valid: createStamp({ + moduleStems: ['identity'], + bidderAliases: [], + userIdModules: [{ moduleName: 'identity', configNames, eidSources }], + }), + invalid: createStamp({ + moduleStems: ['identity'], + bidderAliases: [], + userIdModules: [ + { + moduleName: 'identity', + configNames: [...configNames, 'config-overflow'].sort(), + eidSources, + }, + ], + }), + }); + cases.push({ + valid: createStamp({ + moduleStems: ['identity'], + bidderAliases: [], + userIdModules: [{ moduleName: 'identity', configNames, eidSources }], + }), + invalid: createStamp({ + moduleStems: ['identity'], + bidderAliases: [], + userIdModules: [ + { + moduleName: 'identity', + configNames, + eidSources: [...eidSources, 'source-overflow.example'].sort(), + }, + ], + }), + }); + + for (const boundary of cases) { + const accepted = createBrowserPrebidAdapter({ + pbjs: createReadyPrebid({ stamp: boundary.valid }).pbjs, + }).run(() => 'accepted'); + await expect(accepted.result).resolves.toBe('accepted'); + const refused = createBrowserPrebidAdapter({ + pbjs: createReadyPrebid({ stamp: boundary.invalid }).pbjs, + }).run(vi.fn()); + await expect(refused.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + } + }); + + it('enforces nonempty scalar-valid UTF-8 byte limits and nested lexical uniqueness', async () => { + const invalidStamps = [ + createStamp({ moduleStems: [''] }), + createStamp({ moduleStems: ['é'.repeat(65)] }), + createStamp({ bidderCodes: ['\ud800'], bidderAliases: [] }), + createStamp({ + bidderAliases: [ + { code: 'alphaAlias', moduleStem: 'alphaBidAdapter' }, + { code: 'alphaAlias', moduleStem: 'alphaBidAdapter' }, + ], + }), + createStamp({ + userIdModules: [ + { moduleName: 'sharedIdSystem', configNames: ['z', 'a'], eidSources: ['sharedid.org'] }, + ], + }), + createStamp({ + userIdModules: [ + { + moduleName: 'sharedIdSystem', + configNames: ['sharedId'], + eidSources: ['z.example', 'a.example'], + }, + ], + }), + createStamp({ + userIdModules: [{ moduleName: 'missingSystem', configNames: [], eidSources: [] }], + }), + ]; + const accepted = createStamp({ + moduleStems: ['é'.repeat(64)], + bidderAliases: [], + userIdModules: [], + }); + await expect( + createBrowserPrebidAdapter({ pbjs: createReadyPrebid({ stamp: accepted }).pbjs }).run( + () => 'accepted' + ).result + ).resolves.toBe('accepted'); + + for (const [index, stamp] of invalidStamps.entries()) { + const operation = createBrowserPrebidAdapter({ + pbjs: createReadyPrebid({ stamp }).pbjs, + }).run(vi.fn()); + expect(operation.status, `invalid metadata case ${index}`).toBe('incompatible'); + await expect(operation.result, `invalid metadata case ${index}`).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + } + }); + + it('requires every real API method and contains hostile target and member getters', async () => { + for (const method of [ + 'addAdUnits', + 'getBidResponsesForAdUnitCode', + 'getHighestCpmBids', + 'offEvent', + 'onEvent', + 'processQueue', + 'registerBidAdapter', + 'renderAd', + 'requestBids', + 'setTargetingForGPTAsync', + ] as const) { + const ready = createReadyPrebid(); + Object.defineProperty(ready.pbjs, method, { value: undefined }); + const operation = createBrowserPrebidAdapter({ pbjs: ready.pbjs }).run(vi.fn()); + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + } + + const hostileTarget = Object.defineProperty({}, 'pbjs', { + get: () => { + throw new Error('target getter failed'); + }, + }); + let hostileTargetOperation: + ReturnType['run']> | undefined; + expect(() => { + hostileTargetOperation = createBrowserPrebidAdapter(hostileTarget).run(vi.fn()); + }).not.toThrow(); + await expect(hostileTargetOperation?.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + const hostile = createReadyPrebid(); + Object.defineProperty(hostile.pbjs, 'requestBids', { + get: () => { + throw new Error('member getter failed'); + }, + }); + let hostileMemberOperation: + ReturnType['run']> | undefined; + expect(() => { + hostileMemberOperation = createBrowserPrebidAdapter({ pbjs: hostile.pbjs }).run(vi.fn()); + }).not.toThrow(); + await expect(hostileMemberOperation?.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + }); + + it('rejects missing required user-ID coverage and diagnoses one incompatible object once', async () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const ready = createReadyPrebid(); + const adapter = createBrowserPrebidAdapter( + { pbjs: ready.pbjs }, + { + requiredUserIdModules: [ + { + moduleName: 'sharedIdSystem', + configNames: ['missingConfig'], + eidSources: ['missing.example'], + }, + ], + } + ); + const first = adapter.run(vi.fn()); + const second = adapter.run(vi.fn()); + await expect(first.result).rejects.toMatchObject({ code: 'external_artifact_incompatible' }); + await expect(second.result).rejects.toMatchObject({ code: 'external_artifact_incompatible' }); + expect(adapter.bindingStatus()).toBe('incompatible'); + expect(warning).toHaveBeenCalledTimes(1); + expect(String(warning.mock.calls[0]?.[0]).length).toBeLessThanOrEqual(256); + } finally { + warning.mockRestore(); + } + }); + + it.each(['before', 'after'] as const)( + 'bounds Prebid diagnostics when WeakSet.add persistently throws %s insertion', + async (failure) => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const incompatible = createReadyPrebid({ stamp: createStamp({ abi: 2 }) }); + const adapter = createBrowserPrebidAdapter({ pbjs: incompatible.pbjs }); + const originalWeakSetAdd = WeakSet.prototype.add; + WeakSet.prototype.add = function (this: WeakSet, value: object): WeakSet { + if (failure === 'after') Reflect.apply(originalWeakSetAdd, this, [value]); + throw new Error(`diagnostic tracking failed ${failure} insertion`); + } as typeof WeakSet.prototype.add; + + const poisoned: Array> = []; + const thrown: unknown[] = []; + try { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + poisoned.push(adapter.run(vi.fn())); + } catch (error) { + thrown.push(error); + } + } + } finally { + WeakSet.prototype.add = originalWeakSetAdd; + } + + try { + expect(thrown).toEqual([]); + expect(poisoned).toHaveLength(3); + for (const operation of poisoned) { + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + } + expect(warning).toHaveBeenCalledTimes(failure === 'after' ? 1 : 0); + + const healthy = adapter.run(vi.fn()); + const suppressed = adapter.run(vi.fn()); + await expect(healthy.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + await expect(suppressed.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(warning).toHaveBeenCalledTimes(1); + } finally { + warning.mockRestore(); + adapter.dispose(); + } + } + ); + + it.each(['preflight', 'observation'] as const)( + 'recovers bounded Prebid diagnostics when WeakSet.has poisons %s', + async (failure) => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const incompatible = createReadyPrebid({ stamp: createStamp({ abi: 2 }) }); + const adapter = createBrowserPrebidAdapter({ pbjs: incompatible.pbjs }); + const originalWeakSetHas = WeakSet.prototype.has; + let lookups = 0; + WeakSet.prototype.has = function (this: WeakSet, value: object): boolean { + lookups += 1; + if (failure === 'preflight' || lookups === 2) { + throw new Error(`diagnostic ${failure} lookup failed`); + } + return Reflect.apply(originalWeakSetHas, this, [value]) as boolean; + } as typeof WeakSet.prototype.has; + + let poisoned: ReturnType | undefined; + let thrown: unknown; + try { + poisoned = adapter.run(vi.fn()); + } catch (error) { + thrown = error; + } finally { + WeakSet.prototype.has = originalWeakSetHas; + } + + try { + expect(thrown).toBeUndefined(); + if (!poisoned) throw new Error('Expected a published Prebid operation'); + await expect(poisoned.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(warning).not.toHaveBeenCalled(); + + const healthy = adapter.run(vi.fn()); + const suppressed = adapter.run(vi.fn()); + await expect(healthy.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + await expect(suppressed.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(warning).toHaveBeenCalledTimes(1); + } finally { + warning.mockRestore(); + adapter.dispose(); + } + } + ); + + it('releases pending capacity immediately on abort and adapter disposal', async () => { + vi.useFakeTimers(); + const target: { pbjs?: unknown } = {}; + const adapter = createBrowserPrebidAdapter(target); + const controller = new AbortController(); + const aborted = adapter.run(vi.fn(), { signal: controller.signal }); + const abortedResult = aborted.result.catch((error: unknown) => error); + controller.abort(); + const replacements = Array.from({ length: 64 }, () => adapter.run(() => undefined)); + adapter.dispose(); + + await expect(abortedResult).resolves.toMatchObject({ code: 'caller_aborted' }); + const disposed = await Promise.all( + replacements.map(({ result }) => result.catch((error: unknown) => error)) + ); + expect(disposed).toHaveLength(64); + for (const value of disposed) { + expect(value).toMatchObject({ code: 'operation_disposed' }); + } + }); + + it('invalidates an entered command immediately when the adapter is disposed', async () => { + const ready = createReadyPrebid(); + const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + const operation = adapter.run((prebid) => { + adapter.dispose(); + prebid.requestBids({ mustNotRun: true }); + }); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(operation.status).toBe('present'); + expect(ready.pbjs.requestBids).not.toHaveBeenCalled(); + expect(ready.listeners.size).toBe(0); + }); + + it('throws when Prebid inspection disposes the adapter before an operation is published', () => { + const ready = createReadyPrebid(); + const holder: { adapter?: ReturnType } = {}; + const target = Object.defineProperty({}, 'pbjs', { + get: () => { + holder.adapter?.dispose(); + return ready.pbjs; + }, + }); + const adapter = createBrowserPrebidAdapter(target); + holder.adapter = adapter; + const command = vi.fn(); + + expect(() => adapter.run(command)).toThrowError( + expect.objectContaining({ code: 'operation_disposed' }) + ); + expect(command).not.toHaveBeenCalled(); + expect(ready.commands).toHaveLength(0); + }); + + it('rejects without enqueueing when Prebid inspection disposes a published operation', async () => { + const ready = createReadyPrebid({ deferCommands: true }); + let reads = 0; + const holder: { adapter?: ReturnType } = {}; + const target = Object.defineProperty({}, 'pbjs', { + get: () => { + reads += 1; + if (reads === 2) holder.adapter?.dispose(); + return ready.pbjs; + }, + }); + const adapter = createBrowserPrebidAdapter(target); + holder.adapter = adapter; + const command = vi.fn(); + const operation = adapter.run(command); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(command).not.toHaveBeenCalled(); + expect(ready.commands).toHaveLength(0); + }); + + it('contains disposal reentrant from Prebid member reads and external calls', async () => { + const ready = createReadyPrebid(); + const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + const staleRequest = vi.fn(); + let requestBidsReads = 0; + Object.defineProperty(ready.pbjs, 'requestBids', { + get: () => { + requestBidsReads += 1; + if (requestBidsReads > 1) adapter.dispose(); + return staleRequest; + }, + }); + const memberOperation = adapter.run((prebid) => prebid.requestBids({})); + + await expect(memberOperation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(staleRequest).not.toHaveBeenCalled(); + + const externalReady = createReadyPrebid(); + const externalAdapter = createBrowserPrebidAdapter({ pbjs: externalReady.pbjs }); + externalReady.pbjs.requestBids.mockImplementation(() => externalAdapter.dispose()); + const externalOperation = externalAdapter.run((prebid) => { + prebid.requestBids({ first: true }); + prebid.requestBids({ mustNotRun: true }); + }); + + await expect(externalOperation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(externalReady.pbjs.requestBids).toHaveBeenCalledTimes(1); + }); + + it('rechecks identity after hostile facade member reads and calls', async () => { + const first = createReadyPrebid(); + const replacement = createReadyPrebid(); + const target: { pbjs?: unknown } = { pbjs: first.pbjs }; + const staleRequest = vi.fn(); + Object.defineProperty(first.pbjs, 'requestBids', { + get: () => { + target.pbjs = replacement.pbjs; + return staleRequest; + }, + }); + const operation = createBrowserPrebidAdapter(target).run((prebid) => prebid.requestBids({})); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(operation.status).toBe('incompatible'); + expect(staleRequest).not.toHaveBeenCalled(); + }); + + it('rechecks both object and stamp identity before invoking a deferred command', async () => { + const first = createReadyPrebid({ deferCommands: true }); + const replacement = createReadyPrebid(); + const target: { pbjs?: unknown } = { pbjs: first.pbjs }; + const adapter = createBrowserPrebidAdapter(target); + const command = vi.fn(); + const operation = adapter.run(command); + const result = operation.result.catch((error: unknown) => error); + + target.pbjs = replacement.pbjs; + expect(() => first.commands[0]?.()).not.toThrow(); + await expect(result).resolves.toMatchObject({ code: 'external_artifact_incompatible' }); + expect(operation.status).toBe('incompatible'); + expect(command).not.toHaveBeenCalled(); + + const later = adapter.run(() => 'replacement'); + await expect(later.result).resolves.toBe('replacement'); + }); + + it('marks an operation incompatible when its command replaces the bound object', async () => { + const first = createReadyPrebid(); + const replacement = createReadyPrebid(); + const target: { pbjs?: unknown } = { pbjs: first.pbjs }; + const operation = createBrowserPrebidAdapter(target).run(() => { + target.pbjs = replacement.pbjs; + return 'stale'; + }); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(operation.status).toBe('incompatible'); + }); + + it('rolls back an exact Prebid listener when installation replaces the binding', async () => { + const first = createReadyPrebid(); + const replacement = createReadyPrebid(); + const target: { pbjs?: unknown } = { pbjs: first.pbjs }; + const adapter = createBrowserPrebidAdapter(target); + first.pbjs.onEvent.mockImplementation((type, listener) => { + const registered = first.listeners.get(type) ?? new Set(); + registered.add(listener); + first.listeners.set(type, registered); + target.pbjs = replacement.pbjs; + }); + const operation = adapter.run((prebid) => prebid.subscribe('bidResponse', vi.fn())); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + const installed = first.pbjs.onEvent.mock.calls[0]?.[1]; + expect(first.pbjs.offEvent).toHaveBeenCalledWith('bidResponse', installed); + expect(first.listeners.get('bidResponse')?.size).toBe(0); + }); + + it('grants synchronous highest-bid access only for the active event callback', async () => { + const ready = createReadyPrebid(); + const selected = Object.freeze({ adId: 'r1_selected', adUnitCode: 'slot-one' }); + ready.pbjs.getHighestCpmBids.mockReturnValue([selected]); + const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + let eventFacade: Readonly | undefined; + const listener = vi.fn((event: unknown, prebid: Readonly) => { + eventFacade = prebid; + expect(event).toEqual({ auctionId: 'auction-one' }); + expect(Object.isFrozen(prebid)).toBe(true); + expect(Reflect.ownKeys(prebid)).toEqual(['highestBids']); + expect(prebid.highestBids('slot-one')).toEqual([selected]); + }); + + await adapter.run((prebid) => prebid.subscribe('auctionEnd', listener)).result; + const installed = [...(ready.listeners.get('auctionEnd') ?? [])][0]; + expect(() => installed?.({ auctionId: 'auction-one' })).not.toThrow(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(ready.pbjs.getHighestCpmBids).toHaveBeenCalledExactlyOnceWith('slot-one'); + expect(() => eventFacade?.highestBids('slot-one')).toThrowError( + expect.objectContaining({ code: 'external_artifact_incompatible' }) + ); + adapter.dispose(); + }); + + it('rolls back a Prebid listener when installation disposes and cleanup throws', async () => { + const ready = createReadyPrebid(); + const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + ready.pbjs.onEvent.mockImplementation((type, listener) => { + const registered = ready.listeners.get(type) ?? new Set(); + registered.add(listener); + ready.listeners.set(type, registered); + adapter.dispose(); + }); + ready.pbjs.offEvent.mockImplementation((type, listener) => { + ready.listeners.get(type)?.delete(listener); + throw new Error('cleanup failed'); + }); + const operation = adapter.run((prebid) => prebid.subscribe('bidResponse', vi.fn())); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(ready.pbjs.offEvent).toHaveBeenCalledTimes(1); + expect(ready.listeners.get('bidResponse')?.size).toBe(0); + }); + + it.each(['dispose', 'throw'] as const)( + 'rolls back Prebid subscription ownership when effect registration must %s', + async (failure) => { + const ready = createReadyPrebid(); + const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + const registryError = new Error('effect registry add failed'); + const originalDescriptor = Object.getOwnPropertyDescriptor(Set.prototype, 'add'); + const nativeAdd = Set.prototype.add; + const existingListeners = new Set<(event: unknown) => void>(); + const failedListeners = new Set<(event: unknown) => void>(); + const existingListener = vi.fn(); + const failedListener = vi.fn(); + ready.listeners.set('existing', existingListeners); + ready.listeners.set('failed', failedListeners); + let operation: ReturnType | undefined; + try { + operation = adapter.run((prebid) => { + prebid.subscribe('existing', existingListener); + Object.defineProperty(Set.prototype, 'add', { + configurable: true, + writable: true, + value: function (this: Set, value: unknown): Set { + if ( + typeof value === 'function' && + this !== existingListeners && + this !== failedListeners + ) { + if (failure === 'dispose') adapter.dispose(); + else throw registryError; + } + return Reflect.apply(nativeAdd, this, [value]) as Set; + }, + }); + return prebid.subscribe('failed', failedListener); + }); + } finally { + if (originalDescriptor) Object.defineProperty(Set.prototype, 'add', originalDescriptor); + } + + if (failure === 'dispose') { + await expect(operation?.result).rejects.toMatchObject({ code: 'operation_disposed' }); + } else { + await expect(operation?.result).rejects.toBe(registryError); + } + adapter.dispose(); + adapter.dispose(); + + expect(ready.listeners.get('existing')?.size).toBe(0); + expect(ready.listeners.get('failed')?.size).toBe(0); + expect(ready.pbjs.offEvent).toHaveBeenCalledTimes(2); + } + ); + + it('settles a live Prebid operation and restores listeners when Set.delete is poisoned', async () => { + const ready = createReadyPrebid(); + const originalSetDelete = Set.prototype.delete; + ready.pbjs.offEvent.mockImplementation((type, listener) => { + const registered = ready.listeners.get(type); + if (registered) Reflect.apply(originalSetDelete, registered, [listener]); + }); + const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + const listener = vi.fn(); + const operation = adapter.run((prebid) => { + prebid.subscribe('bidResponse', listener); + return new Promise(() => undefined); + }); + expect(ready.listeners.get('bidResponse')).toHaveLength(1); + + Set.prototype.delete = function (): boolean { + throw new Error('Prebid live cleanup delete failed'); + } as typeof Set.prototype.delete; + try { + expect(() => adapter.dispose()).not.toThrow(); + } finally { + Set.prototype.delete = originalSetDelete; + } + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(ready.listeners.get('bidResponse')).toHaveLength(0); + expect(() => adapter.dispose()).not.toThrow(); + }); + + it('rolls back a failed Prebid command subscription without touching prior global effects', async () => { + const ready = createReadyPrebid(); + const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + const priorListener = vi.fn(); + const failedListener = vi.fn(); + const commandError = new Error('command failed'); + await adapter.run((prebid) => prebid.subscribe('prior', priorListener)).result; + + const operation = adapter.run((prebid) => { + prebid.subscribe('failed', failedListener); + throw commandError; + }); + + await expect(operation.result).rejects.toBe(commandError); + expect(ready.listeners.get('prior')?.size).toBe(1); + expect(ready.listeners.get('failed')?.size).toBe(0); + expect(() => [...(ready.listeners.get('prior') ?? [])][0]?.({})).not.toThrow(); + expect(priorListener).toHaveBeenCalledTimes(1); + expect(failedListener).not.toHaveBeenCalled(); + + adapter.dispose(); + expect(ready.listeners.get('prior')?.size).toBe(0); + expect(ready.pbjs.offEvent).toHaveBeenCalledTimes(2); + }); + + it('promotes fulfilled Prebid command subscriptions and rolls back rejected ones', async () => { + const ready = createReadyPrebid(); + const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + const rejection = new Error('async command failed'); + const rejected = adapter.run((prebid) => { + prebid.subscribe('rejected', vi.fn()); + return Promise.reject(rejection); + }); + + await expect(rejected.result).rejects.toBe(rejection); + expect(ready.listeners.get('rejected')?.size).toBe(0); + + const fulfilled = adapter.run((prebid) => { + prebid.subscribe('fulfilled', vi.fn()); + return Promise.resolve('complete'); + }); + await expect(fulfilled.result).resolves.toBe('complete'); + expect(ready.listeners.get('fulfilled')?.size).toBe(1); + + adapter.dispose(); + expect(ready.listeners.get('fulfilled')?.size).toBe(0); + }); + + it.each(['dispose', 'replacement'] as const)( + 'rolls back a provisional Prebid subscription after async %s', + async (failure) => { + const first = createReadyPrebid(); + const replacement = createReadyPrebid(); + const target: { pbjs?: unknown } = { pbjs: first.pbjs }; + const adapter = createBrowserPrebidAdapter(target); + let resolveCommand!: (value: string) => void; + const commandResult = new Promise((resolve) => { + resolveCommand = resolve; + }); + const operation = adapter.run((prebid) => { + prebid.subscribe('provisional', vi.fn()); + return commandResult; + }); + + if (failure === 'dispose') adapter.dispose(); + else target.pbjs = replacement.pbjs; + resolveCommand('late-success'); + + await expect(operation.result).rejects.toMatchObject({ + code: failure === 'dispose' ? 'operation_disposed' : 'external_artifact_incompatible', + }); + expect(first.listeners.get('provisional')?.size).toBe(0); + } + ); + + it('holds 64 pending operations and fails only overflow synchronously', async () => { + vi.useFakeTimers(); + const target: { pbjs?: unknown } = {}; + const adapter = createBrowserPrebidAdapter(target); + const operations = Array.from({ length: 64 }, () => adapter.run(() => undefined)); + expect(() => adapter.run(() => undefined)).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + + target.pbjs = createReadyPrebid().pbjs; + adapter.notifyReady(); + await expect(Promise.all(operations.map(({ result }) => result))).resolves.toHaveLength(64); + }); + + it('reserves pending Prebid capacity before hostile signal registration reenters', async () => { + const target: { pbjs?: unknown } = {}; + const adapter = createBrowserPrebidAdapter(target); + const accepted: Array> = []; + const overflows: unknown[] = []; + const order: number[] = []; + const signal = { + aborted: false, + addEventListener: vi.fn(() => { + for (let index = 1; index <= 64; index += 1) { + try { + accepted.push(adapter.run(() => order.push(index))); + } catch (error) { + overflows.push(error); + } + } + }), + removeEventListener: vi.fn(), + } as unknown as AbortSignal; + + const outer = adapter.run(() => order.push(0), { signal }); + + expect(accepted).toHaveLength(63); + expect(overflows).toHaveLength(1); + expect(overflows[0]).toMatchObject({ code: 'external_queue_full' }); + + target.pbjs = createReadyPrebid().pbjs; + adapter.notifyReady(); + await expect( + Promise.all([outer.result, ...accepted.map(({ result }) => result)]) + ).resolves.toHaveLength(64); + expect(order).toEqual(Array.from({ length: 64 }, (_, index) => index)); + }); + + it('reserves pending Prebid capacity before poisoned Set.add reenters', async () => { + const target: { pbjs?: unknown } = {}; + const adapter = createBrowserPrebidAdapter(target); + const accepted: Array> = []; + const overflows: unknown[] = []; + const order: number[] = []; + const originalSetAdd = Set.prototype.add; + let reentered = false; + Set.prototype.add = function (this: Set, value: unknown): Set { + if (!reentered) { + reentered = true; + Set.prototype.add = originalSetAdd; + for (let index = 1; index <= 64; index += 1) { + try { + accepted.push(adapter.run(() => order.push(index))); + } catch (error) { + overflows.push(error); + } + } + } + return Reflect.apply(originalSetAdd, this, [value]) as Set; + } as typeof Set.prototype.add; + + let outer: ReturnType | undefined; + try { + outer = adapter.run(() => order.push(0)); + } finally { + Set.prototype.add = originalSetAdd; + } + if (!outer) throw new Error('Expected a published Prebid operation'); + + expect(accepted).toHaveLength(63); + expect(overflows).toHaveLength(1); + expect(overflows[0]).toMatchObject({ code: 'external_queue_full' }); + + target.pbjs = createReadyPrebid().pbjs; + adapter.notifyReady(); + await expect( + Promise.all([outer.result, ...accepted.map(({ result }) => result)]) + ).resolves.toHaveLength(64); + expect(order).toEqual([...Array.from({ length: 63 }, (_, index) => index + 1), 0]); + + target.pbjs = undefined; + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + target.pbjs = createReadyPrebid().pbjs; + adapter.notifyReady(); + await expect(Promise.all(recovered.map(({ result }) => result))).resolves.toHaveLength(64); + }); + + it('rolls back pending Prebid publication when poisoned Set.add throws', async () => { + vi.useFakeTimers(); + const target: { pbjs?: unknown } = {}; + const adapter = createBrowserPrebidAdapter(target); + const publicationError = new Error('Prebid publication failed'); + const command = vi.fn(); + const signalGetter = vi.fn(() => undefined); + const options = Object.defineProperty({}, 'signal', { + get: signalGetter, + }) as { readonly signal?: AbortSignal }; + const originalSetAdd = Set.prototype.add; + const originalSetDelete = Set.prototype.delete; + const poisonedDelete = function (): boolean { + throw new Error('Prebid publication rollback delete failed'); + } as typeof Set.prototype.delete; + let poisonNextAdd = true; + Set.prototype.add = function (this: Set, value: unknown): Set { + if (poisonNextAdd) { + poisonNextAdd = false; + Set.prototype.add = originalSetAdd; + Reflect.apply(originalSetAdd, this, [value]); + throw publicationError; + } + return Reflect.apply(originalSetAdd, this, [value]) as Set; + } as typeof Set.prototype.add; + Set.prototype.delete = poisonedDelete; + + let thrown: unknown; + try { + adapter.run(command, options); + } catch (error) { + thrown = error; + } finally { + Set.prototype.add = originalSetAdd; + Set.prototype.delete = originalSetDelete; + } + + expect(thrown).toBe(publicationError); + expect(command).not.toHaveBeenCalled(); + expect(signalGetter).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + + const recovered = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + target.pbjs = createReadyPrebid().pbjs; + adapter.notifyReady(); + await expect(Promise.all(recovered.map(({ result }) => result))).resolves.toHaveLength(64); + expect(vi.getTimerCount()).toBe(0); + Set.prototype.delete = poisonedDelete; + try { + expect(() => adapter.dispose()).not.toThrow(); + } finally { + Set.prototype.delete = originalSetDelete; + } + await Promise.resolve(); + }); + + it.each(['signal-getter', 'aborted-getter', 'add-throw', 'abort-remove-throw'] as const)( + 'contains hostile Prebid AbortSignal ownership for %s', + async (failure) => { + const adapter = createBrowserPrebidAdapter({}); + const signalError = new Error(`signal failure: ${failure}`); + const listeners = new Set<() => void>(); + const removeEventListener = vi.fn((_type: string, listener: () => void) => { + if (failure === 'abort-remove-throw') throw signalError; + listeners.delete(listener); + }); + const signal = Object.defineProperties( + {}, + { + aborted: { + get: () => { + if (failure === 'aborted-getter') throw signalError; + return false; + }, + }, + addEventListener: { + value: vi.fn((_type: string, listener: () => void) => { + listeners.add(listener); + if (failure === 'add-throw') throw signalError; + if (failure === 'abort-remove-throw') listener(); + }), + }, + removeEventListener: { value: removeEventListener }, + } + ) as AbortSignal; + const options = + failure === 'signal-getter' + ? (Object.defineProperty({}, 'signal', { + get: () => { + throw signalError; + }, + }) as { readonly signal?: AbortSignal }) + : { signal }; + let operation: ReturnType | undefined; + + expect(() => { + operation = adapter.run(vi.fn(), options); + }).not.toThrow(); + if (!operation) throw new Error('Expected a published Prebid operation'); + if (failure === 'abort-remove-throw') { + await expect(operation.result).rejects.toMatchObject({ code: 'caller_aborted' }); + } else { + await expect(operation.result).rejects.toBe(signalError); + } + if (failure === 'add-throw' || failure === 'abort-remove-throw') { + expect(removeEventListener).toHaveBeenCalledTimes(1); + } + + const fillers: Array> = []; + for (let index = 0; index < 64; index += 1) fillers.push(adapter.run(vi.fn())); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + adapter.dispose(); + await Promise.all(fillers.map(({ result }) => result.catch((error: unknown) => error))); + } + ); + + it('settles an operation when its signal getter reentrantly disposes the adapter', async () => { + const adapter = createBrowserPrebidAdapter({}); + const command = vi.fn(); + const options = Object.defineProperty({}, 'signal', { + get: () => { + adapter.dispose(); + return undefined; + }, + }) as { readonly signal?: AbortSignal }; + + const operation = adapter.run(command, options); + + await expect(operation.result).rejects.toMatchObject({ code: 'operation_disposed' }); + expect(command).not.toHaveBeenCalled(); + }); + + it.each([ + 'add-getter', + 'before-install', + 'after-install', + 'reentrant-callback', + 'post-check-throw', + ] as const)( + 'settles Prebid abort transitions during listener registration for %s', + async (transition) => { + vi.useFakeTimers(); + const target: { pbjs?: unknown } = {}; + const adapter = createBrowserPrebidAdapter(target); + const signalError = new Error(`abort transition failure: ${transition}`); + const listeners = new Set<() => void>(); + let aborted = false; + let abortedReads = 0; + const addEventListener = vi.fn((_type: string, listener: () => void) => { + if (transition === 'before-install') aborted = true; + listeners.add(listener); + if (transition === 'after-install') aborted = true; + if (transition === 'reentrant-callback') { + aborted = true; + listener(); + } + }); + const removeEventListener = vi.fn((_type: string, listener: () => void) => { + listeners.delete(listener); + }); + const signal = Object.defineProperties( + {}, + { + aborted: { + get: () => { + abortedReads += 1; + if (transition === 'post-check-throw' && abortedReads === 2) throw signalError; + return aborted; + }, + }, + addEventListener: { + get: () => { + if (transition === 'add-getter') aborted = true; + return addEventListener; + }, + }, + removeEventListener: { value: removeEventListener }, + } + ) as AbortSignal; + const command = vi.fn(); + const operation = adapter.run(command, { signal }); + const result = operation.result.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(10_000); + if (transition === 'post-check-throw') await expect(result).resolves.toBe(signalError); + else await expect(result).resolves.toMatchObject({ code: 'caller_aborted' }); + expect(addEventListener).toHaveBeenCalledTimes(1); + expect(removeEventListener).toHaveBeenCalledTimes(1); + expect(listeners).toHaveLength(0); + + target.pbjs = createReadyPrebid().pbjs; + adapter.notifyReady(); + expect(command).not.toHaveBeenCalled(); + + target.pbjs = undefined; + const fillers = Array.from({ length: 64 }, () => adapter.run(vi.fn())); + expect(() => adapter.run(vi.fn())).toThrowError( + expect.objectContaining({ code: 'external_queue_full' }) + ); + adapter.dispose(); + await Promise.all( + fillers.map(({ result: filler }) => filler.catch((error: unknown) => error)) + ); + } + ); + + it('owns an exact ten-second per-operation deadline and ignores late readiness', async () => { + vi.useFakeTimers(); + const target: { pbjs?: unknown } = {}; + const adapter = createBrowserPrebidAdapter(target); + const first = adapter.run(vi.fn()); + const firstResult = first.result.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(5_000); + const second = adapter.run(vi.fn()); + const secondResult = second.result.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(5_000); + expect(first.status).toBe('timed_out'); + expect(second.status).toBe('pending'); + target.pbjs = createReadyPrebid().pbjs; + adapter.notifyReady(); + + await expect(firstResult).resolves.toMatchObject({ code: 'external_ready_timeout' }); + await expect(secondResult).resolves.toBeUndefined(); + }); + + it('removes aborts and disposal immediately, including deferred commands', async () => { + const deferred = createReadyPrebid({ deferCommands: true }); + const controller = new AbortController(); + const adapter = createBrowserPrebidAdapter({ pbjs: deferred.pbjs }); + const command = vi.fn(); + const operation = adapter.run(command, { signal: controller.signal }); + const result = operation.result.catch((error: unknown) => error); + + controller.abort(); + expect(() => deferred.commands[0]?.()).not.toThrow(); + adapter.dispose(); + + await expect(result).resolves.toMatchObject({ code: 'caller_aborted' }); + expect(command).not.toHaveBeenCalled(); + }); + + it('contains queue, command, and event callback throws', async () => { + const ready = createReadyPrebid(); + const callbackError = new Error('callback failed'); + const listener = vi.fn(() => { + throw callbackError; + }); + const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + const operation = adapter.run((prebid) => { + const unsubscribe = prebid.subscribe('bidResponse', listener); + const installed = [...(ready.listeners.get('bidResponse') ?? [])][0]; + expect(() => installed?.({ adId: 'bid-a' })).not.toThrow(); + unsubscribe(); + throw callbackError; + }); + await expect(operation.result).rejects.toBe(callbackError); + + const pushError = new Error('queue failed'); + const throwing = createReadyPrebid(); + throwing.pbjs.que.push.mockImplementation(() => { + throw pushError; + }); + const pushAdapter = createBrowserPrebidAdapter({ pbjs: throwing.pbjs }); + let pushOperation: ReturnType | undefined; + expect(() => { + pushOperation = pushAdapter.run(() => undefined); + }).not.toThrow(); + await expect(pushOperation?.result).rejects.toBe(pushError); + }); +}); + +describe('version-pinned Trusted Server bid admission', () => { + const preparedBid = () => + recursivelyFreeze({ + auctionId: 'auction-one', + adUnitCode: 'slot-one', + bid: { + requestId: 'request-one', + adId: 'r1_BwcHBwcHBwcHBwcHBwcHBw', + cpm: 1.25, + width: 300, + height: 250, + ad: '' as const, + ttl: 300 as const, + creativeId: 'creative-one', + netRevenue: true as const, + currency: 'USD' as const, + bidderCode: 'trustedServer', + meta: { + advertiserDomains: [] as string[], + tsAuctionId: 'auction-one', + tsBidId: 'bid-one', + }, + }, + }); + + function admissionFixture() { + const ready = createReadyPrebid(); + const stored: object[] = []; + ready.pbjs.getBidResponsesForAdUnitCode.mockImplementation((adUnitCode?: string) => + wrapBids(stored.filter((bid) => (bid as { adUnitCode?: unknown }).adUnitCode === adUnitCode)) + ); + const target: { pbjs: unknown } = { pbjs: ready.pbjs }; + const adapter = createBrowserPrebidAdapter(target); + const auctions: unknown[] = []; + const operation = adapter.run((facade) => { + const boundary = facade as unknown as { + registerTrustedServerBidder(listener: (auction: unknown) => void): () => void; + }; + return boundary.registerTrustedServerBidder((auction) => auctions.push(auction)); + }); + const bidderFactory = ready.pbjs.registerBidAdapter.mock.calls[0]?.[0] as + | (() => { + callBids( + request: unknown, + admit: (adUnitCode: string, bid: Record) => void, + done: () => void + ): void; + }) + | undefined; + const bidder = bidderFactory?.(); + expect(ready.pbjs.registerBidAdapter).toHaveBeenCalledWith(bidderFactory, 'trustedServer'); + const done = vi.fn(); + const emitBidResponse = (bid: object): void => { + for (const listener of ready.listeners.get('bidResponse') ?? []) listener(bid); + }; + const admit = vi.fn((adUnitCode: string, bid: Record) => { + const published = { ...bid, adUnitCode }; + stored.push(published); + emitBidResponse(published); + }); + bidder?.callBids( + { + auctionId: 'auction-one', + bids: [ + { + adUnitCode: 'slot-one', + adUnitId: 'ad-unit-one', + auctionId: 'auction-one', + bidId: 'request-one', + src: 'client', + transactionId: 'transaction-one', + }, + ], + }, + admit, + done + ); + const boundary = adapter as unknown as { + admitTrustedBid(prepared: ReturnType): 'admitted' | 'not_admitted'; + }; + return { + adapter, + admit, + auctions, + boundary, + done, + emitBidResponse, + operation, + ready, + stored, + target, + }; + } + + it('captures one exact auction callback and admits a mutable copy atomically', async () => { + const fixture = admissionFixture(); + await expect(fixture.operation.result).resolves.toBeTypeOf('function'); + + expect(fixture.auctions).toHaveLength(1); + const auction = fixture.auctions[0] as { + auctionId: string; + bids: readonly { adUnitCode: string; requestId: string }[]; + complete(): void; + }; + expect(Object.isFrozen(auction)).toBe(true); + expect(Object.isFrozen(auction.bids)).toBe(true); + expect(auction).toMatchObject({ + auctionId: 'auction-one', + bids: [{ adUnitCode: 'slot-one', requestId: 'request-one' }], + }); + + const prepared = preparedBid(); + expect(fixture.boundary.admitTrustedBid(prepared)).toBe('admitted'); + expect(fixture.admit).toHaveBeenCalledTimes(1); + const admitted = fixture.admit.mock.calls[0]?.[1]; + expect(admitted).toMatchObject(prepared.bid); + expect(admitted).not.toBe(prepared.bid); + expect(admitted).toMatchObject({ + adUnitId: 'ad-unit-one', + auctionId: 'auction-one', + mediaType: 'banner', + source: 'client', + transactionId: 'transaction-one', + }); + expect(Reflect.apply(admitted?.['getSize'] as () => string, admitted, [])).toBe('300x250'); + expect(admitted?.['meta']).not.toBe(prepared.bid.meta); + expect((admitted?.['meta'] as { advertiserDomains?: unknown })?.advertiserDomains).not.toBe( + prepared.bid.meta.advertiserDomains + ); + expect(Object.isFrozen(prepared.bid)).toBe(true); + + auction.complete(); + auction.complete(); + expect(fixture.done).toHaveBeenCalledTimes(1); + }); + + it('returns not_admitted only when neither state nor an event was published', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + fixture.admit.mockImplementation(() => undefined); + + expect(fixture.boundary.admitTrustedBid(preparedBid())).toBe('not_admitted'); + expect(fixture.stored).toEqual([]); + }); + + it('rejects a response query that does not use the pinned self-wrapped array shape', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + fixture.ready.pbjs.getBidResponsesForAdUnitCode.mockImplementation( + () => ({ bids: [] }) as never + ); + + expect(() => fixture.boundary.admitTrustedBid(preparedBid())).toThrowError( + expect.objectContaining({ code: 'external_artifact_incompatible' }) + ); + expect(fixture.admit).not.toHaveBeenCalled(); + }); + + it('makes a request terminal after not_admitted instead of retrying publication', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + fixture.admit.mockImplementation(() => undefined); + + expect(fixture.boundary.admitTrustedBid(preparedBid())).toBe('not_admitted'); + fixture.admit.mockImplementation((adUnitCode, bid) => { + const published = { ...bid, adUnitCode }; + fixture.stored.push(published); + fixture.emitBidResponse(published); + }); + expect(fixture.boundary.admitTrustedBid(preparedBid())).toBe('not_admitted'); + expect(fixture.admit).toHaveBeenCalledTimes(1); + expect(fixture.stored).toEqual([]); + }); + + it('matches response state and events by exact auction, request, and ad-unit identity', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + const prepared = preparedBid(); + fixture.stored.push({ + ...prepared.bid, + auctionId: 'other-auction', + adUnitCode: prepared.adUnitCode, + }); + fixture.admit.mockImplementation((adUnitCode, bid) => { + fixture.emitBidResponse({ + ...bid, + auctionId: 'other-auction', + adUnitCode, + }); + const published = { ...bid, adUnitCode }; + fixture.stored.push(published); + fixture.emitBidResponse(published); + }); + + expect(fixture.boundary.admitTrustedBid(prepared)).toBe('admitted'); + }); + + it('refuses a second live Trusted Server bidder registration on the same binding', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + + const duplicate = fixture.adapter.run((prebid) => prebid.registerTrustedServerBidder(vi.fn())); + + await expect(duplicate.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(fixture.ready.pbjs.registerBidAdapter).toHaveBeenCalledTimes(1); + fixture.adapter.dispose(); + }); + + it('releases the private bidder registration and permits exact replacement', async () => { + const fixture = admissionFixture(); + const release = await fixture.operation.result; + + expect(release).toBeTypeOf('function'); + Reflect.apply(release, undefined, []); + expect(fixture.done).toHaveBeenCalledTimes(1); + + const replacement = fixture.adapter.run((prebid) => + prebid.registerTrustedServerBidder(vi.fn()) + ); + const releaseReplacement = await replacement.result; + expect(releaseReplacement).toBeTypeOf('function'); + expect(fixture.ready.pbjs.registerBidAdapter).toHaveBeenCalledTimes(2); + Reflect.apply(releaseReplacement, undefined, []); + }); + + it('throws a contract violation for partial publication and an ordinary callback throw otherwise', async () => { + const partial = admissionFixture(); + await partial.operation.result; + partial.admit.mockImplementation((adUnitCode, bid) => + partial.emitBidResponse({ ...bid, adUnitCode }) + ); + + expect(() => partial.boundary.admitTrustedBid(preparedBid())).toThrowError( + expect.objectContaining({ code: 'prebid_partial_publication' }) + ); + + const failed = admissionFixture(); + await failed.operation.result; + const callbackFailure = new Error('fictional response callback failure'); + failed.admit.mockImplementation(() => { + throw callbackFailure; + }); + expect(() => failed.boundary.admitTrustedBid(preparedBid())).toThrow(callbackFailure); + }); + + it('rejects detached requests, duplicate admission, binding replacement, and late use', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + + expect( + fixture.boundary.admitTrustedBid( + recursivelyFreeze({ ...preparedBid(), adUnitCode: 'other-slot' }) + ) + ).toBe('not_admitted'); + expect(fixture.boundary.admitTrustedBid(preparedBid())).toBe('admitted'); + expect(() => fixture.boundary.admitTrustedBid(preparedBid())).toThrowError( + expect.objectContaining({ code: 'prebid_partial_publication' }) + ); + + const auction = fixture.auctions[0] as { complete(): void }; + auction.complete(); + expect(fixture.boundary.admitTrustedBid(preparedBid())).toBe('not_admitted'); + + const replaced = admissionFixture(); + await replaced.operation.result; + replaced.target.pbjs = createReadyPrebid().pbjs; + expect(() => replaced.boundary.admitTrustedBid(preparedBid())).toThrowError( + expect.objectContaining({ code: 'external_artifact_incompatible' }) + ); + }); +}); diff --git a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index f22717f79..8176cf91c 100644 --- a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -8,14 +8,46 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { + ARTIFACT_RELEASE_SENTINEL, + assertNoLegacyRuntimeFlags, + assertPurePrebidArtifact, deriveBundleMetadata, main, parseArgs, readAdapterBidderCodes, + readAdapterMetadata, renderIncludedUserIdModulesExport, } from '../build-prebid-external.mjs'; describe('build-prebid-external metadata', () => { + it('rejects any legacy TSJS runtime flag before publishing an artifact', () => { + expect(() => assertNoLegacyRuntimeFlags('window.' + '__' + 'tsjs_prebid = {};')).toThrow( + /legacy TSJS runtime flag/ + ); + expect(() => assertNoLegacyRuntimeFlags('window.pbjs = { que: [] };')).not.toThrow(); + }); + + it('rejects Trusted Server auction, render, and PUC behavior in the external Prebid artifact', () => { + for (const marker of [ + 'TS APS Top Mount Started', + 'TS ADM Start', + 'TS Render Owner Register', + '/_ts/auction', + 'rendererReservationId', + 'lifecycleTicket', + 'tsjs.requestAds', + ]) { + expect(() => assertPurePrebidArtifact(`/* prebid */ ${marker}`)).toThrow( + /TS-owned auction or render behavior/ + ); + } + expect(() => + assertPurePrebidArtifact( + 'window.pbjs={que:[],cmd:[]};window.__prebidProtocol="Prebid Request";Object.defineProperty(window.pbjs,"__trustedServerArtifactV1",{});' + ) + ).not.toThrow(); + }); + it('derives filename, sha256, and SRI from exact bundle bytes', () => { const bundleBytes = Buffer.from('console.log("trusted prebid");\n', 'utf8'); const sha256 = crypto.createHash('sha256').update(bundleBytes).digest('hex'); @@ -37,6 +69,10 @@ describe('build-prebid-external metadata', () => { it('derives registered bidder codes including aliases from prebid metadata', () => { // adfBidAdapter.js registers adf plus the adform/adformOpenRTB aliases. expect(readAdapterBidderCodes(['adf'])).toEqual(['adf', 'adform', 'adformOpenRTB']); + expect(readAdapterMetadata(['adf']).bidderAliases).toEqual([ + { code: 'adform', moduleStem: 'adf' }, + { code: 'adformOpenRTB', moduleStem: 'adf' }, + ]); }); it('maps a module file stem to its registered bidder code', () => { @@ -70,10 +106,41 @@ describe('build-prebid-external metadata', () => { ); const bundle = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); - expect(manifest.userIdModules).toEqual(['pairIdSystem', 'lockrAIMIdSystem']); + expect(manifest).toMatchObject({ + abi: 1, + prebidVersion: '10.26.0', + moduleStems: ['lockrAIMIdSystem', 'pairIdSystem', 'rubicon'], + bidderCodes: ['rubicon'], + bidderAliases: [], + userIdModules: [ + { + moduleName: 'lockrAIMIdSystem', + configNames: ['lockrAIMId'], + eidSources: [], + }, + { + moduleName: 'pairIdSystem', + configNames: ['pairId'], + eidSources: ['google.com'], + }, + ], + }); + expect(manifest.artifactReleaseId).toMatch(/^[0-9a-f]{64}$/); + expect(manifest.filename).toMatch(/^trusted-prebid-[0-9a-f]{64}\.js$/); + expect(manifest.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(manifest.sri).toMatch(/^sha384-/); + expect(bundle).toContain('__trustedServerArtifactV1'); + expect(bundle).toContain('getBidResponsesForAdUnitCode'); + expect(bundle).toContain(manifest.artifactReleaseId); + expect(bundle).not.toContain(ARTIFACT_RELEASE_SENTINEL); + expect(bundle).not.toContain('__' + 'tsjs_'); expect(manifest.bidderCodes).toEqual(['rubicon']); - expect(bundle).toContain('"pairIdSystem"'); - expect(bundle).toContain('"lockrAIMIdSystem"'); + expect(bundle.split(manifest.artifactReleaseId)).toHaveLength(2); + const normalized = bundle.replace(manifest.artifactReleaseId, ARTIFACT_RELEASE_SENTINEL); + expect(crypto.createHash('sha256').update(normalized).digest('hex')).toBe( + manifest.artifactReleaseId + ); + expect(crypto.createHash('sha256').update(bundle).digest('hex')).toBe(manifest.sha256); } finally { fs.rmSync(outputDirectory, { recursive: true, force: true }); } @@ -84,4 +151,9 @@ describe('build-prebid-external metadata', () => { expect(parsed.outDir).toBe(path.resolve(process.cwd(), 'dist/prebid')); }); + + it('canonicalizes module order and rejects duplicate module names', () => { + expect(parseArgs(['--adapters', 'rubicon,adf']).adapters).toEqual(['adf', 'rubicon']); + expect(() => parseArgs(['--adapters', 'rubicon,rubicon'])).toThrow(/duplicates/); + }); }); diff --git a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs new file mode 100644 index 000000000..88adc2934 --- /dev/null +++ b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs @@ -0,0 +1,834 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { TextDecoder, TextEncoder } from 'node:util'; + +import { JSDOM } from 'jsdom'; + +const dist = path.resolve(import.meta.dirname, '../../../dist'); +const manifest = JSON.parse(readFileSync(path.join(dist, 'tsjs-release-v1.json'), 'utf8')); +const source = readFileSync(path.join(dist, 'tsjs-bootstrap.js'), 'utf8'); +const artifactById = new Map(manifest.artifacts.map((artifact) => [artifact.id, artifact])); +const selectedSrc = `/static/tsjs=tsjs-first-display.min.js?m=0001&v=${'c'.repeat(64)}`; +const selectedGptSrc = `/static/tsjs=tsjs-first-display.min.js?m=0081&v=${'c'.repeat(64)}`; +const runtimeSrc = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; +const runtimeBody = ['core', 'render_runtime'] + .map((id) => readFileSync(path.join(dist, artifactById.get(id).file), 'utf8')) + .join(';\n'); +const EMPTY_INTEGRATION_CONFIGS = Object.freeze({ version: 1, entries: Object.freeze([]) }); +const EMPTY_INTEGRATION_CONFIG_DIGEST = createHash('sha256') + .update(JSON.stringify(EMPTY_INTEGRATION_CONFIGS)) + .digest('hex'); +const plain = (value) => JSON.parse(JSON.stringify(value)); +const setCurrentScriptByDom = new WeakMap(); + +function createDocument(firstDisplaySrc = selectedSrc) { + const dom = new JSDOM( + ``, + { runScripts: 'outside-only', url: 'https://publisher.example/page' } + ); + dom.window.TextEncoder = TextEncoder; + dom.window.TextDecoder = TextDecoder; + Object.defineProperty(dom.window.performance, 'mark', { + configurable: true, + value: () => undefined, + }); + const animationFrames = []; + Object.defineProperty(dom.window, 'requestAnimationFrame', { + configurable: true, + value: (callback) => { + animationFrames.push(callback); + return animationFrames.length; + }, + }); + const selected = dom.window.document.querySelector('script#trustedserver-js'); + let currentScript = selected; + Object.defineProperty(dom.window.Document.prototype, 'currentScript', { + configurable: true, + get: () => currentScript, + }); + setCurrentScriptByDom.set(dom, (script) => { + currentScript = script; + }); + return { + animationFrames, + dom, + selected, + setCurrentScript: (script) => { + currentScript = script; + }, + }; +} + +function boot() { + return { + abi: 1, + releaseId: manifest.releaseId, + manifest: { + version: 1, + releaseId: manifest.releaseId, + firstDisplay: { src: selectedSrc, slices: ['first_display'] }, + runtimeSrc, + integrations: [], + }, + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'slot-1', outcome: 'no_bid' }], + }, + slots: [ + { + slot: 'slot-1', + gamUnitPath: '/123/slot-1', + divId: 'slot-1', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [], + }, + integrations: EMPTY_INTEGRATION_CONFIGS, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }; +} + +function outline() { + return { + version: 1, + releaseId: manifest.releaseId, + generation: 1, + projectionDigest: 'e'.repeat(64), + integrationConfigDigest: EMPTY_INTEGRATION_CONFIG_DIGEST, + slices: ['first_display'], + slotCount: 1, + outcomeCount: 1, + capabilities: [], + objectKinds: [], + }; +} + +function selectGpt(bootValue, outlineValue) { + bootValue.manifest.firstDisplay = { + src: selectedGptSrc, + slices: ['first_display', 'gpt_initial'], + }; + outlineValue.slices = ['first_display', 'gpt_initial']; +} + +function selectInitialSlice(bootValue, outlineValue, id, config) { + const firstDisplay = manifest.artifacts.filter((artifact) => + ['first_display_base', 'first_display_slice'].includes(artifact.role) + ); + const index = firstDisplay.findIndex((artifact) => artifact.id === id); + assert.ok(index > 0, `missing first-display slice ${id}`); + const mask = (1 | (1 << index)).toString(16).padStart(4, '0'); + const artifact = artifactById.get(id); + const body = readFileSync(path.join(dist, artifact.file), 'utf8'); + const hash = createHash('sha256').update(body).digest('hex'); + const src = `/static/tsjs=tsjs-first-display.min.js?m=${mask}&v=${hash}`; + bootValue.manifest.firstDisplay = { src, slices: ['first_display', id] }; + const product = id.slice(0, -'_initial'.length); + bootValue.integrations = { version: 1, entries: [{ id: product, config }] }; + outlineValue.slices = ['first_display', id]; + return { body, src }; +} + +function transport(bootValue = boot(), outlineValue = outline()) { + const integrity = { + version: 1, + projectionDigest: createHash('sha256') + .update(JSON.stringify(bootValue.auctionProjection)) + .digest('hex'), + integrationConfigDigest: createHash('sha256') + .update(JSON.stringify(bootValue.integrations)) + .digest('hex'), + }; + if (outlineValue) { + outlineValue.projectionDigest = integrity.projectionDigest; + outlineValue.integrationConfigDigest = integrity.integrationConfigDigest; + } + return { version: 1, boot: bootValue, integrity, outline: outlineValue }; +} + +function evaluateTransport(dom, value) { + dom.window.eval( + `const __TSJS_SERVER_BOOT_TRANSPORT_V1__=${JSON.stringify(JSON.stringify(value))};${source}` + ); + setCurrentScriptByDom.get(dom)?.(null); +} + +function evaluateWithInput(dom) { + evaluateTransport(dom, transport()); +} + +test('generated bootstrap bytes are stamped exactly once and expose no callable global', () => { + assert.equal(source.includes('__TSJS_RELEASE_ID_SENTINEL_V1__'), false); + assert.equal(source.split(manifest.releaseId).length - 1, 1); + const { dom } = createDocument(); + dom.window.tsjs = {}; + + evaluateWithInput(dom); + + assert.equal(dom.window.tsjs_bootstrap, undefined); + assert.equal(dom.window.tsjs.boot.releaseId, manifest.releaseId); + assert.equal(Object.hasOwn(dom.window.tsjs, '_registerFirstDisplay'), false); + dom.window.close(); +}); + +test('generated bootstrap seals the TSJS namespace handoff without mutating currentScript', () => { + const { dom, selected, setCurrentScript } = createDocument(); + const target = {}; + dom.window.tsjs = target; + selected.src = runtimeSrc; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + directBoot.manifest.integrations = [{ id: 'render_runtime', phase: 'takeover' }]; + + evaluateTransport(dom, transport(directBoot, null)); + setCurrentScript(null); + + assert.equal(Object.getOwnPropertyDescriptor(dom.window.document, 'currentScript'), undefined); + const descriptor = Object.getOwnPropertyDescriptor(dom.window, 'tsjs'); + assert.equal(descriptor?.configurable, false); + assert.equal(descriptor?.enumerable, true); + assert.equal(typeof descriptor?.get, 'function'); + assert.throws(() => + Object.defineProperty(dom.window, 'tsjs', { + configurable: true, + value: { publisher: 'replacement' }, + }) + ); + assert.equal(dom.window.tsjs, target); + assert.equal(typeof selected._claimRuntimeV1, 'function'); + assert.equal(selected._claimRuntimeV1(selected), undefined); + dom.window.close(); +}); + +test('generated bootstrap leaves the namespace untouched without exact server input', () => { + for (const input of ['', 'const __TSJS_SERVER_BOOT_TRANSPORT_V1__={};']) { + const { dom } = createDocument(); + const target = { publisher: 'retained' }; + dom.window.tsjs = target; + + dom.window.eval(`${input}${source}`); + + assert.equal(dom.window.tsjs, target); + assert.deepEqual(Object.keys(target), ['publisher']); + dom.window.close(); + } +}); + +test('generated bootstrap transfers the direct persistent watchdog to the selected runtime', () => { + const { dom, selected, setCurrentScript } = createDocument(); + selected.src = runtimeSrc; + const target = { que: [] }; + dom.window.tsjs = target; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + + evaluateTransport(dom, transport(directBoot, null)); + setCurrentScript(selected); + + const claim = selected._claimRuntimeV1; + const originalQuerySelectorAll = dom.window.document.querySelectorAll; + let nestedResult; + let nestedCancelCalls = 0; + let reentered = false; + dom.window.document.querySelectorAll = function (...args) { + if (!reentered) { + reentered = true; + nestedResult = claim(selected); + } + return Reflect.apply(originalQuerySelectorAll, this, args); + }; + const cancel = () => assert.fail('committed direct runtime must not be cancelled'); + const claimed = claim(selected); + assert.equal(nestedResult, undefined); + assert.equal(nestedCancelCalls, 0); + assert.equal(claimed.mode, 'direct'); + const complete = claimed.bind(cancel); + assert.equal(typeof complete, 'function'); + complete('kernel'); + assert.equal(selected._claimRuntimeV1(selected), undefined); + assert.equal(Object.hasOwn(target, '_internal'), false); + assert.equal(target.boot.manifest.firstDisplay, null); + dom.window.close(); +}); + +test('generated runtime reserves the node claim before mutable realm initializers can reenter', () => { + const { dom, selected, setCurrentScript } = createDocument(); + selected.src = runtimeSrc; + const target = { que: [] }; + dom.window.tsjs = target; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + directBoot.manifest.integrations = [{ id: 'render_runtime', phase: 'takeover' }]; + + evaluateTransport(dom, transport(directBoot, null)); + setCurrentScript(selected); + const claim = selected._claimRuntimeV1; + const nativeGetOwnPropertyDescriptor = dom.window.Object.getOwnPropertyDescriptor; + const nativeReflectApply = dom.window.Reflect.apply; + let descriptorReentry; + let applyReentry; + dom.window.Object.getOwnPropertyDescriptor = function (...args) { + descriptorReentry ??= claim(selected); + return nativeGetOwnPropertyDescriptor(...args); + }; + dom.window.Reflect.apply = function (targetFunction, thisArgument, argumentsList) { + applyReentry ??= claim(selected); + return nativeReflectApply(targetFunction, thisArgument, argumentsList); + }; + + dom.window.eval(runtimeBody); + + assert.equal(descriptorReentry, undefined); + assert.equal(applyReentry, undefined); + assert.equal(target._internal.state, 'kernel'); + dom.window.close(); +}); + +test('generated takeover claim reserves authentication against publisher reentry', () => { + const { animationFrames, dom, setCurrentScript } = createDocument(); + const target = { que: [] }; + dom.window.tsjs = target; + + evaluateWithInput(dom); + const nativeFreeze = dom.window.Object.freeze; + const nativeDefineProperty = dom.window.Object.defineProperty; + let capabilityRecordExposed = false; + let claimDescriptorExposed = false; + dom.window.Object.freeze = function (value) { + if ( + value && + typeof value === 'object' && + Object.prototype.hasOwnProperty.call(value, 'bind') && + Object.prototype.hasOwnProperty.call(value, 'complete') + ) { + capabilityRecordExposed = true; + } + return nativeFreeze(value); + }; + dom.window.Object.defineProperty = function (owner, key, descriptor) { + if (key === '_claimRuntimeV1' && typeof descriptor?.value === 'function') { + claimDescriptorExposed = true; + } + return nativeDefineProperty(owner, key, descriptor); + }; + while (animationFrames.length > 0) animationFrames.shift()(dom.window.performance.now()); + + const runtime = dom.window.document.querySelector('script#trustedserver-js-runtime'); + const claim = runtime._claimRuntimeV1; + assert.equal(typeof claim, 'function'); + setCurrentScript(runtime); + const originalQuerySelectorAll = dom.window.document.querySelectorAll; + let nestedResult; + let maliciousFinalizeCalls = 0; + let reentered = false; + dom.window.document.querySelectorAll = function (...args) { + if (!reentered) { + reentered = true; + nestedResult = claim(runtime); + } + return Reflect.apply(originalQuerySelectorAll, this, args); + }; + const claimed = claim(runtime); + assert.equal(claimed.mode, 'takeover'); + let outerFinalizeCalls = 0; + assert.throws(() => + claimed.bind(() => { + outerFinalizeCalls += 1; + return undefined; + }) + ); + assert.equal(nestedResult, undefined); + assert.equal(maliciousFinalizeCalls, 0); + assert.equal(outerFinalizeCalls, 1); + assert.equal(capabilityRecordExposed, false); + assert.equal(claimDescriptorExposed, false); + dom.window.close(); +}); + +test('generated bootstrap classifies a rejected claimed boot as an ABI mismatch', () => { + const { dom, selected, setCurrentScript } = createDocument(); + selected.src = runtimeSrc; + const target = { que: [] }; + dom.window.tsjs = target; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + + evaluateTransport(dom, transport(directBoot, null)); + setCurrentScript(selected); + + const claim = selected._claimRuntimeV1; + assert.equal(typeof claim, 'function'); + const claimed = claim(selected); + assert.equal(claimed.boot, target.boot); + claimed.complete('abi_mismatch'); + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + initialDisplayCommitted: false, + } + ); + dom.window.close(); +}); + +test('generated bootstrap reserves the boot claim across reentrant DOM authentication', () => { + const { dom, selected, setCurrentScript } = createDocument(); + selected.src = runtimeSrc; + const target = { que: [] }; + dom.window.tsjs = target; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + + evaluateTransport(dom, transport(directBoot, null)); + setCurrentScript(selected); + + const claim = selected._claimRuntimeV1; + const querySelectorAll = dom.window.document.querySelectorAll; + let nested; + let reentered = false; + Object.defineProperty(dom.window.document, 'querySelectorAll', { + configurable: true, + value(selector) { + if (!reentered) { + reentered = true; + nested = claim(selected); + } + return Reflect.apply(querySelectorAll, this, [selector]); + }, + }); + + const claimed = claim(selected); + nested?.complete('kernel'); + claimed.complete('abi_mismatch'); + + assert.equal(nested, undefined); + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + initialDisplayCommitted: false, + } + ); + dom.window.close(); +}); + +test('generated bootstrap completes a claimed boot without a reentrant realm callback', () => { + const { dom, selected, setCurrentScript } = createDocument(); + selected.src = runtimeSrc; + const target = { que: [] }; + dom.window.tsjs = target; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + + evaluateTransport(dom, transport(directBoot, null)); + setCurrentScript(selected); + + const claimed = selected._claimRuntimeV1(selected); + const includes = dom.window.Array.prototype.includes; + let reentered = false; + Object.defineProperty(dom.window.Array.prototype, 'includes', { + configurable: true, + value(value, fromIndex) { + if (!reentered) { + reentered = true; + claimed.complete('kernel'); + } + return Reflect.apply(includes, this, [value, fromIndex]); + }, + writable: true, + }); + + assert.doesNotThrow(() => claimed.complete('abi_mismatch')); + assert.equal(reentered, false); + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + initialDisplayCommitted: false, + } + ); + dom.window.close(); +}); + +test('generated base-only takeover failure leaves terminal fallback ownership with bootstrap', () => { + const { animationFrames, dom, setCurrentScript } = createDocument(); + const target = { que: [] }; + dom.window.tsjs = target; + evaluateWithInput(dom); + + const accepted = dom.window.document.createElement('iframe'); + accepted.id = 'accepted-first-display'; + dom.window.document.body.append(accepted); + while (animationFrames.length > 0) animationFrames.shift()(dom.window.performance.now()); + const runtime = dom.window.document.querySelector('script#trustedserver-js-runtime'); + assert.ok(runtime, 'the bootstrap-owned base must reach protected paint without a marker'); + setCurrentScript(runtime); + const claimed = runtime._claimRuntimeV1(runtime); + assert.equal(claimed.boot, target.boot); + claimed.complete('bundle_partial'); + + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'bundle_partial', + initialDisplayCommitted: false, + } + ); + assert.equal(target.boot.manifest.firstDisplay.src, selectedSrc); + assert.equal(accepted.isConnected, true); + dom.window.close(); +}); + +test('generated bootstrap admits the exact post-paint runtime through one private Trusted Types policy', () => { + const { animationFrames, dom, setCurrentScript } = createDocument(); + const target = { que: [] }; + const policies = []; + Object.defineProperty(dom.window, 'trustedTypes', { + configurable: true, + value: { + createPolicy(name, rules) { + assert.equal(name, 'trusted-server#tsjs-v1'); + if (policies.length !== 0) throw new TypeError('duplicate policy'); + policies.push({ name, rules }); + return { createScriptURL: rules.createScriptURL }; + }, + }, + }); + dom.window.tsjs = target; + + evaluateWithInput(dom); + while (animationFrames.length > 0) animationFrames.shift()(dom.window.performance.now()); + + assert.equal(policies.length, 1); + assert.equal(policies[0].name, 'trusted-server#tsjs-v1'); + assert.throws(() => policies[0].rules.createScriptURL('https://attacker.example/core.js')); + const runtime = dom.window.document.querySelector('script#trustedserver-js-runtime'); + assert.equal(runtime?.src, new URL(runtimeSrc, dom.window.location.origin).href); + assert.equal(Object.hasOwn(runtime, '_tsjsTrustedTypesPolicyV1'), false); + const takeover = runtime._claimRuntimeV1; + assert.equal(typeof takeover, 'function'); + const attacker = dom.window.document.createElement('script'); + attacker.id = 'publisher-script'; + dom.window.document.head.append(attacker); + setCurrentScript(attacker); + let finalizeCalls = 0; + assert.equal(takeover(runtime), undefined); + assert.equal(finalizeCalls, 0); + assert.equal(target._claimRuntimeV1, undefined); + assert.equal(runtime._claimRuntimeV1, takeover); + setCurrentScript(runtime); + runtime._claimRuntimeV1(runtime).complete('bundle_partial'); + dom.window.close(); +}); + +test('generated bootstrap stops optional activation after a component installer fails', () => { + const { dom, selected, setCurrentScript } = createDocument(selectedGptSrc); + const target = { que: [] }; + const events = []; + let bindingKeys; + const bootValue = boot(); + const outlineValue = outline(); + selectGpt(bootValue, outlineValue); + bootValue.integrations = { version: 1, entries: [{ id: 'gpt', config: {} }] }; + dom.window.tsjs = target; + evaluateTransport(dom, transport(bootValue, outlineValue)); + setCurrentScript(selected); + + const gpt = Object.freeze([ + 1, + 'gpt_initial', + manifest.releaseId, + (bindings) => { + bindingKeys = Reflect.ownKeys(bindings); + events.push('install-gpt'); + throw new TypeError('invalid generated slice'); + }, + ]); + + assert.equal(target._registerFirstDisplay.call(target, gpt, selected), false); + assert.deepEqual(bindingKeys, ['browser', 'observe', 'register']); + assert.deepEqual(events, ['install-gpt']); + assert.equal(Object.getOwnPropertyDescriptor(target, '_internal')?.value.reason, 'abi_mismatch'); + dom.window.close(); +}); + +test('generated first-display registration reserves authentication against DOM reentry', () => { + const { dom, selected, setCurrentScript } = createDocument(selectedGptSrc); + const target = { que: [] }; + const bootValue = boot(); + const outlineValue = outline(); + selectGpt(bootValue, outlineValue); + bootValue.integrations = { version: 1, entries: [{ id: 'gpt', config: {} }] }; + dom.window.tsjs = target; + evaluateTransport(dom, transport(bootValue, outlineValue)); + setCurrentScript(selected); + + const maliciousInstall = () => assert.fail('a reentrant installer must never activate'); + let genuineInstallCalls = 0; + const genuineInstall = () => { + genuineInstallCalls += 1; + }; + const originalQuerySelectorAll = dom.window.document.querySelectorAll; + let nestedResult; + let reentered = false; + dom.window.document.querySelectorAll = function (...args) { + if (!reentered) { + reentered = true; + nestedResult = target._registerFirstDisplay.call( + target, + Object.freeze([1, 'gpt_initial', manifest.releaseId, maliciousInstall]), + selected + ); + } + return Reflect.apply(originalQuerySelectorAll, this, args); + }; + + const accepted = target._registerFirstDisplay.call( + target, + Object.freeze([1, 'gpt_initial', manifest.releaseId, genuineInstall]), + selected + ); + + assert.equal(nestedResult, false); + assert.equal(accepted, false); + assert.equal(genuineInstallCalls, 1); + assert.equal(Object.getOwnPropertyDescriptor(target, '_internal')?.value.reason, 'abi_mismatch'); + dom.window.close(); +}); + +test('generated optional slices receive their exact parser-time browser bindings', () => { + const fixtures = [ + ['datadome_initial', {}], + ['didomi_initial', { proxyPath: '/integrations/didomi/consent/' }], + ['google_tag_manager_initial', {}], + ['lockr_initial', {}], + ['osano_initial', {}], + ['permutive_initial', {}], + ['sourcepoint_initial', { rewriteSdk: true }], + ['testlight_initial', {}], + ]; + for (const [id, config] of fixtures) { + const bootValue = boot(); + const outlineValue = outline(); + const { body, src } = selectInitialSlice(bootValue, outlineValue, id, config); + const { dom, selected, setCurrentScript } = createDocument(src); + const callback = () => undefined; + if (id === 'lockr_initial') dom.window.identityLockr = { host: 'vendor.example' }; + if (id === 'osano_initial') { + dom.window.__uspapi = (_command, _version, complete) => complete({ uspString: '1YN-' }, true); + dom.window.__gpp = (_command, complete) => + complete({ gppString: 'DBABLA~BVQqAAAAAgA.QA', applicableSections: [7] }, true); + dom.window.__tcfapi = (_command, _version, complete) => + complete( + { + tcString: + 'COwK6gaOwK6gaFmAAAENAPCAAAAAAAAAAAAAAAAAAAAA.IFMsv_Z_G____bvQXQ1f9eY1f9_z_q7ff_3_3-_-3dV1v9zLv9____39nP___9v-_3_f__9P', + }, + true + ); + } + if (id === 'permutive_initial') { + dom.window.permutive = { + config: { + apiHost: 'api.vendor.example', + apiProtocol: 'https', + cdnBaseUrl: 'cdn.vendor.example', + cdnProtocol: 'https', + secureSignalsApiHost: 'signals.vendor.example', + segmentSyncApiHost: 'sync.vendor.example', + }, + }; + dom.window.localStorage.setItem( + 'permutive-app', + JSON.stringify({ core: { cohorts: { all: ['one', 2] } } }) + ); + } + if (id === 'sourcepoint_initial') { + dom.window.localStorage.setItem( + '_sp_user_consent_test', + JSON.stringify({ gppData: { gppString: 'DBABLA', applicableSections: [7] } }) + ); + } + if (id === 'testlight_initial') dom.window.testlight = { que: [callback] }; + dom.window.tsjs = { que: [] }; + + evaluateTransport(dom, transport(bootValue, outlineValue)); + setCurrentScript(selected); + dom.window.eval(body); + + assert.equal( + Object.getOwnPropertyDescriptor(dom.window.tsjs, '_internal'), + undefined, + `${id} must not force fallback` + ); + if (id === 'datadome_initial' || id === 'google_tag_manager_initial') { + const script = dom.window.document.createElement('script'); + script.src = + id === 'datadome_initial' + ? 'https://js.datadome.co/tags.js' + : 'https://www.googletagmanager.com/gtm.js?id=GTM-TEST'; + dom.window.document.head.appendChild(script); + assert.match(script.src, /\/integrations\/(?:datadome|google_tag_manager)\//); + } + if (id === 'didomi_initial') { + assert.equal( + dom.window.didomiConfig.sdkPath, + 'https://publisher.example/integrations/didomi/consent/' + ); + } + if (id === 'lockr_initial') { + assert.equal( + dom.window.identityLockr.host, + 'https://publisher.example/integrations/lockr/api' + ); + } + if (id === 'permutive_initial') { + assert.equal( + dom.window.permutive.config.apiHost, + 'publisher.example/integrations/permutive/api' + ); + } + if (id === 'sourcepoint_initial') assert.match(dom.window.document.cookie, /__gpp=DBABLA/); + if (id === 'testlight_initial') assert.ok(dom.window.tsjs.que.includes(callback)); + dom.window.close(); + } +}); + +test('generated bootstrap commits one non-rendering terminal shell after registration failure', async () => { + const { dom, selected, setCurrentScript } = createDocument(selectedGptSrc); + const drained = []; + const target = { + que: [ + function () { + drained.push(this); + }, + ], + }; + dom.window.tsjs = target; + + const bootValue = boot(); + const outlineValue = outline(); + selectGpt(bootValue, outlineValue); + evaluateTransport(dom, transport(bootValue, outlineValue)); + setCurrentScript(selected); + assert.equal(dom.window.tsjs._registerFirstDisplay.call(target, {}, selected), false); + + assert.deepEqual(Object.keys(target).sort(), [ + '_registerIntegration', + 'addAdUnits', + 'boot', + 'log', + 'que', + 'releaseId', + 'requestAds', + 'version', + ]); + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + initialDisplayCommitted: false, + } + ); + assert.equal(Object.getOwnPropertyDescriptor(target, '_internal')?.enumerable, false); + assert.equal(target._registerIntegration(), false); + assert.equal(drained.length, 1); + assert.equal(drained[0], target); + assert.equal(Object.isFrozen(target.que), true); + assert.equal( + target.que.push(() => drained.push('late')), + 0 + ); + assert.deepEqual(drained, [target, 'late']); + const malformedUnit = dom.window.eval("({code:'',mediaTypes:{}})"); + assert.throws(() => target.addAdUnits(malformedUnit), { + name: 'TsjsUnavailableError', + code: 'runtime_unavailable', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + }); + const validUnit = dom.window.eval( + "({code:'programmatic',mediaTypes:{banner:{sizes:[[300,250]]}}})" + ); + assert.throws(() => target.addAdUnits(validUnit), { + name: 'TsjsUnavailableError', + code: 'runtime_unavailable', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + }); + await assert.rejects(target.requestAds(), { + name: 'TsjsUnavailableError', + code: 'runtime_unavailable', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + }); + const explicitOptions = dom.window.eval("({slots:['slot-1']})"); + await assert.rejects(target.requestAds(explicitOptions), { + name: 'TsjsUnavailableError', + code: 'runtime_unavailable', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + }); + const controller = new dom.window.AbortController(); + controller.abort(); + const abortedOptions = dom.window.eval("({slots:['slot-1']})"); + abortedOptions.signal = controller.signal; + await assert.rejects(target.requestAds(abortedOptions), { + name: 'TsjsUnavailableError', + code: 'runtime_unavailable', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + }); + assert.deepEqual(plain(target.boot.auctionProjection), { + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], + bids: [], + }); + assert.deepEqual(plain(target.boot.integrations), { version: 1, entries: [] }); + dom.window.close(); +}); + +test('generated bootstrap does not overwrite a conflicting non-configurable namespace field', () => { + const { dom, selected, setCurrentScript } = createDocument(selectedGptSrc); + const target = { que: [] }; + const publisherApi = () => undefined; + Object.defineProperty(target, 'publisherApi', { + configurable: false, + enumerable: true, + value: publisherApi, + writable: false, + }); + dom.window.tsjs = target; + + const bootValue = boot(); + const outlineValue = outline(); + selectGpt(bootValue, outlineValue); + evaluateTransport(dom, transport(bootValue, outlineValue)); + setCurrentScript(selected); + assert.equal(dom.window.tsjs._registerFirstDisplay.call(target, {}, selected), false); + + assert.equal(target.publisherApi, publisherApi); + assert.equal(Object.hasOwn(target, 'releaseId'), false); + assert.equal(Object.hasOwn(target, '_internal'), false); + dom.window.close(); +}); diff --git a/crates/trusted-server-js/lib/test/build/release-v1.test.mjs b/crates/trusted-server-js/lib/test/build/release-v1.test.mjs new file mode 100644 index 000000000..3a83c4bbe --- /dev/null +++ b/crates/trusted-server-js/lib/test/build/release-v1.test.mjs @@ -0,0 +1,2362 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { JSDOM } from 'jsdom'; + +import { + RELEASE_SENTINEL, + computeReleaseId, + stampRelease, + validateStampedRelease, +} from '../../scripts/release-v1.mjs'; +import { + checkBundleBudgets, + findTakeoverDeferredSourceViolations, + validateSemanticBundleSets, +} from '../../scripts/check-bundle-budgets.mjs'; +import * as bundleBudgets from '../../scripts/check-bundle-budgets.mjs'; +import * as bundleMetrics from '../../scripts/bundle-metrics.mjs'; +import { + findCutoverTextViolations, + findVendorBoundaryViolations, + generatedTsjsArtifactFiles, +} from '../../scripts/check-hard-cutover-absence.mjs'; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const libDirectory = path.resolve(testDirectory, '../..'); +const repositoryRoot = path.resolve(libDirectory, '../../..'); +const bundle = (id, logical, role = 'integration', phase = 'takeover', trigger = '') => ({ + id, + role, + phase, + trigger, + bytes: Buffer.from(`${logical}${RELEASE_SENTINEL}`), +}); + +const EXPECTED_RELEASE_BUNDLE_ORDER = [ + 'bootstrap', + 'first_display', + 'render_owner_initial', + 'aps_initial', + 'creative_initial', + 'datadome_initial', + 'didomi_initial', + 'google_tag_manager_initial', + 'gpt_initial', + 'lockr_initial', + 'osano_initial', + 'permutive_initial', + 'sourcepoint_initial', + 'prebid_initial', + 'testlight_initial', + 'core', + 'render_runtime', + 'aps', + 'creative', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'gpt_diagnostics', + 'lockr', + 'osano_consent', + 'permutive_context', + 'sourcepoint_consent', + 'prebid', + 'testlight', + 'diagnostics_presentation', + 'gpt_later', + 'osano_lifecycle', + 'permutive_lifecycle', + 'prebid_later', + 'sourcepoint_lifecycle', +]; + +const TAKEOVER_CONSENT_ARTIFACTS = Object.freeze([ + Object.freeze({ + id: 'osano_consent', + capability: 'osano_consent.v1', + }), + Object.freeze({ + id: 'permutive_context', + capability: 'permutive_context.v1', + }), + Object.freeze({ + id: 'sourcepoint_consent', + capability: 'sourcepoint_consent.v1', + }), +]); + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value !== null && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function readBuildEvidence() { + return { + baseline: JSON.parse( + fs.readFileSync( + path.join(libDirectory, 'test/fixtures/performance/aps-tsjs-prechange.json'), + 'utf8' + ) + ), + catalog: JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-catalog-v1.json'), 'utf8') + ), + metrics: JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ), + release: JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') + ), + }; +} + +function buildStructurallyValidDescendant(mutateEvidence = () => {}) { + const evidence = readBuildEvidence(); + const distDirectory = path.resolve(libDirectory, '../dist'); + const logicalContents = new Map( + evidence.release.artifacts.map(({ file }) => { + const source = fs.readFileSync(path.join(distDirectory, file), 'utf8'); + return [file, Buffer.from(source.replace(evidence.release.releaseId, RELEASE_SENTINEL))]; + }) + ); + logicalContents.set( + 'tsjs-core.js', + Buffer.concat([ + logicalContents.get('tsjs-core.js'), + Buffer.from('\n/* descendant release */\n'), + ]) + ); + mutateEvidence(evidence); + + const releaseArtifacts = evidence.release.artifacts.map((artifact) => ({ + id: artifact.id, + role: artifact.role, + phase: artifact.phase ?? '', + trigger: artifact.trigger ?? '', + bytes: logicalContents.get(artifact.file), + })); + const releaseId = computeReleaseId(releaseArtifacts); + const currentArtifactContents = new Map( + evidence.release.artifacts.map(({ file }) => [ + file, + Buffer.from(stampRelease(logicalContents.get(file), releaseId)), + ]) + ); + + evidence.release.releaseId = releaseId; + for (const artifact of evidence.release.artifacts) { + const bytes = currentArtifactContents.get(artifact.file); + artifact.bytes = bytes.byteLength; + artifact.hash = createHash('sha256').update(bytes).digest('hex'); + } + Object.assign( + evidence.metrics.bootstrap, + bundleMetrics.measureBytes(currentArtifactContents.get('tsjs-bootstrap.js')) + ); + const productionArtifacts = evidence.release.artifacts.filter(({ role }) => role !== 'bootstrap'); + for (const [index, module] of evidence.metrics.modules.entries()) { + const artifact = productionArtifacts[index]; + module.rawBytes = artifact.bytes; + module.sha256 = artifact.hash; + } + const setFiles = bundleMetrics.deriveInventorySetFiles( + evidence.release.artifacts, + evidence.catalog.modules + ); + for (const [setName, files] of Object.entries(setFiles)) { + evidence.metrics.sets[setName] = bundleMetrics.measureBundleSet(files, currentArtifactContents); + } + return { ...evidence, currentArtifactContents }; +} + +function executeGeneratedArtifact(window, file, registrations, { preserveTarget = false } = {}) { + const target = preserveTarget + ? window.tsjs + : Object.freeze({ + _registerIntegration: (registration) => { + registrations.push(registration); + return true; + }, + }); + if (!preserveTarget) { + Object.defineProperty(window, 'tsjs', { + configurable: true, + value: target, + }); + } + window.eval(fs.readFileSync(path.resolve(libDirectory, '../dist', file), 'utf8')); +} + +test('generated release inventory pins the server bundle order', () => { + const manifest = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') + ); + + assert.deepEqual( + manifest.artifacts.map(({ id }) => id), + EXPECTED_RELEASE_BUNDLE_ORDER + ); + assert.equal(manifest.artifacts.filter(({ role }) => role === 'bootstrap').length, 1); + assert.equal(manifest.artifacts.filter(({ role }) => role === 'first_display_base').length, 1); + assert.equal(manifest.artifacts.filter(({ role }) => role === 'first_display_slice').length, 13); + assert.equal(manifest.artifacts.filter(({ role }) => role === 'core').length, 1); + assert.equal(manifest.artifacts.filter(({ role }) => role === 'integration').length, 20); + for (const artifact of manifest.artifacts) { + assert.deepEqual(Object.keys(artifact), [ + 'id', + 'role', + 'phase', + 'trigger', + 'inputs', + 'outputs', + 'file', + 'bytes', + 'hash', + ]); + } +}); + +test('release id printer validates the complete generated inventory', () => { + const manifest = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') + ); + const printed = execFileSync( + process.execPath, + [path.join(libDirectory, 'scripts/print-release-id.mjs')], + { encoding: 'utf8' } + ).trim(); + + assert.equal(printed, manifest.releaseId); +}); + +test('generated optional first-display slices self-register through one authenticated sink', () => { + const release = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') + ); + const firstDisplay = release.artifacts.filter(({ phase }) => phase === 'first_display'); + const dom = new JSDOM( + ``, + { + runScripts: 'outside-only', + url: 'https://publisher.example/article', + } + ); + const registrations = []; + const target = {}; + Object.defineProperty(target, '_registerFirstDisplay', { + configurable: true, + enumerable: false, + value(registration) { + assert.equal(this, target); + assert.equal(arguments.length, 1); + dom.window.Object.freeze(registration); + registrations.push(registration); + return true; + }, + writable: false, + }); + Object.defineProperty(dom.window, 'tsjs', { + configurable: true, + value: target, + }); + try { + dom.window.eval( + firstDisplay + .map(({ file }) => fs.readFileSync(path.resolve(libDirectory, '../dist', file), 'utf8')) + .join(';\n') + ); + assert.deepEqual( + registrations.map((registration) => registration[1]), + firstDisplay.slice(1).map(({ id }) => id) + ); + for (const registration of registrations) { + assert.deepEqual(Reflect.ownKeys(registration), ['0', '1', '2', '3', 'length']); + assert.equal(registration[0], 1); + assert.equal(registration[2], release.releaseId); + assert.equal(typeof registration[3], 'function'); + assert.equal(Object.isFrozen(registration), true); + } + } finally { + dom.window.close(); + } +}); + +test('takeover transport co-bundles core and render ownership exactly once', () => { + const metrics = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ); + const core = metrics.modules.find(({ file }) => file === 'tsjs-core.js'); + const renderRuntime = metrics.modules.find(({ file }) => file === 'tsjs-render_runtime.js'); + + assert.ok(core, 'core metrics must exist'); + assert.ok(renderRuntime, 'render_runtime metrics must exist'); + assert.ok( + core.sources.some(({ file }) => file === 'src/integrations/render_runtime/module.ts'), + 'the core transport must co-bundle the mandatory render owner once' + ); + assert.deepEqual( + renderRuntime.sources.map(({ file }) => file), + ['src/integrations/render_runtime/transport_marker.ts'], + 'the logical render artifact must not duplicate the co-bundled implementation' + ); +}); + +test('bootstrap physically owns the logical first-display base without transporting its marker', () => { + const { metrics } = readBuildEvidence(); + const bootstrapSources = new Set(metrics.bootstrap.sources.map(({ file }) => file)); + const marker = metrics.modules.find(({ file }) => file === 'tsjs-first_display.js'); + const reference = metrics.firstDisplay.masks.find(({ mask }) => mask === '008b'); + + assert.equal( + bootstrapSources.has('src/first_display/agent.ts'), + true, + 'the parser-inline bootstrap must physically own the first-display coordinator' + ); + assert.ok(marker, 'the logical first_display catalog marker must remain in release evidence'); + assert.deepEqual( + marker.sources.map(({ file }) => file), + ['src/first_display/base_marker.ts'], + 'the logical marker must not duplicate the bootstrap-owned implementation' + ); + assert.ok(reference, 'the exact semantic 008b first-display mask must be measured'); + assert.equal(reference.ids[0], 'first_display'); + assert.deepEqual( + reference.files, + reference.ids.slice(1).map((id) => `tsjs-${id}.js`), + 'the logical base bit must select bootstrap behavior without adding response bytes' + ); + + for (const forbidden of [ + 'src/first_display/render_journal.ts', + 'src/first_display/render_bridge.ts', + 'src/first_display/leaf/aps_protocol.ts', + 'src/first_display/slices/aps.ts', + 'src/first_display/slices/gpt.ts', + ]) { + assert.equal( + bootstrapSources.has(forbidden), + false, + `bootstrap must not inline optional first-display source ${forbidden}` + ); + } +}); + +test('first-display component entries register directly without a definition runtime', () => { + const { metrics } = readBuildEvidence(); + + for (const [id, component] of Object.entries(metrics.firstDisplay.components)) { + if (id === 'first_display') continue; + assert.equal( + component.sources.some(({ file }) => file === 'src/first_display/slices/definition.ts'), + false, + `${id} must not transport the test-only slice-definition helper` + ); + } +}); + +test('generated integration artifacts execute their release-bound catalog entrypoints', () => { + const release = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') + ); + const dom = new JSDOM('', { + runScripts: 'outside-only', + url: 'https://publisher.example/article', + }); + const registrations = []; + try { + executeGeneratedArtifact(dom.window, 'tsjs-render_runtime.js', registrations); + assert.equal( + registrations.length, + 0, + 'the catalog marker must not publish a duplicate render owner' + ); + registrations.push({ id: 'render_runtime', phase: 'takeover' }); + for (const artifact of release.artifacts.filter( + ({ role, id }) => role === 'integration' && id !== 'render_runtime' + )) { + executeGeneratedArtifact(dom.window, artifact.file, registrations); + } + const integrationArtifacts = release.artifacts.filter( + ({ role, id }) => role === 'integration' && id !== 'render_runtime' + ); + for (let index = 0; index < integrationArtifacts.length; index += 1) { + const artifact = integrationArtifacts[index]; + const registration = registrations[index + 1]; + assert.deepEqual( + Reflect.ownKeys(registration), + artifact.phase === 'takeover' + ? ['abi', 'id', 'phase', 'releaseId', 'prepareSync', 'prepare'] + : ['abi', 'id', 'phase', 'releaseId', 'prepare'] + ); + assert.equal(typeof registration.prepare, 'function'); + assert.equal( + Object.prototype.hasOwnProperty.call(registration, 'prepareSync'), + artifact.phase === 'takeover' + ); + } + assert.deepEqual( + registrations.map(({ id }) => id), + release.artifacts.filter(({ role }) => role === 'integration').map(({ id }) => id) + ); + assert.deepEqual( + registrations.map(({ phase }) => phase), + release.artifacts.filter(({ role }) => role === 'integration').map(({ phase }) => phase) + ); + } finally { + dom.window.close(); + } +}); + +test('generated takeover transport owns branded render operations without GPT duplication', () => { + const metrics = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ); + const renderRuntime = metrics.modules.find(({ file }) => file === 'tsjs-render_runtime.js'); + const gpt = metrics.modules.find(({ file }) => file === 'tsjs-gpt.js'); + + assert.ok(renderRuntime, 'render_runtime metrics must exist'); + assert.ok(gpt, 'GPT metrics must exist'); + assert.ok( + metrics.modules + .find(({ file }) => file === 'tsjs-core.js') + ?.sources.some(({ file }) => file === 'src/services/render.ts'), + 'the co-bundled takeover transport must own the branded render implementation' + ); + assert.equal( + gpt.sources.some(({ file }) => file === 'src/services/render.ts'), + false, + 'GPT must invoke branded operations through render.v1' + ); + assert.equal( + gpt.sources.some(({ file }) => file === 'src/core/render.ts'), + false, + 'GPT must obtain the core-owned iframe constructor through render.v1' + ); + assert.equal( + gpt.sources.some(({ file }) => file === 'src/adapters/messaging.ts'), + false, + 'GPT must consume the core-owned messaging boundary without recompiling it' + ); + assert.equal( + metrics.modules + .find(({ file }) => file === 'tsjs-core.js') + ?.sources.some(({ file }) => file === 'src/core/puc_shell.ts'), + false, + 'the GPT-owned PUC shell helper must not inflate the always-on core transport' + ); + assert.equal( + gpt.sources.some(({ file }) => file === 'src/core/puc_shell.ts'), + true, + 'the sole PUC owner must carry its guarded collapsed-shell resize helper' + ); + for (const source of [ + 'src/core/contracts/auction_projection.ts', + 'src/core/contracts/generated/renderer_validator_v1.ts', + 'src/core/contracts/aps_renderer.ts', + 'src/core/config.ts', + 'src/services/projections.ts', + 'src/kernel/identity.ts', + ]) { + assert.equal( + gpt.sources.some(({ file }) => file === source), + false, + `GPT must consume core-owned ${source} behavior through capabilities` + ); + } +}); + +test('generated bootstrap uses only the compact sealed transport parser', () => { + const metrics = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ); + const sources = new Set(metrics.bootstrap.sources.map(({ file }) => file)); + + assert.equal(sources.has('src/core/contracts/server_boot_transport.ts'), true); + for (const forbidden of [ + 'src/core/contracts/boot.ts', + 'src/core/contracts/auction_projection.ts', + 'src/core/contracts/integration_configs.ts', + ]) { + assert.equal(sources.has(forbidden), false, `bootstrap must not reach ${forbidden}`); + } +}); + +test('persistent core consumes generated capacity without bundling the build catalog', () => { + const metrics = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ); + const core = metrics.modules.find(({ file }) => file === 'tsjs-core.js'); + const sources = new Set(core.sources.map(({ file }) => file)); + const buildSource = fs.readFileSync(path.resolve(libDirectory, 'build-all.mjs'), 'utf8'); + const registrySource = fs.readFileSync( + path.resolve(libDirectory, 'src/kernel/integration_registry.ts'), + 'utf8' + ); + + assert.match( + buildSource, + /__TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__:\s*JSON\.stringify\(releaseCatalog\.length\)/u + ); + assert.match( + registrySource, + /import \{ EMBEDDED_MAX_MANIFEST_MODULES \} from '\.\/contracts\/release_capacity';/u + ); + assert.match(registrySource, /const MAX_INTEGRATIONS = EMBEDDED_MAX_MANIFEST_MODULES;/u); + assert.doesNotMatch(registrySource, /const MAX_INTEGRATIONS = 20;/u); + // The generated scalar is substituted before bundling, so its declaration is + // intentionally tree-shaken along with the build-only catalog. + assert.equal(sources.has('src/kernel/contracts/release_capacity.ts'), false); + assert.equal(sources.has('src/kernel/release_catalog.ts'), false); +}); + +test('messaging protocol binding is declared before schema initialization', () => { + const source = fs.readFileSync(path.resolve(libDirectory, 'src/adapters/messaging.ts'), 'utf8'); + const binding = + "import { TSJS_MESSAGE_PROTOCOL_V1 } from '../kernel/contracts/message_protocol';"; + const initialization = 'export const PROTOCOL_MESSAGE_SCHEMAS_V1'; + + assert.ok(source.indexOf(binding) >= 0, 'the messaging protocol must have a local binding'); + assert.ok( + source.indexOf(binding) < source.indexOf(initialization), + 'the messaging protocol binding must precede schema initialization' + ); +}); + +test('generated APS bootstrap configuration preserves its public wire keys', () => { + const source = fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-aps_initial.js'), 'utf8'); + + assert.match( + source, + /TS APS Bootstrap Configure",version:2,bootstrapNonce:[^,}]+,["']?rendererNonce["']?:/u, + 'the independently minified APS slice must serialize rendererNonce with its authored name' + ); +}); + +test('co-bundled render_runtime and independent GPT start one branded display flow', async () => { + const release = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') + ); + const dom = new JSDOM( + '
', + { + runScripts: 'outside-only', + url: 'https://publisher.example/article', + } + ); + const registrations = []; + const preparationDisposers = []; + const activationDisposers = []; + const displayCalls = []; + try { + const targeting = new Map(); + const definedSlots = []; + const pubads = { + addEventListener: () => undefined, + disableInitialLoad: () => undefined, + getSlots: () => definedSlots, + refresh: () => undefined, + removeEventListener: () => undefined, + }; + dom.window.googletag = { + apiReady: true, + pubadsReady: true, + cmd: { push: (command) => (command(), 1) }, + defineSlot: (adUnitPath, _sizes, elementId) => { + const slot = { + addService: () => slot, + clearTargeting: (key) => { + if (key === undefined) targeting.clear(); + else targeting.delete(key); + return slot; + }, + getAdUnitPath: () => adUnitPath, + getSlotElementId: () => elementId, + getTargeting: (key) => targeting.get(key) ?? [], + setTargeting: (key, value) => { + targeting.set(key, typeof value === 'string' ? [value] : [...value]); + return slot; + }, + }; + definedSlots.push(slot); + return slot; + }, + destroySlots: () => true, + display: (elementId) => displayCalls.push(elementId), + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => pubads, + setConfig: () => undefined, + }; + const takeoverBody = fs.readFileSync( + path.resolve(libDirectory, '../dist/tsjs-core.js'), + 'utf8' + ); + const runtimeHash = createHash('sha256').update(takeoverBody).digest('hex'); + const boot = dom.window.eval(`(() => { + const freeze = Object.freeze; + const placement = freeze({ + slot: 'slot-one', + gamUnitPath: '/123/slot-one', + divId: 'slot-one', + formats: freeze([freeze([300, 250])]), + targeting: freeze({}) + }); + const bid = freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: 'slot-one', + provider: 'trusted', + upstreamBidId: 'upstream-one', + cpm: 2, + currency: 'USD', + targeting: freeze({ hb_bidder: 'trusted' }), + rendererReservationId: 'r1_aaaaaaaaaaaaaaaaaaaaaa', + renderSource: freeze({ + type: 'adm', + version: 1, + adm: '
trusted
', + width: 300, + height: 250 + }) + }); + return freeze({ + abi: 1, + releaseId: '${release.releaseId}', + manifest: freeze({ + version: 1, + releaseId: '${release.releaseId}', + firstDisplay: null, + runtimeSrc: '/static/tsjs=tsjs-unified.min.js?v=${runtimeHash}', + integrations: freeze([ + freeze({ id: 'render_runtime', phase: 'takeover' }), + freeze({ id: 'gpt', phase: 'takeover' }) + ]) + }), + auctionProjection: freeze({ + version: 1, + auction: freeze({ + version: 1, + auctionId: 'generated-cross-bundle', + results: freeze([freeze({ + slot: 'slot-one', + outcome: 'winner', + candidateId: 'AAAAAAAAAAAA' + })]) + }), + slots: freeze([placement]), + bids: freeze([bid]) + }), + integrations: freeze({ + version: 1, + entries: freeze([freeze({ + id: 'gpt', + config: freeze({ gamAttributionEnabled: false, pageBidsEnabled: false }) + })]) + }), + creative: freeze({ + version: 1, + enabled: false, + clickGuard: false, + renderGuard: false + }), + diagnostics: freeze({ + version: 1, + renderTraceOverlay: false, + gpt: freeze({ active: false }) + }) + }); + })()`); + const runtimeScript = dom.window.document.createElement('script'); + runtimeScript.id = 'trustedserver-js'; + runtimeScript.src = `/static/tsjs=tsjs-unified.min.js?v=${runtimeHash}`; + dom.window.document.head.append(runtimeScript); + const integrity = dom.window.Object.create(dom.window.Object.prototype); + dom.window.Object.assign(integrity, { + version: 1, + projectionDigest: createHash('sha256') + .update(JSON.stringify(boot.auctionProjection)) + .digest('hex'), + integrationConfigDigest: createHash('sha256') + .update(JSON.stringify(boot.integrations)) + .digest('hex'), + }); + dom.window.Object.freeze(integrity); + const target = dom.window.Object.create(dom.window.Object.prototype); + dom.window.Object.assign(target, { boot, que: dom.window.Array() }); + const currentScript = () => runtimeScript; + const claimedRuntime = dom.window.Object.create(dom.window.Object.prototype); + dom.window.Object.assign(claimedRuntime, { + boot, + integrity, + complete: () => undefined, + currentScript, + source: runtimeScript, + target, + mode: 'direct', + bind: (cancel) => (typeof cancel === 'function' ? () => undefined : undefined), + }); + dom.window.Object.freeze(claimedRuntime); + let claimLive = true; + Object.defineProperty(runtimeScript, '_claimRuntimeV1', { + configurable: false, + enumerable: false, + value: (source) => { + if (!claimLive || source !== runtimeScript) return undefined; + claimLive = false; + return claimedRuntime; + }, + writable: false, + }); + Object.defineProperty(dom.window.Document.prototype, 'currentScript', { + configurable: true, + get: currentScript, + }); + let runtimeReadLive = true; + Object.defineProperty(dom.window, 'tsjs', { + configurable: false, + enumerable: true, + get: () => { + if (!runtimeReadLive) return target; + runtimeReadLive = false; + return runtimeScript; + }, + }); + let publisherMicrotaskRan = false; + dom.window.queueMicrotask(() => { + publisherMicrotaskRan = true; + }); + dom.window.eval(takeoverBody); + executeGeneratedArtifact(dom.window, 'tsjs-gpt.js', registrations, { preserveTarget: true }); + assert.equal( + dom.window.tsjs?._internal?.state, + 'kernel', + `takeover transport should commit: ${JSON.stringify(dom.window.tsjs?._internal)}` + ); + assert.equal( + publisherMicrotaskRan, + false, + 'no-agent preparation, activation, and commit must not yield to publisher microtasks' + ); + await new Promise((resolve) => queueMicrotask(resolve)); + assert.equal(publisherMicrotaskRan, true); + for (let index = 0; index < 10 && displayCalls.length === 0; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + assert.equal(displayCalls.length, 1); + assert.equal(displayCalls[0], definedSlots[0]); + } finally { + activationDisposers.reverse().forEach((release) => release()); + preparationDisposers.reverse().forEach((release) => release()); + dom.window.close(); + } +}); + +for (const fixture of TAKEOVER_CONSENT_ARTIFACTS) { + test(`generated ${fixture.id} artifact activates through runtime.v1 and publishes ${fixture.capability}`, () => { + const dom = new JSDOM('', { + runScripts: 'outside-only', + url: 'https://publisher.example/article', + }); + const registrations = []; + const preparationDisposers = []; + const activationDisposers = []; + const afterCommit = []; + try { + executeGeneratedArtifact(dom.window, `tsjs-${fixture.id}.js`, registrations); + assert.equal(registrations.length, 1); + const registration = registrations[0]; + const runtime = dom.window.Object.freeze({ + registerAuctionContext: () => () => undefined, + }); + const config = + fixture.id === 'sourcepoint_consent' + ? dom.window.eval('Object.freeze({ rewriteSdk: false })') + : dom.window.eval('Object.freeze({})'); + const prepared = registration.prepare( + dom.window.Object.freeze({ + config, + interfaces: dom.window.Object.freeze({ 'runtime.v1': runtime }), + onDispose: (callback) => preparationDisposers.push(callback), + signal: new dom.window.AbortController().signal, + }) + ); + assert.deepEqual(Reflect.ownKeys(prepared.interfaces), [fixture.capability]); + assert.equal(Object.isFrozen(prepared.interfaces[fixture.capability]), true); + prepared.activate( + dom.window.Object.freeze({ + afterCommit: (callback) => afterCommit.push(callback), + onDispose: (callback) => activationDisposers.push(callback), + signal: new dom.window.AbortController().signal, + }) + ); + assert.ok( + activationDisposers.length > 0 || afterCommit.length > 0, + 'real takeover activation must acquire or schedule owned behavior' + ); + } finally { + activationDisposers.reverse().forEach((release) => release()); + preparationDisposers.reverse().forEach((release) => release()); + dom.window.close(); + } + }); +} + +test('bundle metrics use the required five-module reference vector', () => { + const metrics = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ); + const catalog = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-catalog-v1.json'), 'utf8') + ); + const release = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') + ); + + const idsByFile = new Map(release.artifacts.map(({ id, file }) => [file, id])); + const actualIds = Object.fromEntries( + Object.entries(metrics.sets).map(([name, set]) => [ + name, + set.files.map((file) => idsByFile.get(file)), + ]) + ); + + assert.deepEqual(actualIds, bundleMetrics.deriveSemanticBundleSetIds(catalog.modules)); + assert.equal(metrics.bootstrap.file, 'tsjs-bootstrap.js'); + assert.equal( + metrics.compression.concatenationSeparator, + bundleMetrics.BUNDLE_SEPARATOR.toString('utf8') + ); + for (const size of ['rawBytes', 'gzipBytes', 'brotliBytes']) { + assert.ok(Number.isSafeInteger(metrics.bootstrap[size]) && metrics.bootstrap[size] > 0); + } + + assert.deepEqual(metrics.sets.reference.files, [ + 'tsjs-core.js', + 'tsjs-render_runtime.js', + 'tsjs-creative.js', + 'tsjs-gpt.js', + 'tsjs-prebid.js', + 'tsjs-datadome.js', + ]); +}); + +test('bundle metrics enumerate and hash every reachable first-display mask', () => { + const { metrics } = readBuildEvidence(); + const masks = metrics.firstDisplay.masks; + + assert.ok(Array.isArray(masks)); + assert.equal(masks.length, 3_584); + assert.equal(new Set(masks.map(({ mask }) => mask)).size, masks.length); + assert.equal(new Set(masks.map(({ sha256 }) => sha256)).size, masks.length); + for (const measurement of masks) { + assert.match(measurement.mask, /^[0-9a-f]{4}$/u); + assert.equal(measurement.ids[0], 'first_display'); + if (!measurement.ids.includes('gpt_initial')) { + assert.equal(measurement.ids.includes('render_owner_initial'), false); + assert.equal(measurement.ids.includes('aps_initial'), false); + assert.equal(measurement.ids.includes('prebid_initial'), false); + } + if (measurement.ids.includes('aps_initial')) { + assert.equal(measurement.ids.includes('render_owner_initial'), true); + } + if (measurement.ids.includes('render_owner_initial')) { + assert.equal(measurement.ids.includes('gpt_initial'), true); + } + assert.deepEqual( + measurement.files, + measurement.ids.slice(1).map((id) => `tsjs-${id}.js`) + ); + for (const size of ['rawBytes', 'gzipBytes', 'brotliBytes']) { + assert.ok( + Number.isSafeInteger(measurement[size]) && + (size === 'rawBytes' ? measurement[size] >= 0 : measurement[size] > 0) + ); + } + assert.equal(typeof measurement.permitted, 'boolean'); + assert.match(measurement.sha256, /^[0-9a-f]{64}$/u); + } +}); + +test('candidate architecture obeys every independent absolute transfer ceiling', () => { + const { metrics, release, catalog } = readBuildEvidence(); + const currentArtifactContents = new Map( + release.artifacts.map(({ file }) => [ + file, + fs.readFileSync(path.resolve(libDirectory, '../dist', file)), + ]) + ); + const report = bundleBudgets.buildCandidateArchitectureSizeReport({ + metrics, + release, + catalog, + currentArtifactContents, + }); + + assert.doesNotThrow(() => bundleBudgets.enforceCandidateArchitectureSizeCeilings(report)); + assert.equal(report.firstDisplay.masks.length, 3_584); + assert.ok(report.firstDisplay.permittedMasks.length > 0); + assert.ok(report.firstDisplay.permittedMasks.length < report.firstDisplay.masks.length); + for (const name of ['minimal', 'reference', 'aps']) { + assert.equal(report.firstDisplay.named[name].permitted, true); + } + assert.deepEqual(report.firstDisplay.named.reference.ids, [ + 'first_display', + 'creative_initial', + 'datadome_initial', + 'gpt_initial', + 'prebid_initial', + ]); + assert.deepEqual(report.firstDisplay.named.aps.ids, [ + 'first_display', + 'render_owner_initial', + 'aps_initial', + 'creative_initial', + 'gpt_initial', + ]); + assert.deepEqual(Object.keys(report.firstDisplay.named), [ + 'minimal', + 'reference', + 'aps', + 'largestRaw', + 'largestGzip', + 'largestBrotli', + ]); + assert.deepEqual(Object.keys(report.ceilings), [ + 'bootstrap', + 'firstDisplayAgent', + 'referencePersistent', + 'maximalTotal', + ]); + const maximalNonBootstrap = bundleMetrics.measureBundleSet( + release.artifacts.filter(({ role }) => role !== 'bootstrap').map(({ file }) => file), + currentArtifactContents + ); + assert.deepEqual(report.maximalTotal, { + rawBytes: maximalNonBootstrap.rawBytes, + gzipBytes: maximalNonBootstrap.gzipBytes, + brotliBytes: maximalNonBootstrap.brotliBytes, + }); +}); + +test('absolute transfer ceilings reject independent one-byte regressions', () => { + const ceilings = bundleBudgets.CANDIDATE_ARCHITECTURE_SIZE_CEILINGS; + for (const semanticSet of Object.keys(ceilings)) { + for (const size of ['rawBytes', 'gzipBytes', 'brotliBytes']) { + const report = Object.fromEntries( + Object.entries(ceilings).map(([name, limits]) => [name, { ...limits }]) + ); + report[semanticSet][size] += 1; + assert.throws( + () => bundleBudgets.enforceCandidateArchitectureSizeCeilings(report), + new RegExp(`${semanticSet}\\.${size} exceeds`) + ); + } + } +}); + +test('generated mask allowlist must exactly match size-admitted reachable masks', () => { + const { metrics, release, catalog } = readBuildEvidence(); + const currentArtifactContents = new Map( + release.artifacts.map(({ file }) => [ + file, + fs.readFileSync(path.resolve(libDirectory, '../dist', file)), + ]) + ); + catalog.permittedFirstDisplayMasks.pop(); + + assert.throws( + () => + bundleBudgets.buildCandidateArchitectureSizeReport({ + metrics, + release, + catalog, + currentArtifactContents, + }), + /generated permitted first-display masks/ + ); +}); + +test('bundle metrics has sole ownership of semantic transfer-set derivation', () => { + const comparatorSource = fs.readFileSync( + path.join(libDirectory, 'scripts/check-bundle-budgets.mjs'), + 'utf8' + ); + + assert.equal(typeof bundleMetrics.deriveSemanticBundleSetIds, 'function'); + assert.match( + comparatorSource, + /import\s*\{[^}]*deriveSemanticBundleSetIds[^}]*\}\s*from '\.\/bundle-metrics\.mjs'/s + ); + assert.doesNotMatch(comparatorSource, /const REFERENCE_INCLUDE_ORDER|function isCatalogModule/); + assert.doesNotMatch(comparatorSource, /function deriveSemanticBundleSetIds\s*\(/); +}); + +test('role-correct budgets use deterministic pure aggregation and compression metrics', () => { + const metrics = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ); + const catalog = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-catalog-v1.json'), 'utf8') + ); + const release = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') + ); + const contents = new Map( + release.artifacts.map(({ file }) => [ + file, + fs.readFileSync(path.resolve(libDirectory, '../dist', file)), + ]) + ); + + assert.equal(typeof bundleMetrics.deriveInventorySetFiles, 'function'); + assert.equal(typeof bundleMetrics.measureBundleSet, 'function'); + assert.equal(typeof bundleMetrics.measureBytes, 'function'); + assert.deepEqual( + bundleMetrics.deriveInventorySetFiles(release.artifacts, catalog.modules), + Object.fromEntries(Object.entries(metrics.sets).map(([name, set]) => [name, set.files])) + ); + for (const [name, files] of Object.entries( + bundleMetrics.deriveInventorySetFiles(release.artifacts, catalog.modules) + )) { + assert.deepEqual(bundleMetrics.measureBundleSet(files, contents), metrics.sets[name]); + } + assert.deepEqual( + bundleMetrics.measureBytes(contents.get('tsjs-bootstrap.js')), + Object.fromEntries( + ['rawBytes', 'gzipBytes', 'brotliBytes', 'sha256'].map((key) => [key, metrics.bootstrap[key]]) + ) + ); +}); + +test('release build has one bundle aggregation and compression measurement owner', () => { + const buildSource = fs.readFileSync(path.join(libDirectory, 'build-all.mjs'), 'utf8'); + + assert.match( + buildSource, + /import\s*\{[^}]*deriveInventorySetFiles[^}]*measureBundleSet[^}]*measureBytes[^}]*\}\s*from '\.\/scripts\/bundle-metrics\.mjs'/s + ); + assert.match(buildSource, /deriveInventorySetFiles\(artifactInventory, releaseCatalog\)/); + assert.match(buildSource, /measureBundleSet\(/); + assert.match(buildSource, /measureBytes\(bootstrapBytes\)/); + assert.doesNotMatch(buildSource, /node:zlib|const separator\s*=|function compress\s*\(/); + assert.doesNotMatch(buildSource, /function measureBundleSet\s*\(/); + assert.doesNotMatch(buildSource, /MINIMAL_TAKEOVER_IDS|REFERENCE_TAKEOVER_IDS/); +}); + +test('reduced remediation capture appends provenance without changing earlier evidence', () => { + const baseline = JSON.parse( + fs.readFileSync( + path.join(libDirectory, 'test/fixtures/performance/aps-tsjs-prechange.json'), + 'utf8' + ) + ); + const original = Object.fromEntries( + Object.entries(baseline).filter( + ([key]) => key !== 'roleCorrectTransfer' && key !== 'reviewRemediationTransfer' + ) + ); + + assert.ok(baseline.roleCorrectTransfer, 'role-correct capture must be appended'); + assert.deepEqual(baseline.roleCorrectTransfer.source, { + ref: 'spec/aps-tsjs-resilience-design', + sha: '4e2c307923b838716d95e2feeebb994a37bb8025', + }); + assert.equal( + baseline.roleCorrectTransfer.originalTopLevelSha256, + createHash('sha256').update(canonicalJson(original)).digest('hex') + ); + assert.equal( + baseline.roleCorrectTransfer.originalTopLevelSha256, + '53f762603ad49239f1756171440be422e190cc231efafc56cf37a11e1a38ddf4' + ); + assert.equal( + baseline.roleCorrectTransfer.compression.concatenationSeparator, + bundleMetrics.BUNDLE_SEPARATOR.toString('utf8') + ); + assert.ok( + baseline.reviewRemediationTransfer, + 'review remediation capture must be appended after the immutable intermediate capture' + ); + assert.deepEqual(baseline.reviewRemediationTransfer.source, { + ref: 'spec/aps-tsjs-resilience-design', + sha: '91b3533ae7c07e03fa77441e0d94f27e31965d9e', + }); + assert.equal( + baseline.reviewRemediationTransfer.originalTopLevelSha256, + baseline.roleCorrectTransfer.originalTopLevelSha256 + ); + assert.equal( + baseline.reviewRemediationTransfer.roleCorrectTransferSha256, + bundleBudgets.canonicalJsonSha256(baseline.roleCorrectTransfer) + ); + assert.ok(baseline.reviewRemediationTransfer.sets.minimal.rawBytes <= 220_000); + assert.ok(baseline.reviewRemediationTransfer.sets.minimal.gzipBytes <= 59_000); + assert.ok( + baseline.reviewRemediationTransfer.sets.minimal.brotliBytes < + baseline.roleCorrectTransfer.sets.minimal.brotliBytes + ); + for (const size of ['rawBytes', 'gzipBytes', 'brotliBytes']) { + assert.ok( + baseline.reviewRemediationTransfer.sets.reference[size] < + baseline.roleCorrectTransfer.sets.reference[size] + ); + assert.ok( + baseline.reviewRemediationTransfer.sets.maximal[size] <= + baseline.roleCorrectTransfer.sets.maximal[size] + ); + } +}); + +test('bundle budget membership rejects every noncanonical release inventory shape', () => { + const metrics = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ); + const catalog = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-catalog-v1.json'), 'utf8') + ); + const release = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') + ); + const rejectReleaseMutation = (mutate, pattern) => { + const candidate = structuredClone(release); + mutate(candidate.artifacts); + assert.throws(() => validateSemanticBundleSets(metrics, candidate, catalog), pattern); + }; + + assert.doesNotThrow(() => validateSemanticBundleSets(metrics, release, catalog)); + rejectReleaseMutation((artifacts) => { + artifacts[0].file = 'gpt-bootstrap-fallback.js'; + }, /bootstrap\/bootstrap\/tsjs-bootstrap\.js/); + rejectReleaseMutation((artifacts) => { + artifacts.find(({ id }) => id === 'core').id = 'runtime_core'; + }, /core\/core\/tsjs-core\.js/); + rejectReleaseMutation((artifacts) => { + artifacts.find(({ id }) => id === 'render_runtime').role = 'core'; + }, /integration\/render_runtime\/tsjs-render_runtime\.js/); + rejectReleaseMutation((artifacts) => { + artifacts[artifacts.length - 1] = structuredClone(artifacts.at(-2)); + }, /invalid or duplicate artifact/); + rejectReleaseMutation((artifacts) => { + artifacts[artifacts.length - 1] = { + ...artifacts.at(-1), + id: 'unknown', + file: 'tsjs-unknown.js', + }; + }, /sourcepoint_lifecycle/); + rejectReleaseMutation((artifacts) => artifacts.pop(), /exact catalog artifact count/); + + const multiplyCounted = structuredClone(metrics); + multiplyCounted.sets.minimal.files.push(multiplyCounted.sets.minimal.files[0]); + assert.throws( + () => validateSemanticBundleSets(multiplyCounted, release, catalog), + /contains a duplicate/ + ); + + const omittedMaximalModule = structuredClone(metrics); + omittedMaximalModule.sets.maximal.files.pop(); + assert.throws( + () => validateSemanticBundleSets(omittedMaximalModule, release, catalog), + /buildMetrics\.sets\.maximal has semantic ids/ + ); +}); + +test('takeover bundle graphs exclude deferred entries and transitive presentation sources', () => { + const { metrics, release } = readBuildEvidence(); + const cleanMetrics = structuredClone(metrics); + assert.deepEqual(findTakeoverDeferredSourceViolations(cleanMetrics, release), []); + + const reachesDeferredEntry = structuredClone(cleanMetrics); + reachesDeferredEntry.modules + .find(({ file }) => file === 'tsjs-core.js') + .sources.push({ + file: 'src/integrations/gpt/later.ts', + renderedBytes: 1, + }); + assert.deepEqual(findTakeoverDeferredSourceViolations(reachesDeferredEntry, release), [ + 'core reaches deferred-owned source src/integrations/gpt/later.ts', + ]); + + const reachesPresentationHelper = structuredClone(cleanMetrics); + reachesPresentationHelper.modules + .find(({ file }) => file === 'tsjs-core.js') + .sources.push({ + file: 'src/integrations/gpt_diagnostics/overlay.ts', + renderedBytes: 1, + }); + assert.deepEqual(findTakeoverDeferredSourceViolations(reachesPresentationHelper, release), [ + 'core reaches deferred-owned source src/integrations/gpt_diagnostics/overlay.ts', + ]); + + const reachesRenderTracePresentation = structuredClone(cleanMetrics); + reachesRenderTracePresentation.modules + .find(({ file }) => file === 'tsjs-core.js') + .sources.push({ + file: 'src/integrations/gpt_diagnostics/presentation.ts', + renderedBytes: 1, + }); + assert.deepEqual(findTakeoverDeferredSourceViolations(reachesRenderTracePresentation, release), [ + 'core reaches deferred-owned source src/integrations/gpt_diagnostics/presentation.ts', + ]); +}); + +test('permanent comparator pins every historical and role-correct evidence subtree', () => { + const evidence = readBuildEvidence(); + assert.equal(typeof bundleBudgets.validateRoleCorrectTransfer, 'function'); + assert.doesNotThrow(() => bundleBudgets.validateRoleCorrectTransfer(evidence)); + + const originalMutations = { + schemaVersion: (candidate) => (candidate.schemaVersion = 2), + mode: (candidate) => (candidate.mode = 'changed'), + source: (candidate) => (candidate.source.sha = 'a'.repeat(40)), + environment: (candidate) => (candidate.environment.node = 'changed'), + sampling: (candidate) => (candidate.sampling.warmups += 1), + bundles: (candidate) => (candidate.bundles.minimal.rawBytes += 1), + performance: (candidate) => (candidate.performance.bootToFirstDisplayMs.samples[0] += 1), + evidence: (candidate) => (candidate.evidence.workflowRunId += 1), + }; + for (const [subtree, mutate] of Object.entries(originalMutations)) { + const candidate = structuredClone(evidence); + mutate(candidate.baseline); + assert.throws( + () => bundleBudgets.validateRoleCorrectTransfer(candidate), + /historical evidence digest/, + `${subtree} mutation must fail` + ); + } + + const captureMutations = { + schemaVersion: (candidate) => (candidate.schemaVersion = 2), + source: (candidate) => (candidate.source.sha = 'a'.repeat(40)), + originalTopLevelSha256: (candidate) => (candidate.originalTopLevelSha256 = 'a'.repeat(64)), + tools: (candidate) => (candidate.tools.node = 'changed'), + compression: (candidate) => (candidate.compression.gzip.level = 8), + release: (candidate) => (candidate.release.artifacts[0].bytes += 1), + sourceOwners: (candidate) => candidate.sourceOwners['src/kernel/runtime.ts'].push('gpt'), + sets: (candidate) => candidate.sets.maximal.artifactIds.pop(), + }; + for (const [subtree, mutate] of Object.entries(captureMutations)) { + const candidate = structuredClone(evidence); + mutate(candidate.baseline.roleCorrectTransfer); + assert.throws( + () => bundleBudgets.validateRoleCorrectTransfer(candidate), + /role-correct capture digest/, + `${subtree} mutation must fail` + ); + } + + const remediationMutations = { + source: (candidate) => (candidate.source.sha = 'b'.repeat(40)), + roleCorrectTransferSha256: (candidate) => + (candidate.roleCorrectTransferSha256 = 'b'.repeat(64)), + release: (candidate) => (candidate.release.artifacts[1].bytes += 1), + sourceOwners: (candidate) => candidate.sourceOwners['src/core/index.ts'].push('gpt'), + logicalProviderSources: (candidate) => candidate.logicalProviderSources.render_runtime.pop(), + physicalMarkerOwners: (candidate) => (candidate.physicalMarkerOwners.render_runtime = 'gpt'), + graphReport: (candidate) => (candidate.graphReport.largestContributions[0].renderedBytes += 1), + sets: (candidate) => (candidate.sets.minimal.gzipBytes += 1), + }; + for (const [subtree, mutate] of Object.entries(remediationMutations)) { + const candidate = structuredClone(evidence); + mutate(candidate.baseline.reviewRemediationTransfer); + assert.throws( + () => bundleBudgets.validateRoleCorrectTransfer(candidate), + /review-remediation capture digest/, + `${subtree} remediation mutation must fail` + ); + } +}); + +test('bundle check authenticates historical capture provenance for descendant builds', () => { + const evidence = readBuildEvidence(); + const intermediate = evidence.baseline.roleCorrectTransfer; + const capture = evidence.baseline.reviewRemediationTransfer; + + assert.doesNotThrow(() => bundleBudgets.validateFrozenCaptureProvenance(intermediate, capture)); + assert.doesNotThrow(() => bundleBudgets.validateCaptureSourceProvenance(capture)); + assert.doesNotThrow(() => + bundleBudgets.validateRoleCorrectTransfer({ ...evidence, verifyGitProvenance: true }) + ); +}); + +test('capture provenance rejects mismatched recorded lock and tool metadata', () => { + const baseline = readBuildEvidence().baseline; + const mutations = { + packageLockSha256: (candidate) => (candidate.tools.packageLockSha256 = '0'.repeat(64)), + node: (candidate) => (candidate.tools.node = 'v0.0.0'), + npm: (candidate) => (candidate.tools.npm = '0.0.0'), + typescript: (candidate) => (candidate.tools.typescript = '0.0.0'), + vite: (candidate) => (candidate.tools.vite = '0.0.0'), + esbuild: (candidate) => (candidate.tools.esbuild = '0.0.0'), + }; + + for (const captureName of ['roleCorrectTransfer', 'reviewRemediationTransfer']) { + for (const [field, mutate] of Object.entries(mutations)) { + const intermediate = structuredClone(baseline.roleCorrectTransfer); + const capture = structuredClone(baseline.reviewRemediationTransfer); + mutate(captureName === 'roleCorrectTransfer' ? intermediate : capture); + assert.throws( + () => bundleBudgets.validateFrozenCaptureProvenance(intermediate, capture), + new RegExp(field, 'i'), + `${captureName}.${field}` + ); + } + } +}); + +test('capture provenance rejects an invalid source or missing captured build input', () => { + const baseline = readBuildEvidence().baseline; + const capture = baseline.reviewRemediationTransfer; + const invalidSource = structuredClone(capture); + invalidSource.source.sha = '0'.repeat(40); + + assert.throws( + () => bundleBudgets.validateCaptureSourceProvenance(invalidSource), + /capture source SHA/ + ); + const invalidIntermediate = structuredClone(baseline.roleCorrectTransfer); + invalidIntermediate.source.sha = '0'.repeat(40); + assert.throws( + () => bundleBudgets.validateFrozenCaptureProvenance(invalidIntermediate, capture), + /capture source SHA/ + ); + assert.doesNotThrow(() => bundleBudgets.validateCaptureSourceProvenance(capture)); + assert.throws( + () => + bundleBudgets.validateCaptureSourceProvenance(capture, { + buildInputs: ['crates/trusted-server-js/lib/does-not-exist'], + }), + /captured build input does not exist/ + ); +}); + +test('authenticated frozen captures report descendant release drift without rejecting it', () => { + const descendant = buildStructurallyValidDescendant(); + + const result = bundleBudgets.validateRoleCorrectTransfer(descendant); + + assert.notEqual( + descendant.release.releaseId, + descendant.baseline.reviewRemediationTransfer.release.releaseId + ); + assert.deepEqual(Object.keys(result.captureReports), [ + 'roleCorrectTransfer', + 'reviewRemediationTransfer', + ]); + for (const report of Object.values(result.captureReports)) { + assert.equal( + report.minimal.rawBytes.deltaBytes, + report.minimal.rawBytes.currentBytes - report.minimal.rawBytes.capturedBytes + ); + assert.equal(Object.hasOwn(report.minimal.rawBytes, 'ceilingBytes'), false); + } + + const coreIndex = descendant.release.artifacts + .filter(({ role }) => role !== 'bootstrap') + .findIndex(({ id }) => id === 'core'); + descendant.metrics.modules[coreIndex].sources.push({ + file: 'src/test/descendant_fake.ts', + renderedBytes: 1, + }); + assert.throws( + () => bundleBudgets.validateRoleCorrectTransfer(descendant), + /production test\/fake\/no-op seam/ + ); + descendant.metrics.modules[coreIndex].sources.pop(); + descendant.release.artifacts.pop(); + assert.throws( + () => bundleBudgets.validateRoleCorrectTransfer(descendant), + /live catalog artifact count|exact catalog artifact count/ + ); +}); + +test('valid live catalog and release drift is independent from both frozen captures', () => { + const authoredCatalog = bundleBudgets.loadAuthoredReleaseCatalog(); + const descendant = buildStructurallyValidDescendant((evidence) => { + const catalogEntry = evidence.catalog.modules.find(({ id }) => id === 'testlight'); + const artifact = evidence.release.artifacts.find(({ id }) => id === 'testlight'); + const authoredEntry = authoredCatalog.find(({ id }) => id === 'testlight'); + catalogEntry.phase = 'deferred'; + catalogEntry.trigger = 'first_display_or_idle'; + artifact.phase = 'deferred'; + artifact.trigger = 'first_display_or_idle'; + authoredEntry.phase = 'deferred'; + authoredEntry.trigger = 'first_display_or_idle'; + }); + + assert.throws( + () => bundleBudgets.validateRoleCorrectTransfer(descendant), + /generated catalog entry .* differs from current authored catalog/ + ); + assert.doesNotThrow(() => + bundleBudgets.validateRoleCorrectTransfer({ ...descendant, authoredCatalog }) + ); +}); + +test('current release validation is capture-independent while generated bytes stay authoritative', () => { + const evidence = readBuildEvidence(); + const contents = new Map( + evidence.release.artifacts.map(({ file }) => [ + file, + fs.readFileSync(path.resolve(libDirectory, '../dist', file)), + ]) + ); + assert.doesNotThrow(() => + bundleBudgets.validateRoleCorrectTransfer({ + ...evidence, + currentArtifactContents: contents, + }) + ); + const changed = structuredClone(evidence); + changed.release.artifacts[0].hash = 'a'.repeat(64); + assert.throws( + () => + bundleBudgets.validateRoleCorrectTransfer({ + ...changed, + currentArtifactContents: contents, + }), + /current artifact bytes/ + ); + const changedBytes = new Map(contents); + changedBytes.set('tsjs-bootstrap.js', Buffer.from('changed')); + assert.throws( + () => + bundleBudgets.validateRoleCorrectTransfer({ + ...evidence, + currentArtifactContents: changedBytes, + }), + /current artifact bytes/ + ); + + const understatedMetrics = structuredClone(evidence); + understatedMetrics.metrics.sets.minimal.rawBytes -= 1; + assert.throws( + () => + bundleBudgets.validateRoleCorrectTransfer({ + ...understatedMetrics, + currentArtifactContents: contents, + }), + /build metrics do not match current artifact bytes/ + ); + + const changedMetadata = structuredClone(evidence); + changedMetadata.release.artifacts.find(({ id }) => id === 'aps').inputs = []; + assert.throws( + () => bundleBudgets.validateRoleCorrectTransfer(changedMetadata), + /must consume runtime\.v1/ + ); + + const unexpectedReleaseField = structuredClone(evidence); + unexpectedReleaseField.release.unexpected = true; + assert.throws( + () => bundleBudgets.validateRoleCorrectTransfer(unexpectedReleaseField), + /current release inventory must have exact keys/ + ); + + const unexpectedArtifactField = structuredClone(evidence); + unexpectedArtifactField.release.artifacts[0].unexpected = true; + assert.throws( + () => bundleBudgets.validateRoleCorrectTransfer(unexpectedArtifactField), + /current release artifact 0 must have exact keys/ + ); +}); + +test('current semantic graph rejects malformed, duplicate, unresolved, late, and deferred capabilities', () => { + const mutations = [ + [ + /invalid output capability/, + (candidate) => candidate.release.artifacts.find(({ id }) => id === 'aps').outputs.push('bad'), + ], + [ + /multiple providers/, + (candidate) => + candidate.release.artifacts.find(({ id }) => id === 'aps').outputs.push('runtime.v1'), + ], + [ + /unknown capability/, + (candidate) => + candidate.release.artifacts.find(({ id }) => id === 'aps').inputs.push('missing.v1'), + ], + [ + /provider must precede consumer/, + (candidate) => + candidate.release.artifacts.find(({ id }) => id === 'render_runtime').inputs.push('gpt.v1'), + ], + [ + /deferred integration cannot provide/, + (candidate) => + candidate.release.artifacts.find(({ id }) => id === 'gpt_later').outputs.push('late.v1'), + ], + [ + /bootstrap invariant/, + (candidate) => candidate.release.artifacts[0].outputs.push('bootstrap.v1'), + ], + [ + /core invariant/, + (candidate) => + candidate.release.artifacts.find(({ id }) => id === 'core').inputs.push('runtime.v1'), + ], + ]; + + for (const [pattern, mutate] of mutations) { + const candidate = readBuildEvidence(); + mutate(candidate); + assert.throws(() => bundleBudgets.validateRoleCorrectTransfer(candidate), pattern); + } +}); + +test('source ownership drift is report-only for an otherwise valid current graph', () => { + const evidence = readBuildEvidence(); + const currentArtifactContents = new Map( + evidence.release.artifacts.map(({ file }) => [ + file, + fs.readFileSync(path.resolve(libDirectory, '../dist', file)), + ]) + ); + const omittedModuleSource = structuredClone(evidence); + const creativeIndex = omittedModuleSource.release.artifacts + .filter(({ role }) => role !== 'bootstrap') + .findIndex(({ id }) => id === 'creative'); + const creativeModule = omittedModuleSource.metrics.modules[creativeIndex]; + const sourceIndex = creativeModule.sources.findIndex( + ({ file }) => file === 'src/shared/scheduler.ts' + ); + assert.notEqual(sourceIndex, -1); + assert.notEqual(creativeModule.sources[sourceIndex].file, creativeModule.entry); + creativeModule.sources.splice(sourceIndex, 1); + assert.doesNotThrow(() => + bundleBudgets.validateRoleCorrectTransfer({ + ...omittedModuleSource, + currentArtifactContents, + }) + ); +}); + +test('harmless current source reassignment does not consult captured ownership', () => { + const evidence = readBuildEvidence(); + const creative = evidence.metrics.modules.find(({ file }) => file === 'tsjs-creative.js'); + const datadome = evidence.metrics.modules.find(({ file }) => file === 'tsjs-datadome.js'); + const sourceIndex = creative.sources.findIndex(({ file }) => file === 'src/shared/async.ts'); + assert.notEqual(sourceIndex, -1); + const [asyncSource] = creative.sources.splice(sourceIndex, 1); + datadome.sources.push(asyncSource); + + assert.doesNotThrow(() => bundleBudgets.validateRoleCorrectTransfer(evidence)); +}); + +test('current source classification fails closed for renamed provider source', () => { + const { metrics, release } = readBuildEvidence(); + const gpt = metrics.modules.find(({ file }) => file === 'tsjs-gpt.js'); + const provider = gpt.sources.find(({ file }) => file === 'src/integrations/gpt/module.ts'); + provider.file = 'src/integrations/gpt/provider.ts'; + + assert.match( + bundleBudgets.findProductionGraphViolations(metrics, release).join('\n'), + /current provider source is missing.*gpt\/module\.ts|unclassified current production source.*gpt\/provider\.ts/ + ); +}); + +test('unknown shared source cannot bridge deferred and takeover artifacts', () => { + const { metrics, release } = readBuildEvidence(); + const source = { + file: 'src/shared/new_deferred_runtime.ts', + renderedBytes: 1, + }; + metrics.modules.find(({ file }) => file === 'tsjs-core.js').sources.push({ ...source }); + metrics.modules.find(({ file }) => file === 'tsjs-gpt_later.js').sources.push({ ...source }); + + assert.match( + bundleBudgets.findProductionGraphViolations(metrics, release).join('\n'), + /unclassified current production source src\/shared\/new_deferred_runtime\.ts/ + ); +}); + +test('provider implementation moved under core remains unclassified', () => { + const { metrics, release } = readBuildEvidence(); + const gpt = metrics.modules.find(({ file }) => file === 'tsjs-gpt.js'); + const provider = gpt.sources.find(({ file }) => file === 'src/integrations/gpt/module.ts'); + provider.file = 'src/core/gpt_provider.ts'; + + assert.match( + bundleBudgets.findProductionGraphViolations(metrics, release).join('\n'), + /unclassified current production source src\/core\/gpt_provider\.ts/ + ); +}); + +test('deferred implementation moved under shared remains unclassified', () => { + const { metrics, release } = readBuildEvidence(); + const prebidLater = metrics.modules.find(({ file }) => file === 'tsjs-prebid_later.js'); + const deferred = prebidLater.sources.find( + ({ file }) => file === 'src/integrations/prebid/refresh.ts' + ); + deferred.file = 'src/shared/prebid_refresh_runtime.ts'; + + assert.match( + bundleBudgets.findProductionGraphViolations(metrics, release).join('\n'), + /unclassified current production source src\/shared\/prebid_refresh_runtime\.ts/ + ); +}); + +test('new deferred-owned source cannot reach a takeover artifact', () => { + const { metrics, release } = readBuildEvidence(); + const deferredSource = { + file: 'src/integrations/gpt_diagnostics/presentation/new_panel.ts', + renderedBytes: 1, + }; + metrics.modules + .find(({ file }) => file === 'tsjs-diagnostics_presentation.js') + .sources.push({ + ...deferredSource, + }); + metrics.modules.find(({ file }) => file === 'tsjs-core.js').sources.push({ ...deferredSource }); + + assert.match( + bundleBudgets.findProductionGraphViolations(metrics, release).join('\n'), + /core reaches deferred-owned source.*new_panel\.ts/ + ); +}); + +test('current graph inventory rejects cleared bootstrap sources', () => { + const evidence = readBuildEvidence(); + const currentArtifactContents = new Map( + evidence.release.artifacts.map(({ file }) => [ + file, + fs.readFileSync(path.resolve(libDirectory, '../dist', file)), + ]) + ); + evidence.metrics.bootstrap.sources = []; + assert.throws( + () => bundleBudgets.validateRoleCorrectTransfer({ ...evidence, currentArtifactContents }), + /does not contain its entry source/ + ); +}); + +test('current graph validation has no captured ownership-policy parameter', () => { + const { metrics, release } = readBuildEvidence(); + assert.equal(bundleBudgets.findProductionGraphViolations.length, 2); + assert.deepEqual(bundleBudgets.findProductionGraphViolations(metrics, release), []); +}); + +test('source ownership graph rejects duplicate bootstrap sources', () => { + const { metrics, release } = readBuildEvidence(); + const duplicateBootstrapSource = structuredClone(metrics); + duplicateBootstrapSource.bootstrap.sources.push( + structuredClone(duplicateBootstrapSource.bootstrap.sources[0]) + ); + assert.throws( + () => bundleBudgets.findProductionGraphViolations(duplicateBootstrapSource, release), + /tsjs-bootstrap\.js\.sources\[.*\] is invalid/ + ); +}); + +test('exact release key validation accepts equivalent insertion order', () => { + const evidence = readBuildEvidence(); + evidence.release = Object.fromEntries(Object.entries(evidence.release).reverse()); + evidence.release.artifacts = evidence.release.artifacts.map((artifact) => + Object.fromEntries(Object.entries(artifact).reverse()) + ); + + assert.doesNotThrow(() => bundleBudgets.validateRoleCorrectTransfer(evidence)); +}); + +test('transfer capture reports deltas without an acceptance ceiling', () => { + assert.equal(typeof bundleBudgets.buildTransferCaptureReport, 'function'); + const captured = Object.fromEntries( + ['bootstrap', 'minimal', 'reference', 'maximal'].map((setName) => [ + setName, + { rawBytes: 10, gzipBytes: 10, brotliBytes: 10 }, + ]) + ); + const current = structuredClone(captured); + for (const set of Object.values(current)) { + set.rawBytes = 12; + set.gzipBytes = 12; + set.brotliBytes = 12; + } + const report = bundleBudgets.buildTransferCaptureReport(captured, current); + assert.deepEqual(report.reference.gzipBytes, { + capturedBytes: 10, + currentBytes: 12, + deltaBytes: 2, + }); + assert.equal(Object.hasOwn(report.reference.gzipBytes, 'ceilingBytes'), false); +}); + +test('production bundle graphs reject every current forbidden edge', () => { + const { metrics, release } = readBuildEvidence(); + assert.equal(typeof bundleBudgets.findProductionGraphViolations, 'function'); + assert.deepEqual(bundleBudgets.findProductionGraphViolations(metrics, release), []); + const rejectSource = (artifactId, file, pattern) => { + const candidate = structuredClone(metrics); + const artifactIndex = release.artifacts + .filter(({ role }) => role !== 'bootstrap') + .findIndex(({ id }) => id === artifactId); + candidate.modules[artifactIndex].sources.push({ file, renderedBytes: 1 }); + assert.match( + bundleBudgets.findProductionGraphViolations(candidate, release).join('\n'), + pattern + ); + }; + + rejectSource('gpt', 'src/integrations/render_runtime/module.ts', /inlines provider core/); + rejectSource('aps', 'src/kernel/runtime.ts', /inlines provider core/); + rejectSource('gpt', 'src/adapters/prebid.ts', /inlines provider prebid/); + rejectSource('aps', 'src/shared/dom_insertion_dispatcher.ts', /forbidden shared source/); + for (const artifactId of ['first_display', 'aps']) { + rejectSource( + artifactId, + 'src/shared/aps_documents.ts', + /unclassified current production source/ + ); + rejectSource( + artifactId, + 'src/core/contracts/generated/renderer_validator_document_v1.ts', + /unclassified current production source/ + ); + } + rejectSource('aps', 'src/test/fake_adapter.ts', /test\/fake\/no-op seam/); + const vendoredProvider = structuredClone(metrics); + vendoredProvider.modules[1].sources.push({ + file: 'node_modules/prebid.js/build/dist/prebid.js', + renderedBytes: 1, + }); + assert.throws( + () => bundleBudgets.findProductionGraphViolations(vendoredProvider, release), + /sources\[.*\] is invalid/ + ); +}); + +test('first-display render ownership is physically split at the source-neutral boundary', () => { + const { metrics, release } = readBuildEvidence(); + const sources = (id) => + new Set( + metrics.modules.find(({ file }) => file === `tsjs-${id}.js`).sources.map(({ file }) => file) + ); + const ownerSources = sources('render_owner_initial'); + const apsSources = sources('aps_initial'); + const baseSources = sources('first_display'); + + assert.equal(ownerSources.has('src/first_display/render_journal.ts'), true); + assert.equal(ownerSources.has('src/kernel/contracts/puc_dynamic_owner.ts'), true); + assert.equal(ownerSources.has('src/first_display/render_bridge.ts'), false); + assert.equal(ownerSources.has('src/first_display/leaf/aps_protocol.ts'), false); + assert.equal(apsSources.has('src/first_display/render_bridge.ts'), true); + assert.equal(apsSources.has('src/first_display/leaf/aps_protocol.ts'), true); + assert.equal(apsSources.has('src/first_display/render_journal.ts'), false); + assert.equal(apsSources.has('src/kernel/contracts/puc_dynamic_owner.ts'), false); + assert.equal(baseSources.has('src/first_display/render_journal.ts'), false); + assert.equal(baseSources.has('src/first_display/adm_render_bridge.ts'), false); + + const ownerBody = fs.readFileSync( + path.resolve(libDirectory, '../dist/tsjs-render_owner_initial.js'), + 'utf8' + ); + const apsLiterals = [...ownerBody.matchAll(/TS APS [A-Za-z ]+/gu)].map(([value]) => value); + assert.ok(apsLiterals.length > 0); + assert.deepEqual(new Set(apsLiterals), new Set(['TS APS Top Mount Started'])); + + const movedJournal = structuredClone(metrics); + movedJournal.modules + .find(({ file }) => file === 'tsjs-aps_initial.js') + .sources.push({ file: 'src/first_display/render_journal.ts', renderedBytes: 1 }); + assert.match( + bundleBudgets.findProductionGraphViolations(movedJournal, release).join('\n'), + /render_owner_initial-owned source src\/first_display\/render_journal\.ts/ + ); + + const movedAps = structuredClone(metrics); + movedAps.modules + .find(({ file }) => file === 'tsjs-render_owner_initial.js') + .sources.push({ file: 'src/first_display/render_bridge.ts', renderedBytes: 1 }); + assert.match( + bundleBudgets.findProductionGraphViolations(movedAps, release).join('\n'), + /aps_initial-owned source src\/first_display\/render_bridge\.ts/ + ); +}); + +test('production bundle graphs scan bootstrap sources for test and fake seams', () => { + const { metrics, release } = readBuildEvidence(); + metrics.bootstrap.sources.push({ file: 'src/test/fake_adapter.ts', renderedBytes: 1 }); + + assert.match( + bundleBudgets.findProductionGraphViolations(metrics, release).join('\n'), + /bootstrap reaches production test\/fake\/no-op seam src\/test\/fake_adapter\.ts/ + ); +}); + +test('production bundle graphs reject provider implementation modules, not only entries', () => { + const { metrics, release } = readBuildEvidence(); + const gptIndex = release.artifacts + .filter(({ role }) => role !== 'bootstrap') + .findIndex(({ id }) => id === 'gpt'); + metrics.modules[gptIndex].sources.push({ + file: 'src/integrations/render_runtime/module.ts', + renderedBytes: 1, + }); + + assert.match( + bundleBudgets.findProductionGraphViolations(metrics, release).join('\n'), + /gpt inlines provider core.*src\/integrations\/render_runtime\/module\.ts/ + ); +}); + +test('current provider policy rejects duplicated provider source without capture input', () => { + const { metrics, release } = readBuildEvidence(); + const providerSource = 'src/services/render.ts'; + const gptIndex = release.artifacts + .filter(({ role }) => role !== 'bootstrap') + .findIndex(({ id }) => id === 'gpt'); + metrics.modules[gptIndex].sources.push({ file: providerSource, renderedBytes: 1 }); + + assert.match( + bundleBudgets.findProductionGraphViolations(metrics, release).join('\n'), + /gpt inlines provider core.*src\/services\/render\.ts/ + ); +}); + +test('bundle graph report freezes largest contributions and repeated attributions', () => { + const { metrics, release } = readBuildEvidence(); + const report = bundleBudgets.buildProductionGraphReport(metrics, release); + + assert.equal(report.largestContributions.length, 20); + assert.equal(report.largestContributions[0].source, 'src/services/slots.ts'); + assert.ok(report.repeatedAttributions.some(({ source }) => source === 'src/core/release.ts')); +}); + +for (const [consumerId, providerId, providerSource] of [ + ['gpt_later', 'gpt', 'src/integrations/gpt/module.ts'], + ['osano_lifecycle', 'osano_consent', 'src/integrations/osano/consent.ts'], + ['prebid_later', 'prebid', 'src/integrations/prebid/module.ts'], + ['sourcepoint_lifecycle', 'sourcepoint_consent', 'src/integrations/sourcepoint/consent.ts'], + ['gpt_later', 'gpt', 'src/integrations/gpt/startup.ts'], + ['prebid_later', 'prebid', 'src/integrations/prebid/startup.ts'], + ['diagnostics_presentation', 'gpt_diagnostics', 'src/integrations/gpt_diagnostics/store.ts'], +]) { + test(`production bundle graph rejects ${consumerId} inlining ${providerId} implementation`, () => { + const { metrics, release } = readBuildEvidence(); + const artifactIndex = release.artifacts + .filter(({ role }) => role !== 'bootstrap') + .findIndex(({ id }) => id === consumerId); + metrics.modules[artifactIndex].sources.push({ file: providerSource, renderedBytes: 1 }); + + assert.match( + bundleBudgets.findProductionGraphViolations(metrics, release).join('\n'), + new RegExp( + `${consumerId} inlines provider ${providerId}.*${providerSource.replaceAll('.', '\\.')}` + ) + ); + }); +} + +test('takeover bundle graph rejects deferred-presentation-only source ownership', () => { + const { metrics, release } = readBuildEvidence(); + const coreIndex = release.artifacts + .filter(({ role }) => role !== 'bootstrap') + .findIndex(({ id }) => id === 'core'); + metrics.modules[coreIndex].sources.push({ + file: 'src/integrations/gpt_diagnostics/exhaustive.ts', + renderedBytes: 1, + }); + + assert.match( + bundleBudgets.findProductionGraphViolations(metrics, release).join('\n'), + /core reaches deferred-owned source src\/integrations\/gpt_diagnostics\/exhaustive\.ts/ + ); +}); + +test('production bundle graphs reject actual underscore-named test seams', () => { + const { metrics, release } = readBuildEvidence(); + const apsIndex = release.artifacts + .filter(({ role }) => role !== 'bootstrap') + .findIndex(({ id }) => id === 'aps'); + metrics.modules[apsIndex].sources.push({ + file: 'src/composition/browser_test.ts', + renderedBytes: 1, + }); + + assert.match( + bundleBudgets.findProductionGraphViolations(metrics, release).join('\n'), + /aps reaches production test\/fake\/no-op seam src\/composition\/browser_test\.ts/ + ); +}); + +test('bundle check authenticates frozen captures and reports both without enforcing them', () => { + const result = checkBundleBudgets(); + + assert.equal(result.roleCorrectStatus, 'immutable-intermediate'); + assert.equal(result.reviewRemediationStatus, 'immutable-report-only'); + assert.equal(result.transferCapturesEnforced, false); + assert.deepEqual(Object.keys(result.historicalDeltas), [ + 'bootstrap', + 'minimal', + 'reference', + 'maximal', + ]); + for (const report of Object.values(result.historicalDeltas)) { + for (const size of ['rawBytes', 'gzipBytes', 'brotliBytes']) { + assert.equal( + report[size].deltaBytes, + report[size].currentBytes - report[size].historicalBytes + ); + } + } + assert.deepEqual(Object.keys(result.frozenTransferReports), [ + 'roleCorrectTransfer', + 'reviewRemediationTransfer', + ]); + for (const captureReport of Object.values(result.frozenTransferReports)) { + for (const report of Object.values(captureReport)) { + for (const size of ['rawBytes', 'gzipBytes', 'brotliBytes']) { + assert.equal( + report[size].deltaBytes, + report[size].currentBytes - report[size].capturedBytes + ); + assert.equal(Object.hasOwn(report[size], 'ceilingBytes'), false); + } + } + } + + const commandReport = bundleBudgets.summarizeBundleBudgetCommandReport(result); + assert.equal(commandReport.candidateArchitecture.firstDisplay.reachableMaskCount, 3_584); + assert.equal( + commandReport.candidateArchitecture.firstDisplay.permittedMaskCount, + result.candidateArchitecture.firstDisplay.permittedMasks.length + ); + assert.equal(Object.hasOwn(commandReport.candidateArchitecture.firstDisplay, 'masks'), false); +}); + +test('takeover render trace source is data-only and guarded against presentation regression', () => { + const traceSource = fs.readFileSync(path.join(libDirectory, 'src/core/trace.ts'), 'utf8'); + const architectureSource = fs.readFileSync( + path.join(libDirectory, 'scripts/check-hard-cutover-absence.mjs'), + 'utf8' + ); + + assert.doesNotMatch( + traceSource, + /\b(?:Document|HTMLElement|MutationObserver)\b|createElement|getElementById|querySelector|clipboard|data-ts-/ + ); + assert.match(architectureSource, /core render trace presentation leakage/); +}); + +test('bundle budgets are exposed through the package and enforced after the CI build', () => { + const packageJson = JSON.parse(fs.readFileSync(path.join(libDirectory, 'package.json'), 'utf8')); + const workflow = fs.readFileSync(path.join(repositoryRoot, '.github/workflows/test.yml'), 'utf8'); + const buildStep = workflow.indexOf('run: npm run build'); + const releaseStep = workflow.indexOf('run: npm run test:release'); + const budgetStep = workflow.indexOf('run: npm run check:bundle'); + + assert.equal(packageJson.scripts['check:bundle'], 'node scripts/check-bundle-budgets.mjs'); + assert.notEqual(buildStep, -1); + assert.ok(releaseStep > buildStep, 'release verification must run after the TSJS build'); + assert.ok(budgetStep > buildStep, 'bundle budget check must run after the TSJS build'); + assert.ok(budgetStep > releaseStep, 'bundle budget check must run after release verification'); +}); + +test('Vitest bounds worker concurrency with the supported Vitest 4 option', () => { + const configSource = fs.readFileSync(path.join(libDirectory, 'vitest.config.ts'), 'utf8'); + + assert.doesNotMatch(configSource, /\bthreads\s*:\s*false\b/u); + assert.match(configSource, /\bmaxWorkers\s*:\s*2\b/u); +}); + +test('hard-cutover absence is exposed once and enforced after both production builds', () => { + const packageJson = JSON.parse(fs.readFileSync(path.join(libDirectory, 'package.json'), 'utf8')); + const workflow = fs.readFileSync(path.join(repositoryRoot, '.github/workflows/test.yml'), 'utf8'); + const buildStep = workflow.indexOf('run: npm run build'); + const externalPrebidStep = workflow.indexOf('run: npm run build:prebid-external'); + const absenceStep = workflow.indexOf('run: npm run check:hard-cutover-absence'); + + assert.equal( + packageJson.scripts['check:hard-cutover-absence'], + 'node scripts/check-hard-cutover-absence.mjs' + ); + assert.equal( + packageJson.scripts['check:architecture'], + packageJson.scripts['check:hard-cutover-absence'], + 'architecture and absence commands must share one policy implementation' + ); + assert.notEqual(buildStep, -1); + assert.ok(externalPrebidStep > buildStep, 'pure Prebid must build after the TSJS release'); + assert.ok(absenceStep > externalPrebidStep, 'absence must run after both production builds'); +}); + +test('hard-cutover policy rejects every retired wire, runtime, and public surface', () => { + const retired = [ + ['src/legacy.ts', '"/integrations/aps/renderer"'], + [ + 'src/legacy.ts', + "JSON.stringify({message:'Prebid Response',rendererVersion:'4',rendererUrl})", + ], + ['src/legacy.ts', "port.postMessage({message:'TS APS Start'})"], + ['src/legacy.ts', 'window.__tsjs_gpt_enabled = true'], + ['src/legacy.ts', 'script.setAttribute("data-ts-gam-attribution", "1")'], + ['src/legacy.ts', 'interface TsjsApiV1 {}'], + ['src/legacy.ts', 'tsjs.renderAdUnit("slot")'], + ['src/legacy.ts', 'tsjs.setConfig({debug: true})'], + ['src/legacy.ts', 'const value = tsjs.getConfig()'], + ['src/legacy.ts', 'const slots = tsjs.adSlots'], + ['src/legacy.ts', 'const trace = tsjs.renders'], + ['src/legacy.ts', 'const diagnostics = tsjs.gptDiagnostics'], + ['src/legacy.ts', 'tsjs.version = "0.1.0"'], + ['src/legacy.ts', 'window.dispatchEvent(new Event("tsjs:adRendered"))'], + ['src/composition/browser.ts', 'export function createBrowserRuntime() {}'], + ]; + for (const [file, source] of retired) { + assert.ok( + findCutoverTextViolations(file, source).length > 0, + `retired cutover surface should be rejected: ${source}` + ); + } +}); + +test('browser cutover fixtures do not reconstruct the retired integration config carrier', () => { + for (const relativePath of [ + 'crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts', + 'crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts', + 'crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts', + ]) { + const source = fs.readFileSync(path.join(repositoryRoot, relativePath), 'utf8'); + assert.doesNotMatch(source, /_integrationConfig/u, relativePath); + } +}); + +test('vendor boundary rejects APS, GPT, and PUC artifacts but permits conformance metadata', () => { + const vendored = [ + ['fixtures/prebid-creative.js', 'window._aps = new Map();'], + ['fixtures/gpt.js', 'window.googletag = window.googletag || {};'], + ['fixtures/gpt.js.map', '{"sources":["gpt.js"]}'], + ['fixtures/gpt.js.sha256', 'deadbeef'], + ['fixtures/prebid-universal-creative-1.17.2.js', 'window.renderAd = function() {};'], + ['fixtures/renamed.js', '/*! Prebid Universal Creative v1.17.2 */'], + ['fixtures/renamed.js', '/*! @license Google Publisher Tag */'], + ['fixtures/renamed.js', '/*! Copyright Amazon Publisher Services */'], + ['fixtures/vendor.json', '{"pucIntegrity":"sha384-deadbeef"}'], + ]; + for (const [file, source] of vendored) { + assert.ok( + findVendorBoundaryViolations(file, source).length > 0, + `vendored upstream artifact should be rejected: ${file}` + ); + } + assert.deepEqual( + findVendorBoundaryViolations( + 'browser/helpers/gam-test-network.ts', + 'export const REAL_GAM_PUC_RELEASE = "1.17.2"; Object.freeze({pucRelease: REAL_GAM_PUC_RELEASE});' + ), + [] + ); + assert.deepEqual( + findVendorBoundaryViolations( + 'browser/fixtures/fictional-aps-runner.js', + '// Fictional hermetic APS runner fixture; not copied from APS.\nwindow._aps = new Map();' + ), + [] + ); +}); + +test('hard-cutover scan traverses the complete generated release inventory', () => { + const release = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') + ); + const generated = new Set( + generatedTsjsArtifactFiles(libDirectory).map((file) => path.resolve(file)) + ); + + for (const { file } of release.artifacts) { + assert.equal( + generated.has(path.resolve(libDirectory, '../dist', file)), + true, + `hard-cutover scan must traverse generated artifact ${file}` + ); + } + assert.equal( + generated.has(path.resolve(libDirectory, '../dist/tsjs-release-v1.json')), + true, + 'hard-cutover scan must traverse its generated release manifest' + ); +}); + +test('registered integration dispatch selects post-switch evidence without changing the instrument', () => { + const workflow = fs.readFileSync( + path.join(repositoryRoot, '.github/workflows/integration-tests.yml'), + 'utf8' + ); + + assert.match( + workflow, + /mode: \$\{\{ startsWith\(inputs\.evidence_id, 'aps-tsjs-postswitch-'\) && 'postswitch' \|\| 'preswitch' \}\}/ + ); +}); + +test('protected real-GAM evidence is dispatchable for an unmerged branch without duplicating the suite', () => { + const integrationWorkflow = fs.readFileSync( + path.join(repositoryRoot, '.github/workflows/integration-tests.yml'), + 'utf8' + ); + const realGamWorkflow = fs.readFileSync( + path.join(repositoryRoot, '.github/workflows/aps-real-gam.yml'), + 'utf8' + ); + assert.match(realGamWorkflow, /workflow_call:/); + assert.doesNotMatch(integrationWorkflow, /real_gam_evidence_id:/); + assert.match( + integrationWorkflow, + /real-gam-attestation:[\s\S]*?startsWith\(inputs\.evidence_id, 'aps-tsjs-cutover-'\)[\s\S]*?uses: \.\/\.github\/workflows\/aps-real-gam\.yml/ + ); + assert.match( + integrationWorkflow, + /real-gam-attestation:[\s\S]*?evidence_id: \$\{\{ inputs\.evidence_id \}\}/ + ); +}); + +function workflowJob(source, name) { + const marker = ` ${name}:\n`; + const start = source.indexOf(marker); + assert.notEqual(start, -1, `workflow must contain ${name}`); + const remainder = source.slice(start + marker.length); + const nextJob = remainder.search(/^ {2}[a-zA-Z0-9_-]+:\n/mu); + return nextJob === -1 ? remainder : remainder.slice(0, nextJob); +} + +test('APS and TSJS workflows keep feature programs in repository script files', () => { + const workflows = { + performance: fs.readFileSync( + path.join(repositoryRoot, '.github/workflows/tsjs-performance-gate.yml'), + 'utf8' + ), + realGam: fs.readFileSync( + path.join(repositoryRoot, '.github/workflows/aps-real-gam.yml'), + 'utf8' + ), + quality: workflowJob( + fs.readFileSync(path.join(repositoryRoot, '.github/workflows/test.yml'), 'utf8'), + 'cutover-quality-evidence' + ), + conformance: workflowJob( + fs.readFileSync(path.join(repositoryRoot, '.github/workflows/integration-tests.yml'), 'utf8'), + 'browser-tests-aps-tsjs-conformance' + ), + cutover: workflowJob( + fs.readFileSync(path.join(repositoryRoot, '.github/workflows/integration-tests.yml'), 'utf8'), + 'cutover-suite' + ), + }; + const combined = Object.values(workflows).join('\n'); + + for (const [name, workflow] of Object.entries(workflows)) { + assert.doesNotMatch(workflow, /^\s*run:\s*[>|]/mu, `${name} must not embed a run program`); + assert.doesNotMatch(workflow, /\bnode\s+-e\b/u, `${name} must not embed JavaScript`); + assert.doesNotMatch( + workflow, + /(?:^|\n)\s*(?:for|while)\s+\S+/u, + `${name} must not embed loops` + ); + assert.doesNotMatch( + workflow, + /<<-?['"]?[A-Z][A-Z0-9_]*['"]?/u, + `${name} must not embed heredocs` + ); + } + + for (const script of [ + 'scripts/ci/read-toolchains.sh', + 'scripts/ci/aps-real-gam.sh', + 'scripts/ci/aps-tsjs-cutover.sh', + 'scripts/ci/aps-tsjs-evidence.mjs', + 'scripts/ci/aps-tsjs-quality.sh', + 'scripts/ci/tsjs-performance.sh', + ]) { + assert.ok(combined.includes(script), `a workflow must invoke ${script}`); + assert.ok(fs.existsSync(path.join(repositoryRoot, script)), `${script} must be a real file`); + } + + for (const match of combined.matchAll(/(?:bash|node)\s+(scripts\/ci\/[^\s"']+)/gu)) { + assert.ok( + fs.existsSync(path.join(repositoryRoot, match[1])), + `workflow script target must exist: ${match[1]}` + ); + } + + const performanceScript = fs.readFileSync( + path.join(repositoryRoot, 'scripts/ci/tsjs-performance.sh'), + 'utf8' + ); + const performanceConfig = + 'crates/trusted-server-integration-tests/browser/playwright.performance.config.ts'; + assert.doesNotMatch( + performanceScript, + /printf[\s\S]*export default/u, + 'the performance action must not synthesize an executable config' + ); + assert.ok( + performanceScript.includes(`--config="$repository_root/${performanceConfig}"`), + 'the performance action must invoke its checked-in Playwright config' + ); + assert.ok( + fs.existsSync(path.join(repositoryRoot, performanceConfig)), + 'the performance Playwright config must be a real repository file' + ); + const performanceConfigSource = fs.readFileSync( + path.join(repositoryRoot, performanceConfig), + 'utf8' + ); + assert.match( + performanceConfigSource, + /timeout: 30_000[\s\S]*retries: 0[\s\S]*workers: 1[\s\S]*browserName: "chromium"/u, + 'the checked-in performance config must preserve the immutable single-Chromium instrument' + ); +}); + +test('cutover workflows bind exact release evidence and prior artifact provenance', () => { + const qualityWorkflow = fs.readFileSync( + path.join(repositoryRoot, '.github/workflows/test.yml'), + 'utf8' + ); + const integrationWorkflow = fs.readFileSync( + path.join(repositoryRoot, '.github/workflows/integration-tests.yml'), + 'utf8' + ); + const realGamWorkflow = fs.readFileSync( + path.join(repositoryRoot, '.github/workflows/aps-real-gam.yml'), + 'utf8' + ); + const qualityScript = fs.readFileSync( + path.join(repositoryRoot, 'scripts/ci/aps-tsjs-quality.sh'), + 'utf8' + ); + const cutoverScript = fs.readFileSync( + path.join(repositoryRoot, 'scripts/ci/aps-tsjs-cutover.sh'), + 'utf8' + ); + const evidenceScript = fs.readFileSync( + path.join(repositoryRoot, 'scripts/ci/aps-tsjs-evidence.mjs'), + 'utf8' + ); + const evidenceSelfTest = execFileSync( + process.execPath, + [path.join(repositoryRoot, 'scripts/ci/aps-tsjs-evidence.mjs'), 'self-test'], + { encoding: 'utf8' } + ); + const realGamNetwork = fs.readFileSync( + path.join( + repositoryRoot, + 'crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts' + ), + 'utf8' + ); + const hardCutoverPolicy = fs.readFileSync( + path.join( + repositoryRoot, + 'crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs' + ), + 'utf8' + ); + + for (const [name, workflow] of [ + ['quality', qualityWorkflow], + ['integration', integrationWorkflow], + ['real-GAM', realGamWorkflow], + ]) { + assert.match(workflow, /evidence_id:[\s\S]*?required: true/, `${name} evidence id`); + assert.match(workflow, /release_id:[\s\S]*?required: true/, `${name} release id`); + assert.match(workflow, /scripts\/ci\/aps-tsjs-evidence\.mjs/, `${name} evidence script`); + } + for (const [name, workflow] of [ + ['integration', integrationWorkflow], + ['real-GAM', realGamWorkflow], + ]) { + assert.match(workflow, /previous_artifact_id:[\s\S]*?required: true/, `${name} prior input`); + } + assert.match(evidenceScript, /evidence-manifest\.json/); + assert.match(evidenceScript, /commitSha/); + assert.match(evidenceScript, /runId/); + assert.match(evidenceScript, /conclusion/); + assert.match(evidenceScript, /previousArtifactId/); + assert.match(evidenceSelfTest, /APS\/TSJS evidence self-test passed/); + assert.match(qualityWorkflow, /aps-tsjs-quality-\$\{\{ github\.run_id \}\}/); + assert.match(qualityScript, /set -euo pipefail[\s\S]*?quality\.log/); + assert.match(qualityScript, /tsjs-build-metrics-v1\.json/); + assert.match(integrationWorkflow, /aps-tsjs-cutover-\$\{\{ github\.sha \}\}/); + assert.match(cutoverScript, /for runtime in axum fastly cloudflare spin/); + assert.match(cutoverScript, /aps-proxy-\$runtime\.log/); + assert.match(cutoverScript, /--project=chromium --project=firefox --project=webkit/); + assert.match( + cutoverScript, + /TS_BROWSER_PROJECTS=chromium,firefox,webkit[\s\\]*npx playwright test/, + 'the cutover script must declare every requested Playwright project itself' + ); + assert.match(integrationWorkflow, /Scrub all integration evidence before upload/); + assert.match(realGamWorkflow, /aps-real-gam-\$\{\{ github\.run_id \}\}/); + assert.match(evidenceScript, /capabilities\?/); + assert.match(realGamNetwork, /pucRelease\.value !== expectedPucRelease/); + assert.match(hardCutoverPolicy, /PUC package is vendored into the local harness/); +}); + +test('release id changes independently with id, role, phase, trigger, bytes, and order', () => { + const base = [bundle('core', 'a'), bundle('gpt', 'b')]; + assert.notEqual(computeReleaseId(base), computeReleaseId([bundle('changed', 'a'), base[1]])); + assert.notEqual(computeReleaseId(base), computeReleaseId([bundle('core', 'a', 'core'), base[1]])); + assert.notEqual( + computeReleaseId(base), + computeReleaseId([bundle('core', 'a', 'integration', 'deferred'), base[1]]) + ); + assert.notEqual( + computeReleaseId(base), + computeReleaseId([ + bundle('core', 'a', 'integration', 'takeover', 'first_display_or_idle'), + base[1], + ]) + ); + assert.notEqual(computeReleaseId(base), computeReleaseId([bundle('core', 'changed'), base[1]])); + assert.notEqual(computeReleaseId(base), computeReleaseId([base[1], base[0]])); +}); + +test('u64 length framing distinguishes ambiguous concatenations and artifact counts', () => { + const left = [bundle('a', 'bc'), bundle('d', 'e')]; + const right = [bundle('ab', 'c'), bundle('d', 'e')]; + assert.notEqual(computeReleaseId(left), computeReleaseId(right)); + assert.notEqual(computeReleaseId([bundle('a', 'bc')]), computeReleaseId(left)); +}); + +test('sentinel multiplicity and remnants fail closed', () => { + assert.throws(() => computeReleaseId([bundle('core', RELEASE_SENTINEL)]), /exactly one/); + assert.throws( + () => + computeReleaseId([ + { + id: 'core', + role: 'core', + phase: '', + trigger: '', + bytes: Buffer.from('none'), + }, + ]), + /exactly one/ + ); + assert.throws(() => stampRelease(`${RELEASE_SENTINEL}${RELEASE_SENTINEL}`, 'a'.repeat(64))); +}); + +test('wrong release and missing bundle fail validation', () => { + const release = computeReleaseId([bundle('core', 'a')]); + const stamped = stampRelease(bundle('core', 'a').bytes, release); + assert.doesNotThrow(() => + validateStampedRelease([{ id: 'core', bytes: stamped }], release, ['core']) + ); + assert.throws(() => + validateStampedRelease([{ id: 'core', bytes: stamped }], 'b'.repeat(64), ['core']) + ); + assert.throws(() => + validateStampedRelease([{ id: 'core', bytes: stamped }], release, ['core', 'gpt']) + ); +}); diff --git a/crates/trusted-server-js/lib/test/composition/browser-node-import.test.ts b/crates/trusted-server-js/lib/test/composition/browser-node-import.test.ts new file mode 100644 index 000000000..344b4b141 --- /dev/null +++ b/crates/trusted-server-js/lib/test/composition/browser-node-import.test.ts @@ -0,0 +1,16 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest'; + +describe('browser composition in a non-DOM runtime', () => { + it('imports without claiming browser globals and constructs the no-op composition', async () => { + expect(globalThis.document).toBeUndefined(); + + const { createNoopBrowserComposition } = await import('../../src/composition/browser_test'); + const composition = createNoopBrowserComposition(); + + expect(Object.isFrozen(composition)).toBe(true); + expect(Object.isFrozen(composition.adapters)).toBe(true); + expect(composition.adapters.messaging.createChannel()).toBeUndefined(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts new file mode 100644 index 000000000..a248f68b3 --- /dev/null +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -0,0 +1,4147 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createBrowserGoogletagAdapter, + createNoopGoogletagAdapter, + type GoogletagAdapter, + type GoogletagBindingStatus, + type GoogletagDiagnosticsFact, + type GoogletagDiagnosticsObserver, + type GoogletagFacade, + type GoogletagPublisherCallObserver, + type GoogletagPublisherRefreshCall, + type GptSlotTokenV1, + type GptTraceCycleOrdinalV1, +} from '../../src/adapters/googletag'; +import { + createBrowserMessagingAdapter, + createNoopMessagingAdapter, + type CaptureMessageListener, + type MessagingAdapter, +} from '../../src/adapters/messaging'; +import { + createNoopPrebidAdapter, + PrebidAdmissionContractError, + type PrebidAdapter, + type PrebidBindingStatus, + type PrebidEventFacade, + type PrebidFacade, + type PrebidTrustedServerAuctionV1, + type PreparedTrustedBidV1, +} from '../../src/adapters/prebid'; +import { + BROWSER_TEST_DIAGNOSTICS_PROVIDER_ID, + BROWSER_TEST_TRACE_PROVIDER_ID, + createBrowserComposition, + createNoopBrowserComposition, + createTestBrowserRuntimeComposition, +} from '../../src/composition/browser_test'; +import { log as localLog } from '../../src/core/log'; +import { + createDiagnosticsPresentationIntegrationRegistration, + TRACE_PANEL_ID, +} from '../../src/integrations/gpt_diagnostics/presentation'; +import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../src/core/types'; +import { createCreativeIntegrationRegistration as createProductionCreativeIntegrationRegistration } from '../../src/integrations/creative/module'; +import { createDataDomeIntegrationRegistration } from '../../src/integrations/datadome/module'; +import { createDidomiIntegrationRegistration } from '../../src/integrations/didomi/module'; +import { createGoogleTagManagerIntegrationRegistration } from '../../src/integrations/google_tag_manager/module'; +import { createLegacyGptRegistrationForTest as createGptIntegrationRegistration } from '../helpers/legacy_gpt_registration'; +import { isGuardInstalled, resetGuardState } from '../../src/integrations/gpt/script_guard'; +import { createGptDiagnosticsIntegrationRegistration } from '../../src/integrations/gpt_diagnostics/module'; +import { createLockrIntegrationRegistration } from '../../src/integrations/lockr/module'; +import { createOsanoIntegrationRegistration } from '../../src/integrations/osano/module'; +import { createOsanoLifecycleIntegrationRegistration } from '../../src/integrations/osano/lifecycle'; +import { createPermutiveIntegrationRegistration } from '../../src/integrations/permutive/module'; +import { createPermutiveLifecycleIntegrationRegistration } from '../../src/integrations/permutive/lifecycle'; +import { createSourcepointIntegrationRegistration } from '../../src/integrations/sourcepoint/module'; +import { createSourcepointLifecycleIntegrationRegistration } from '../../src/integrations/sourcepoint/lifecycle'; +import { createTestlightIntegrationRegistration } from '../../src/integrations/testlight/module'; +import { createRenderRuntimeIntegrationRegistration } from '../../src/integrations/render_runtime/module'; +import { publicLog } from '../../src/kernel/fallback'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, + PreparedIntegration, +} from '../../src/kernel/integration_registry'; +import { RELEASE_CATALOG } from '../../src/kernel/release_catalog'; +import { + createRenderAttempt, + type CommittedRenderArtifact, + type RenderAttempt, +} from '../../src/services/render'; + +const DEFERRED_INTEGRATION_IDS = new Set([ + 'diagnostics_presentation', + 'gpt_later', + 'osano_lifecycle', + 'permutive_lifecycle', + 'prebid_later', + 'sourcepoint_lifecycle', +]); + +const GPT_DIAGNOSTICS_TEST_IDS = Object.freeze([ + BROWSER_TEST_DIAGNOSTICS_PROVIDER_ID, + 'gpt_diagnostics', + 'diagnostics_presentation', +]); + +const BROWSER_TEST_OPTIONAL_GPT_DIAG_PROVIDER_ID = 'browser_test_optional_gpt_diag_provider'; + +function runtimeManifest(releaseId: string, ids: readonly string[]) { + return { + version: 1 as const, + releaseId, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => + DEFERRED_INTEGRATION_IDS.has(id) + ? { + id, + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + src: `/static/tsjs=tsjs-${id}.min.js?v=${'d'.repeat(64)}`, + } + : { id, phase: 'takeover' as const } + ), + }; +} + +function runtimeCatalog(ids: readonly string[]) { + return Object.freeze( + ids.map((id) => { + const canonical = RELEASE_CATALOG.find((entry) => entry.id === id); + if (canonical) { + return Object.freeze({ + id, + phase: canonical.phase, + trigger: canonical.trigger, + config: canonical.config, + consumes: canonical.consumes, + provides: canonical.provides, + }); + } + return Object.freeze({ + id, + phase: DEFERRED_INTEGRATION_IDS.has(id) ? ('deferred' as const) : ('takeover' as const), + trigger: DEFERRED_INTEGRATION_IDS.has(id) ? ('first_display_or_idle' as const) : null, + config: null, + consumes: Object.freeze( + id === BROWSER_TEST_DIAGNOSTICS_PROVIDER_ID + ? ['runtime.v1'] + : id === 'gpt_diagnostics' + ? ['runtime.v1', 'gpt.events.v1'] + : id === 'diagnostics_presentation' + ? ['runtime.v1', 'trace.presentation.v1', 'gpt_diag.v1?gpt_diagnostics_active'] + : [] + ), + provides: Object.freeze( + id === BROWSER_TEST_DIAGNOSTICS_PROVIDER_ID + ? ['gpt.events.v1', 'trace.v1', 'trace.presentation.v1'] + : id === BROWSER_TEST_TRACE_PROVIDER_ID + ? ['trace.v1', 'trace.presentation.v1'] + : id === 'gpt_diagnostics' || id === BROWSER_TEST_OPTIONAL_GPT_DIAG_PROVIDER_ID + ? ['gpt_diag.v1'] + : [] + ), + }); + }) + ); +} + +function exactLegacyRuntime( + interfaces: Readonly>, + id: 'creative' | 'prebid' +): Readonly<{ activate: (config?: unknown) => () => void; start: (config: unknown) => void }> { + const runtime = interfaces[id] as Readonly<{ activate?: unknown; start?: unknown }> | undefined; + if ( + !runtime || + !Object.isFrozen(runtime) || + typeof runtime.activate !== 'function' || + typeof runtime.start !== 'function' + ) { + throw new TypeError(`${id} test runtime is unavailable`); + } + return runtime as Readonly<{ + activate: (config?: unknown) => () => void; + start: (config: unknown) => void; + }>; +} + +function testTakeoverRegistration( + id: string, + releaseId: string, + prepare: ( + context: IntegrationPrepareContext + ) => PreparedIntegration | PromiseLike +): IntegrationRegistration { + return Object.freeze({ + abi: 1, + id, + phase: 'takeover', + releaseId, + prepareSync: (context: IntegrationPrepareContext) => prepare(context) as PreparedIntegration, + prepare, + }); +} + +function createLegacyPrebidIntegrationRegistration(releaseId: string): IntegrationRegistration { + const prepare = ({ config, interfaces }: IntegrationPrepareContext) => { + const runtime = exactLegacyRuntime(interfaces, 'prebid'); + return Object.freeze({ + activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { + const release = runtime.activate(); + onDispose(release); + afterCommit(() => runtime.start(config)); + }, + }); + }; + return Object.freeze({ + abi: 1, + id: 'prebid', + phase: 'takeover', + releaseId, + prepareSync: prepare, + prepare, + }); +} + +function createLegacyCreativeIntegrationRegistration(releaseId: string): IntegrationRegistration { + const prepare = ({ config, interfaces }: IntegrationPrepareContext) => { + const creative = config as Readonly<{ + clickGuard?: unknown; + enabled?: unknown; + renderGuard?: unknown; + }>; + if (!creative.enabled || (!creative.clickGuard && !creative.renderGuard)) { + return Object.freeze({ activate: () => undefined }); + } + const runtime = exactLegacyRuntime(interfaces, 'creative'); + return Object.freeze({ + activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { + const release = runtime.activate(config); + onDispose(release); + afterCommit(() => runtime.start(config)); + }, + }); + }; + return Object.freeze({ + abi: 1, + id: 'creative', + phase: 'takeover', + releaseId, + prepareSync: prepare, + prepare, + }); +} + +function createTarget() { + return { + googletag: undefined as unknown, + pbjs: undefined as unknown, + addEventListener: + vi.fn<(type: 'message', listener: CaptureMessageListener, capture: true) => void>(), + removeEventListener: + vi.fn<(type: 'message', listener: CaptureMessageListener, capture: true) => void>(), + }; +} + +function browserSlotPlacement(slot: string, divId = slot) { + return Object.freeze({ + slot, + gamUnitPath: `/123/${slot}`, + divId, + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({}), + }); +} + +function fakeGoogletagAdapter( + bindingStatus: () => GoogletagBindingStatus = () => 'pending' +): GoogletagAdapter { + return Object.freeze({ ...createNoopGoogletagAdapter(), bindingStatus }); +} + +function synchronousGptAdapter(initialSlots: readonly object[] = []) { + type Listener = Readonly<{ + callback: Parameters[1]; + diagnosticsOwner: boolean; + }>; + const listeners = new Map>(); + const physicalSlots: object[] = [...initialSlots]; + const targeting = new WeakMap>(); + const bindingToken = Object.freeze({}); + const display = vi.fn(); + const refresh = vi.fn(); + const diagnosticsSlots = new WeakMap(); + const diagnosticFacts: GoogletagDiagnosticsFact[] = []; + const traceTokens = new WeakMap(); + const traceCycles = new WeakMap(); + let traceTokenSequence = 0; + let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; + let publisherObserver: GoogletagPublisherCallObserver | undefined; + const traceTokenFor = (slot: object): GptSlotTokenV1 => { + const existing = traceTokens.get(slot); + if (existing) return existing; + const token = `gt1_${(++traceTokenSequence).toString(36)}` as GptSlotTokenV1; + traceTokens.set(slot, token); + return token; + }; + const transactionalDefine: GoogletagFacade['transactionalDefine'] = ( + definition, + isGenerationCurrent, + prepareCommit + ) => { + if (!isGenerationCurrent()) return Object.freeze({ status: 'discarded' as const }); + const slot = { + addService: vi.fn(), + getAdUnitPath: () => definition.adUnitPath, + getSlotElementId: () => definition.elementId, + }; + const admission = prepareCommit(slot); + if (!admission.commit() || !isGenerationCurrent()) { + admission.rollback(); + return Object.freeze({ status: 'discarded' as const }); + } + physicalSlots.push(slot); + return Object.freeze({ status: 'defined' as const, slot }); + }; + const facade: GoogletagFacade = Object.freeze({ + adUnitPath: (slot: object) => + 'getAdUnitPath' in slot && typeof slot.getAdUnitPath === 'function' + ? slot.getAdUnitPath() + : undefined, + bindingToken: () => bindingToken, + clearTargeting: vi.fn((slot: object, key?: string) => { + const values = targeting.get(slot); + if (key === undefined) values?.clear(); + else values?.delete(key); + }), + enableServices: () => undefined, + transactionalDefine, + display, + getTargeting: vi.fn((slot: object, key: string) => + Object.freeze([...(targeting.get(slot)?.get(key) ?? [])]) + ), + observeTargeting: () => Object.assign(vi.fn(), { isCurrent: () => true }), + refresh, + serviceState: () => + Object.freeze({ apiReady: true, initialLoadDisabled: false, pubadsReady: true }), + setTargeting: vi.fn((slot: object, key: string, value: string | readonly string[]) => { + const values = targeting.get(slot) ?? new Map(); + targeting.set(slot, values); + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + slotElementId: (slot: object) => + 'getSlotElementId' in slot && typeof slot.getSlotElementId === 'function' + ? slot.getSlotElementId() + : undefined, + slots: () => Object.freeze([...physicalSlots]), + subscribe: ( + eventType: string, + listener: Parameters[1], + diagnosticsOwner = false + ) => { + const registered = listeners.get(eventType) ?? new Set(); + const entry = Object.freeze({ callback: listener, diagnosticsOwner }); + registered.add(entry); + listeners.set(eventType, registered); + return () => registered.delete(entry); + }, + transactionalReplace: () => Object.freeze({ status: 'destroyed' as const }), + }); + const adapter: GoogletagAdapter = Object.freeze({ + bindingStatus: () => 'present', + enqueueGamAttribution: vi.fn(() => true), + diagnosticsIdentity: () => undefined, + dispose: vi.fn(), + notifyReady: vi.fn(), + observeDiagnostics: (observer: GoogletagDiagnosticsObserver) => { + if (diagnosticsObserver) return undefined; + diagnosticsObserver = observer; + return () => { + if (diagnosticsObserver === observer) diagnosticsObserver = undefined; + }; + }, + observePublisherCalls: (observer: GoogletagPublisherCallObserver) => { + publisherObserver = observer; + return () => { + if (publisherObserver === observer) publisherObserver = undefined; + }; + }, + run: (command: (gpt: Readonly) => Value) => { + let result: Promise; + try { + result = Promise.resolve(command(facade)); + } catch (error) { + result = Promise.reject(error); + } + return Object.freeze({ status: 'present' as const, result, dispose: vi.fn() }); + }, + traceToken: traceTokenFor, + }); + return { + adapter, + emit: (eventType: string, event: unknown): void => { + let acceptedHandle: unknown; + const publishFact = (handle: unknown): void => { + if (typeof event !== 'object' || event === null || !('slot' in event)) return; + const physicalSlot = event.slot; + if (typeof physicalSlot !== 'object' || physicalSlot === null) return; + let safeSlot = diagnosticsSlots.get(physicalSlot); + if (!safeSlot) { + const elementId = + 'getSlotElementId' in physicalSlot && + typeof physicalSlot.getSlotElementId === 'function' + ? physicalSlot.getSlotElementId() + : undefined; + const adUnitPath = + 'getAdUnitPath' in physicalSlot && typeof physicalSlot.getAdUnitPath === 'function' + ? physicalSlot.getAdUnitPath() + : undefined; + const createdSlot = Object.freeze({ + token: Object.freeze(Object.create(null) as object), + traceToken: traceTokenFor(physicalSlot), + ...(typeof elementId === 'string' ? { elementId } : {}), + ...(typeof adUnitPath === 'string' ? { adUnitPath } : {}), + }); + diagnosticsSlots.set(physicalSlot, createdSlot); + safeSlot = createdSlot; + } + if (eventType === 'slotRequested' && handle !== undefined) { + const next = ((traceCycles.get(physicalSlot) ?? 0) + 1) as GptTraceCycleOrdinalV1; + traceCycles.set(physicalSlot, next); + } + const cycleOrdinal = traceCycles.get(physicalSlot); + const fact = Object.freeze({ + ...event, + kind: eventType, + observedAtMs: 1, + slot: Object.freeze({ + ...safeSlot, + ...(cycleOrdinal === undefined ? {} : { cycleOrdinal }), + }), + }) as Parameters[0]; + diagnosticFacts.push(fact); + diagnosticsObserver?.(fact); + }; + for (const listener of listeners.get(eventType) ?? []) { + const handle = listener.callback(event); + if (!listener.diagnosticsOwner) { + if (handle !== undefined) acceptedHandle = handle; + if (eventType === 'slotRequested' || eventType === 'slotRenderEnded') { + publishFact(handle); + } + continue; + } + publishFact(acceptedHandle); + } + }, + diagnosticsObserverActive: () => diagnosticsObserver !== undefined, + diagnosticFacts: () => Object.freeze([...diagnosticFacts]), + display, + listenerInventory: () => + Object.freeze( + [...listeners.entries()] + .filter(([, registered]) => registered.size > 0) + .map(([eventType, registered]) => Object.freeze([eventType, registered.size] as const)) + ), + listenerRoles: (eventType: string) => + Object.freeze( + [...(listeners.get(eventType) ?? [])].map((listener) => listener.diagnosticsOwner) + ), + publisherRefresh: (call: Readonly) => { + const observer = publisherObserver; + if (!observer?.refresh) throw new Error('Publisher observer is unavailable'); + return observer.refresh(call); + }, + physicalSlots: () => Object.freeze([...physicalSlots]), + refresh, + targetingFor: (slot: object) => new Map(targeting.get(slot) ?? []), + }; +} + +function fakePrebidAdapter( + bindingStatus: () => PrebidBindingStatus = () => 'pending' +): PrebidAdapter { + return Object.freeze({ ...createNoopPrebidAdapter(), bindingStatus }); +} + +function synchronousPrebidAdapter( + admission: (prepared: Readonly) => 'admitted' | 'not_admitted' = () => + 'admitted' +) { + let auctionListener: ((auction: Readonly) => void) | undefined; + let auctionEndListener: + ((event: unknown, prebid: Readonly) => void) | undefined; + let admitted: Readonly | undefined; + const admitTrustedBid = vi.fn((prepared: Readonly) => { + const result = admission(prepared); + if (result === 'admitted') admitted = prepared; + return result; + }); + const requestBids = vi.fn(); + const setTargetingForGpt = vi.fn(); + const facade = Object.freeze({ + addAdUnits: vi.fn(), + highestBids: vi.fn(() => Object.freeze([])), + processQueue: vi.fn(), + registerBidAdapter: vi.fn(), + registerTrustedServerBidder: vi.fn( + (listener: (auction: Readonly) => void) => { + auctionListener = listener; + return () => { + auctionListener = undefined; + }; + } + ), + renderAd: vi.fn(), + requestBids, + setTargetingForGpt, + subscribe: vi.fn( + ( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ) => { + if (eventType === 'auctionEnd') auctionEndListener = listener; + return () => { + if (auctionEndListener === listener) auctionEndListener = undefined; + }; + } + ), + }) satisfies PrebidFacade; + const adapter = Object.freeze({ + ...createNoopPrebidAdapter(), + admitTrustedBid, + bindingStatus: () => 'present' as const, + run: (command: (prebid: Readonly) => Value) => { + let result: Promise; + try { + result = Promise.resolve(command(facade)); + } catch (error) { + result = Promise.reject(error); + } + return Object.freeze({ status: 'present' as const, result, dispose: vi.fn() }); + }, + }) satisfies PrebidAdapter; + return { + adapter, + admitTrustedBid, + auction: (auction: Readonly): void => auctionListener?.(auction), + auctionEnd: (auctionId: string): void => { + const prepared = admitted; + const highest = prepared + ? Object.freeze([ + Object.freeze({ + ...prepared.bid, + adUnitCode: prepared.adUnitCode, + auctionId: prepared.auctionId, + }), + ]) + : Object.freeze([]); + auctionEndListener?.( + Object.freeze({ auctionId }), + Object.freeze({ highestBids: () => highest }) + ); + }, + requestBids, + setTargetingForGpt, + }; +} + +function fakeMessagingAdapter( + installCaptureListener: MessagingAdapter['installCaptureListener'] = () => vi.fn() +): MessagingAdapter { + return Object.freeze({ ...createNoopMessagingAdapter(), installCaptureListener }); +} + +describe('browser composition', () => { + afterEach(() => { + vi.useRealTimers(); + document.head.querySelectorAll('script#trustedserver-js').forEach((script) => script.remove()); + Object.defineProperty(document, 'currentScript', { configurable: true, value: null }); + }); + + it('constructs live adapters without changing production globals', () => { + const target = createTarget(); + const composition = createBrowserComposition({ target }); + + expect(composition.adapters.googletag.bindingStatus()).toBe('pending'); + expect(composition.adapters.prebid.bindingStatus()).toBe('pending'); + expect(target.addEventListener).not.toHaveBeenCalled(); + + target.googletag = {}; + target.pbjs = {}; + expect(composition.adapters.googletag.bindingStatus()).toBe('incompatible'); + expect(composition.adapters.prebid.bindingStatus()).toBe('incompatible'); + + target.googletag = 1; + target.pbjs = 'not-prebid'; + expect(composition.adapters.googletag.bindingStatus()).toBe('incompatible'); + expect(composition.adapters.prebid.bindingStatus()).toBe('incompatible'); + }); + + it('routes the first-display measure through the concrete test composition', async () => { + const display = vi.fn(); + const pubadsService = {}; + const performance = { mark: vi.fn(), measure: vi.fn() }; + const target = { + ...createTarget(), + googletag: { + apiReady: true, + cmd: { + push: (command: () => void): number => { + command(); + return 1; + }, + }, + display, + pubads: vi.fn(() => pubadsService), + }, + performance, + }; + const composition = createBrowserComposition({ target }); + + target.googletag.display('publisher-slot'); + expect(performance.mark).not.toHaveBeenCalled(); + + await expect( + composition.adapters.googletag.run((gpt) => { + gpt.display('authoritative-slot'); + gpt.display('replay-slot'); + }).result + ).resolves.toBeUndefined(); + + expect(performance.mark).toHaveBeenCalledExactlyOnceWith('tsjs:first-display'); + expect(performance.measure).toHaveBeenCalledExactlyOnceWith( + 'tsjs:boot-to-first-display', + 'tsjs:bids-script', + 'tsjs:first-display' + ); + expect(display).toHaveBeenCalledTimes(3); + }); + + it('routes an attributable empty GPT cycle through the owned slot and PUC services', async () => { + const gpt = synchronousGptAdapter(); + let prefix = 0; + const reservationId = `r1_${'a'.repeat(22)}`; + const source = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
fictional fallback
', + width: 300, + height: 250, + }); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: 'slot-one', + provider: 'trusted', + upstreamBidId: 'upstream-one', + cpm: 1, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trusted' }), + rendererReservationId: reservationId, + renderSource: source, + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([ + Object.freeze({ + slot: 'slot-one', + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + slots: Object.freeze([browserSlotPlacement('slot-one')]), + bids: Object.freeze([bid]), + }); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: runtimeManifest('a'.repeat(64), []), + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: projection, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + createIdentityIssuerForTest: () => { + prefix += 1; + return createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(prefix); + return target; + }, + }); + }, + } + ); + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + const session = composition.runtimeSessionForTest(); + const navigation = session?.currentNavigation; + const batch = navigation?.createAuctionBatch('gpt-primary'); + const services = session?.interfaces; + const artifacts = services?.['artifacts']; + const reservations = composition.reservationServiceForTest(); + const slots = composition.slotServiceForTest(); + if (!navigation || !batch || !artifacts || !reservations || !slots) { + throw new Error('Expected runtime-owned GPT dependencies'); + } + const createAttempt = (parentAttemptId?: string): RenderAttempt => { + const owner = batch.createRenderAttempt('slot-one'); + if (!owner.ok) throw new Error(owner.reason); + const attempt = createRenderAttempt({ + artifacts: artifacts as Parameters[0]['artifacts'], + owner: owner.value, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as Readonly<{ type: 'aps' | 'adm'; version: 1 }>) + : undefined, + reservations, + ...(parentAttemptId === undefined ? {} : { parentAttemptId }), + }); + if (!attempt.ok) throw new Error(attempt.reason); + return attempt.value; + }; + const ownerResult = batch.createRenderAttempt('slot-one'); + if (!ownerResult.ok) throw new Error(ownerResult.reason); + const primaryResult = createRenderAttempt({ + artifacts: artifacts as Parameters[0]['artifacts'], + owner: ownerResult.value, + prepareRenderSource: () => source, + reservations, + }); + if (!primaryResult.ok) throw new Error(primaryResult.reason); + const primary = primaryResult.value; + const physicalSlot = Object.freeze({}); + const slotElement = document.createElement('div'); + slotElement.id = 'slot-one'; + document.body.append(slotElement); + expect( + slots.adoptGptSlot(navigation.generation, 'slot-one', { + definition: { + adUnitPath: '/123/slot-one', + elementId: 'slot-one', + sizes: Object.freeze([[300, 250]]), + }, + ownership: 'trusted_server', + slot: physicalSlot, + }) + ).toEqual({ ok: true }); + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: primary.id, + slot: primary.slot, + navigationGeneration: primary.navigationGeneration, + dispose: vi.fn(), + }) satisfies CommittedRenderArtifact; + const projectedBid = ( + navigation.currentAuctionProjection as Readonly<{ bids: readonly BrowserAuctionBidV1[] }> + ).bids[0]; + if (!projectedBid || !('rendererReservationId' in projectedBid)) { + throw new Error('Expected the parsed owned projected winner'); + } + const projectedPlacement = ( + navigation.currentAuctionProjection as Readonly> + ).slots[0]; + if (!projectedPlacement) throw new Error('Expected the parsed projected placement'); + let fallback: RenderAttempt | undefined; + const operation = await composition.publishGptWinnerForTest({ + artifact, + attempt: primary, + bid: projectedBid, + createFallback: (parentAttemptId) => { + fallback = createAttempt(parentAttemptId); + return Object.freeze({ ok: true as const, value: fallback }); + }, + operation: 'refresh', + owner: ownerResult.value, + placement: projectedPlacement, + requestClass: 'primary', + slot: physicalSlot, + }); + expect(operation.ok).toBe(true); + + await Promise.resolve(); + await Promise.resolve(); + expect(gpt.refresh).toHaveBeenCalledExactlyOnceWith( + [physicalSlot], + Object.freeze({ changeCorrelator: false }) + ); + gpt.emit('slotRequested', { slot: physicalSlot }); + gpt.emit('slotRenderEnded', { + isEmpty: true, + responseIdentifier: 'response-one', + slot: physicalSlot, + }); + await Promise.resolve(); + + expect(primary.snapshot().outcome).toEqual({ outcome: 'failed', reason: 'gam_empty' }); + expect(fallback).toBeDefined(); + fallback?.fail('winner_not_renderable'); + expect(operation.ok && operation.value.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'fallback', + primary: { outcome: 'failed', reason: 'gam_empty' }, + fallback: { outcome: 'failed', reason: 'winner_not_renderable' }, + }, + }); + composition.runtime.dispose(); + slotElement.remove(); + }); + + it('publishes the accepted initial projection through the production GPT lifecycle', async () => { + const releaseId = 'a'.repeat(64); + const gpt = synchronousGptAdapter(); + const placement = browserSlotPlacement('initial-slot'); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: placement.slot, + provider: 'trusted', + upstreamBidId: 'initial-upstream', + cpm: 1.5, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trusted', pos: 'bid' }), + rendererReservationId: `r1_${'i'.repeat(22)}`, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
initial winner
', + width: 300, + height: 250, + }), + }); + const projection = { + version: 1, + auction: { + version: 1, + auctionId: 'initial-production', + results: [ + { slot: placement.slot, outcome: 'winner' as const, candidateId: bid.candidateId }, + ], + }, + slots: [{ ...placement, targeting: { pos: 'placement', section: 'news' } }], + bids: [bid], + }; + const element = document.createElement('div'); + element.id = placement.divId; + document.body.append(element); + let prefix = 0; + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: runtimeManifest(releaseId, ['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: { + auctionProjection: projection, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + createIdentityIssuerForTest: () => { + prefix += 1; + return createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(prefix); + return target; + }, + }); + }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + await vi.waitFor(() => expect(gpt.physicalSlots()).toHaveLength(1)); + const physicalSlot = gpt.physicalSlots()[0]; + expect(physicalSlot).toBeDefined(); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(physicalSlot); + expect(gpt.refresh).not.toHaveBeenCalled(); + expect(gpt.targetingFor(physicalSlot!)).toEqual( + new Map([ + ['hb_adid', [bid.rendererReservationId]], + ['hb_bidder', ['trusted']], + ['pos', ['bid']], + ['section', ['news']], + ]) + ); + gpt.emit('slotRequested', { slot: physicalSlot }); + gpt.emit('slotRenderEnded', { + isEmpty: true, + responseIdentifier: 'initial-empty-response', + slot: physicalSlot, + }); + await vi.waitFor(() => expect(element.querySelector('iframe')).not.toBeNull()); + } finally { + composition.runtime.dispose(); + element.remove(); + } + }); + + it('reuses one publisher GPT slot resolved through a unique responsive DOM prefix', async () => { + const releaseId = 'a'.repeat(64); + const publisherSlot = { + getAdUnitPath: () => '/publisher/existing', + getSlotElementId: () => 'responsive-mobile', + }; + const gpt = synchronousGptAdapter([publisherSlot]); + const placement = { + slot: 'responsive-slot', + gamUnitPath: '/123/responsive-slot', + divId: 'responsive-', + formats: [[300, 250]], + targeting: {}, + }; + const bid = { + candidateId: 'CCCCCCCCCCCC', + slot: placement.slot, + provider: 'trusted', + upstreamBidId: 'responsive-upstream', + cpm: 1, + currency: 'USD' as const, + targeting: {}, + rendererReservationId: `r1_${'r'.repeat(22)}`, + renderSource: { + type: 'adm' as const, + version: 1 as const, + adm: '
responsive winner
', + width: 300, + height: 250, + }, + }; + const element = document.createElement('div'); + element.id = 'responsive-mobile'; + document.body.append(element); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: runtimeManifest(releaseId, ['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: { + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'responsive-initial', + results: [ + { slot: placement.slot, outcome: 'winner' as const, candidateId: bid.candidateId }, + ], + }, + slots: [placement], + bids: [bid], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + createIdentityIssuerForTest: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(7); + return target; + }, + }), + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + await vi.waitFor(() => expect(gpt.refresh).toHaveBeenCalledOnce()); + expect(gpt.physicalSlots()).toEqual([publisherSlot]); + expect(gpt.display).not.toHaveBeenCalled(); + expect(gpt.refresh).toHaveBeenCalledExactlyOnceWith( + [publisherSlot], + Object.freeze({ changeCorrelator: false }) + ); + } finally { + composition.runtime.dispose(); + element.remove(); + } + }); + + it('derives exact APS validation coordinates only for the real browser target', () => { + const renderer = { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + }; + const message = { + message: 'TS APS Top Mount Started', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }; + const validation = { validateApsRenderer: () => renderer !== undefined }; + + const browser = createBrowserComposition({ messagingValidation: validation }); + expect( + browser.adapters.messaging.parseProtocolMessage('apsTopMountStarted', message) + ).toBeDefined(); + + const injected = createBrowserComposition({ + target: createTarget(), + messagingValidation: validation, + }); + expect( + injected.adapters.messaging.parseProtocolMessage('apsTopMountStarted', message) + ).toBeDefined(); + }); + + it('installs the capture-phase message listener synchronously and disposes once', () => { + const target = createTarget(); + const composition = createBrowserComposition({ target }); + const listener = vi.fn(); + + const dispose = composition.adapters.messaging.installCaptureListener(listener); + expect(dispose).toBeTypeOf('function'); + + expect(target.addEventListener).toHaveBeenCalledTimes(1); + const installed = target.addEventListener.mock.calls[0]?.[1]; + expect(installed).toBeTypeOf('function'); + expect(target.addEventListener).toHaveBeenCalledWith('message', installed, true); + + dispose?.(); + dispose?.(); + expect(target.removeEventListener).toHaveBeenCalledTimes(1); + expect(target.removeEventListener).toHaveBeenCalledWith('message', installed, true); + }); + + it('uses exact injected fakes without constructing concrete adapters', () => { + const googletag = fakeGoogletagAdapter(() => 'present'); + const prebid = fakePrebidAdapter(() => 'incompatible'); + const messaging = fakeMessagingAdapter(); + + const composition = createBrowserComposition({ + adapters: { googletag, messaging, prebid }, + }); + + expect(composition.adapters).toEqual({ googletag, messaging, prebid }); + expect(Object.isFrozen(composition.adapters)).toBe(true); + expect(Object.isFrozen(composition)).toBe(true); + }); + + it('provides a side-effect-free no-op composition for kernel and service tests', () => { + const composition = createNoopBrowserComposition(); + const listener = vi.fn(); + + expect(composition.adapters.googletag.bindingStatus()).toBe('pending'); + expect(composition.adapters.prebid.bindingStatus()).toBe('pending'); + const disposeMessaging = composition.adapters.messaging.installCaptureListener(listener); + expect(disposeMessaging).toBeUndefined(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('classifies exactly the six deferred integration IDs without a suffix heuristic', () => { + const releaseId = 'a'.repeat(64); + const deferredIds = Object.freeze([ + 'diagnostics_presentation', + 'gpt_later', + 'osano_lifecycle', + 'permutive_lifecycle', + 'prebid_later', + 'sourcepoint_lifecycle', + ]); + const rows = runtimeManifest(releaseId, [ + 'takeover_lifecycle', + ...deferredIds, + 'later_takeover', + ]).integrations; + + expect(rows.map(({ id, phase }) => [id, phase])).toEqual([ + ['takeover_lifecycle', 'takeover'], + ...deferredIds.map((id) => [id, 'deferred']), + ['later_takeover', 'takeover'], + ]); + }); + + it.each([false, true])( + 'installs only the active GPT diagnostics fact path when boot active is %s', + async (active) => { + const releaseId = 'a'.repeat(64); + const target: Record = {}; + const gpt = synchronousGptAdapter(); + const integrationIds = active ? GPT_DIAGNOSTICS_TEST_IDS : Object.freeze([]); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: runtimeManifest(releaseId, integrationIds), + knownIntegrationIds: integrationIds, + catalog: runtimeCatalog(integrationIds), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'boot', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + if (active) { + expect( + composition.runtime.registerIntegration( + composition.createDiagnosticsCapabilityProviderRegistrationForTest() + ) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + createGptDiagnosticsIntegrationRegistration(releaseId) + ) + ).toBe(true); + } + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + const inventory = Object.fromEntries(gpt.listenerInventory()); + expect(inventory).toEqual( + active + ? { + impressionViewable: 1, + slotOnload: 1, + slotRenderEnded: 1, + slotRequested: 1, + slotResponseReceived: 1, + slotVisibilityChanged: 1, + } + : { slotRenderEnded: 1, slotRequested: 1 } + ); + expect(gpt.diagnosticsObserverActive()).toBe(active); + const diagnostics = target['diagnostics'] as + { readonly gpt?: { snapshot(): { slots: readonly unknown[] } } } | undefined; + expect(Reflect.ownKeys(diagnostics ?? {}).sort()).toEqual( + active ? ['gpt', 'renderTrace'] : ['renderTrace'] + ); + expect(target).not.toHaveProperty('gpt.events.v1'); + expect(target).not.toHaveProperty('trace.v1'); + expect(target).not.toHaveProperty('gpt_diag.v1'); + + if (active) { + const observedSlot = Object.freeze({ + getSlotElementId: () => 'diagnostic-slot', + getAdUnitPath: () => '/diagnostic/slot', + }); + gpt.emit('slotRequested', { slot: observedSlot }); + gpt.emit('slotResponseReceived', { slot: observedSlot }); + gpt.emit('slotRenderEnded', { slot: observedSlot, isEmpty: false, size: [300, 250] }); + expect(diagnostics?.gpt?.snapshot().slots).toHaveLength(1); + } + + composition.runtime.dispose(); + expect(gpt.diagnosticsObserverActive()).toBe(false); + } + ); + + it('composes the production GPT adapter lifecycle handle through SlotService and ingress into trace', async () => { + const releaseId = 'a'.repeat(64); + const listeners = new Map void>>(); + const refresh = vi.fn(); + const pubads = { + addEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + const registered = listeners.get(type) ?? new Set(); + registered.add(listener); + listeners.set(type, registered); + }), + getSlots: vi.fn(() => [] as object[]), + refresh, + removeEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + listeners.get(type)?.delete(listener); + }), + }; + const concreteAdapter = createBrowserGoogletagAdapter({ + googletag: { + apiReady: true, + pubadsReady: true, + cmd: { push: (callback: () => void) => (callback(), 1) }, + display: vi.fn(), + getConfig: vi.fn(() => ({ disableInitialLoad: false })), + pubads: vi.fn(() => pubads), + setConfig: vi.fn(), + }, + performance: { now: () => 17 }, + }); + const integrationIds = GPT_DIAGNOSTICS_TEST_IDS; + const target: Record = {}; + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: runtimeManifest(releaseId, integrationIds), + knownIntegrationIds: integrationIds, + catalog: runtimeCatalog(integrationIds), + boot: { + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'production-adapter-cycle', + results: [{ slot: 'production-adapter-slot', outcome: 'no_bid' }], + }, + slots: [browserSlotPlacement('production-adapter-slot')], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: concreteAdapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + const physicalSlot = Object.freeze({ + clearTargeting: vi.fn(), + getAdUnitPath: () => '/123/production-adapter-slot', + getSlotElementId: () => 'production-adapter-slot', + getTargeting: vi.fn(() => []), + setTargeting: vi.fn(), + }); + const emit = (type: string, fields: Readonly> = {}): void => { + const event = { slot: physicalSlot, ...fields }; + for (const listener of listeners.get(type) ?? []) listener(event); + }; + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + composition.createDiagnosticsCapabilityProviderRegistrationForTest() + ) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + createGptDiagnosticsIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const navigation = composition.runtimeSessionForTest()?.currentNavigation; + const slots = composition.slotServiceForTest(); + if (!navigation || !slots) throw new Error('Expected active production adapter composition'); + expect( + slots.adoptGptSlot(navigation.generation, 'production-adapter-slot', { + definition: { + adUnitPath: '/123/production-adapter-slot', + elementId: 'production-adapter-slot', + sizes: Object.freeze([[300, 250]]), + }, + ownership: 'trusted_server', + slot: physicalSlot, + }) + ).toEqual({ ok: true }); + const request = slots.request({ + intentId: 'production-adapter-request', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'production-adapter-slot', + requestClass: 'primary', + }); + await vi.waitFor(() => + expect(refresh).toHaveBeenCalledExactlyOnceWith( + [physicalSlot], + Object.freeze({ changeCorrelator: false }) + ) + ); + + emit('slotRequested'); + emit('slotRenderEnded', { isEmpty: false, responseIdentifier: 'production-response' }); + await expect(request.result).resolves.toEqual({ + responseIdentifier: 'production-response', + status: 'rendered', + }); + const diagnostics = target['diagnostics'] as { + readonly renderTrace: { current(): Readonly> }; + }; + expect(diagnostics.renderTrace.current()['production-adapter-slot']).toEqual( + expect.objectContaining({ + gamEmpty: false, + path: 'gam-refresh', + rendered: true, + }) + ); + expect(listeners.get('slotRequested')).toHaveLength(1); + expect(listeners.get('slotRenderEnded')).toHaveLength(1); + } finally { + composition.runtime.dispose(); + } + }); + + it('activates reversible core effects in exact order and disposes them in reverse', async () => { + const target = {}; + const order: string[] = []; + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId: 'a'.repeat(64), + manifest: runtimeManifest('a'.repeat(64), ['test']), + knownIntegrationIds: Object.freeze(['test']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'boot', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + prebid: fakePrebidAdapter(), + messaging: fakeMessagingAdapter(() => { + order.push('bridge'); + return () => order.push('dispose-bridge'); + }), + }, + coreActivations: { + correctnessGptListeners: ({ onDispose }, adapters) => { + expect(Object.isFrozen(adapters)).toBe(true); + onDispose(() => order.push('dispose-gpt')); + order.push('gpt'); + }, + }, + } + ); + + expect(composition.runtime.state).toBe('unclaimed'); + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + testTakeoverRegistration( + 'test', + 'a'.repeat(64), + ({ + interfaces, + onDispose, + }: { + interfaces: Readonly>; + onDispose(callback: () => void): void; + }) => { + expect(interfaces).not.toHaveProperty('diagnostics'); + onDispose(() => order.push('dispose-module')); + return Object.freeze({ activate: () => order.push('module') }); + } + ) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['bridge', 'gpt', 'module']); + expect(composition.pucBridgeForTest()).toBeDefined(); + const diagnostics = ( + target as { + diagnostics?: { + renderTrace?: { + current(): Readonly>; + history(): readonly unknown[]; + subscribe(listener: (record: unknown) => void): () => void; + }; + }; + } + ).diagnostics; + expect(Object.isFrozen(diagnostics)).toBe(true); + expect(Reflect.ownKeys(diagnostics ?? {})).toEqual(['renderTrace']); + expect(Reflect.ownKeys(diagnostics?.renderTrace ?? {}).sort()).toEqual([ + 'current', + 'history', + 'subscribe', + ]); + expect(diagnostics).not.toHaveProperty('publish'); + expect(diagnostics).not.toHaveProperty('dispose'); + + composition.runtime.dispose(); + expect(order).toEqual([ + 'bridge', + 'gpt', + 'module', + 'dispose-module', + 'dispose-gpt', + 'dispose-bridge', + ]); + expect(composition.pucBridgeForTest()).toBeUndefined(); + expect(() => composition.adapters.googletag.run(() => undefined)).toThrowError( + expect.objectContaining({ code: 'operation_disposed' }) + ); + expect(() => composition.adapters.prebid.run(() => undefined)).toThrowError( + expect.objectContaining({ code: 'operation_disposed' }) + ); + expect(Object.isFrozen(composition)).toBe(true); + expect(Object.isFrozen(composition.runtime)).toBe(true); + expect(diagnostics?.renderTrace?.current()).toEqual({}); + expect(diagnostics?.renderTrace?.history()).toEqual([]); + }); + + it('starts core slot listeners before module activation and disposes both listeners', async () => { + const releaseId = 'a'.repeat(64); + const subscriptions: string[] = []; + const releases: string[] = []; + const facade = { + bindingToken: () => Object.freeze({}), + subscribe: (eventType: string) => { + subscriptions.push(eventType); + return () => releases.push(eventType); + }, + } as unknown as GoogletagFacade; + const googletag: GoogletagAdapter = Object.freeze({ + bindingStatus: () => 'present', + enqueueGamAttribution: vi.fn(() => true), + diagnosticsIdentity: () => undefined, + dispose: vi.fn(), + notifyReady: vi.fn(), + observeDiagnostics: () => vi.fn(), + observePublisherCalls: () => vi.fn(), + traceToken: () => undefined, + run: (command: (gpt: Readonly) => T) => { + const result = Promise.resolve(command(facade)); + return Object.freeze({ status: 'present' as const, result, dispose: vi.fn() }); + }, + }); + const correctness = vi.fn( + ( + _context: unknown, + _adapters: unknown, + services: { readonly slots: { readonly snapshotForTest: () => { records: number } } } + ) => { + expect(subscriptions).toEqual(['slotRequested', 'slotRenderEnded']); + expect(services.slots.snapshotForTest().records).toBe(0); + } + ); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: runtimeManifest(releaseId, ['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag, + messaging: fakeMessagingAdapter(() => { + expect(subscriptions).toEqual([]); + return vi.fn(); + }), + prebid: fakePrebidAdapter(), + }, + coreActivations: { + correctnessGptListeners: correctness, + }, + gptStartupForTest: () => { + expect(subscriptions).toEqual(['slotRequested', 'slotRenderEnded']); + }, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(correctness).toHaveBeenCalledOnce(); + composition.runtime.dispose(); + expect(releases).toEqual(['slotRenderEnded', 'slotRequested']); + }); + + it('activates one six-fact GPT diagnostics stream and publishes only diagnostics.gpt', async () => { + const releaseId = 'a'.repeat(64); + const target: Record = {}; + const gpt = synchronousGptAdapter(); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: runtimeManifest(releaseId, GPT_DIAGNOSTICS_TEST_IDS), + knownIntegrationIds: GPT_DIAGNOSTICS_TEST_IDS, + catalog: runtimeCatalog(GPT_DIAGNOSTICS_TEST_IDS), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + composition.createDiagnosticsCapabilityProviderRegistrationForTest() + ) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + createGptDiagnosticsIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + expect(gpt.diagnosticsObserverActive()).toBe(true); + expect( + [...gpt.listenerInventory()].sort(([left], [right]) => left.localeCompare(right)) + ).toEqual([ + ['impressionViewable', 1], + ['slotOnload', 1], + ['slotRenderEnded', 1], + ['slotRequested', 1], + ['slotResponseReceived', 1], + ['slotVisibilityChanged', 1], + ]); + const diagnostics = target['diagnostics'] as + | { + readonly gpt?: { + snapshot(): { readonly slots: readonly { readonly slotElementId?: string }[] }; + }; + readonly renderTrace?: object; + } + | undefined; + expect(Reflect.ownKeys(diagnostics ?? {}).sort()).toEqual(['gpt', 'renderTrace']); + expect(Reflect.ownKeys(diagnostics?.gpt ?? {}).sort()).toEqual( + ['export', 'hide', 'show', 'snapshot', 'subscribe'].sort() + ); + expect(diagnostics).not.toHaveProperty('publish'); + expect(target['gptDiagnostics']).toBeUndefined(); + expect(target['__tsjs_gpt_diagnostics_runtime']).toBeUndefined(); + + const observedSlot = Object.freeze({ + getSlotElementId: () => 'composition-slot', + getAdUnitPath: () => '/example/composition-slot', + }); + gpt.emit('slotRequested', { slot: observedSlot }); + gpt.emit('slotResponseReceived', { slot: observedSlot }); + expect(diagnostics?.gpt?.snapshot().slots[0]?.slotElementId).toBe('composition-slot'); + + composition.runtime.dispose(); + await Promise.resolve(); + expect(gpt.diagnosticsObserverActive()).toBe(false); + expect(gpt.listenerInventory()).toEqual([]); + }); + + it('keeps the core diagnostics ingress private from integration modules', async () => { + const releaseId = 'a'.repeat(64); + const gpt = synchronousGptAdapter(); + const integrationIds = Object.freeze([ + BROWSER_TEST_DIAGNOSTICS_PROVIDER_ID, + 'diagnostics_probe', + 'gpt_diagnostics', + 'diagnostics_presentation', + ]); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: runtimeManifest(releaseId, integrationIds), + knownIntegrationIds: integrationIds, + catalog: runtimeCatalog(integrationIds), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + composition.createDiagnosticsCapabilityProviderRegistrationForTest() + ) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + testTakeoverRegistration( + 'diagnostics_probe', + releaseId, + ({ interfaces }: IntegrationPrepareContext) => { + expect(interfaces).not.toHaveProperty('diagnostics'); + const trace = interfaces['trace.v1'] as Readonly>; + expect(Reflect.ownKeys(trace).sort()).toEqual( + ['diagnostics', 'enrich', 'observations', 'prune', 'record'].sort() + ); + expect(Reflect.ownKeys(trace['observations'] as object)).toEqual(['publish']); + expect(trace).not.toHaveProperty('attachPresentation'); + return Object.freeze({ activate: vi.fn() }); + } + ) + ) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + createGptDiagnosticsIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + } finally { + composition.runtime.dispose(); + } + }); + + it('routes safe GPT facts into the same-impression render trace state machine', async () => { + const releaseId = 'a'.repeat(64); + const target: Record = {}; + const gpt = synchronousGptAdapter(); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: runtimeManifest(releaseId, GPT_DIAGNOSTICS_TEST_IDS), + knownIntegrationIds: GPT_DIAGNOSTICS_TEST_IDS, + catalog: runtimeCatalog(GPT_DIAGNOSTICS_TEST_IDS), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + composition.createDiagnosticsCapabilityProviderRegistrationForTest() + ) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + createGptDiagnosticsIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const addAdUnits = target['addAdUnits'] as (unit: unknown) => unknown; + addAdUnits({ + code: 'gpt-trace-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }); + const physicalSlot = Object.freeze({ + getSlotElementId: () => 'gpt-trace-slot', + getAdUnitPath: () => '/example/gpt-trace-slot', + }); + const navigation = composition.runtimeSessionForTest()?.currentNavigation; + if (!navigation) throw new Error('Expected active navigation'); + expect( + composition.slotServiceForTest()?.adoptGptSlot(navigation.generation, 'gpt-trace-slot', { + ownership: 'publisher', + slot: physicalSlot, + }) + ).toEqual({ ok: true }); + expect(gpt.listenerRoles('slotRequested')).toEqual([false]); + + gpt.emit('slotRequested', { slot: physicalSlot }); + expect(composition.slotServiceForTest()?.snapshotForTest().cycles).toBe(1); + gpt.emit('slotRenderEnded', { slot: physicalSlot, isEmpty: false }); + gpt.emit('impressionViewable', { slot: physicalSlot }); + expect(gpt.diagnosticFacts().map((fact) => [fact.kind, fact.slot.cycleOrdinal])).toEqual([ + ['slotRequested', 1], + ['slotRenderEnded', 1], + ['impressionViewable', 1], + ]); + + const diagnostics = target['diagnostics'] as { + renderTrace: { + current(): Readonly>>>; + history(): readonly Readonly>[]; + }; + }; + expect(diagnostics.renderTrace.current()['gpt-trace-slot']).toEqual( + expect.objectContaining({ + path: 'gam-refresh', + rendered: true, + gamEmpty: false, + injected: false, + visible: true, + servedFrom: 'gam', + }) + ); + expect(diagnostics.renderTrace.history()).toHaveLength(1); + } finally { + composition.runtime.dispose(); + } + }); + + it('reconciles a trusted terminal that arrives after the GPT render fact', async () => { + const releaseId = 'a'.repeat(64); + const target: Record = {}; + const gpt = synchronousGptAdapter(); + const renderSource = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
reverse-order winner
', + width: 300, + height: 250, + }); + const auctionFetcher = vi.fn(async () => ({ + ok: true, + json: async () => ({ + id: 'reverse-auction', + cur: 'USD', + seatbid: [ + { + seat: 'fictional', + bid: [ + { + id: 'r1_AAAAAAAAAAAAAAAAAAAAAA', + impid: 'reverse-order-slot', + price: 1, + adm: renderSource.adm, + w: renderSource.width, + h: renderSource.height, + ext: { + trusted_server: { + candidate_id: 'AAAAAAAAAAAA', + slot_id: 'reverse-order-slot', + render_source: renderSource, + }, + }, + }, + ], + }, + ], + ext: { + trusted_server: { + slot_results: { + version: 1, + auctionId: 'reverse-auction', + results: [ + { + slot: 'reverse-order-slot', + outcome: 'winner', + candidateId: 'AAAAAAAAAAAA', + }, + ], + }, + }, + }, + }), + })); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: runtimeManifest(releaseId, GPT_DIAGNOSTICS_TEST_IDS), + knownIntegrationIds: GPT_DIAGNOSTICS_TEST_IDS, + catalog: runtimeCatalog(GPT_DIAGNOSTICS_TEST_IDS), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + auctionFetcherForTest: auctionFetcher, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + composition.createDiagnosticsCapabilityProviderRegistrationForTest() + ) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + createGptDiagnosticsIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const api = target as { + addAdUnits(value: unknown): unknown; + requestAds(options: unknown): Promise; + diagnostics: { + renderTrace: { + current(): Readonly>>>; + history(): readonly Readonly>[]; + }; + }; + }; + api.addAdUnits({ + code: 'reverse-order-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }); + document.body.innerHTML = '
'; + const physicalSlot = Object.freeze({ + getSlotElementId: () => 'reverse-order-slot', + getAdUnitPath: () => '/example/reverse-order-slot', + }); + const navigation = composition.runtimeSessionForTest()?.currentNavigation; + if (!navigation) throw new Error('Expected active navigation'); + expect( + composition + .slotServiceForTest() + ?.adoptGptSlot(navigation.generation, 'reverse-order-slot', { + ownership: 'publisher', + slot: physicalSlot, + }) + ).toEqual({ ok: true }); + gpt.emit('slotRequested', { slot: physicalSlot }); + gpt.emit('slotRenderEnded', { slot: physicalSlot, isEmpty: false }); + const provisional = api.diagnostics.renderTrace.current()['reverse-order-slot']; + + const request = api.requestAds({ slots: ['reverse-order-slot'] }); + await vi.waitFor(() => + expect(document.querySelector('#reverse-order-slot iframe')).not.toBeNull() + ); + document + .querySelector('#reverse-order-slot iframe') + ?.dispatchEvent(new Event('load')); + await expect(request).resolves.toEqual({ + slots: [{ slot: 'reverse-order-slot', path: 'primary', outcome: 'accepted' }], + }); + + expect(api.diagnostics.renderTrace.current()['reverse-order-slot']).toEqual( + expect.objectContaining({ + seq: provisional?.['seq'], + count: provisional?.['count'], + at: provisional?.['at'], + path: 'auction', + rendered: true, + injected: true, + gamEmpty: false, + servedFrom: 'inline', + }) + ); + expect(api.diagnostics.renderTrace.history()).toHaveLength(1); + gpt.emit('slotVisibilityChanged', { slot: physicalSlot, inViewPercentage: 0 }); + expect(api.diagnostics.renderTrace.current()['reverse-order-slot']).toEqual( + expect.objectContaining({ seq: provisional?.['seq'], path: 'auction', visible: false }) + ); + expect(api.diagnostics.renderTrace.history()).toHaveLength(1); + } finally { + composition.runtime.dispose(); + document.body.innerHTML = ''; + } + }); + + it('injects GPT and Prebid module boundaries with only server-frozen configuration', async () => { + const releaseId = 'a'.repeat(64); + const target = {}; + const gptConfig = Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }); + const prebidConfig = Object.freeze({ + accountId: 'test', + timeout: 1_000, + debug: false, + bidders: Object.freeze(['rubicon']), + clientSideBidders: Object.freeze(['rubicon']), + excludedGamAdUnitPathSuffixes: Object.freeze([]), + }); + const providedBindings = vi.fn((id: string) => ({ + config: id === 'prebid' ? prebidConfig : gptConfig, + interfaces: Object.freeze({ publisherControlled: Object.freeze({}) }), + })); + const startGpt = vi.fn((received: unknown) => { + expect(received).toEqual(gptConfig); + expect(Object.isFrozen(received)).toBe(true); + expect((target as { version?: unknown }).version).toBe('1.0.0'); + }); + const startPrebid = vi.fn((received: unknown) => { + expect(received).toEqual(prebidConfig); + expect(Object.isFrozen(received)).toBe(true); + expect((target as { version?: unknown }).version).toBe('1.0.0'); + }); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: runtimeManifest(releaseId, ['gpt', 'prebid']), + knownIntegrationIds: Object.freeze(['gpt', 'prebid']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + integrations: { + version: 1, + entries: [ + { id: 'gpt', config: gptConfig }, + { id: 'prebid', config: prebidConfig }, + ], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: providedBindings, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(() => vi.fn()), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + gptStartupForTest: startGpt, + prebidStartupForTest: startPrebid, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + createLegacyPrebidIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + expect(providedBindings).toHaveBeenCalledTimes(2); + expect(providedBindings).toHaveBeenNthCalledWith(1, 'gpt'); + expect(providedBindings).toHaveBeenNthCalledWith(2, 'prebid'); + expect(startGpt).toHaveBeenCalledExactlyOnceWith(gptConfig); + expect(startPrebid).toHaveBeenCalledExactlyOnceWith(prebidConfig); + expect(isGuardInstalled()).toBe(true); + expect(composition.runtimeSessionForTest()?.interfaces).not.toHaveProperty( + 'publisherControlled' + ); + } finally { + composition.runtime.dispose(); + resetGuardState(); + } + expect(isGuardInstalled()).toBe(false); + }); + + it('composes the configured Prebid refresh policy through the owned GPT boundary', async () => { + const releaseId = 'a'.repeat(64); + const target: Record = {}; + const gpt = synchronousGptAdapter(); + const prebid = synchronousPrebidAdapter(); + const prebidConfig = Object.freeze({ + accountId: 'test', + timeout: 1_500, + debug: false, + bidders: Object.freeze(['client']), + clientSideBidders: Object.freeze(['client']), + excludedGamAdUnitPathSuffixes: Object.freeze([]), + }); + let request: + | Readonly<{ + adUnits: readonly object[]; + bidsBackHandler: () => void; + timeout: number; + }> + | undefined; + prebid.requestBids.mockImplementation((candidate: unknown) => { + request = candidate as typeof request; + request?.bidsBackHandler(); + }); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: runtimeManifest(releaseId, ['gpt', 'prebid']), + knownIntegrationIds: Object.freeze(['gpt', 'prebid']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + integrations: { + version: 1, + entries: [ + { + id: 'gpt', + config: { gamAttributionEnabled: false, pageBidsEnabled: true }, + }, + { id: 'prebid', config: prebidConfig }, + ], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: (id) => ({ + config: id === 'prebid' ? prebidConfig : Object.freeze({}), + interfaces: Object.freeze({}), + }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: prebid.adapter, + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + expect( + composition.runtime.registerIntegration(createLegacyPrebidIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + const api = target as { + addAdUnits(unit: unknown): Readonly<{ registered: readonly string[] }>; + }; + expect( + api.addAdUnits({ + code: 'refresh-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [ + { bidder: 'server', params: { placement: 7 } }, + { bidder: 'client', params: { placement: 'browser' } }, + ], + }) + ).toEqual({ registered: ['refresh-slot'] }); + const navigation = composition.runtimeSessionForTest()?.currentNavigation; + const slots = composition.slotServiceForTest(); + const physicalSlot = Object.freeze({ getAdUnitPath: () => '/network/refresh-slot' }); + if (!navigation || !slots) throw new Error('Expected the active refresh composition'); + expect( + slots.adoptGptSlot(navigation.generation, 'refresh-slot', { + definition: { + adUnitPath: '/network/refresh-slot', + elementId: 'refresh-slot', + sizes: Object.freeze([[300, 250]]), + }, + ownership: 'publisher', + slot: physicalSlot, + }) + ).toEqual({ ok: true }); + + const refreshOptions = Object.freeze({ changeCorrelator: false }); + const decision = gpt.publisherRefresh( + Object.freeze({ + requestedSlots: Object.freeze([physicalSlot]), + slots: Object.freeze([physicalSlot]), + options: refreshOptions, + }) + ); + expect(decision).toMatchObject({ + action: 'defer', + slots: [physicalSlot], + completion: expect.any(Promise), + }); + if (decision?.action !== 'defer') throw new Error('Expected the composed refresh policy'); + await decision.completion; + + expect(request?.timeout).toBe(1_500); + expect(request?.adUnits).toEqual([ + { + code: 'refresh-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [ + { + bidder: 'trustedServer', + params: { bidderParams: { server: { placement: 7 } } }, + }, + { bidder: 'client', params: { placement: 'browser' } }, + ], + }, + ]); + expect(prebid.setTargetingForGpt).toHaveBeenCalledExactlyOnceWith(['refresh-slot']); + composition.runtime.dispose(); + }); + + it('owns every remaining integration in one maximal composed transaction', async () => { + vi.useFakeTimers(); + const releaseId = 'a'.repeat(64); + const target = {}; + const takeoverMembers = Object.freeze([ + ['render_runtime', createRenderRuntimeIntegrationRegistration] as const, + ['datadome', createDataDomeIntegrationRegistration] as const, + ['didomi', createDidomiIntegrationRegistration] as const, + ['google_tag_manager', createGoogleTagManagerIntegrationRegistration] as const, + ['lockr', createLockrIntegrationRegistration] as const, + ['osano_consent', createOsanoIntegrationRegistration] as const, + ['permutive_context', createPermutiveIntegrationRegistration] as const, + ['sourcepoint_consent', createSourcepointIntegrationRegistration] as const, + ['testlight', createTestlightIntegrationRegistration] as const, + ]); + const deferredMembers = Object.freeze([ + ['osano_lifecycle', createOsanoLifecycleIntegrationRegistration] as const, + ['permutive_lifecycle', createPermutiveLifecycleIntegrationRegistration] as const, + ['sourcepoint_lifecycle', createSourcepointLifecycleIntegrationRegistration] as const, + ]); + const members = Object.freeze([...takeoverMembers, ...deferredMembers]); + const ids = Object.freeze(members.map(([id]) => id)); + const configFor = (id: string): unknown => { + if (id === 'didomi') return Object.freeze({ proxyPath: '/integrations/didomi/consent/' }); + if (id === 'sourcepoint_consent') return Object.freeze({ rewriteSdk: true }); + return undefined; + }; + const manifest = runtimeManifest(releaseId, ids); + expect(manifest.integrations.map(({ id, phase }) => [id, phase])).toEqual([ + ['render_runtime', 'takeover'], + ['datadome', 'takeover'], + ['didomi', 'takeover'], + ['google_tag_manager', 'takeover'], + ['lockr', 'takeover'], + ['osano_consent', 'takeover'], + ['permutive_context', 'takeover'], + ['sourcepoint_consent', 'takeover'], + ['testlight', 'takeover'], + ['osano_lifecycle', 'deferred'], + ['permutive_lifecycle', 'deferred'], + ['sourcepoint_lifecycle', 'deferred'], + ]); + const runtimeScript = document.createElement('script'); + runtimeScript.id = 'trustedserver-js'; + runtimeScript.src = new URL(manifest.runtimeSrc, window.location.origin).href; + document.head.append(runtimeScript); + let executingScript: HTMLScriptElement | null = runtimeScript; + const currentScript = vi + .spyOn(document, 'currentScript', 'get') + .mockImplementation(() => executingScript); + const appendChildBefore = Element.prototype.appendChild; + const insertBeforeBefore = Element.prototype.insertBefore; + const didomiBefore = Object.getOwnPropertyDescriptor(window, 'didomiConfig'); + const testlightBefore = Object.getOwnPropertyDescriptor(window, 'testlight'); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest, + knownIntegrationIds: ids, + catalog: runtimeCatalog(ids), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: (id) => ({ config: configFor(id), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + const originalHeadAppend = document.head.append.bind(document.head); + const loadedDeferredIds: string[] = []; + const headAppend = vi.spyOn(document.head, 'append').mockImplementation((...nodes) => { + originalHeadAppend(...nodes); + for (const node of nodes) { + if (!(node instanceof HTMLScriptElement) || node === runtimeScript) continue; + const member = deferredMembers.find(([id]) => node.src.includes(`tsjs-${id}.min.js`)); + if (!member) continue; + executingScript = node; + loadedDeferredIds.push(member[0]); + expect(composition.runtime.registerIntegration(member[1](releaseId))).toBe(true); + node.onload?.(new Event('load')); + executingScript = runtimeScript; + } + }); + + expect(composition.runtime.start()).toBe(true); + for (const [, createRegistration] of takeoverMembers) { + expect(composition.runtime.registerIntegration(createRegistration(releaseId))).toBe(true); + } + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(composition.runtime.protectFirstDisplayAttemptBatch([Promise.resolve()])).toBe(true); + await vi.advanceTimersByTimeAsync(2_500); + expect(loadedDeferredIds).toEqual(deferredMembers.map(([id]) => id)); + expect(composition.auctionContextRegistryForTest()?.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: [], + }); + expect(vi.getTimerCount()).toBeGreaterThan(0); + + composition.runtime.dispose(); + composition.runtime.dispose(); + expect(vi.getTimerCount()).toBe(0); + expect(Element.prototype.appendChild).toBe(appendChildBefore); + expect(Element.prototype.insertBefore).toBe(insertBeforeBefore); + expect(Object.getOwnPropertyDescriptor(window, 'didomiConfig')).toEqual(didomiBefore); + expect(Object.getOwnPropertyDescriptor(window, 'testlight')).toEqual(testlightBefore); + headAppend.mockRestore(); + currentScript.mockRestore(); + runtimeScript.remove(); + }); + + it('injects the exact creative boot into reversible activation and post-commit startup', async () => { + const releaseId = 'a'.repeat(64); + const creative = Object.freeze({ + version: 1 as const, + enabled: true, + clickGuard: true, + renderGuard: false, + }); + const release = vi.fn(); + const activateCreative = vi.fn((received: unknown) => { + expect(received).toEqual(creative); + expect(Object.isFrozen(received)).toBe(true); + return release; + }); + const startCreative = vi.fn((received: unknown) => { + expect(received).toEqual(creative); + expect(Object.isFrozen(received)).toBe(true); + }); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: runtimeManifest(releaseId, ['creative']), + knownIntegrationIds: Object.freeze(['creative']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + creativeActivationForTest: activateCreative, + creativeStartupForTest: startCreative, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + createLegacyCreativeIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(activateCreative).toHaveBeenCalledTimes(1); + expect(startCreative).toHaveBeenCalledTimes(1); + expect(activateCreative.mock.calls[0]?.[0]).toBe(startCreative.mock.calls[0]?.[0]); + + composition.runtime.dispose(); + composition.runtime.dispose(); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('commits enabled creative with both guards false without creative effects', async () => { + const releaseId = 'a'.repeat(64); + const activateCreative = vi.fn(); + const startCreative = vi.fn(); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: runtimeManifest(releaseId, []), + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: true, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + creativeActivationForTest: activateCreative, + creativeStartupForTest: startCreative, + } + ); + + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(activateCreative).not.toHaveBeenCalled(); + expect(startCreative).not.toHaveBeenCalled(); + + composition.runtime.dispose(); + }); + + it.each([ + [ + 'disabled click guard bit', + { version: 1, enabled: false, clickGuard: true, renderGuard: false }, + [], + ], + [ + 'disabled render guard bit', + { version: 1, enabled: false, clickGuard: false, renderGuard: true }, + [], + ], + [ + 'disabled creative manifest member', + { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + ['creative'], + ], + [ + 'missing enabled creative manifest member', + { version: 1, enabled: true, clickGuard: true, renderGuard: false }, + [], + ], + ] as const)('rejects creative ABI mismatch: %s', async (_caseName, creative, manifestIds) => { + const releaseId = 'a'.repeat(64); + const activateCreative = vi.fn(); + const startCreative = vi.fn(); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: runtimeManifest(releaseId, manifestIds), + knownIntegrationIds: Object.freeze(['creative']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + creativeActivationForTest: activateCreative, + creativeStartupForTest: startCreative, + } + ); + + expect(composition.runtime.start()).toBe(true); + if (manifestIds.length === 1) { + expect( + composition.runtime.registerIntegration( + createProductionCreativeIntegrationRegistration(releaseId) + ) + ).toBe(true); + } + await expect(composition.runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect(activateCreative).not.toHaveBeenCalled(); + expect(startCreative).not.toHaveBeenCalled(); + }); + + it('owns the real creative click guard through the composition lifecycle', async () => { + const releaseId = 'a'.repeat(64); + const addEventListener = vi.spyOn(document, 'addEventListener'); + const removeEventListener = vi.spyOn(document, 'removeEventListener'); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: runtimeManifest(releaseId, ['creative']), + knownIntegrationIds: Object.freeze(['creative']), + catalog: runtimeCatalog(Object.freeze(['creative'])), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + createProductionCreativeIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(addEventListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1); + expect(addEventListener.mock.calls.filter(([type]) => type === 'auxclick')).toHaveLength(1); + } finally { + composition.runtime.dispose(); + addEventListener.mockRestore(); + } + expect(removeEventListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1); + expect(removeEventListener.mock.calls.filter(([type]) => type === 'auxclick')).toHaveLength(1); + removeEventListener.mockRestore(); + }); + + it('publishes and promotes one exact Prebid winner through runtime-owned PUC state', async () => { + const releaseId = 'a'.repeat(64); + const prebid = synchronousPrebidAdapter(); + const reservationId = `r1_${'p'.repeat(22)}`; + let captureListener: CaptureMessageListener | undefined; + const messagingTarget = { + addEventListener: vi.fn( + (_type: 'message', listener: CaptureMessageListener, _capture: true) => { + captureListener = listener; + } + ), + removeEventListener: vi.fn(), + }; + const messaging = createBrowserMessagingAdapter(messagingTarget); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: 'slot-one', + provider: 'trusted', + upstreamBidId: 'upstream-one', + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trustedServer' }), + rendererReservationId: reservationId, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
private creative
', + width: 300, + height: 250, + }), + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'auction-one', + results: Object.freeze([ + Object.freeze({ + slot: bid.slot, + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + slots: Object.freeze([browserSlotPlacement(bid.slot)]), + bids: Object.freeze([bid]), + }); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: runtimeManifest(releaseId, ['prebid']), + knownIntegrationIds: Object.freeze(['prebid']), + boot: { + auctionProjection: projection, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging, + prebid: prebid.adapter, + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + createIdentityIssuerForTest: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(9); + return target; + }, + }), + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + createLegacyPrebidIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const complete = vi.fn(); + prebid.auction( + Object.freeze({ + auctionId: 'auction-one', + bids: Object.freeze([Object.freeze({ adUnitCode: bid.slot, requestId: 'request-one' })]), + complete, + }) + ); + + expect(complete).toHaveBeenCalledTimes(1); + expect(prebid.admitTrustedBid).toHaveBeenCalledTimes(1); + expect(prebid.admitTrustedBid.mock.calls[0]?.[0]).toMatchObject({ + auctionId: 'auction-one', + adUnitCode: bid.slot, + bid: { adId: reservationId, requestId: 'request-one' }, + }); + expect(composition.reservationServiceForTest()?.recognize(reservationId)).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + + prebid.auctionEnd('auction-one'); + expect(composition.reservationServiceForTest()?.recognize(reservationId)).toMatchObject({ + state: 'renderable', + }); + expect(composition.pucBridgeForTest()?.snapshotInventoryForTest()).toMatchObject({ + attempts: 1, + }); + + const claimPort = { + addEventListener: vi.fn(), + close: vi.fn(), + postMessage: vi.fn(), + removeEventListener: vi.fn(), + start: vi.fn(), + }; + captureListener?.({ + data: JSON.stringify({ + message: 'Prebid Request', + adId: reservationId, + adServerDomain: 'ads.example.com', + }), + ports: [claimPort], + source: Object.freeze({ frame: 'selected-creative' }), + stopImmediatePropagation: vi.fn(), + } as unknown as MessageEvent); + expect(composition.pucBridgeForTest()?.snapshotInventoryForTest()).toMatchObject({ + attempts: 1, + liveTickets: 0, + pendingClaims: 1, + }); + expect(claimPort.postMessage).not.toHaveBeenCalled(); + expect(claimPort.close).not.toHaveBeenCalled(); + } finally { + composition.runtime.dispose(); + } + }); + + const prebidPublicationFailureCases: readonly (readonly [ + string, + (prepared: Readonly) => 'admitted' | 'not_admitted', + 'prebid_admission_failed' | 'prebid_contract_violation', + ])[] = [ + ['not admitted', () => 'not_admitted', 'prebid_admission_failed'], + [ + 'partial publication', + () => { + throw new PrebidAdmissionContractError(); + }, + 'prebid_contract_violation', + ], + ]; + it.each(prebidPublicationFailureCases)( + 'settles a %s Prebid publication as an exact slot lifecycle failure', + async (_case, admission, reason) => { + const releaseId = 'a'.repeat(64); + const prebid = synchronousPrebidAdapter(admission); + const reservationId = `r1_${'q'.repeat(22)}`; + const bid = Object.freeze({ + candidateId: 'BBBBBBBBBBBB', + slot: 'failed-slot', + provider: 'trusted', + upstreamBidId: 'failed-upstream', + cpm: 2.5, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trustedServer' }), + rendererReservationId: reservationId, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
must not render
', + width: 300, + height: 250, + }), + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'failed-auction', + results: Object.freeze([ + Object.freeze({ + slot: bid.slot, + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + slots: Object.freeze([browserSlotPlacement(bid.slot)]), + bids: Object.freeze([bid]), + }); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: runtimeManifest(releaseId, ['prebid', 'lifecycle_probe']), + knownIntegrationIds: Object.freeze(['prebid', 'lifecycle_probe']), + boot: { + auctionProjection: projection, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: prebid.adapter, + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + createLegacyPrebidIntegrationRegistration(releaseId) + ) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + testTakeoverRegistration( + 'lifecycle_probe', + releaseId, + ({ interfaces }: { interfaces: Readonly> }) => { + expect(interfaces).not.toHaveProperty('diagnostics'); + return Object.freeze({ activate: vi.fn() }); + } + ) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const complete = vi.fn(); + + prebid.auction( + Object.freeze({ + auctionId: 'failed-auction', + bids: Object.freeze([ + Object.freeze({ adUnitCode: bid.slot, requestId: 'failed-request' }), + ]), + complete, + }) + ); + + expect(complete).toHaveBeenCalledOnce(); + expect(composition.reservationServiceForTest()?.recognize(reservationId)).toMatchObject({ + recognized: true, + state: reason, + }); + expect( + composition.runtimeSessionForTest()?.currentNavigation?.snapshotInventoryForTest() + ).toMatchObject({ + attempts: 0, + batches: 0, + }); + } finally { + composition.runtime.dispose(); + } + } + ); + + it('hands late publisher GPT calls through the adapter into runtime-owned slot state', async () => { + const releaseId = 'a'.repeat(64); + const slot = Object.freeze({ id: 'trusted-slot' }); + const unrelated = Object.freeze({ id: 'publisher-slot' }); + const refresh = vi.fn((_slots?: readonly object[], _options?: unknown) => undefined); + const display = vi.fn((_target: unknown) => undefined); + const destroySlots = vi.fn((_slots?: readonly object[]) => true); + const listeners = new Map void>>(); + const pubads = { + addEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + const registered = listeners.get(type) ?? new Set(); + registered.add(listener); + listeners.set(type, registered); + }), + disableInitialLoad: vi.fn(), + getSlots: vi.fn(() => [slot, unrelated]), + refresh, + removeEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + listeners.get(type)?.delete(listener); + }), + }; + const nativeDefineSlot = vi.fn((_path: string, _sizes: unknown, _elementId: string) => + Object.freeze({ id: 'duplicate' }) + ); + const googletag = { + apiReady: true, + pubadsReady: true, + cmd: { push: (command: () => void) => (command(), 0) }, + defineSlot: nativeDefineSlot, + destroySlots, + display, + getConfig: vi.fn(() => ({ disableInitialLoad: true })), + pubads: () => pubads, + setConfig: vi.fn(), + }; + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: runtimeManifest(releaseId, ['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: { + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'slot', outcome: 'no_bid' }], + }, + slots: [browserSlotPlacement('slot')], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: createBrowserGoogletagAdapter({ googletag }), + messaging: fakeMessagingAdapter(() => vi.fn()), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const navigation = composition.runtimeSessionForTest()?.currentNavigation; + const slots = composition.slotServiceForTest(); + if (!navigation || !slots) throw new Error('Expected active GPT composition'); + expect( + slots.adoptGptSlot(navigation.generation, 'slot', { + definition: { + adUnitPath: '/trusted/path', + elementId: 'slot-div', + sizes: Object.freeze([[300, 250]]), + }, + elementIdPrefix: 'slot-', + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + + expect(googletag.defineSlot('/publisher/mismatch', [728, 90], 'slot-div')).toBe(slot); + expect(nativeDefineSlot).not.toHaveBeenCalled(); + expect(googletag.display('slot-div')).toBeUndefined(); + expect(display).not.toHaveBeenCalled(); + const options = Object.freeze({ changeCorrelator: true, publisher: 'preserved' }); + expect(pubads.refresh(undefined, options)).toBeUndefined(); + expect(refresh).toHaveBeenCalledExactlyOnceWith([unrelated], options); + pubads.refresh([slot], options); + expect(refresh).toHaveBeenLastCalledWith([slot], options); + const request = slots.request({ + intentId: 'publisher-owned', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'cycle_unattributable', + }); + expect(googletag.destroySlots([slot])).toBe(true); + expect(slots.isBoundGptSlot(navigation.generation, 'slot', slot)).toBe(false); + } finally { + composition.runtime.dispose(); + resetGuardState(); + } + expect(destroySlots).toHaveBeenCalledTimes(1); + }); + + it('constructs one session lazily from accepted boot and keeps it across SPA replacement', async () => { + const projection = { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'initial-slot', outcome: 'no_bid' }], + }, + slots: [browserSlotPlacement('initial-slot')], + bids: [], + }; + let prefix = 0; + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: runtimeManifest('a'.repeat(64), []), + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: projection, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + prebid: fakePrebidAdapter(), + messaging: fakeMessagingAdapter(), + }, + coreActivations: { + correctnessGptListeners: vi.fn(), + }, + createIdentityIssuerForTest: () => { + prefix += 1; + return createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(prefix); + return target; + }, + }); + }, + } + ); + + expect(composition.runtimeSessionForTest()).toBeUndefined(); + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const session = composition.runtimeSessionForTest(); + expect(session).toBeDefined(); + expect(composition.runtimeSessionForTest()).toBe(session); + const slotService = composition.slotServiceForTest(); + const targetingService = composition.targetingServiceForTest(); + const reservationService = composition.reservationServiceForTest(); + const rendererNonces = composition.rendererNonceRegistryForTest(); + expect(slotService).toBeDefined(); + expect(targetingService).toBeDefined(); + expect(reservationService).toBeDefined(); + expect(rendererNonces).toBeDefined(); + expect(session?.interfaces['slots']).toBe(slotService); + expect(session?.interfaces['targeting']).toBe(targetingService); + expect(session?.interfaces['reservations']).toBe(reservationService); + expect(session?.interfaces['rendererNonces']).toBe(rendererNonces); + expect(session?.interfaces['renderDirectAps']).toBeTypeOf('function'); + expect(session?.interfaces['renderDirectAdm']).toBeTypeOf('function'); + expect(session?.interfaces['renderDirectCache']).toBeUndefined(); + expect(session?.currentNavigation?.interfaces).toBe(session?.interfaces); + expect(session?.currentNavigation?.currentAuctionProjection).toEqual(projection); + expect(Object.isFrozen(session?.currentNavigation?.currentAuctionProjection)).toBe(true); + + const initialNavigation = session?.currentNavigation; + const artifactBatch = initialNavigation?.createAuctionBatch('accepted-artifact'); + const artifactOwner = artifactBatch?.createRenderAttempt('accepted-artifact-slot'); + const artifactStore = session?.interfaces['artifacts'] as + Parameters[0]['artifacts'] | undefined; + if (!artifactOwner?.ok || !artifactStore || !reservationService) { + throw new Error('Expected accepted-artifact dependencies'); + } + const acceptedSource = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
accepted
', + width: 300, + height: 250, + }); + const acceptedAttempt = createRenderAttempt({ + artifacts: artifactStore, + owner: artifactOwner.value, + prepareRenderSource: () => acceptedSource, + reservations: reservationService, + }); + if (!acceptedAttempt.ok) throw new Error(acceptedAttempt.reason); + const disposeAcceptedArtifact = vi.fn(); + const acceptedArtifact = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: acceptedAttempt.value.id, + slot: acceptedAttempt.value.slot, + navigationGeneration: acceptedAttempt.value.navigationGeneration, + dispose: disposeAcceptedArtifact, + }); + expect( + acceptedAttempt.value.admitDirectWinner(acceptedSource, Object.freeze({ selectedCpm: 1 })) + ).toBe(true); + expect(acceptedAttempt.value.beginDirect()).toBe(true); + expect(acceptedAttempt.value.beginAdm(acceptedArtifact)).toBe(true); + expect(acceptedAttempt.value.accept()).toBe(true); + expect(artifactStore.current('accepted-artifact-slot')).toBe(acceptedArtifact); + + projection.auction.auctionId = 'publisher-mutated'; + expect( + ( + session?.currentNavigation?.currentAuctionProjection as { + auction: { auctionId: string }; + } + ).auction.auctionId + ).toBe('initial'); + const replacement = session?.replaceNavigation(); + expect(replacement).toMatchObject({ ok: true }); + if (!replacement?.ok) throw new Error('Expected SPA navigation'); + expect(disposeAcceptedArtifact).toHaveBeenCalledOnce(); + expect(artifactStore.current('accepted-artifact-slot')).toBeUndefined(); + expect(replacement.value.currentAuctionProjection).toBeUndefined(); + expect(composition.runtimeSessionForTest()).toBe(session); + expect(composition.reservationServiceForTest()).toBe(reservationService); + + const pageBids = composition.pageBidsControllerForTest(); + expect( + pageBids?.commit({ + version: 1, + auction: { + version: 1, + auctionId: 'spa', + results: [{ slot: 'spa-slot', outcome: 'no_bid' }], + }, + slots: [browserSlotPlacement('spa-slot')], + bids: [], + }) + ).toEqual({ status: 'committed' }); + expect(composition.projectionSlotsForTest()).toEqual(['spa-slot']); + + composition.runtime.dispose(); + expect(session?.disposed).toBe(true); + expect(slotService?.snapshotForTest()).toEqual({ + cycles: 0, + intents: 0, + physicalSlots: 0, + records: 0, + }); + expect(targetingService?.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + expect(reservationService?.snapshotInventoryForTest()).toMatchObject({ + disposed: true, + size: 0, + }); + expect(rendererNonces?.snapshotForTest()).toMatchObject({ disposed: true }); + expect(composition.slotServiceForTest()).toBeUndefined(); + expect(composition.targetingServiceForTest()).toBeUndefined(); + expect(composition.reservationServiceForTest()).toBeUndefined(); + expect(composition.rendererNonceRegistryForTest()).toBeUndefined(); + }); + + it('commits canonical page-bids into a replacement navigation without mutating boot', async () => { + const nativeReplaceState = history.replaceState.bind(history); + const releaseId = 'a'.repeat(64); + const initialProjection = { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'initial-slot', outcome: 'no_bid' }], + }, + slots: [browserSlotPlacement('initial-slot')], + bids: [], + }; + const spaProjection = { + version: 1, + auction: { + version: 1, + auctionId: 'spa-auction', + results: [{ slot: 'spa-slot', outcome: 'no_bid' }], + }, + slots: [browserSlotPlacement('spa-slot')], + bids: [], + }; + const fetchPageBids = vi.fn(async () => ({ + ok: true, + json: async () => spaProjection, + })); + const target: Record = {}; + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: runtimeManifest(releaseId, ['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: { + auctionProjection: initialProjection, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + pageBidsFetcherForTest: fetchPageBids, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const boot = (target as { boot: Readonly<{ auctionProjection: object }> }).boot; + const initialNavigation = composition.runtimeSessionForTest()?.currentNavigation; + + history.pushState({}, '', '/spa-route?section=one'); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledOnce()); + expect(fetchPageBids).toHaveBeenCalledWith( + '/_ts/page-bids?path=%2Fspa-route%3Fsection%3Done', + expect.objectContaining({ + credentials: 'include', + headers: { 'X-TSJS-Page-Bids': '1' }, + signal: expect.any(AbortSignal), + }) + ); + await vi.waitFor(() => + expect( + composition.runtimeSessionForTest()?.currentNavigation?.currentAuctionProjection + ).toMatchObject({ auction: { auctionId: 'spa-auction' } }) + ); + + expect(composition.runtimeSessionForTest()?.currentNavigation).not.toBe(initialNavigation); + expect(initialNavigation?.disposed).toBe(true); + expect(composition.projectionSlotsForTest()).toEqual(['spa-slot']); + expect(boot.auctionProjection).toMatchObject({ auction: { auctionId: 'initial' } }); + expect(Object.isFrozen(boot.auctionProjection)).toBe(true); + + history.replaceState({}, '', '/spa-replaced'); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledTimes(2)); + expect(fetchPageBids).toHaveBeenLastCalledWith( + '/_ts/page-bids?path=%2Fspa-replaced', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + + nativeReplaceState({}, '', '/spa-popped'); + window.dispatchEvent(new PopStateEvent('popstate')); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledTimes(3)); + window.dispatchEvent(new PopStateEvent('popstate')); + await Promise.resolve(); + expect(fetchPageBids).toHaveBeenCalledTimes(3); + + fetchPageBids.mockResolvedValueOnce({ + ok: false, + json: async () => spaProjection, + }); + history.pushState({}, '', '/spa-retry'); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledTimes(4)); + history.replaceState({}, '', '/spa-retry'); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledTimes(5)); + expect(fetchPageBids).toHaveBeenLastCalledWith( + '/_ts/page-bids?path=%2Fspa-retry', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + } finally { + composition.runtime.dispose(); + history.replaceState({}, '', '/'); + } + }); + + it('publishes a committed page-bids winner through the replacement navigation GPT lifecycle', async () => { + const releaseId = 'a'.repeat(64); + const gpt = synchronousGptAdapter(); + const placement = browserSlotPlacement('spa-winner'); + const bid = { + candidateId: 'BBBBBBBBBBBB', + slot: placement.slot, + provider: 'trusted', + upstreamBidId: 'spa-upstream', + cpm: 2, + currency: 'USD' as const, + targeting: { hb_bidder: 'trusted' }, + rendererReservationId: `r1_${'s'.repeat(22)}`, + renderSource: { + type: 'adm' as const, + version: 1 as const, + adm: '
spa winner
', + width: 300, + height: 250, + }, + }; + const spaProjection = { + version: 1, + auction: { + version: 1, + auctionId: 'spa-production', + results: [ + { slot: placement.slot, outcome: 'winner' as const, candidateId: bid.candidateId }, + ], + }, + slots: [placement], + bids: [bid], + }; + const fetchPageBids = vi.fn(async () => ({ ok: true, json: async () => spaProjection })); + const element = document.createElement('div'); + element.id = placement.divId; + document.body.append(element); + let prefix = 0; + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: runtimeManifest(releaseId, ['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial-empty', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + createIdentityIssuerForTest: () => { + prefix += 1; + return createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(prefix); + return target; + }, + }); + }, + pageBidsFetcherForTest: fetchPageBids, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + history.pushState({}, '', '/spa-production'); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(gpt.physicalSlots()).toHaveLength(1)); + const physicalSlot = gpt.physicalSlots()[0]; + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(physicalSlot); + expect(gpt.targetingFor(physicalSlot!)).toEqual( + new Map([ + ['hb_adid', [bid.rendererReservationId]], + ['hb_bidder', ['trusted']], + ]) + ); + expect( + composition.runtimeSessionForTest()?.currentNavigation?.currentAuctionProjection + ).toMatchObject({ auction: { auctionId: 'spa-production' } }); + } finally { + composition.runtime.dispose(); + element.remove(); + } + }); + + it('unwinds a lazily-created session when navigation identity generation fails', async () => { + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: runtimeManifest('a'.repeat(64), []), + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + coreActivations: { + correctnessGptListeners: vi.fn(), + }, + createIdentityIssuerForTest: () => ({ + ok: false, + reason: 'identity_generation_failed', + }), + } + ); + + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(composition.runtimeSessionForTest()).toBeUndefined(); + expect(composition.projectionSlotsForTest()).toBeUndefined(); + expect(composition.pucBridgeForTest()).toBeUndefined(); + }); + + it('falls back before publishing services when the PUC capture listener cannot install', async () => { + const correctnessGptListeners = vi.fn(); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: runtimeManifest('a'.repeat(64), []), + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(() => undefined), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners }, + } + ); + + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(correctnessGptListeners).not.toHaveBeenCalled(); + expect(composition.pucBridgeForTest()).toBeUndefined(); + expect(composition.slotServiceForTest()).toBeUndefined(); + }); + + it('releases initial programmatic slots before admitting a replacement SPA projection', async () => { + let prefix = 0; + const programmaticSlots = Object.freeze( + Array.from({ length: 256 }, (_, index) => `programmatic-${index}`) + ); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: runtimeManifest('a'.repeat(64), []), + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + admittedProgrammaticSlotsForTest: programmaticSlots, + coreActivations: { + correctnessGptListeners: vi.fn(), + }, + createIdentityIssuerForTest: () => { + prefix += 1; + return createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(prefix); + return target; + }, + }); + }, + } + ); + + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(composition.projectionSlotsForTest()).toEqual(programmaticSlots); + const replacement = composition.runtimeSessionForTest()?.replaceNavigation(); + expect(replacement).toMatchObject({ ok: true }); + expect(composition.projectionSlotsForTest()).toEqual([]); + + expect( + composition.pageBidsControllerForTest()?.commit({ + version: 1, + auction: { + version: 1, + auctionId: 'spa', + results: [{ slot: 'spa-slot', outcome: 'no_bid' }], + }, + slots: [browserSlotPlacement('spa-slot')], + bids: [], + }) + ).toEqual({ status: 'committed' }); + expect(composition.projectionSlotsForTest()).toEqual(['spa-slot']); + }); + + it('fails closed when admitted programmatic input contains duplicate slot ids', async () => { + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: runtimeManifest('a'.repeat(64), []), + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + admittedProgrammaticSlotsForTest: Object.freeze(['duplicate', 'duplicate']), + coreActivations: { + correctnessGptListeners: vi.fn(), + }, + } + ); + + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(composition.runtimeSessionForTest()).toBeUndefined(); + expect(composition.projectionSlotsForTest()).toBeUndefined(); + }); + + it.each([ + [2, 255], + [1, 256], + ] as const)( + 'rejects one atomic initial registration of %i server plus %i programmatic records', + async (serverCount, programmaticCount) => { + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: runtimeManifest('a'.repeat(64), []), + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: Array.from({ length: serverCount }, (_, index) => ({ + outcome: 'no_bid' as const, + slot: `server-${index}`, + })), + }, + slots: Array.from({ length: serverCount }, (_, index) => + browserSlotPlacement(`server-${index}`) + ), + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + admittedProgrammaticSlotsForTest: Object.freeze( + Array.from({ length: programmaticCount }, (_, index) => `programmatic-${index}`) + ), + coreActivations: { + correctnessGptListeners: vi.fn(), + }, + } + ); + + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(composition.runtimeSessionForTest()).toBeUndefined(); + expect(composition.slotServiceForTest()).toBeUndefined(); + expect(composition.projectionSlotsForTest()).toBeUndefined(); + } + ); + + it('owns an immutable copy of admitted programmatic slot input for navigation cleanup', async () => { + const programmaticSlots = ['programmatic-one', 'programmatic-two']; + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: runtimeManifest('a'.repeat(64), []), + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + admittedProgrammaticSlotsForTest: programmaticSlots, + coreActivations: { + correctnessGptListeners: vi.fn(), + }, + } + ); + + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + programmaticSlots[0] = 'publisher-mutated'; + programmaticSlots.length = 1; + + expect(composition.runtimeSessionForTest()?.replaceNavigation()).toMatchObject({ ok: true }); + expect(composition.projectionSlotsForTest()).toEqual([]); + }); + + it('constructs or activates nothing after a terminal fallback', async () => { + vi.useFakeTimers(); + const serviceConstruction = vi.fn(() => ({ + config: Object.freeze({}), + interfaces: Object.freeze({}), + })); + const adapterActivation = vi.fn(() => 'pending' as const); + const listenerActivation = vi.fn(() => vi.fn()); + const latePreparation = vi.fn(); + const target = {}; + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId: 'a'.repeat(64), + manifest: runtimeManifest('a'.repeat(64), ['missing']), + knownIntegrationIds: Object.freeze(['missing']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'boot', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: serviceConstruction, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(adapterActivation), + prebid: fakePrebidAdapter(adapterActivation), + messaging: fakeMessagingAdapter(listenerActivation), + }, + coreActivations: { + correctnessGptListeners: adapterActivation, + }, + } + ); + + expect(composition.runtime.start()).toBe(true); + const installed = composition.runtime.install(); + await vi.advanceTimersByTimeAsync(10_000); + await expect(installed).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(composition.runtimeSessionForTest()).toBeUndefined(); + expect(composition.projectionSlotsForTest()).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + + expect( + (target as { _registerIntegration(value: unknown): boolean })._registerIntegration( + Object.freeze({ + abi: 1, + id: 'missing', + phase: 'takeover', + releaseId: 'a'.repeat(64), + prepare: latePreparation, + }) + ) + ).toBe(false); + await vi.runAllTimersAsync(); + + expect(serviceConstruction).not.toHaveBeenCalled(); + expect(adapterActivation).not.toHaveBeenCalled(); + expect(listenerActivation).not.toHaveBeenCalled(); + expect(latePreparation).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('isolates and locally logs a throwing auction-context contributor', async () => { + const releaseId = 'a'.repeat(64); + const target = {}; + const requestConfigs: unknown[] = []; + const auctionFetcher = vi.fn(async (_input: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as { config: unknown }; + requestConfigs.push(body.config); + return { + ok: true, + json: async () => ({ + id: 'context-auction', + cur: 'USD', + seatbid: [], + ext: { + trusted_server: { + slot_results: { + version: 1, + auctionId: 'context-auction', + results: [{ slot: 'server-slot', outcome: 'no_bid' }], + }, + }, + }, + }), + }; + }); + const warn = vi.spyOn(localLog, 'warn').mockImplementation(() => undefined); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: runtimeManifest(releaseId, ['context_test']), + knownIntegrationIds: Object.freeze(['context_test']), + boot: { + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'server-slot', outcome: 'no_bid' }], + }, + slots: [browserSlotPlacement('server-slot')], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + auctionFetcherForTest: auctionFetcher, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + testTakeoverRegistration('context_test', releaseId, () => + Object.freeze({ activate: vi.fn() }) + ) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect((target as { log?: unknown }).log).toBe(publicLog); + + const registry = composition.auctionContextRegistryForTest(); + const session = composition.runtimeSessionForTest(); + expect(registry).toBeDefined(); + expect(session).toBeDefined(); + expect( + registry?.register( + 'context_test', + () => { + throw new Error('publisher contributor'); + }, + session! + ) + ).toBe(true); + + const api = target as { + requestAds(options?: unknown): Promise<{ readonly slots: readonly object[] }>; + }; + await expect(api.requestAds({ slots: ['server-slot'] })).resolves.toEqual({ + slots: [{ slot: 'server-slot', path: 'primary', outcome: 'no_bid' }], + }); + const diagnostics = target as { + diagnostics?: { renderTrace?: { history(): readonly unknown[] } }; + }; + expect(diagnostics.diagnostics?.renderTrace?.history()).toEqual([]); + + expect(requestConfigs).toEqual([{}]); + expect(warn).toHaveBeenCalledExactlyOnceWith('auction context: contributor failed', { + integrationId: 'context_test', + reason: 'contributor_failed', + }); + } finally { + composition.runtime.dispose(); + warn.mockRestore(); + } + }); + + it('exercises transactional addAdUnits and invocation-time requestAds snapshots through the test kernel', async () => { + const releaseId = 'a'.repeat(64); + const integrationIds = Object.freeze([ + BROWSER_TEST_TRACE_PROVIDER_ID, + 'context_test', + 'diagnostics_presentation', + ]); + const catalogIds = Object.freeze([ + BROWSER_TEST_TRACE_PROVIDER_ID, + BROWSER_TEST_OPTIONAL_GPT_DIAG_PROVIDER_ID, + 'context_test', + 'diagnostics_presentation', + ]); + const manifest = runtimeManifest(releaseId, integrationIds); + const runtimeScript = document.createElement('script'); + runtimeScript.id = 'trustedserver-js'; + runtimeScript.src = new URL(manifest.runtimeSrc, window.location.origin).href; + document.head.append(runtimeScript); + let executingScript: HTMLScriptElement | null = runtimeScript; + const currentScript = vi + .spyOn(document, 'currentScript', 'get') + .mockImplementation(() => executingScript); + const frames: FrameRequestCallback[] = []; + const idle: Array<() => void> = []; + const presentationAnimationFrame = vi.fn(); + vi.stubGlobal('requestAnimationFrame', presentationAnimationFrame); + const target = {}; + const preGateSlot = document.createElement('div'); + preGateSlot.id = 'pre-gate-overlay-slot'; + document.body.append(preGateSlot); + const requestBodies: Array<{ + adUnits: Array<{ code: string }>; + config: Readonly>; + }> = []; + const auctionFetcher = vi.fn(async (_input: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as { + adUnits: Array<{ code: string }>; + config: Readonly>; + }; + requestBodies.push(body); + const slots = body.adUnits.map(({ code }) => code); + const winnerSlot = + requestBodies.length === 1 || (slots.length === 1 && slots[0] === 'ambiguous-slot') + ? slots[0] + : undefined; + const candidateId = 'AAAAAAAAAAAA'; + const renderSource = { + type: 'adm', + version: 1, + adm: '
programmatic winner
', + width: 300, + height: 250, + } as const; + return { + ok: true, + json: async () => ({ + id: `auction-${requestBodies.length}`, + cur: 'USD', + seatbid: winnerSlot + ? [ + { + seat: 'fictional', + bid: [ + { + id: 'r1_AAAAAAAAAAAAAAAAAAAAAA', + impid: winnerSlot, + price: 1, + adm: renderSource.adm, + w: renderSource.width, + h: renderSource.height, + ext: { + trusted_server: { + candidate_id: candidateId, + slot_id: winnerSlot, + render_source: renderSource, + }, + }, + }, + ], + }, + ] + : [], + ext: { + trusted_server: { + slot_results: { + version: 1, + auctionId: `auction-${requestBodies.length}`, + results: slots.map((slot) => + slot === winnerSlot + ? { slot, outcome: 'winner', candidateId } + : { slot, outcome: 'no_bid' } + ), + }, + }, + }, + }), + }; + }); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + document, + manifest, + knownIntegrationIds: catalogIds, + catalog: runtimeCatalog(catalogIds), + boot: { + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'server-slot', outcome: 'no_bid' }], + }, + slots: [browserSlotPlacement('server-slot')], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: true, gpt: { active: false } }, + }, + phaseScheduler: { + cancelAnimationFrame: vi.fn(), + cancelIdleCallback: vi.fn(), + clearTimeout, + requestAnimationFrame: (callback) => { + frames.push(callback); + return frames.length; + }, + requestIdleCallback: (callback) => { + idle.push(callback); + return idle.length; + }, + setTimeout, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + auctionFetcherForTest: auctionFetcher, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + const originalHeadAppend = document.head.append.bind(document.head); + const loadedPresentation = vi.fn(); + const headAppend = vi.spyOn(document.head, 'append').mockImplementation((...nodes) => { + originalHeadAppend(...nodes); + for (const node of nodes) { + if (!(node instanceof HTMLScriptElement) || node === runtimeScript) continue; + expect(node.src).toBe( + new URL( + manifest.integrations.find(({ id }) => id === 'diagnostics_presentation')!.src!, + window.location.origin + ).href + ); + executingScript = node; + expect( + composition.runtime.registerIntegration( + createDiagnosticsPresentationIntegrationRegistration(releaseId) + ) + ).toBe(true); + loadedPresentation(); + node.onload?.(new Event('load')); + executingScript = runtimeScript; + } + }); + + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + composition.createTraceCapabilityProviderRegistrationForTest() + ) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + testTakeoverRegistration('context_test', releaseId, () => + Object.freeze({ activate: vi.fn() }) + ) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); + expect(presentationAnimationFrame).not.toHaveBeenCalled(); + expect(frames).toEqual([]); + expect(idle).toEqual([]); + expect(loadedPresentation).not.toHaveBeenCalled(); + expect(preGateSlot.getAttributeNames().filter((name) => name.startsWith('data-ts-'))).toEqual( + [] + ); + expect(composition.runtimeSessionForTest()?.interfaces).not.toHaveProperty('gpt.events.v1'); + expect(composition.runtimeSessionForTest()?.interfaces).not.toHaveProperty('gpt_diag.v1'); + expect(composition.runtime.protectFirstDisplayAttemptBatch([Promise.resolve()])).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + frames.shift()?.(1); + frames.shift()?.(2); + idle.shift()?.(); + await vi.waitFor(() => expect(loadedPresentation).toHaveBeenCalledOnce()); + expect(document.getElementById(TRACE_PANEL_ID)).not.toBeNull(); + expect(presentationAnimationFrame).not.toHaveBeenCalled(); + const contextContributor = vi.fn(() => ({ page: 'context' })); + const session = composition.runtimeSessionForTest(); + expect(session).toBeDefined(); + expect( + composition + .auctionContextRegistryForTest() + ?.register('context_test', contextContributor, session!) + ).toBe(true); + const api = target as { + addAdUnits(value: unknown): { readonly registered: readonly string[] }; + requestAds(options?: unknown): Promise<{ readonly slots: readonly object[] }>; + }; + const programmatic = { + code: 'programmatic-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'fictional', params: { placement: 7 } }], + }; + + expect(api.addAdUnits(programmatic)).toEqual({ registered: ['programmatic-slot'] }); + expect(composition.projectionSlotsForTest()).toEqual(['server-slot', 'programmatic-slot']); + const slotService = composition.slotServiceForTest(); + expect(slotService?.resolveRegisteredSlot('programmatic-slot')).toMatchObject({ + domAliases: [], + registeredSlotId: 'programmatic-slot', + source: 'programmatic', + }); + expect(slotService?.resolveDomAlias('programmatic-slot')).toBeUndefined(); + expect(() => + api.addAdUnits([ + { + code: 'must-roll-back', + mediaTypes: { banner: { sizes: [[1, 1]] } }, + }, + { + code: 'server-slot', + mediaTypes: { banner: { sizes: [[1, 1]] } }, + }, + ]) + ).toThrowError(expect.objectContaining({ code: 'slot_collision', unitIndex: 1 })); + expect(composition.projectionSlotsForTest()).toEqual(['server-slot', 'programmatic-slot']); + document.body.insertAdjacentHTML( + 'beforeend', + '
placeholder
' + ); + const explicit = api.requestAds({ slots: ['unknown', 'programmatic-slot'] }); + await vi.waitFor(() => + expect(document.querySelector('#programmatic-slot iframe')).not.toBeNull() + ); + const frame = document.querySelector('#programmatic-slot iframe'); + expect(frame?.srcdoc).toContain('programmatic winner'); + frame?.dispatchEvent(new Event('load')); + await expect(explicit).resolves.toEqual({ + slots: [ + { slot: 'unknown', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + { slot: 'programmatic-slot', path: 'primary', outcome: 'accepted' }, + ], + }); + const renderTrace = ( + target as { + diagnostics?: { + renderTrace?: { + current(): Readonly>>>; + history(): readonly Readonly>[]; + }; + }; + } + ).diagnostics?.renderTrace; + expect(renderTrace?.current()['programmatic-slot']).toEqual( + expect.objectContaining({ + slotId: 'programmatic-slot', + path: 'auction', + rendered: true, + injected: true, + elementId: 'programmatic-slot', + servedFrom: 'inline', + count: 1, + }) + ); + expect(renderTrace?.history()).toHaveLength(1); + expect(Object.isFrozen(renderTrace?.history()[0])).toBe(true); + const programmaticSlot = document.getElementById('programmatic-slot'); + await vi.waitFor(() => { + expect(programmaticSlot?.getAttribute('data-ts-rendered')).toBe('true'); + expect(programmaticSlot?.getAttribute('data-ts-injected')).toBe('true'); + expect(document.getElementById(TRACE_PANEL_ID)?.textContent).toContain('programmatic-slot'); + }); + expect(target).not.toHaveProperty('renders'); + expect(target).not.toHaveProperty('renderLog'); + expect(target).not.toHaveProperty('renderSeq'); + expect(requestBodies[0]).toEqual({ + adUnits: [programmatic], + config: { page: 'context' }, + }); + expect(contextContributor).toHaveBeenCalledOnce(); + + const omitted = api.requestAds(); + expect( + api.addAdUnits({ + code: 'later-slot', + mediaTypes: { banner: { sizes: [[728, 90]] } }, + }) + ).toEqual({ registered: ['later-slot'] }); + await expect(omitted).resolves.toEqual({ + slots: [ + { slot: 'server-slot', path: 'primary', outcome: 'no_bid' }, + { slot: 'programmatic-slot', path: 'primary', outcome: 'no_bid' }, + ], + }); + expect(requestBodies[1]?.adUnits.map(({ code }) => code)).toEqual([ + 'server-slot', + 'programmatic-slot', + ]); + expect(requestBodies[1]?.adUnits).not.toContainEqual( + expect.objectContaining({ code: 'later-slot' }) + ); + expect(requestBodies[1]?.config).toEqual({ page: 'context' }); + expect(contextContributor).toHaveBeenCalledTimes(2); + expect(auctionFetcher).toHaveBeenCalledTimes(2); + + expect( + slotService?.register(session!.currentNavigation!, [ + { + adUnitCode: '/network/path', + domAliases: ['publisher-alias'], + registeredSlotId: 'alias-owner', + source: 'server', + }, + ]) + ).toMatchObject({ ok: true }); + await expect( + api.requestAds({ slots: ['publisher-alias', '/network/path', 'alias-owner'] }) + ).resolves.toEqual({ + slots: [ + { slot: 'publisher-alias', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + { slot: '/network/path', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + { slot: 'alias-owner', path: 'primary', outcome: 'no_bid' }, + ], + }); + expect(requestBodies[2]?.adUnits.map(({ code }) => code)).toEqual(['alias-owner']); + + expect( + api.addAdUnits({ + code: 'ambiguous-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }) + ).toEqual({ registered: ['ambiguous-slot'] }); + document.body.insertAdjacentHTML( + 'beforeend', + '
' + ); + await expect(api.requestAds({ slots: ['ambiguous-slot'] })).resolves.toEqual({ + slots: [ + { + slot: 'ambiguous-slot', + path: 'primary', + outcome: 'failed', + reason: 'slot_unresolved', + }, + ], + }); + expect(document.querySelectorAll('[id="ambiguous-slot"] iframe')).toHaveLength(0); + expect(contextContributor).toHaveBeenCalledTimes(4); + expect(auctionFetcher).toHaveBeenCalledTimes(4); + + session?.currentNavigation?.dispose(); + expect(renderTrace?.current()).toEqual({}); + await vi.waitFor(() => expect(programmaticSlot?.hasAttribute('data-ts-rendered')).toBe(false)); + + composition.runtime.dispose(); + expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); + expect(preGateSlot.getAttributeNames().filter((name) => name.startsWith('data-ts-'))).toEqual( + [] + ); + expect(() => api.addAdUnits(programmatic)).toThrowError( + expect.objectContaining({ name: 'AdUnitRegistrationError', code: 'slot_collision' }) + ); + document.body.innerHTML = ''; + headAppend.mockRestore(); + currentScript.mockRestore(); + runtimeScript.remove(); + preGateSlot.remove(); + vi.unstubAllGlobals(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts b/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts new file mode 100644 index 000000000..79700c42e --- /dev/null +++ b/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts @@ -0,0 +1,1032 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createNoopGoogletagAdapter, + type GoogletagDiagnosticsObserver, +} from '../../src/adapters/googletag'; +import { createNoopMessagingAdapter } from '../../src/adapters/messaging'; +import { createNoopPrebidAdapter } from '../../src/adapters/prebid'; +import { createTestBrowserRuntimeComposition } from '../../src/composition/browser_test'; +import { createApsIntegrationRegistration } from '../../src/integrations/aps/module'; +import { createCreativeIntegrationRegistration } from '../../src/integrations/creative/module'; +import { createDataDomeIntegrationRegistration } from '../../src/integrations/datadome/module'; +import { createDidomiIntegrationRegistration } from '../../src/integrations/didomi/module'; +import { createGoogleTagManagerIntegrationRegistration } from '../../src/integrations/google_tag_manager/module'; +import { createGptLaterIntegrationRegistration } from '../../src/integrations/gpt/later'; +import { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; +import { createGptDiagnosticsIntegrationRegistration } from '../../src/integrations/gpt_diagnostics/module'; +import { createDiagnosticsPresentationIntegrationRegistration } from '../../src/integrations/gpt_diagnostics/presentation'; +import { createLockrIntegrationRegistration } from '../../src/integrations/lockr/module'; +import { createOsanoLifecycleIntegrationRegistration } from '../../src/integrations/osano/lifecycle'; +import { createOsanoIntegrationRegistration } from '../../src/integrations/osano/module'; +import { createPermutiveLifecycleIntegrationRegistration } from '../../src/integrations/permutive/lifecycle'; +import { createPermutiveIntegrationRegistration } from '../../src/integrations/permutive/module'; +import { createPrebidLaterIntegrationRegistration } from '../../src/integrations/prebid/later'; +import { createPrebidIntegrationRegistration } from '../../src/integrations/prebid/module'; +import { createRenderRuntimeIntegrationRegistration } from '../../src/integrations/render_runtime/module'; +import { createSourcepointLifecycleIntegrationRegistration } from '../../src/integrations/sourcepoint/lifecycle'; +import { createSourcepointIntegrationRegistration } from '../../src/integrations/sourcepoint/module'; +import { createTestlightIntegrationRegistration } from '../../src/integrations/testlight/module'; +import type { BootManifestV1 } from '../../src/core/types'; +import type { + IntegrationActivationContext, + IntegrationCatalogEntry, + IntegrationPrepareContext, + IntegrationRegistration, + PreparedIntegration, +} from '../../src/kernel/integration_registry'; +import { + MAX_TAKEOVER_MODULES, + MAX_MANIFEST_MODULES, + RELEASE_CATALOG, +} from '../../src/kernel/release_catalog'; + +const TEST_RELEASE_ID = 'a'.repeat(64); +const EXPECTED_MAXIMAL_INTEGRATION_IDS = Object.freeze([ + 'render_runtime', + 'aps', + 'creative', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'gpt_diagnostics', + 'lockr', + 'osano_consent', + 'permutive_context', + 'sourcepoint_consent', + 'prebid', + 'testlight', + 'diagnostics_presentation', + 'gpt_later', + 'osano_lifecycle', + 'permutive_lifecycle', + 'prebid_later', + 'sourcepoint_lifecycle', +]); +const RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; +const DEFERRED_INTEGRATION_IDS = Object.freeze([ + 'diagnostics_presentation', + 'gpt_later', + 'osano_lifecycle', + 'permutive_lifecycle', + 'prebid_later', + 'sourcepoint_lifecycle', +] as const); + +type RegistrationFactory = (release: string) => IntegrationRegistration; + +const REGISTRATION_FACTORIES = new Map([ + ['render_runtime', createRenderRuntimeIntegrationRegistration], + ['aps', createApsIntegrationRegistration], + ['creative', createCreativeIntegrationRegistration], + ['datadome', createDataDomeIntegrationRegistration], + ['didomi', createDidomiIntegrationRegistration], + ['google_tag_manager', createGoogleTagManagerIntegrationRegistration], + ['gpt', createGptIntegrationRegistration], + ['gpt_diagnostics', createGptDiagnosticsIntegrationRegistration], + ['lockr', createLockrIntegrationRegistration], + ['osano_consent', createOsanoIntegrationRegistration], + ['permutive_context', createPermutiveIntegrationRegistration], + ['prebid', createPrebidIntegrationRegistration], + ['sourcepoint_consent', createSourcepointIntegrationRegistration], + ['testlight', createTestlightIntegrationRegistration], + ['diagnostics_presentation', createDiagnosticsPresentationIntegrationRegistration], + ['gpt_later', createGptLaterIntegrationRegistration], + ['osano_lifecycle', createOsanoLifecycleIntegrationRegistration], + ['permutive_lifecycle', createPermutiveLifecycleIntegrationRegistration], + ['prebid_later', createPrebidLaterIntegrationRegistration], + ['sourcepoint_lifecycle', createSourcepointLifecycleIntegrationRegistration], +]); + +function maximalIntegrationIds(): readonly string[] { + return Object.freeze(RELEASE_CATALOG.map(({ id }) => id)); +} + +function maximalManifest(): Readonly { + return Object.freeze({ + version: 1, + releaseId: TEST_RELEASE_ID, + firstDisplay: null, + runtimeSrc: RUNTIME_SRC, + integrations: Object.freeze( + RELEASE_CATALOG.map(({ id, phase, trigger }) => { + if (phase === 'takeover') return Object.freeze({ id, phase }); + if (trigger !== 'first_display_or_idle') { + throw new TypeError(`Deferred fixture ${id} is missing its canonical trigger`); + } + return Object.freeze({ + id, + phase, + trigger, + src: `/static/tsjs=tsjs-${id}.min.js?v=${'d'.repeat(64)}`, + }); + }) + ), + }); +} + +function maximalRegistryCatalog(): readonly IntegrationCatalogEntry[] { + return Object.freeze( + RELEASE_CATALOG.map(({ id, phase, trigger, config, consumes, provides }) => + Object.freeze({ id, phase, trigger, config, consumes, provides }) + ) + ); +} + +function tracedRegistration( + registration: IntegrationRegistration, + events: string[], + failAfterActivation?: string +): IntegrationRegistration { + const tracePrepared = (prepared: PreparedIntegration): PreparedIntegration => { + const traced: PreparedIntegration = { + activate: (activationContext: IntegrationActivationContext): void => { + events.push(`activate:${registration.id}`); + activationContext.onDispose(() => events.push(`dispose:${registration.id}`)); + prepared.activate(activationContext); + if (registration.id === failAfterActivation) { + throw new Error(`injected ${registration.id} activation failure`); + } + }, + }; + const interfacesDescriptor = Object.getOwnPropertyDescriptor(prepared, 'interfaces'); + if (interfacesDescriptor) Object.defineProperty(traced, 'interfaces', interfacesDescriptor); + return Object.freeze(traced); + }; + const prepare = async (context: IntegrationPrepareContext): Promise => { + events.push(`prepare:${registration.id}`); + return tracePrepared(await registration.prepare(context)); + }; + if (registration.phase === 'deferred') { + return Object.freeze({ + abi: registration.abi, + id: registration.id, + phase: registration.phase, + releaseId: registration.releaseId, + prepare, + }); + } + const prepareSync = (context: IntegrationPrepareContext): PreparedIntegration => { + events.push(`prepare:${registration.id}`); + return tracePrepared(registration.prepareSync(context)); + }; + return Object.freeze({ + abi: registration.abi, + id: registration.id, + phase: registration.phase, + releaseId: registration.releaseId, + prepareSync, + prepare, + }); +} + +function integrationConfig(id: string): unknown { + if (id === 'didomi') return Object.freeze({ proxyPath: '/integrations/didomi/consent/' }); + if (id === 'gpt') { + return Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }); + } + if (id === 'prebid') { + return Object.freeze({ + accountId: 'test', + timeout: 1_000, + debug: false, + bidders: Object.freeze([]), + clientSideBidders: Object.freeze([]), + excludedGamAdUnitPathSuffixes: Object.freeze([]), + }); + } + if (id === 'sourcepoint_consent') return Object.freeze({ rewriteSdk: true }); + if ( + [ + 'aps', + 'datadome', + 'google_tag_manager', + 'lockr', + 'osano_consent', + 'permutive_context', + 'testlight', + ].includes(id) + ) { + return Object.freeze({}); + } + return undefined; +} + +const MAXIMAL_CONFIG_PRODUCTS = Object.freeze([ + ['aps', 'aps'], + ['datadome', 'datadome'], + ['didomi', 'didomi'], + ['google_tag_manager', 'google_tag_manager'], + ['gpt', 'gpt'], + ['lockr', 'lockr'], + ['osano', 'osano_consent'], + ['permutive', 'permutive_context'], + ['prebid', 'prebid'], + ['sourcepoint', 'sourcepoint_consent'], + ['testlight', 'testlight'], +] as const); + +function maximalIntegrationConfigs( + overrides: Readonly> | undefined +): Readonly> { + return Object.freeze({ + version: 1, + entries: Object.freeze( + MAXIMAL_CONFIG_PRODUCTS.map(([id, moduleId]) => + Object.freeze({ + id, + config: + overrides && Object.prototype.hasOwnProperty.call(overrides, moduleId) + ? overrides[moduleId] + : integrationConfig(moduleId), + }) + ) + ), + }); +} + +interface MaximalHarnessOptions { + readonly blockDeferredId?: (typeof DEFERRED_INTEGRATION_IDS)[number]; + readonly configOverrides?: Readonly>; + readonly failAfterActivation?: string; +} + +interface TrackedListener { + readonly capture: boolean; + readonly listener: EventListenerOrEventListenerObject; + readonly target: EventTarget; + readonly type: string; +} + +interface TrackedDeferredDeadline { + active: boolean; + readonly handle: ReturnType; + id?: string; + readonly identity: number; + readonly startedAt: number; +} + +function captureOption(options?: boolean | AddEventListenerOptions): boolean { + return typeof options === 'boolean' ? options : options?.capture === true; +} + +function createMaximalHarness(options: MaximalHarnessOptions = {}) { + const integrationIds = maximalIntegrationIds(); + const events: string[] = []; + const registrations = integrationIds.map((id) => { + const factory = REGISTRATION_FACTORIES.get(id); + if (!factory) throw new Error(`Missing real registration factory for ${id}`); + return tracedRegistration(factory(TEST_RELEASE_ID), events, options.failAfterActivation); + }); + const registrationsById = new Map( + registrations.map((registration) => [registration.id, registration]) + ); + const takeoverRegistrations = registrations.filter(({ phase }) => phase === 'takeover'); + const deferredRegistrations = registrations.filter(({ phase }) => phase === 'deferred'); + const frames: FrameRequestCallback[] = []; + const idle: Array<() => void> = []; + const deferredDeadlines: TrackedDeferredDeadline[] = []; + let nextDeferredDeadlineIdentity = 1; + const trackedSetTimeout = ( + callback: () => void, + delayMs: number + ): ReturnType => { + let deadline: TrackedDeferredDeadline | undefined; + const handle = setTimeout(() => { + if (deadline) deadline.active = false; + callback(); + }, delayMs); + if (delayMs === 10_000) { + deadline = { + active: true, + handle, + identity: nextDeferredDeadlineIdentity, + startedAt: Date.now(), + }; + nextDeferredDeadlineIdentity += 1; + deferredDeadlines.push(deadline); + } + return handle; + }; + const trackedClearTimeout = (handle: ReturnType): void => { + const deadline = deferredDeadlines.find((candidate) => candidate.handle === handle); + if (deadline) deadline.active = false; + clearTimeout(handle); + }; + // JSDOM lazily installs its selector engine's own document-scoped listeners. + // Materialize that test-environment infrastructure before tracking runtime effects. + document.querySelectorAll('[id]'); + const activeObservers = new Set(); + const activeMutationObservers = new Set(); + const listenerRecords: TrackedListener[] = []; + const eventTargetPrototype = EventTarget.prototype; + const addDescriptor = Object.getOwnPropertyDescriptor(eventTargetPrototype, 'addEventListener'); + const removeDescriptor = Object.getOwnPropertyDescriptor( + eventTargetPrototype, + 'removeEventListener' + ); + if ( + !addDescriptor || + !('value' in addDescriptor) || + typeof addDescriptor.value !== 'function' || + !removeDescriptor || + !('value' in removeDescriptor) || + typeof removeDescriptor.value !== 'function' + ) { + throw new Error('EventTarget listener intrinsics are unavailable'); + } + const nativeAdd = addDescriptor.value as EventTarget['addEventListener']; + const nativeRemove = removeDescriptor.value as EventTarget['removeEventListener']; + Object.defineProperty(eventTargetPrototype, 'addEventListener', { + ...addDescriptor, + value: function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + listenerOptions?: boolean | AddEventListenerOptions + ): void { + Reflect.apply(nativeAdd, this, [type, listener, listenerOptions]); + if (this !== window && this !== document) return; + const capture = captureOption(listenerOptions); + if ( + !listenerRecords.some( + (record) => + record.target === this && + record.type === type && + record.listener === listener && + record.capture === capture + ) + ) { + listenerRecords.push({ capture, listener, target: this, type }); + } + }, + }); + Object.defineProperty(eventTargetPrototype, 'removeEventListener', { + ...removeDescriptor, + value: function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + listenerOptions?: boolean | EventListenerOptions + ): void { + Reflect.apply(nativeRemove, this, [type, listener, listenerOptions]); + const capture = captureOption(listenerOptions); + const index = listenerRecords.findIndex( + (record) => + record.target === this && + record.type === type && + record.listener === listener && + record.capture === capture + ); + if (index >= 0) listenerRecords.splice(index, 1); + }, + }); + + const NativeMutationObserver = window.MutationObserver; + class TrackedMutationObserver extends NativeMutationObserver { + public constructor(callback: MutationCallback) { + super(callback); + activeMutationObservers.add(this); + } + + public override disconnect(): void { + activeMutationObservers.delete(this); + super.disconnect(); + } + } + vi.stubGlobal('MutationObserver', TrackedMutationObserver); + + let activeCaptureListeners = 0; + const captureListenerIdentities = new Set<(event: MessageEvent) => void>(); + const googletag = Object.freeze({ + ...createNoopGoogletagAdapter(), + observeDiagnostics: (observer: GoogletagDiagnosticsObserver) => { + activeObservers.add(observer); + return (): void => { + activeObservers.delete(observer); + }; + }, + }); + const messaging = Object.freeze({ + ...createNoopMessagingAdapter(), + installCaptureListener: (listener: (event: MessageEvent) => void) => { + activeCaptureListeners += 1; + captureListenerIdentities.add(listener); + window.addEventListener('message', listener, true); + let active = true; + return (): void => { + if (!active) return; + active = false; + activeCaptureListeners -= 1; + captureListenerIdentities.delete(listener); + window.removeEventListener('message', listener, true); + }; + }, + }); + const target: Record = {}; + const appendChildBefore = Element.prototype.appendChild; + const insertBeforeBefore = Element.prototype.insertBefore; + const fetchBefore = Object.getOwnPropertyDescriptor(window, 'fetch'); + const sendBeaconBefore = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const didomiBefore = Object.getOwnPropertyDescriptor(window, 'didomiConfig'); + const testlightBefore = Object.getOwnPropertyDescriptor(window, 'testlight'); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId: TEST_RELEASE_ID, + manifest: maximalManifest(), + knownIntegrationIds: integrationIds, + catalog: maximalRegistryCatalog(), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + integrations: maximalIntegrationConfigs(options.configOverrides), + creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + getBindings: (id) => + Object.freeze({ + config: + options.configOverrides !== undefined && + Object.prototype.hasOwnProperty.call(options.configOverrides, id) + ? options.configOverrides?.[id] + : integrationConfig(id), + interfaces: Object.freeze({}), + }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + phaseScheduler: { + cancelAnimationFrame: vi.fn(), + cancelIdleCallback: vi.fn(), + clearTimeout: trackedClearTimeout, + requestAnimationFrame: (callback) => { + frames.push(callback); + return frames.length; + }, + requestIdleCallback: (callback) => { + idle.push(callback); + return idle.length; + }, + setTimeout: trackedSetTimeout, + }, + }, + { + adapters: { + googletag, + messaging, + prebid: createNoopPrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect(composition.runtime.start()).toBe(false); + for (const registration of takeoverRegistrations) { + expect( + composition.runtime.registerIntegration(registration), + `register ${registration.id}` + ).toBe(true); + events.push(`register:${registration.id}`); + } + const runtimeScript = document.querySelector('#trustedserver-js'); + if (!runtimeScript) throw new Error('Maximal fixture takeover script is unavailable'); + const nativeHeadAppend = document.head.append.bind(document.head); + const appendDeferred = vi.spyOn(document.head, 'append').mockImplementation((...nodes) => { + nativeHeadAppend(...nodes); + for (const node of nodes) { + if (!(node instanceof HTMLScriptElement) || node === runtimeScript) continue; + const matchedId = /\/static\/tsjs=tsjs-([a-z0-9_-]+)\.min\.js$/.exec( + new URL(node.src).pathname + )?.[1]; + const registration = matchedId ? registrationsById.get(matchedId) : undefined; + if (!registration || registration.phase !== 'deferred') { + throw new Error(`Unexpected deferred artifact ${node.src}`); + } + const deadline = [...deferredDeadlines] + .reverse() + .find((candidate) => candidate.active && candidate.id === undefined); + if (!deadline) throw new Error(`Deferred deadline is unavailable for ${registration.id}`); + deadline.id = registration.id; + if (registration.id === options.blockDeferredId) continue; + Object.defineProperty(document, 'currentScript', { configurable: true, value: node }); + expect( + composition.runtime.registerIntegration(registration), + `register deferred ${registration.id}` + ).toBe(true); + events.push(`register:${registration.id}`); + node.onload?.(new Event('load')); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: runtimeScript, + }); + } + }); + + const loadDeferred = async (): Promise => { + expect(composition.runtime.protectFirstDisplayAttemptBatch([Promise.resolve()])).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + expect(frames).toHaveLength(1); + frames.shift()?.(1); + expect(frames).toHaveLength(1); + frames.shift()?.(2); + expect(idle).toHaveLength(1); + idle.shift()?.(); + const expectedDeferredIds = deferredRegistrations + .map(({ id }) => id) + .filter((id) => id !== options.blockDeferredId); + await vi.waitFor(() => { + expect( + events + .filter((event) => event.startsWith('register:')) + .filter((event) => deferredRegistrations.some(({ id }) => event === `register:${id}`)) + ).toEqual(expectedDeferredIds.map((id) => `register:${id}`)); + expect(deferredDeadlines.filter(({ id }) => id !== undefined)).toHaveLength( + deferredRegistrations.length + ); + }); + }; + + const assertReleased = async (): Promise => { + composition.runtime.dispose(); + composition.runtime.dispose(); + await Promise.resolve(); + expect(activeObservers.size).toBe(0); + expect(activeMutationObservers.size).toBe(0); + expect(activeCaptureListeners).toBe(0); + expect( + listenerRecords.map(({ capture, target: listenerTarget, type }) => ({ + capture, + target: listenerTarget.constructor.name, + type, + })) + ).toEqual([]); + expect(composition.auctionContextRegistryForTest()).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + expect(Element.prototype.appendChild).toBe(appendChildBefore); + expect(Element.prototype.insertBefore).toBe(insertBeforeBefore); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchBefore); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconBefore); + expect(Object.getOwnPropertyDescriptor(window, 'didomiConfig')).toEqual(didomiBefore); + expect(Object.getOwnPropertyDescriptor(window, 'testlight')).toEqual(testlightBefore); + }; + + const restoreInstrumentation = (): void => { + for (const record of [...listenerRecords]) { + try { + Reflect.apply(nativeRemove, record.target, [record.type, record.listener, record.capture]); + } catch { + // Test cleanup must not hide the first assertion failure. + } + } + listenerRecords.length = 0; + for (const observer of [...activeMutationObservers]) observer.disconnect(); + appendDeferred.mockRestore(); + Object.defineProperty(eventTargetPrototype, 'addEventListener', addDescriptor); + Object.defineProperty(eventTargetPrototype, 'removeEventListener', removeDescriptor); + }; + + return Object.freeze({ + assertReleased, + takeoverIntegrationIds: takeoverRegistrations.map(({ id }) => id), + composition, + deferredDeadlines: () => + Object.freeze( + deferredDeadlines.flatMap(({ active, id, identity, startedAt }) => + id === undefined ? [] : [Object.freeze({ active, id, identity, startedAt })] + ) + ), + deferredIntegrationIds: deferredRegistrations.map(({ id }) => id), + events, + integrationIds, + loadDeferred, + ownershipIdentities: () => + Object.freeze({ + adapters: composition.adapters, + captureListeners: Object.freeze([...captureListenerIdentities]), + runtime: composition.runtime, + }), + resourceCounts: () => + Object.freeze({ + captureListeners: activeCaptureListeners, + listeners: listenerRecords.length, + mutationObservers: activeMutationObservers.size, + observers: activeObservers.size, + }), + restoreInstrumentation, + target, + }); +} + +describe('generated maximal browser runtime transaction', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('derives the complete maximal fixture in canonical release order', () => { + const integrationIds = maximalIntegrationIds(); + const manifest = maximalManifest(); + + expect(integrationIds).toEqual(EXPECTED_MAXIMAL_INTEGRATION_IDS); + expect(integrationIds).toHaveLength(MAX_MANIFEST_MODULES); + expect(manifest).toMatchObject({ + version: 1, + releaseId: TEST_RELEASE_ID, + firstDisplay: null, + runtimeSrc: RUNTIME_SRC, + }); + expect(manifest.integrations.map(({ id }) => id)).toEqual(integrationIds); + expect( + manifest.integrations + .slice(0, MAX_TAKEOVER_MODULES) + .every(({ phase }) => phase === 'takeover') + ).toBe(true); + expect( + manifest.integrations.slice(MAX_TAKEOVER_MODULES).every(({ phase }) => phase === 'deferred') + ).toBe(true); + for (const entry of manifest.integrations) { + expect(Object.isFrozen(entry)).toBe(true); + if (entry.phase === 'takeover') { + expect(Reflect.ownKeys(entry).sort()).toEqual(['id', 'phase']); + } else { + expect(Reflect.ownKeys(entry).sort()).toEqual(['id', 'phase', 'src', 'trigger']); + expect(entry.trigger).toBe('first_display_or_idle'); + expect(entry.src).toBe(`/static/tsjs=tsjs-${entry.id}.min.js?v=${'d'.repeat(64)}`); + } + } + expect(Object.isFrozen(manifest)).toBe(true); + expect(Object.isFrozen(manifest.integrations)).toBe(true); + }); + + it.each([ + ['provider', true], + ['non-provider', false], + ] as const)( + 'preserves exact prepared interfaces for a %s registration', + async (_name, provider) => { + const capability = Object.freeze({ invoke: vi.fn() }); + const providerInterfaces = Object.freeze({ 'fixture.v1': capability }); + const activate = vi.fn(); + const registration: IntegrationRegistration = Object.freeze({ + abi: 1, + id: 'fixture', + phase: 'takeover', + releaseId: TEST_RELEASE_ID, + prepareSync: () => + provider + ? Object.freeze({ activate, interfaces: providerInterfaces }) + : Object.freeze({ activate }), + prepare: async () => + provider + ? Object.freeze({ activate, interfaces: providerInterfaces }) + : Object.freeze({ activate }), + }); + const prepared = await tracedRegistration(registration, []).prepare({ + config: undefined, + interfaces: Object.freeze({}), + signal: new AbortController().signal, + onDispose: vi.fn(), + }); + + expect(Object.isFrozen(prepared)).toBe(true); + expect(Reflect.ownKeys(prepared).sort()).toEqual( + provider ? ['activate', 'interfaces'] : ['activate'] + ); + if (provider) { + expect(Object.getOwnPropertyDescriptor(prepared, 'interfaces')).toMatchObject({ + enumerable: true, + value: providerInterfaces, + }); + expect(prepared.interfaces).toBe(providerInterfaces); + expect(prepared.interfaces?.['fixture.v1']).toBe(capability); + } else { + expect(Object.prototype.hasOwnProperty.call(prepared, 'interfaces')).toBe(false); + } + } + ); + + it('owns all server bundles once and disposes them in exact reverse generated order', async () => { + vi.useFakeTimers(); + const harness = createMaximalHarness(); + try { + const installed = await harness.composition.runtime.install(); + if (installed.state === 'fallback') { + throw new Error(`${installed.reason}: ${harness.events.join(',')}`); + } + expect(installed).toEqual({ + state: 'kernel', + runtimeFailures: [], + dispose: expect.any(Function), + }); + expect(harness.composition.runtime.state).toBe('kernel'); + + await harness.loadDeferred(); + expect(harness.events.filter((event) => event.startsWith('register:'))).toEqual( + harness.integrationIds.map((id) => `register:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('prepare:'))).toEqual( + harness.integrationIds.map((id) => `prepare:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('activate:'))).toEqual( + harness.integrationIds.map((id) => `activate:${id}`) + ); + + window.dispatchEvent(new Event('resize')); + harness.composition.runtime.dispose(); + harness.composition.runtime.dispose(); + await Promise.resolve(); + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual( + [...harness.integrationIds].reverse().map((id) => `dispose:${id}`) + ); + await harness.assertReleased(); + } finally { + harness.restoreInstrumentation(); + } + }); + + it.each(DEFERRED_INTEGRATION_IDS)( + 'starts five deferred siblings independently while %s remains blocked to its own deadline', + async (blockedId) => { + vi.useFakeTimers(); + const harness = createMaximalHarness({ blockDeferredId: blockedId }); + try { + const installed = await harness.composition.runtime.install(); + expect(installed.state).toBe('kernel'); + const ownershipBefore = harness.ownershipIdentities(); + expect(ownershipBefore.captureListeners).toHaveLength(1); + + await harness.loadDeferred(); + + const activeIds = harness.integrationIds.filter((id) => id !== blockedId); + await vi.waitFor(() => { + expect(harness.events.filter((event) => event.startsWith('register:'))).toEqual( + activeIds.map((id) => `register:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('prepare:'))).toEqual( + activeIds.map((id) => `prepare:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('activate:'))).toEqual( + activeIds.map((id) => `activate:${id}`) + ); + }); + + const startedDeadlines = harness.deferredDeadlines(); + expect(startedDeadlines.map(({ id }) => id)).toEqual(DEFERRED_INTEGRATION_IDS); + expect(new Set(startedDeadlines.map(({ identity }) => identity).values()).size).toBe(6); + expect(new Set(startedDeadlines.map(({ startedAt }) => startedAt).values()).size).toBe(1); + expect(startedDeadlines.filter(({ active }) => active).map(({ id }) => id)).toEqual([ + blockedId, + ]); + expect(document.querySelector(`script[src*="tsjs-${blockedId}.min.js"]`)).not.toBeNull(); + expect(harness.ownershipIdentities()).toEqual(ownershipBefore); + + const elapsedSinceStart = Date.now() - (startedDeadlines[0]?.startedAt ?? Date.now()); + await vi.advanceTimersByTimeAsync(9_999 - elapsedSinceStart); + expect(harness.deferredDeadlines().find(({ id }) => id === blockedId)?.active).toBe(true); + await vi.advanceTimersByTimeAsync(1); + expect(harness.deferredDeadlines().find(({ id }) => id === blockedId)?.active).toBe(false); + expect(document.querySelector(`script[src*="tsjs-${blockedId}.min.js"]`)).toBeNull(); + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual([]); + expect(harness.ownershipIdentities()).toEqual(ownershipBefore); + + await harness.assertReleased(); + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual( + [...activeIds].reverse().map((id) => `dispose:${id}`) + ); + } finally { + harness.composition.runtime.dispose(); + await Promise.resolve(); + harness.restoreInstrumentation(); + } + } + ); + + it.each(DEFERRED_INTEGRATION_IDS)( + 'contains an acquired %s failure without delaying or replacing deferred siblings', + async (failureId) => { + vi.useFakeTimers(); + const harness = createMaximalHarness({ failAfterActivation: failureId }); + try { + const installed = await harness.composition.runtime.install(); + expect(installed.state).toBe('kernel'); + const ownershipBefore = harness.ownershipIdentities(); + expect(ownershipBefore.captureListeners).toHaveLength(1); + + await harness.loadDeferred(); + + await vi.waitFor(() => { + expect(harness.events.filter((event) => event.startsWith('register:'))).toEqual( + harness.integrationIds.map((id) => `register:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('prepare:'))).toEqual( + harness.integrationIds.map((id) => `prepare:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('activate:'))).toEqual( + harness.integrationIds.map((id) => `activate:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual([ + `dispose:${failureId}`, + ]); + }); + + const startedDeadlines = harness.deferredDeadlines(); + expect(startedDeadlines.map(({ id }) => id)).toEqual(DEFERRED_INTEGRATION_IDS); + expect(new Set(startedDeadlines.map(({ identity }) => identity).values()).size).toBe(6); + expect(new Set(startedDeadlines.map(({ startedAt }) => startedAt).values()).size).toBe(1); + expect(startedDeadlines.some(({ active }) => active)).toBe(false); + expect(harness.ownershipIdentities()).toEqual(ownershipBefore); + + await harness.assertReleased(); + const disposalEvents = harness.events.filter((event) => event.startsWith('dispose:')); + expect(disposalEvents).toHaveLength(harness.integrationIds.length); + for (const id of harness.integrationIds) { + expect(disposalEvents.filter((event) => event === `dispose:${id}`)).toHaveLength(1); + } + } finally { + harness.composition.runtime.dispose(); + await Promise.resolve(); + harness.restoreInstrumentation(); + } + } + ); + + it.each([ + { + name: 'a real activation fails after acquiring its composed effects', + failureId: 'permutive_context', + phase: 'activate' as const, + }, + { + name: 'one real registration receives malformed frozen config', + failureId: 'sourcepoint_consent', + phase: 'prepare' as const, + }, + ])('fails closed when $name', async ({ failureId, phase }) => { + vi.useFakeTimers(); + const harness = createMaximalHarness( + phase === 'activate' + ? { failAfterActivation: failureId } + : { + configOverrides: Object.freeze({ + [failureId]: Object.freeze({ rewriteSdk: 'yes' }), + }), + } + ); + try { + const installed = await harness.composition.runtime.install(); + const failureIndex = harness.takeoverIntegrationIds.indexOf(failureId); + const preparedIds = + phase === 'activate' + ? harness.takeoverIntegrationIds + : harness.takeoverIntegrationIds.slice(0, failureIndex + 1); + const activatedIds = + phase === 'activate' ? harness.takeoverIntegrationIds.slice(0, failureIndex + 1) : []; + + expect(installed).toEqual({ state: 'fallback', reason: 'bundle_partial' }); + expect(harness.composition.runtime.state).toBe('fallback'); + expect(harness.target['_internal']).toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(harness.events.filter((event) => event.startsWith('register:'))).toEqual( + harness.takeoverIntegrationIds.map((id) => `register:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('prepare:'))).toEqual( + preparedIds.map((id) => `prepare:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('activate:'))).toEqual( + activatedIds.map((id) => `activate:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual( + [...activatedIds].reverse().map((id) => `dispose:${id}`) + ); + + await harness.assertReleased(); + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual( + [...activatedIds].reverse().map((id) => `dispose:${id}`) + ); + } finally { + harness.restoreInstrumentation(); + } + }); + + it.each([ + { name: 'missing SDK globals reach their bounded readiness timeouts', kind: 'readiness' }, + { name: 'hostile consent storage fails only its after-commit owner', kind: 'storage' }, + { + name: 'matcher false positives and throwing publisher callbacks stay isolated', + kind: 'matcher', + }, + ] as const)('isolates $name across all real registrations', async ({ kind }) => { + vi.useFakeTimers(); + const callbackOrder: string[] = []; + const publisherBinding: { target?: Record } = {}; + let falsePositiveScript: HTMLScriptElement | undefined; + if (kind === 'readiness') { + vi.stubGlobal('identityLockr', undefined); + vi.stubGlobal('permutive', undefined); + } + if (kind === 'storage') { + vi.stubGlobal( + 'localStorage', + new Proxy({} as Storage, { + get: () => { + throw new Error('publisher storage is unavailable'); + }, + }) + ); + } + if (kind === 'matcher') { + vi.stubGlobal('testlight', { + que: [ + function (this: unknown): void { + callbackOrder.push(this === publisherBinding.target ? 'throw:bound' : 'throw:unbound'); + throw new Error('publisher queue callback failed'); + }, + function (this: unknown): void { + callbackOrder.push( + this === publisherBinding.target ? 'survive:bound' : 'survive:unbound' + ); + }, + ], + }); + } + const harness = createMaximalHarness(); + publisherBinding.target = harness.target; + try { + const installed = await harness.composition.runtime.install(); + const expectedRuntimeFailures = + kind === 'storage' ? [{ id: 'sourcepoint_consent', phase: 'after_commit' }] : []; + const activeIntegrationIds = + kind === 'storage' + ? harness.integrationIds.filter((id) => id !== 'sourcepoint_lifecycle') + : harness.integrationIds; + + expect(installed).toEqual({ + state: 'kernel', + runtimeFailures: expectedRuntimeFailures, + dispose: expect.any(Function), + }); + expect(harness.composition.runtime.state).toBe('kernel'); + await harness.loadDeferred(); + expect(harness.events.filter((event) => event.startsWith('prepare:'))).toEqual( + activeIntegrationIds.map((id) => `prepare:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('activate:'))).toEqual( + activeIntegrationIds.map((id) => `activate:${id}`) + ); + expect(harness.resourceCounts()).toMatchObject({ + captureListeners: 1, + listeners: expect.any(Number), + mutationObservers: expect.any(Number), + observers: 1, + }); + expect(harness.resourceCounts().listeners).toBeGreaterThan(0); + expect(harness.resourceCounts().mutationObservers).toBeGreaterThan(0); + + if (kind === 'readiness') { + await vi.runAllTimersAsync(); + expect(harness.composition.runtime.state).toBe('kernel'); + expect(vi.getTimerCount()).toBe(0); + } + if (kind === 'matcher') { + expect(callbackOrder).toEqual(['throw:bound', 'survive:bound']); + falsePositiveScript = document.createElement('script'); + const originalUrl = 'https://publisher.example/assets/www.googletagmanager.com/gtm.js'; + falsePositiveScript.src = originalUrl; + document.head.appendChild(falsePositiveScript); + expect(falsePositiveScript.src).toBe(originalUrl); + } + + falsePositiveScript?.remove(); + const disposedBeforeRuntimeRelease = + kind === 'storage' ? ['dispose:sourcepoint_consent'] : []; + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual( + disposedBeforeRuntimeRelease + ); + await harness.assertReleased(); + const reverseIds = [...activeIntegrationIds].reverse(); + const expectedDisposals = + kind === 'storage' + ? [ + 'dispose:sourcepoint_consent', + ...reverseIds + .filter((id) => id !== 'sourcepoint_consent') + .map((id) => `dispose:${id}`), + ] + : reverseIds.map((id) => `dispose:${id}`); + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual( + expectedDisposals + ); + } finally { + falsePositiveScript?.remove(); + harness.restoreInstrumentation(); + } + }); +}); diff --git a/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs b/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs new file mode 100644 index 000000000..666d5ec4d --- /dev/null +++ b/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs @@ -0,0 +1,462 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import vm from 'node:vm'; + +const corpus = JSON.parse( + await readFile(new URL('../fixtures/aps-renderer-v1-corpus.json', import.meta.url), 'utf8') +); +const goldenEnvelope = JSON.parse( + await readFile(new URL('../fixtures/aps-renderer-v1.json', import.meta.url), 'utf8') +); +const validatorUrl = new URL( + '../../../../trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js', + import.meta.url +); +const validatorSource = await readFile(validatorUrl, 'utf8'); +const bootstrapDocument = await readFile( + new URL( + '../../../../trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html', + import.meta.url + ), + 'utf8' +); + +function setPath(root, path, value) { + let parent = root; + for (const segment of path.slice(0, -1)) parent = parent[segment]; + parent[path.at(-1)] = value; +} + +function deletePath(root, path) { + let parent = root; + for (const segment of path.slice(0, -1)) parent = parent[segment]; + delete parent[path.at(-1)]; +} + +function encodeBytes(value) { + return Buffer.from(value).toString('base64'); +} + +function materialize(vector) { + const descriptor = structuredClone(corpus.baseDescriptor); + const envelope = structuredClone(goldenEnvelope); + const operation = vector.operation; + let encodedEnvelope; + + switch (operation.kind) { + case 'none': + break; + case 'descriptor-delete': + delete descriptor[operation.field]; + break; + case 'descriptor-set': + descriptor[operation.field] = operation.value; + break; + case 'descriptor-repeat': + descriptor[operation.field] = + operation.unit.repeat(operation.count) + (operation.suffix ?? ''); + break; + case 'bid-id-repeat': { + const value = operation.unit.repeat(operation.count) + (operation.suffix ?? ''); + descriptor.bidId = value; + setPath(envelope, ['seatbid', 0, 'bid', 0, 'id'], value); + break; + } + case 'dimension': { + descriptor[operation.field] = operation.value; + setPath( + envelope, + ['seatbid', 0, 'bid', 0, operation.field === 'width' ? 'w' : 'h'], + operation.value + ); + break; + } + case 'dimensions': + descriptor.width = operation.width; + descriptor.height = operation.height; + setPath(envelope, ['seatbid', 0, 'bid', 0, 'w'], operation.width); + setPath(envelope, ['seatbid', 0, 'bid', 0, 'h'], operation.height); + break; + case 'creative-url': + descriptor.creativeUrl = operation.value; + setPath(envelope, ['seatbid', 0, 'bid', 0, 'ext', 'creativeurl'], operation.value); + break; + case 'creative-url-bytes': { + const prefix = 'https://creative.example/'; + const value = prefix + 'a'.repeat(operation.bytes - prefix.length); + descriptor.creativeUrl = value; + setPath(envelope, ['seatbid', 0, 'bid', 0, 'ext', 'creativeurl'], value); + break; + } + case 'aax-literal': + encodedEnvelope = operation.value; + break; + case 'aax-bytes': + encodedEnvelope = encodeBytes(Uint8Array.from(operation.values)); + break; + case 'aax-raw-json': + encodedEnvelope = encodeBytes(operation.value); + break; + case 'aax-decoded-bytes': { + const serialized = JSON.stringify(envelope); + assert.ok(serialized.length <= operation.bytes, vector.id); + encodedEnvelope = encodeBytes(serialized + ' '.repeat(operation.bytes - serialized.length)); + break; + } + case 'aax-raw-price': { + const serialized = JSON.stringify(envelope); + const raw = serialized.replace('"price":1.23', `"price":${operation.value}`); + assert.notEqual(raw, serialized, vector.id); + encodedEnvelope = encodeBytes(raw); + break; + } + case 'envelope-set': + setPath(envelope, operation.path, operation.value); + break; + case 'envelope-delete': + deletePath(envelope, operation.path); + break; + case 'duplicate-seat': + envelope.seatbid.push(structuredClone(envelope.seatbid[0])); + break; + case 'duplicate-bid': + envelope.seatbid[0].bid.push(structuredClone(envelope.seatbid[0].bid[0])); + break; + default: + throw new Error(`unknown APS renderer corpus operation: ${operation.kind}`); + } + + descriptor.aaxResponse = encodedEnvelope ?? encodeBytes(JSON.stringify(envelope)); + return descriptor; +} + +test('the exact generated ES5 validator matches every shared corpus vector', () => { + const context = vm.createContext({ + URL, + TextEncoder, + TextDecoder, + atob, + btoa, + inputJson: '', + publisherOrigin: corpus.publisherOrigin, + }); + vm.runInContext(validatorSource, context, { + filename: 'aps_renderer_validator_v1.js', + }); + + for (const vector of corpus.vectors) { + context.inputJson = JSON.stringify(materialize(vector)); + const actual = vm.runInContext( + 'classifyApsRendererV1(JSON.parse(inputJson), publisherOrigin)', + context + ); + assert.equal(actual, vector.expected, vector.id); + } +}); + +test('the embedded validator remains ES5 syntax', () => { + assert.doesNotMatch(validatorSource, /=>|\b(?:const|let|class)\b|\?\.|\?\?/); +}); + +function bootstrapScript() { + const match = + /^\n\n'; + expect(snapshotFirstDisplayHandoffV1(withAdm)).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1(handoff({ livePort: new MessageChannel().port1 })) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ + attempts: [{ ...((handoff().attempts as object[])[0] as object), state: 'pending' }], + }) + ) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1(handoff({ slices: ['first_display'], parserState: [] })) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ + parserState: [ + { + sliceId: 'gpt_initial', + observations: ['gam', 'v', 'extra'], + values: [ + ['gam', false], + ['v', 1], + ['extra', true], + ], + }, + ], + }) + ) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ + artifacts: [ + { + ...((handoff().artifacts as object[])[0] as object), + token: `r1_${'b'.repeat(22)}`, + }, + ], + }) + ) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ trace: { nextSequence: 2, nextGlobalSlotOrdinal: 2, slots: [] } }) + ) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ + tombstones: [{ ...(handoff().tombstones as object[])[0], value: 'opaque-reservation' }], + }) + ) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ + tombstones: [{ ...(handoff().tombstones as object[])[0], expiresAtMs: 0 }], + }) + ) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ + timing: { bidsScriptMs: 2, firstDisplayMs: 1, terminalMs: 3, paintMs: 4 }, + }) + ) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ + attempts: [ + { + ...((handoff().attempts as object[])[0] as object), + id: 'attempt-1', + }, + ], + }) + ) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ + attempts: [ + { + ...((handoff().attempts as object[])[0] as object), + state: 'no_bid', + }, + ], + }) + ) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ + attempts: [ + { + ...((handoff().attempts as object[])[0] as object), + state: 'failed', + reason: null, + }, + ], + }) + ) + ).toBeUndefined(); + expect(snapshotFirstDisplayHandoffV1(handoff({ parserState: [] }))).toBeUndefined(); + const parserRow = (handoff().parserState as object[])[0]!; + expect( + snapshotFirstDisplayHandoffV1(handoff({ parserState: [parserRow, parserRow] })) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ + parserState: [ + { sliceId: 'gpt_initial', observations: ['ready'], values: [['other', true]] }, + ], + }) + ) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ + highWater: { + ...(handoff().highWater as object), + navigationAttemptPrefix: 'nav1', + }, + }) + ) + ).toBeUndefined(); + }); + + it('validates the render-owner parser row inside a complete owner-to-GPT handoff', () => { + const gptParserState = (handoff().parserState as object[])[0]!; + const accepted = snapshotFirstDisplayHandoffV1( + handoff({ + slices: ['first_display', 'render_owner_initial', 'gpt_initial'], + parserState: [ + { + sliceId: 'render_owner_initial', + observations: ['protocol_version'], + values: [['protocol_version', 1]], + }, + gptParserState, + ], + }) + ); + + expect(accepted?.slices).toEqual(['first_display', 'render_owner_initial', 'gpt_initial']); + }); + + it('enforces 255/256/257 slot and outcome boundaries and strict high-water counters', () => { + const base = handoff(); + const slot = (base.slots as object[])[0]!; + const attempt = (base.attempts as object[])[0]!; + for (const count of [255, 256]) { + const slots = Array.from({ length: count }, (_, index) => ({ + ...(slot as Record), + id: `slot-${index}`, + domId: `div-${index}`, + outcome: 'no_bid', + committedArtifact: 'none', + targetingOwnership: [], + gptToken: null, + })); + const attempts = Array.from({ length: count }, (_, index) => ({ + ...(attempt as Record), + id: `a1_AAECAwQFBgc${index.toString(36).padStart(11, 'A')}`, + slotId: `slot-${index}`, + ordinal: index + 1, + state: 'no_bid', + })); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ + slots, + attempts, + artifacts: [], + gptDiagnostics: { facts: [], overflowCount: 0, dropCount: 0 }, + tombstones: [], + cycles: [], + trace: { + nextSequence: 1, + nextGlobalSlotOrdinal: count + 1, + slots: slots.map((entry) => ({ + slotId: entry.id, + impressions: 0, + bindings: [], + })), + }, + highWater: { + ...(base.highWater as object), + nextNavigationAttemptOrdinal: count + 1, + nextAttemptOrdinal: count + 1, + nextSlotRegistrationOrdinal: count + 1, + }, + }) + ) + ).toBeDefined(); + } + + const tooMany = Array.from({ length: 257 }, (_, index) => ({ + ...(slot as Record), + id: `slot-${index}`, + domId: `div-${index}`, + })); + expect(snapshotFirstDisplayHandoffV1(handoff({ slots: tooMany }))).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ highWater: { ...(base.highWater as object), nextAttemptOrdinal: 1 } }) + ) + ).toBeUndefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ trace: { ...(base.trace as object), nextSequence: 1 } }) + ) + ).toBeUndefined(); + }); + + it('publishes the exact independent size ceilings', () => { + expect(MAX_FIRST_DISPLAY_NON_DIAGNOSTICS_BYTES).toBe(8 * 1024 * 1024); + expect(MAX_GPT_FACT_BYTES).toBe(512 * 1024); + expect(MAX_FIRST_DISPLAY_HANDOFF_BYTES).toBe(8.5 * 1024 * 1024); + }); + + it('enforces the exact normalized diagnostics shape and per-fact byte ceiling', () => { + const subsection = handoff().gptDiagnostics as { + facts: Array>; + overflowCount: number; + dropCount: number; + }; + expect(snapshotFirstDisplayHandoffV1(handoff())).toBeDefined(); + expect( + snapshotFirstDisplayHandoffV1( + handoff({ gptFacts: subsection.facts, gptFactOverflow: 0, gptDiagnostics: undefined }) + ) + ).toBeUndefined(); + subsection.facts[0]!.elementId = 'x'.repeat(1_001); + expect(snapshotFirstDisplayHandoffV1(handoff({ gptDiagnostics: subsection }))).toBeUndefined(); + }); + + it('advances the global GPT token high-water above retained diagnostics facts', () => { + const subsection = handoff().gptDiagnostics as { + facts: Array>; + overflowCount: number; + dropCount: number; + }; + subsection.facts[0]!.token = 'gt1_3'; + subsection.facts[0]!.runtimeSlotNumber = 3; + + expect(snapshotFirstDisplayHandoffV1(handoff({ gptDiagnostics: subsection }))).toBeUndefined(); + }); + + it('enforces the exact 8 MiB non-diagnostics canonical boundary', () => { + const base = handoff({ + attempts: [], + tombstones: [], + artifacts: [], + gptDiagnostics: { facts: [], overflowCount: 0, dropCount: 0 }, + cycles: [], + trace: { nextSequence: 1, nextGlobalSlotOrdinal: 257, slots: [] }, + highWater: { + ...(handoff().highWater as object), + nextAttemptOrdinal: 1, + nextSlotRegistrationOrdinal: 257, + }, + }); + const slot = (handoff().slots as Array>)[0]!; + base.slots = Array.from({ length: 256 }, (_, slotIndex) => ({ + ...slot, + id: `slot-${slotIndex}`, + aliases: [], + domId: `div-${slotIndex}`, + outcome: 'no_bid', + committedArtifact: 'none', + targetingOwnership: [], + gptToken: null, + targeting: Array.from({ length: 32 }, (_, targetingIndex) => [`k${targetingIndex}`, '']), + })); + base.attempts = Array.from({ length: 256 }, (_, slotIndex) => ({ + id: `a1_AAECAwQFBgc${slotIndex.toString(36).padStart(11, 'A')}`, + slotId: `slot-${slotIndex}`, + ordinal: slotIndex + 1, + state: 'no_bid', + reason: null, + })); + base.highWater = { + ...(base.highWater as object), + nextNavigationAttemptOrdinal: 257, + nextAttemptOrdinal: 257, + }; + base.trace = { + nextSequence: 1, + nextGlobalSlotOrdinal: 257, + slots: (base.slots as Array>).map((entry) => ({ + slotId: entry.id, + impressions: 0, + bindings: [], + })), + }; + const encoder = new TextEncoder(); + let remaining = + MAX_FIRST_DISPLAY_NON_DIAGNOSTICS_BYTES - encoder.encode(JSON.stringify(base)).byteLength; + expect(remaining).toBeGreaterThan(0); + for (const slotValue of base.slots as Array>) { + for (const pair of slotValue.targeting as string[][]) { + const next = Math.min(4096, remaining); + pair[1] = 'x'.repeat(next); + remaining -= next; + if (remaining === 0) break; + } + if (remaining === 0) break; + } + expect(remaining).toBe(0); + expect(encoder.encode(JSON.stringify(base))).toHaveLength( + MAX_FIRST_DISPLAY_NON_DIAGNOSTICS_BYTES + ); + expect(snapshotFirstDisplayHandoffV1(base)).toBeDefined(); + + const firstPair = ( + (base.slots as Array>)[0]!.targeting as string[][] + )[0]!; + firstPair[1] += 'x'; + expect(snapshotFirstDisplayHandoffV1(base)).toBeUndefined(); + firstPair[1] = firstPair[1]!.slice(0, -2); + expect(snapshotFirstDisplayHandoffV1(base)).toBeDefined(); + }); + + it('mints a release-bound one-use capsule and clears every outcome', () => { + const physicalSlot = {}; + const artifact = {}; + const capsule = createFirstDisplayOwnershipCapsuleV1(HASH, 7, [physicalSlot, artifact]); + expect(capsule?.consume('b'.repeat(64), 7)).toBeUndefined(); + expect(capsule?.consume(HASH, 8)).toBeUndefined(); + expect(capsule?.consume(HASH, 7)).toEqual([physicalSlot, artifact]); + expect(capsule?.consume(HASH, 7)).toBeUndefined(); + + const cleared = createFirstDisplayOwnershipCapsuleV1(HASH, 7, [physicalSlot]); + cleared?.clear(); + expect(cleared?.consume(HASH, 7)).toBeUndefined(); + expect( + createFirstDisplayOwnershipCapsuleV1(HASH, 7, [physicalSlot, physicalSlot]) + ).toBeUndefined(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/first_display/driver.test.ts b/crates/trusted-server-js/lib/test/first_display/driver.test.ts new file mode 100644 index 000000000..04edfe2c2 --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/driver.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + FirstDisplayGoogletagBatchCallbacks, + FirstDisplayGptBoundCycleV1, + FirstDisplayGptHandoffCycleV1, +} from '../../src/first_display/adapters/googletag'; +import { createFirstDisplayProjectedDriver } from '../../src/first_display/driver'; +import type { FirstDisplayGptProtocolV1 } from '../../src/first_display/leaf/gpt_protocol'; +import { snapshotFirstDisplayBatchV1 } from '../../src/first_display/leaf/projection'; + +function batch() { + return snapshotFirstDisplayBatchV1( + Object.freeze({ + version: 1, + projectionDigest: 'b'.repeat(64), + projection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([ + Object.freeze({ slot: 'slot-1', outcome: 'winner', candidateId: 'candidate001' }), + ]), + }), + slots: Object.freeze([ + Object.freeze({ + slot: 'slot-1', + gamUnitPath: '/123/example', + divId: 'slot-1', + formats: Object.freeze([Object.freeze([300, 250])]), + targeting: Object.freeze({}), + }), + ]), + bids: Object.freeze([ + Object.freeze({ + candidateId: 'candidate001', + slot: 'slot-1', + provider: 'example', + upstreamBidId: 'upstream-1', + cpm: 1.25, + currency: 'USD', + targeting: Object.freeze({}), + rendererReservationId: `r1_${'a'.repeat(22)}`, + renderSource: Object.freeze({ + type: 'adm', + version: 1, + adm: '
example
', + width: 300, + height: 250, + }), + }), + ]), + }), + }) + )!; +} + +function harness() { + const value = batch(); + let gptCallbacks: FirstDisplayGoogletagBatchCallbacks | undefined; + let renderTerminal: + ((result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void) | undefined; + const gptBatch = { + start: vi.fn((callbacks: FirstDisplayGoogletagBatchCallbacks) => { + gptCallbacks = callbacks; + return true; + }), + captureDiagnosticsHandoff: vi.fn(() => + Object.freeze([ + [cycleOwner.value] + .filter((cycle): cycle is FirstDisplayGptHandoffCycleV1 => cycle !== undefined) + .map((cycle) => + Object.freeze([ + cycle[6], + cycle[7], + 2, + false, + Object.freeze([]), + Object.freeze([ + Object.freeze([ + 1, + 'response-one', + Object.freeze(['slotRequested', 'slotRenderEnded'] as const), + 'completed', + ] as const), + ]), + ] as const) + ), + Object.freeze([]), + 2, + 0, + 0, + ] as const) + ), + captureHandoff: vi.fn(() => + [cycleOwner.value] + .filter((cycle): cycle is FirstDisplayGptHandoffCycleV1 => cycle !== undefined) + .map((cycle) => + Object.freeze([cycle[6], cycle[1].id, cycle[3], cycle[8], cycle[7], cycle[4]] as const) + ) + ), + closeIngress: vi.fn(() => { + events.push('gpt:close'); + return true; + }), + detachCommittedSlots: vi.fn(() => true), + dispose: vi.fn(), + }; + const protocol: FirstDisplayGptProtocolV1 = Object.freeze([ + 1, + 'gpt', + vi.fn(() => + Object.freeze([ + gptBatch.start, + gptBatch.closeIngress, + gptBatch.captureHandoff, + gptBatch.captureDiagnosticsHandoff, + gptBatch.detachCommittedSlots, + gptBatch.dispose, + ] as const) + ), + ]); + const events: string[] = []; + const cycleOwner: { value?: FirstDisplayGptHandoffCycleV1 } = {}; + const renderer = { + bind: vi.fn((_cycle: FirstDisplayGptBoundCycleV1, terminal: typeof renderTerminal) => { + events.push('render:bind'); + renderTerminal = terminal; + return true; + }), + recordGam: vi.fn((_cycle: FirstDisplayGptBoundCycleV1, result: string) => { + events.push(`render:gam:${result}`); + return true; + }), + recordFailure: vi.fn(() => true), + retire: vi.fn(() => true), + sweepCommittedArtifacts: vi.fn(() => 0), + captureHandoff: vi.fn(() => [[], [], 0, 1, 1] as const), + closeIngress: vi.fn(() => { + events.push('render:close'); + return true; + }), + detachCommittedArtifacts: vi.fn(() => true), + sealTsAdmission: vi.fn(() => events.push('render:seal')), + dispose: vi.fn(() => events.push('render:dispose')), + }; + const driver = createFirstDisplayProjectedDriver({ + batch: value, + gpt: protocol, + gptInput: [window, () => undefined, document, (callback) => callback], + renderer, + }); + const terminals: unknown[] = []; + driver.start( + value.outcomes, + () => { + events.push('action'); + return true; + }, + (slotId, result, reason) => terminals.push([slotId, result, reason]) + ); + const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze([ + value.projection.bids[0]!, + document.createElement('div'), + () => true, + 'trusted_server' as const, + {}, + value.projection.slots[0]!, + 'slot-1', + 'gt1_1', + ]); + cycleOwner.value = Object.freeze([...cycle, Object.freeze([])] as const); + return { + cycle, + driver, + events, + gptBatch, + getGptCallbacks: () => gptCallbacks!, + renderer, + setCapturedCycle: (cycle: FirstDisplayGptHandoffCycleV1) => { + cycleOwner.value = cycle; + }, + getRenderTerminal: () => renderTerminal!, + terminals, + }; +} + +describe('projected first-display driver', () => { + it('joins exact GPT delivery with renderer-owned completion', () => { + const h = harness(); + expect(h.getGptCallbacks()[0](h.cycle)).toBeUndefined(); + expect(h.getGptCallbacks()[2]()).toBe(true); + h.getGptCallbacks()[3](h.cycle, 'nonempty_gam'); + expect(h.terminals).toEqual([]); + + h.getRenderTerminal()('accepted', null); + h.getRenderTerminal()('failed', 'internal_error'); + expect(h.events).toEqual(['render:bind', 'action', 'render:gam:nonempty_gam']); + expect(h.terminals).toEqual([['slot-1', 'accepted', null]]); + + h.getGptCallbacks()[4]?.(h.cycle); + h.getGptCallbacks()[4]?.( + Object.freeze([ + h.cycle[0], + h.cycle[1], + h.cycle[2], + h.cycle[3], + {}, + h.cycle[5], + h.cycle[6], + h.cycle[7], + ]) + ); + expect(h.renderer.retire).toHaveBeenCalledExactlyOnceWith(h.cycle); + }); + + it('fails an empty or mismatched physical GPT cycle without guessing', () => { + const empty = harness(); + empty.getGptCallbacks()[0](empty.cycle); + empty.getGptCallbacks()[2](); + empty.getGptCallbacks()[3](empty.cycle, 'gam_empty'); + expect(empty.renderer.recordGam).toHaveBeenCalledWith(empty.cycle, 'gam_empty'); + + const mismatched = harness(); + mismatched.getGptCallbacks()[0](mismatched.cycle); + mismatched.getGptCallbacks()[2](); + mismatched.getGptCallbacks()[3]( + Object.freeze([ + mismatched.cycle[0], + mismatched.cycle[1], + mismatched.cycle[2], + mismatched.cycle[3], + {}, + mismatched.cycle[5], + mismatched.cycle[6], + mismatched.cycle[7], + ]), + 'nonempty_gam' + ); + expect(mismatched.terminals).toEqual([['slot-1', 'failed', 'gpt_request_failed']]); + expect(mismatched.renderer.recordGam).not.toHaveBeenCalled(); + }); + + it('owns exact-once sealing and disposal', () => { + const h = harness(); + h.getGptCallbacks()[0](h.cycle); + h.getGptCallbacks()[2](); + h.getGptCallbacks()[1]('slot-1', 'gpt_request_timeout'); + expect(h.terminals).toEqual([['slot-1', 'failed', 'gpt_request_timeout']]); + + h.driver.sealTsAdmission(); + h.driver.dispose(); + h.driver.dispose(); + expect(h.renderer.sealTsAdmission).toHaveBeenCalledOnce(); + expect(h.gptBatch.dispose).toHaveBeenCalledOnce(); + expect(h.renderer.dispose).toHaveBeenCalledOnce(); + }); + + it('captures accepted objects before detaching them from both provisional owners', () => { + const h = harness(); + h.getGptCallbacks()[0](h.cycle); + h.getGptCallbacks()[2](); + h.getGptCallbacks()[3](h.cycle, 'nonempty_gam'); + h.getRenderTerminal()('accepted', null); + h.driver.sealTsAdmission(); + + expect(h.driver.closeIngress()).toBe(true); + expect(h.gptBatch.closeIngress).toHaveBeenCalledExactlyOnceWith(['slot-1']); + expect(h.events.slice(-2)).toEqual(['gpt:close', 'render:close']); + expect(h.driver.captureHandoff()).toEqual({ + artifacts: [], + clockEpochMs: 0, + cycles: [[...h.cycle, []]], + diagnosticCycles: [ + { + nextCycleOrdinal: 2, + quarantines: [], + records: [ + { + ordinal: 1, + responseIdentifier: 'response-one', + seen: ['slotRequested', 'slotRenderEnded'], + state: 'completed', + }, + ], + slotId: 'slot-1', + token: 'gt1_1', + unknownPriorCycle: false, + }, + ], + gptDiagnostics: { facts: [], overflowCount: 0, dropCount: 0 }, + identities: [h.cycle[4]], + nextReservationOrdinal: 1, + nextTraceTokenOrdinal: 2, + nextTicketOrdinal: 1, + tombstones: [], + }); + expect(h.driver.detachCommittedArtifacts()).toBe(true); + expect(h.gptBatch.detachCommittedSlots).toHaveBeenCalledWith(['slot-1']); + expect(h.renderer.detachCommittedArtifacts).toHaveBeenCalledOnce(); + }); + + it('captures live publisher ownership and a hydration replacement from late GPT handoff', () => { + const h = harness(); + h.getGptCallbacks()[0](h.cycle); + h.getGptCallbacks()[2](); + h.getGptCallbacks()[3](h.cycle, 'nonempty_gam'); + h.getRenderTerminal()('accepted', null); + h.driver.sealTsAdmission(); + const replacement = document.createElement('div'); + replacement.id = 'slot-1-hydrated'; + document.body.append(replacement); + h.setCapturedCycle( + Object.freeze([ + h.cycle[0], + replacement, + h.cycle[2], + 'publisher', + h.cycle[4], + h.cycle[5], + h.cycle[6], + h.cycle[7], + Object.freeze([]), + ]) + ); + + expect(h.driver.closeIngress()).toBe(true); + const captured = h.driver.captureHandoff(); + expect(captured?.cycles[0]?.[1]).toBe(replacement); + expect(captured?.cycles[0]?.[3]).toBe('publisher'); + }); + + it('rejects an action list that does not exactly match the immutable batch', () => { + const value = batch(); + const driver = createFirstDisplayProjectedDriver({ + batch: value, + gpt: Object.freeze([ + 1, + 'gpt', + () => + Object.freeze([ + () => true, + () => true, + () => [], + () => undefined, + () => true, + () => undefined, + ] as const), + ]), + gptInput: [window, () => undefined, document, (callback) => callback], + renderer: { + bind: () => true, + recordGam: () => true, + recordFailure: () => true, + retire: () => true, + sweepCommittedArtifacts: () => 0, + captureHandoff: () => [[], [], 0, 1, 1] as const, + closeIngress: () => true, + detachCommittedArtifacts: () => true, + sealTsAdmission: () => undefined, + dispose: () => undefined, + }, + }); + expect(() => + driver.start( + Object.freeze([{ slotId: 'other', kind: 'gpt_adm' }]), + () => true, + () => undefined + ) + ).toThrow('tsjs'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts new file mode 100644 index 000000000..bd55feecb --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts @@ -0,0 +1,1771 @@ +import { describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; + +import { createFirstDisplayGoogletagBatch } from '../../src/first_display/adapters/googletag'; +import { enqueueFirstDisplayGamAttribution } from '../../src/first_display/adapters/googletag'; +import type { + FirstDisplayGoogletagBatch, + FirstDisplayGoogletagBatchCallbacks, +} from '../../src/first_display/adapters/googletag'; +import { snapshotFirstDisplayBatchV1 } from '../../src/first_display/leaf/projection'; + +const RESERVATION_ID = `r1_${'a'.repeat(22)}`; + +function startAdapter( + adapter: FirstDisplayGoogletagBatch, + callbacks: Readonly<{ + onBound: FirstDisplayGoogletagBatchCallbacks[0]; + onFailure: FirstDisplayGoogletagBatchCallbacks[1]; + onFirstAction: FirstDisplayGoogletagBatchCallbacks[2]; + onRenderEnded: FirstDisplayGoogletagBatchCallbacks[3]; + onRetire?: FirstDisplayGoogletagBatchCallbacks[4]; + }> +): boolean { + return adapter.start( + Object.freeze([ + callbacks.onBound, + callbacks.onFailure, + callbacks.onFirstAction, + callbacks.onRenderEnded, + callbacks.onRetire, + ]) + ); +} + +function fixture() { + return Object.freeze({ + version: 1, + projectionDigest: 'b'.repeat(64), + projection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([ + Object.freeze({ slot: 'slot-1', outcome: 'winner', candidateId: 'candidate001' }), + ]), + }), + slots: Object.freeze([ + Object.freeze({ + slot: 'slot-1', + gamUnitPath: '/123/example', + divId: 'slot-1', + formats: Object.freeze([Object.freeze([300, 250])]), + targeting: Object.freeze({ placement: 'article' }), + }), + ]), + bids: Object.freeze([ + Object.freeze({ + candidateId: 'candidate001', + slot: 'slot-1', + provider: 'example', + upstreamBidId: 'upstream-1', + cpm: 1.25, + currency: 'USD', + targeting: Object.freeze({ hb_pb: '1.25' }), + rendererReservationId: RESERVATION_ID, + renderSource: Object.freeze({ + type: 'adm', + version: 1, + adm: '
example
', + width: 300, + height: 250, + }), + }), + ]), + }), + }); +} + +function twoSlotFixture() { + const secondReservationId = `r1_${'b'.repeat(22)}`; + return Object.freeze({ + version: 1, + projectionDigest: 'b'.repeat(64), + projection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([ + Object.freeze({ slot: 'slot-1', outcome: 'winner', candidateId: 'candidate001' }), + Object.freeze({ slot: 'slot-2', outcome: 'winner', candidateId: 'candidate002' }), + ]), + }), + slots: Object.freeze([ + Object.freeze({ + slot: 'slot-1', + gamUnitPath: '/123/example-one', + divId: 'slot-1', + formats: Object.freeze([Object.freeze([300, 250])]), + targeting: Object.freeze({ placement: 'article' }), + }), + Object.freeze({ + slot: 'slot-2', + gamUnitPath: '/123/example-two', + divId: 'slot-2', + formats: Object.freeze([Object.freeze([728, 90])]), + targeting: Object.freeze({ placement: 'sidebar' }), + }), + ]), + bids: Object.freeze([ + Object.freeze({ + candidateId: 'candidate001', + slot: 'slot-1', + provider: 'example', + upstreamBidId: 'upstream-1', + cpm: 1.25, + currency: 'USD', + targeting: Object.freeze({ hb_pb: '1.25' }), + rendererReservationId: RESERVATION_ID, + renderSource: Object.freeze({ + type: 'adm', + version: 1, + adm: '
example one
', + width: 300, + height: 250, + }), + }), + Object.freeze({ + candidateId: 'candidate002', + slot: 'slot-2', + provider: 'example', + upstreamBidId: 'upstream-2', + cpm: 2.5, + currency: 'USD', + targeting: Object.freeze({ hb_pb: '2.50' }), + rendererReservationId: secondReservationId, + renderSource: Object.freeze({ + type: 'adm', + version: 1, + adm: '
example two
', + width: 728, + height: 90, + }), + }), + ]), + }), + }); +} + +function protocol() { + return Object.freeze({ + deadlines: Object.freeze({ + externalReadyMs: 10_000 as const, + requestStartMs: 3_000 as const, + completionMs: 10_000 as const, + }), + requestPlan: (candidate: unknown) => { + const value = candidate as { initialLoadDisabled: boolean; ownership: string }; + if (value.ownership === 'publisher') { + return Object.freeze({ + operations: Object.freeze(['refresh'] as const), + requestOperation: 0, + }); + } + return value.initialLoadDisabled + ? Object.freeze({ + operations: Object.freeze(['display', 'refresh'] as const), + requestOperation: 1 as const, + }) + : Object.freeze({ operations: Object.freeze(['display'] as const), requestOperation: 0 }); + }, + classifyRenderEnded: (candidate: unknown) => { + const value = candidate as { isEmpty?: unknown }; + return value.isEmpty === true + ? ('gam_empty' as const) + : value.isEmpty === false + ? ('nonempty_gam' as const) + : undefined; + }, + }); +} + +describe('first-display GPT adapter', () => { + it('owns enabled GAM attribution before publisher parser work without a winning bid', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const commands: Array<() => void> = []; + const order: string[] = []; + const setConfig = vi.fn(() => order.push('trusted-server')); + Object.defineProperty(dom.window, 'tsjs', { + configurable: true, + value: { adInit: true }, + }); + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { cmd: commands, setConfig }, + writable: true, + }); + expect(enqueueFirstDisplayGamAttribution(dom.window as unknown as Window)).toBe(true); + commands.push(() => order.push('publisher')); + commands.splice(0).forEach((command) => command()); + + expect(order).toEqual(['trusted-server', 'publisher']); + expect(setConfig).toHaveBeenCalledExactlyOnceWith({ targeting: { ts: 'true' } }); + }); + + it('creates an absent GPT queue and isolates a missing or throwing targeting API', () => { + const absent: { googletag?: unknown } = {}; + expect(enqueueFirstDisplayGamAttribution(absent as unknown as Window)).toBe(true); + const created = absent.googletag as { cmd: Array<() => void> }; + expect(created.cmd).toHaveLength(1); + expect(() => created.cmd[0]?.()).not.toThrow(); + + const publisher = vi.fn(); + const commands: Array<() => void> = []; + const throwing = { + googletag: { + cmd: commands, + setConfig: () => { + throw new Error('targeting unavailable'); + }, + }, + }; + expect(enqueueFirstDisplayGamAttribution(throwing as unknown as Window)).toBe(true); + commands.push(publisher); + expect(() => commands.splice(0).forEach((command) => command())).not.toThrow(); + expect(publisher).toHaveBeenCalledOnce(); + }); + + it('observes publisher GPT calls, events, and targeting until ingress closes', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const listeners = new Map void>(); + const mutations = vi.fn(() => true); + const retired = vi.fn(); + const slot = { + clearTargeting: vi.fn(() => undefined), + getSlotElementId: () => 'slot-1', + getTargeting: () => [], + setTargeting: vi.fn((_key: string, _value: string) => slot), + }; + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: vi.fn(), + removeEventListener: () => undefined, + }; + const binding = { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: vi.fn(() => slot), + destroySlots: vi.fn(() => true), + display: vi.fn(), + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }; + Object.defineProperty(dom.window, 'googletag', { configurable: true, value: binding }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + onNativeMutation: mutations, + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + onRetire: retired, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + mutations.mockClear(); + + slot.setTargeting('publisher', 'value'); + service.refresh([slot]); + binding.display('publisher-slot'); + listeners.get('slotRequested')?.({ slot, responseIdentifier: 'response-two' }); + listeners.get('slotRenderEnded')?.({ + slot, + responseIdentifier: 'response-two', + isEmpty: false, + }); + expect(mutations).toHaveBeenCalledTimes(5); + expect(retired).toHaveBeenCalledOnce(); + + expect(adapter.closeIngress(['slot-1'])).toBe(true); + const diagnostics = adapter.captureDiagnosticsHandoff(); + const [cycle] = diagnostics?.[0] ?? []; + expect(cycle?.[2]).toBe(3); + expect(cycle?.[5]).toEqual([ + expect.arrayContaining([1, 'completed']), + [2, 'response-two', ['slotRequested', 'slotRenderEnded'], 'completed'], + ]); + mutations.mockClear(); + slot.setTargeting('publisher', 'later'); + service.refresh([slot]); + binding.display('publisher-slot'); + listeners.get('slotRequested')?.({ slot }); + expect(mutations).not.toHaveBeenCalled(); + }); + + it('defines, targets, and starts one TS slot before attributing its render event', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const events: string[] = []; + const listeners = new Map void>(); + const slot = { + addService: vi.fn(() => slot), + getSlotElementId: vi.fn(() => 'slot-1'), + setTargeting: vi.fn((key: string) => { + events.push(`target:${key}`); + return slot; + }), + }; + const service = { + addEventListener: vi.fn((name: string, listener: (event: unknown) => void) => { + listeners.set(name, listener); + }), + enableSingleRequest: vi.fn(() => { + events.push('sra'); + return true; + }), + getSlots: vi.fn(() => []), + refresh: vi.fn(() => events.push('refresh')), + removeEventListener: vi.fn(), + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: false, + cmd: { push: (command: () => void) => command() }, + defineSlot: vi.fn(() => slot), + display: vi.fn(() => events.push('display')), + enableServices: vi.fn(() => events.push('services')), + getConfig: vi.fn(() => ({ disableInitialLoad: false })), + pubads: vi.fn(() => service), + }, + writable: true, + }); + const batch = snapshotFirstDisplayBatchV1(fixture()); + expect(batch).toBeDefined(); + const timers: Array<() => void> = []; + const renders: unknown[] = []; + const failures: unknown[] = []; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch!.projection, + protocol: protocol(), + setTimer: (callback) => { + timers.push(callback); + return callback; + }, + clearTimer: (handle) => { + const index = timers.indexOf(handle as () => void); + if (index >= 0) timers.splice(index, 1); + }, + }); + + expect( + startAdapter(adapter, { + onBound: (cycle) => { + expect(cycle[1]).toBe(dom.window.document.getElementById('slot-1')); + events.push(`bound:${cycle[6]}:${cycle[3]}`); + }, + onFailure: (slotId, reason) => failures.push([slotId, reason]), + onFirstAction: () => { + events.push('first-action'); + return true; + }, + onRenderEnded: (cycle, result) => renders.push([cycle[6], result]), + }) + ).toBe(true); + expect(events).toEqual([ + 'bound:slot-1:trusted_server', + 'target:hb_adid', + 'target:hb_pb', + 'target:placement', + 'first-action', + 'sra', + 'services', + 'display', + ]); + expect(slot.setTargeting.mock.calls).toEqual([ + ['hb_adid', `r1_${'a'.repeat(22)}`], + ['hb_pb', '1.25'], + ['placement', 'article'], + ]); + + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + expect(renders).toEqual([['slot-1', 'nonempty_gam']]); + expect(failures).toEqual([]); + expect(timers).toHaveLength(0); + adapter.dispose(); + expect(service.removeEventListener.mock.calls.map(([name]) => name)).toEqual([ + 'slotRequested', + 'slotRenderEnded', + ]); + }); + + it.each(['enableSingleRequest', 'enableServices'] as const)( + 'fails the entire batch without a request when %s throws', + (failure) => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const slots = new Map(); + const display = vi.fn(); + const refresh = vi.fn(); + const service = { + addEventListener: vi.fn(), + enableSingleRequest: vi.fn(() => { + if (failure === 'enableSingleRequest') throw new Error('fictional SRA failure'); + return true; + }), + getSlots: () => [], + refresh, + removeEventListener: vi.fn(), + }; + const binding = { + pubadsReady: false, + cmd: { push: (command: () => void) => command() }, + defineSlot: (_path: string, _sizes: unknown, elementId: string) => { + const targeting = new Map(); + const slot = { + addService: () => slot, + clearTargeting: (key: string) => targeting.delete(key), + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: (key: string, value: string) => { + targeting.set(key, [value]); + return slot; + }, + }; + slots.set(elementId, slot); + return slot; + }, + display, + enableServices: () => { + if (failure === 'enableServices') throw new Error('fictional service failure'); + }, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }; + Object.defineProperty(dom.window, 'googletag', { configurable: true, value: binding }); + const failures: unknown[] = []; + const firstAction = vi.fn(() => true); + const batch = snapshotFirstDisplayBatchV1(twoSlotFixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + }); + + startAdapter(adapter, { + onBound: () => undefined, + onFailure: (slotId, reason) => failures.push([slotId, reason]), + onFirstAction: firstAction, + onRenderEnded: () => undefined, + }); + + expect(firstAction).toHaveBeenCalledOnce(); + expect(failures).toEqual([ + ['slot-1', 'gpt_request_failed'], + ['slot-2', 'gpt_request_failed'], + ]); + expect(display).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + } + ); + + it('arms every synchronous SRA display cycle before the first display requests all slots', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const listeners = new Map void>(); + const slots: object[] = []; + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [], + refresh: vi.fn(), + removeEventListener: vi.fn(), + }; + const display = vi.fn(() => { + for (const slot of slots) listeners.get('slotRequested')?.({ slot }); + for (const slot of slots) listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + }); + const binding = { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: (_path: string, _sizes: unknown, _elementId: string) => { + const targeting = new Map(); + const slot = { + addService: () => slot, + clearTargeting: (key: string) => targeting.delete(key), + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: (key: string, value: string) => { + targeting.set(key, [value]); + return slot; + }, + }; + slots.push(slot); + return slot; + }, + display, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }; + Object.defineProperty(dom.window, 'googletag', { configurable: true, value: binding }); + const rendered: string[] = []; + const failures: unknown[] = []; + const firstAction = vi.fn(() => true); + const batch = snapshotFirstDisplayBatchV1(twoSlotFixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + }); + + startAdapter(adapter, { + onBound: () => undefined, + onFailure: (slotId, reason) => failures.push([slotId, reason]), + onFirstAction: firstAction, + onRenderEnded: (cycle) => rendered.push(cycle[6]), + }); + + expect(display).toHaveBeenCalledOnce(); + expect(firstAction).toHaveBeenCalledOnce(); + expect(rendered).toEqual(['slot-1', 'slot-2']); + expect(failures).toEqual([]); + }); + + it('invalidates the delayed-owner binding when a publisher replaces the physical slot', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const listeners = new Map void>(); + const slot = { + addService: () => slot, + getSlotElementId: () => 'slot-1', + setTargeting: () => slot, + }; + const replacement = { + addService: () => replacement, + getSlotElementId: () => 'slot-1', + setTargeting: () => replacement, + }; + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [], + removeEventListener: () => undefined, + }; + const defineSlot = vi.fn().mockReturnValueOnce(slot).mockReturnValueOnce(replacement); + let destroyMode: 'throw' | 'false' | 'true' = 'throw'; + const destroySlots = vi.fn((slots?: readonly object[]) => { + if (destroyMode === 'throw') throw new Error('fictional destroy failure'); + if (destroyMode === 'false') return false; + if (Array.isArray(slots)) (slots as object[]).length = 0; + return true; + }); + const binding = { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot, + destroySlots, + display: (_elementId: string) => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: binding, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const bound: { value?: Parameters[0] } = {}; + const retired = vi.fn(); + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + }); + startAdapter(adapter, { + onBound: (cycle) => { + bound.value = cycle; + }, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + onRetire: retired, + }); + + expect(bound.value?.[2]()).toBe(true); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + expect(bound.value?.[2]()).toBe(true); + + expect(() => binding.destroySlots([slot])).toThrow('fictional destroy failure'); + expect(bound.value?.[2]()).toBe(true); + expect(retired).not.toHaveBeenCalled(); + destroyMode = 'false'; + expect(binding.destroySlots([slot])).toBe(false); + expect(bound.value?.[2]()).toBe(true); + expect(retired).not.toHaveBeenCalled(); + destroyMode = 'true'; + const targets = [slot]; + expect(binding.destroySlots(targets)).toBe(true); + expect(targets).toEqual([]); + expect(binding.defineSlot('/publisher/replacement', [[300, 250]], 'slot-1')).toBe(replacement); + binding.display('slot-1'); + expect(bound.value?.[2]()).toBe(false); + expect(retired).toHaveBeenCalledOnce(); + }); + + it('cancels a pending command and compare-restores an adapter-created GPT queue', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const batch = snapshotFirstDisplayBatchV1(fixture()); + const timers: Array<() => void> = []; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch!.projection, + protocol: protocol(), + setTimer: (callback) => { + timers.push(callback); + return callback; + }, + clearTimer: () => undefined, + }); + expect( + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }) + ).toBe(true); + expect( + (dom.window as unknown as { googletag?: { cmd?: unknown[] } }).googletag?.cmd + ).toHaveLength(1); + + adapter.dispose(); + expect(Object.prototype.hasOwnProperty.call(dom.window, 'googletag')).toBe(false); + }); + + it('uses publisher refresh and compare-restores only unchanged targeting', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: vi.fn(() => 'slot-1'), + getTargeting: vi.fn((key: string) => targeting.get(key) ?? []), + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const refresh = vi.fn(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh, + removeEventListener: vi.fn(), + }; + const defineSlot = vi.fn(); + const display = vi.fn(); + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot, + display, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + const firstAction = vi.fn(() => true); + + startAdapter(adapter, { + onBound: (cycle) => expect(cycle[3]).toBe('publisher'), + onFailure: () => undefined, + onFirstAction: firstAction, + onRenderEnded: () => undefined, + }); + expect(defineSlot).not.toHaveBeenCalled(); + expect(display).not.toHaveBeenCalled(); + expect(refresh).toHaveBeenCalledWith([slot], { changeCorrelator: false }); + expect(firstAction).toHaveBeenCalledOnce(); + + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + slot.setTargeting('placement', 'article'); + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(adapter.captureHandoff()?.[0]?.[3]).toEqual([ + { installed: RESERVATION_ID, key: 'hb_adid', prior: [] }, + { installed: '1.25', key: 'hb_pb', prior: ['publisher-original'] }, + ]); + adapter.dispose(); + expect(targeting.get('placement')).toEqual(['article']); + expect(targeting.get('hb_pb')).toEqual(['publisher-original']); + expect(targeting.has('hb_adid')).toBe(false); + }); + + it.each(['reentrant_refresh', 'slot_object_display'] as const)( + 'does not attribute a competing publisher %s to the pending TS request', + (competition) => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const listeners = new Map void>(); + const slot = { + getSlotElementId: () => 'slot-1', + getTargeting: () => [], + setTargeting: () => slot, + clearTargeting: () => slot, + }; + let reentered = false; + const refresh = vi.fn((_slots?: readonly object[]) => { + if (competition === 'reentrant_refresh' && !reentered) { + reentered = true; + service.refresh([slot]); + } + }); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh, + removeEventListener: () => undefined, + }; + const display = vi.fn((_slot?: unknown) => undefined); + const binding = { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: binding, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const failure = vi.fn(); + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + diagnosticsActive: true, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: failure, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + + if (competition === 'slot_object_display') binding.display(slot as never); + listeners.get('slotRequested')?.({ slot }); + + expect(refresh).toHaveBeenCalledTimes(competition === 'reentrant_refresh' ? 2 : 1); + expect(display).toHaveBeenCalledTimes(competition === 'slot_object_display' ? 1 : 0); + expect(failure).toHaveBeenCalledWith('slot-1', 'cycle_unattributable'); + expect(adapter.closeIngress([])).toBe(true); + expect(adapter.captureDiagnosticsHandoff()?.[1][0]).toMatchObject({ + event: 'slotRequested', + requestedSlotSizes: null, + }); + adapter.dispose(); + } + ); + + it.each(['exact', 'hydration'] as const)( + 'hands off a %s late publisher definition without creating a second GPT slot', + (mode) => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const warning = vi.spyOn(dom.window.console, 'warn').mockImplementation(() => undefined); + const listeners = new Map void>(); + const targeting = new Map(); + const slot = { + addService: () => slot, + clearTargeting: (key: string) => targeting.delete(key), + getAdUnitPath: () => '/123/example', + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: (key: string, value: string | readonly string[]) => { + targeting.set(key, typeof value === 'string' ? [value] : [...value]); + return slot; + }, + }; + let defined = false; + let disabled = false; + const refresh = vi.fn((_slots?: readonly object[], _options?: unknown) => undefined); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => (defined ? [slot] : []), + refresh, + removeEventListener: () => undefined, + }; + const defineSlot = vi.fn((_path?: unknown, _sizes?: unknown, _elementId?: unknown) => { + defined = true; + return slot; + }); + const display = vi.fn((_target?: unknown) => undefined); + const binding = { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot, + destroySlots: vi.fn(() => true), + display, + getConfig: () => ({ disableInitialLoad: disabled }), + pubads: () => service, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: binding, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + disabled = true; + + let elementId = 'slot-1'; + let path: string = '/publisher/mismatch'; + let sizes: readonly (readonly [number, number])[] = [[728, 90]]; + if (mode === 'hydration') { + dom.window.document.getElementById('slot-1')?.remove(); + const replacement = dom.window.document.createElement('div'); + replacement.id = 'slot-1-hydrated'; + dom.window.document.body.append(replacement); + elementId = replacement.id; + path = '/123/example'; + sizes = [[300, 250]]; + } + expect(binding.defineSlot(path, sizes, elementId)).toBe(slot); + expect(defineSlot).toHaveBeenCalledTimes(1); + if (mode === 'exact') { + expect(warning).toHaveBeenCalledExactlyOnceWith('GPT publisher handoff metadata mismatch', { + formatsMismatch: true, + pathMismatch: true, + }); + slot.setTargeting('hb_pb', 'publisher'); + } else { + expect(warning).not.toHaveBeenCalled(); + } + + const displayCalls = display.mock.calls.length; + const refreshCalls = refresh.mock.calls.length; + binding.display(elementId); + service.refresh([slot], { changeCorrelator: false } as never); + expect(display).toHaveBeenCalledTimes(displayCalls); + expect(refresh).toHaveBeenCalledTimes(refreshCalls); + binding.display(elementId); + service.refresh([slot], { changeCorrelator: false } as never); + expect(display).toHaveBeenCalledTimes(displayCalls + 1); + expect(refresh).toHaveBeenCalledTimes(refreshCalls + 1); + + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(adapter.captureHandoff()?.[0]?.slice(0, 3)).toEqual([ + 'slot-1', + elementId, + 'publisher', + ]); + expect(adapter.captureHandoff()?.[0]?.[3]).toEqual( + mode === 'exact' + ? [ + { installed: RESERVATION_ID, key: 'hb_adid', prior: [] }, + { installed: 'article', key: 'placement', prior: [] }, + ] + : [ + { installed: RESERVATION_ID, key: 'hb_adid', prior: [] }, + { installed: '1.25', key: 'hb_pb', prior: [] }, + { installed: 'article', key: 'placement', prior: [] }, + ] + ); + adapter.dispose(); + expect(binding.destroySlots).not.toHaveBeenCalled(); + } + ); + + it('rejects targeting handoff after a publisher replaces an observed method', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + const replacement = vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }); + Object.defineProperty(slot, 'setTargeting', { + configurable: true, + value: replacement, + writable: true, + }); + slot.setTargeting('hb_pb', '1.25'); + + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(adapter.captureHandoff()?.[0]?.[3]).toEqual([]); + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + adapter.dispose(); + expect(replacement).toHaveBeenCalledWith('hb_pb', '1.25'); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + }); + + it.each(['close', 'dispose'] as const)( + 'does not restore stale publisher targeting through a replacement during %s cleanup', + (cleanup) => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + const replacement = vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }); + Object.defineProperty(slot, 'setTargeting', { + configurable: true, + value: replacement, + writable: true, + }); + slot.setTargeting('hb_pb', '1.25'); + + if (cleanup === 'close') { + expect(adapter.closeIngress([])).toBe(true); + adapter.dispose(); + } else { + adapter.dispose(); + } + expect(replacement).toHaveBeenCalledOnce(); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + } + ); + + it.each(['close', 'dispose'] as const)( + 'honors a same-value publisher write from getTargeting during %s cleanup', + (cleanup) => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + let cleanupPhase = false; + let reentrantCleanupCalls = 0; + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: vi.fn((key: string) => { + if (cleanupPhase && key === 'hb_pb' && reentrantCleanupCalls === 0) { + reentrantCleanupCalls += 1; + slot.setTargeting('hb_pb', '1.25'); + } + return targeting.get(key) ?? []; + }), + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + cleanupPhase = true; + if (cleanup === 'close') { + expect(adapter.closeIngress([])).toBe(true); + adapter.dispose(); + } else { + adapter.dispose(); + } + expect(reentrantCleanupCalls).toBe(1); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + } + ); + + it('seals retained targeting while same-value publisher writes are still observed', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + let sealing = false; + let reentrantSealingCalls = 0; + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: vi.fn((key: string) => { + if (sealing && key === 'hb_pb' && reentrantSealingCalls === 0) { + reentrantSealingCalls += 1; + slot.setTargeting('hb_pb', '1.25'); + } + return targeting.get(key) ?? []; + }), + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + sealing = true; + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(adapter.captureHandoff()?.[0]?.[3].some(({ key }) => key === 'hb_pb')).toBe(false); + expect(reentrantSealingCalls).toBe(1); + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + adapter.dispose(); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + }); + + it('cleans failed TS slots while publisher targeting observation is still continuous', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + const publisherSlot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return publisherSlot; + }), + }; + const failedSlot = { + addService: () => failedSlot, + getSlotElementId: () => 'slot-2', + setTargeting: () => failedSlot, + }; + const listeners = new Map void>(); + const destroySlots = vi.fn((slots: object[]) => { + expect(slots).toEqual([failedSlot]); + slots.length = 0; + publisherSlot.setTargeting('hb_pb', '1.25'); + return true; + }); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [publisherSlot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: () => failedSlot, + destroySlots, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(twoSlotFixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + for (const slot of [publisherSlot, failedSlot]) { + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + } + + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(destroySlots).toHaveBeenCalledOnce(); + expect(adapter.captureHandoff()?.[0]?.[3].some(({ key }) => key === 'hb_pb')).toBe(false); + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + adapter.dispose(); + expect(destroySlots).toHaveBeenCalledOnce(); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + }); + + it('cleans failed publisher targeting before handoff with exact nested-write attribution', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const acceptedTargeting = new Map([['hb_pb', ['accepted-original']]]); + const failedTargeting = new Map([['hb_pb', ['failed-original']]]); + let cleanupPhase = false; + let reentrantCleanupCalls = 0; + const acceptedSlot = { + clearTargeting: vi.fn((key: string) => acceptedTargeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => acceptedTargeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + acceptedTargeting.set(key, Array.isArray(value) ? [...value] : [value]); + return acceptedSlot; + }), + }; + const reenterAcceptedSlot = () => { + if (!cleanupPhase || reentrantCleanupCalls > 0) return; + reentrantCleanupCalls += 1; + acceptedSlot.setTargeting('hb_pb', '1.25'); + }; + const failedSlot = { + clearTargeting: vi.fn((key: string) => { + failedTargeting.delete(key); + reenterAcceptedSlot(); + }), + getSlotElementId: () => 'slot-2', + getTargeting: (key: string) => failedTargeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + failedTargeting.set(key, Array.isArray(value) ? [...value] : [value]); + reenterAcceptedSlot(); + return failedSlot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [acceptedSlot, failedSlot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(twoSlotFixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + for (const slot of [acceptedSlot, failedSlot]) { + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + } + + cleanupPhase = true; + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(failedTargeting.get('hb_pb')).toEqual(['failed-original']); + expect(reentrantCleanupCalls).toBe(1); + expect(adapter.captureHandoff()?.[0]?.[3].some(({ key }) => key === 'hb_pb')).toBe(false); + const cleanupWrites = + failedSlot.clearTargeting.mock.calls.length + failedSlot.setTargeting.mock.calls.length; + + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + adapter.dispose(); + expect(reentrantCleanupCalls).toBe(1); + expect( + failedSlot.clearTargeting.mock.calls.length + failedSlot.setTargeting.mock.calls.length + ).toBe(cleanupWrites); + expect(acceptedTargeting.get('hb_pb')).toEqual(['1.25']); + }); + + it('marks refresh as the first request when initial load is disabled', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const events: string[] = []; + const slot = { + addService: () => slot, + getSlotElementId: () => 'slot-1', + setTargeting: () => slot, + }; + const service = { + addEventListener: (name: string) => events.push(`listen:${name}`), + getSlots: () => [], + refresh: () => events.push('refresh'), + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: () => slot, + display: () => events.push('display'), + getConfig: () => ({ disableInitialLoad: true }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => { + events.push('first-action'); + return true; + }, + onRenderEnded: () => undefined, + }); + expect(events).toEqual([ + 'listen:slotRequested', + 'listen:slotRenderEnded', + 'display', + 'first-action', + 'refresh', + ]); + }); + + it('removes a timed-out command so it cannot act after the readiness deadline', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const timers: Array<() => void> = []; + const failures: unknown[] = []; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => { + timers.push(callback); + return callback; + }, + clearTimer: () => undefined, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: (slotId, reason) => failures.push([slotId, reason]), + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + const binding = (dom.window as unknown as { googletag: { cmd: Array<() => void> } }).googletag; + expect(binding.cmd).toHaveLength(1); + + timers[0]?.(); + expect(failures).toEqual([['slot-1', 'external_ready_timeout']]); + expect(binding.cmd).toHaveLength(0); + }); + + it('requires an attributable request before render completion and bounds request start', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const listeners = new Map void>(); + const timers: Array<() => void> = []; + const failures: unknown[] = []; + const renders: unknown[] = []; + const slot = { + addService: () => slot, + getSlotElementId: () => 'slot-1', + setTargeting: () => slot, + }; + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [], + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: () => slot, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => { + timers.push(callback); + return callback; + }, + clearTimer: (handle) => { + const index = timers.indexOf(handle as () => void); + if (index >= 0) timers.splice(index, 1); + }, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: (slotId, reason) => failures.push([slotId, reason]), + onFirstAction: () => true, + onRenderEnded: (cycle, result) => renders.push([cycle[6], result]), + }); + + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + expect(renders).toEqual([]); + expect(timers).toHaveLength(2); + timers[0]?.(); + expect(failures).toEqual([['slot-1', 'gpt_request_timeout']]); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + expect(renders).toEqual([]); + }); + + it('uses the unique hydrated element id for slot definition and display', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const listeners = new Map void>(); + const slot = { + addService: () => slot, + getSlotElementId: () => 'slot-1-hydrated', + setTargeting: () => slot, + }; + const defineSlot = vi.fn(() => slot); + const display = vi.fn(); + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot, + display, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => ({ + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [], + removeEventListener: () => undefined, + }), + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + + expect(defineSlot).toHaveBeenCalledWith('/123/example', [[300, 250]], 'slot-1-hydrated'); + expect(display).toHaveBeenCalledWith('slot-1-hydrated'); + }); + + it('captures exact terminal slots, closes ingress, and detaches only committed identities', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const listeners = new Map void>(); + const slot = { + addService: () => slot, + getSlotElementId: () => 'slot-1', + setTargeting: () => slot, + }; + const destroySlots = vi.fn(() => true); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [], + removeEventListener: vi.fn(), + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: () => slot, + destroySlots, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const value = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: value.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(adapter.captureHandoff()).toEqual([expect.arrayContaining(['slot-1', slot])]); + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(false); + adapter.dispose(); + + expect(service.removeEventListener).toHaveBeenCalledTimes(2); + expect(destroySlots).not.toHaveBeenCalled(); + }); + + it('captures six exact normalized diagnostics facts with stable slot identity', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + vi.spyOn(dom.window.performance, 'now').mockReturnValue(12.5); + const listeners = new Map void>(); + const slot = { + addService: () => slot, + getAdUnitPath: () => '/123/example', + getSlotElementId: () => 'slot-1', + setTargeting: () => slot, + }; + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [], + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: () => slot, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const value = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + diagnosticsActive: true, + document: dom.window.document, + projection: value.projection, + protocol: protocol(), + setTimer: (callback) => callback, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + + listeners.get('slotRequested')?.({ slot, responseIdentifier: 'response-one' }); + listeners.get('slotResponseReceived')?.({ slot, responseIdentifier: 'response-one' }); + listeners.get('slotRenderEnded')?.({ + slot, + responseIdentifier: 'response-one', + isEmpty: false, + size: [300, 250], + isBackfill: false, + slotContentChanged: true, + }); + listeners.get('slotOnload')?.({ slot }); + listeners.get('impressionViewable')?.({ slot }); + listeners.get('slotVisibilityChanged')?.({ slot, inViewPercentage: 42 }); + expect(adapter.closeIngress(['slot-1'])).toBe(true); + + const diagnostics = adapter.captureDiagnosticsHandoff(); + expect([...listeners.keys()]).toEqual([ + 'slotRequested', + 'slotRenderEnded', + 'slotResponseReceived', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', + ]); + expect(diagnostics?.[2]).toBe(2); + expect(diagnostics?.[3]).toBe(0); + expect(diagnostics?.[4]).toBe(0); + expect(diagnostics?.[1]).toHaveLength(6); + expect(diagnostics?.[0]).toEqual([ + [ + 'slot-1', + 'gt1_1', + 2, + false, + [], + [ + [ + 1, + 'response-one', + [ + 'slotRequested', + 'slotResponseReceived', + 'slotRenderEnded', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', + ], + 'completed', + ], + ], + ], + ]); + expect(diagnostics?.[1][0]).toEqual({ + version: 1, + event: 'slotRequested', + token: 'gt1_1', + runtimeSlotNumber: 1, + cycleOrdinal: 1, + disposition: 'matched', + issueReason: null, + capturedAtMs: 12.5, + elementId: 'slot-1', + adUnitPath: '/123/example', + requestedSlotSizes: [[300, 250]], + isEmpty: null, + renderedSize: null, + isBackfill: null, + slotContentChanged: null, + visibilityPercent: null, + }); + expect(diagnostics?.[1][2]).toMatchObject({ + event: 'slotRenderEnded', + requestedSlotSizes: null, + isEmpty: false, + renderedSize: [300, 250], + isBackfill: false, + slotContentChanged: true, + }); + expect(diagnostics?.[1][5]).toMatchObject({ + event: 'slotVisibilityChanged', + visibilityPercent: 42, + }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/first_display/handoff.test.ts b/crates/trusted-server-js/lib/test/first_display/handoff.test.ts new file mode 100644 index 000000000..2a02e9166 --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/handoff.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createFirstDisplayHandoffOwner, + finalizeFirstDisplayAgentCaptureV1, +} from '../../src/shared/first_display_handoff'; + +const RELEASE_ID = 'a'.repeat(64); +const RESERVATION_ID = `r1_${'a'.repeat(22)}`; + +function handoff( + revision: number, + overrides: Record = {} +): Record { + return { + captureVersion: 1, + releaseId: RELEASE_ID, + generation: 1, + projectionDigest: 'b'.repeat(64), + integrationConfigDigest: 'c'.repeat(64), + slices: ['first_display'], + slots: [], + attempts: [], + tombstones: [], + artifacts: [], + parserState: [], + gptDiagnostics: { facts: [], overflowCount: 0, dropCount: 0 }, + timing: { bidsScriptMs: 1, firstDisplayMs: null, terminalMs: 2, paintMs: 3 }, + highWater: { + navigationAttemptPrefix: 'AAECAwQFBgc', + nextNavigationAttemptOrdinal: 1, + nextAttemptOrdinal: 1, + nextSlotRegistrationOrdinal: 1, + reservationClockEpochMs: 0, + nextReservationOrdinal: 1, + nextTicketOrdinal: 1, + }, + cycles: [], + trace: { nextSequence: 1, nextGlobalSlotOrdinal: 1, slots: [] }, + mutationRevision: revision, + identityCount: 0, + ...overrides, + }; +} + +function acceptedHandoff(revision: number): Record { + return handoff(revision, { + identityCount: 2, + slices: ['first_display', 'gpt_initial'], + slots: [ + { + id: 'slot-1', + aliases: [], + domId: 'div-1', + gamPath: '/123/slot-1', + formats: [[300, 250]], + owner: 'trusted_server', + outcome: 'accepted', + targeting: [['hb_adid', RESERVATION_ID]], + targetingOwnership: [], + committedArtifact: 'gpt_adm', + gptToken: 'gt1_1', + }, + ], + attempts: [ + { + id: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + slotId: 'slot-1', + ordinal: 1, + state: 'accepted', + reason: null, + }, + ], + artifacts: [ + { + hostPosition: null, + hostPositionPriority: null, + slotId: 'slot-1', + kind: 'gpt_adm', + owner: 'trusted_server', + token: RESERVATION_ID, + }, + ], + parserState: [ + { + sliceId: 'gpt_initial', + observations: ['gam', 'v'], + values: [ + ['gam', false], + ['v', 1], + ], + }, + ], + timing: { bidsScriptMs: 1, firstDisplayMs: 2, terminalMs: 3, paintMs: 4 }, + highWater: { + navigationAttemptPrefix: 'AAECAwQFBgc', + nextNavigationAttemptOrdinal: 2, + nextAttemptOrdinal: 2, + nextSlotRegistrationOrdinal: 2, + reservationClockEpochMs: 0, + nextReservationOrdinal: 2, + nextTicketOrdinal: 1, + }, + cycles: [ + { + slotId: 'slot-1', + token: 'gt1_1', + nextCycleOrdinal: 2, + unknownPriorCycle: false, + records: [ + { + ordinal: 1, + responseIdentifier: 'response-one', + seen: ['slotRequested', 'slotRenderEnded'], + state: 'completed', + }, + ], + quarantines: [], + }, + ], + trace: { + nextSequence: 2, + nextGlobalSlotOrdinal: 2, + slots: [ + { + slotId: 'slot-1', + impressions: 1, + bindings: [ + { + atMs: 3, + cycleOrdinal: 1, + historySequence: 1, + state: 'completed', + token: 'gt1_1', + }, + ], + }, + ], + }, + }); +} + +function owner(options: { initialRevision?: number } = {}) { + const failures: string[] = []; + const events: string[] = []; + return { + events, + failures, + value: createFirstDisplayHandoffOwner({ + releaseId: RELEASE_ID, + generation: 1, + ...(options.initialRevision === undefined + ? {} + : { initialMutationRevision: options.initialRevision }), + isCurrentGeneration: () => true, + isTerminal: () => true, + isPainted: () => true, + closeIngress: () => events.push('close-ingress'), + onFailure: (reason) => failures.push(reason), + }), + }; +} + +describe('first-display final handoff owner', () => { + it('materializes the compact data handoff and capsule only inside the finalizer call', () => { + const artifact = {}; + const physicalSlot = {}; + const finalized = finalizeFirstDisplayAgentCaptureV1([ + { + releaseId: RELEASE_ID, + generation: 1, + integrationConfigDigest: 'c'.repeat(64), + slices: ['first_display'], + }, + ['b'.repeat(64), []], + new Map(), + new Map(), + new Map(), + [], + [['slot-1', 'slot-1', 'trusted_server', [], 'gt1_1', physicalSlot]], + [[['slot-1', 'gt1_1', 2, false, [], []]], [], 2, 0, 0], + [ + [[null, null, artifact, 'gpt_adm', 'trusted_server', 'slot-1', 'reservation-1']], + [], + 3, + 1, + 1, + ], + [1, null, 2, 3, 3], + 1, + 0, + ]); + + expect(finalized?.handoff).toMatchObject({ + captureVersion: 1, + releaseId: RELEASE_ID, + generation: 1, + mutationRevision: 0, + identityCount: 2, + }); + expect(finalized?.capsule.consume(RELEASE_ID, 1)).toEqual([physicalSlot, artifact]); + expect(finalized?.capsule.consume(RELEASE_ID, 1)).toBeUndefined(); + }); + + it('seals the final revision and mints one release-bound capsule in the same task', () => { + const h = owner(); + const physicalSlot = {}; + const artifact = {}; + expect(h.value.observeMutation()).toBe(true); + expect(h.value.observeMutation()).toBe(true); + + const final = h.value.finalize(() => ({ + candidate: acceptedHandoff(2), + identities: [physicalSlot, artifact], + })); + expect(final?.handoff.mutationRevision).toBe(2); + expect(Object.isFrozen(final?.handoff)).toBe(true); + expect(final?.capsule.consume(RELEASE_ID, 1)).toEqual([physicalSlot, artifact]); + expect(final?.capsule.consume(RELEASE_ID, 1)).toBeUndefined(); + expect(h.value.state).toBe('finalized'); + expect(h.events).toEqual(['close-ingress']); + expect(h.failures).toEqual([]); + }); + + it('drains a synchronous native mutation while closing ingress before final capture', () => { + const failures: string[] = []; + const value = createFirstDisplayHandoffOwner({ + releaseId: RELEASE_ID, + generation: 1, + isCurrentGeneration: () => true, + isTerminal: () => true, + isPainted: () => true, + closeIngress: () => { + expect(value.observeMutation()).toBe(true); + }, + onFailure: (reason) => failures.push(reason), + }); + + expect( + value.finalize(() => ({ + candidate: handoff(1), + identities: [], + })) + ).toBeDefined(); + expect(value.mutationRevision).toBe(1); + expect(failures).toEqual([]); + }); + + it('clears the capsule and fails closed on duplicate finalization or stale revision', () => { + const duplicate = owner(); + const identity = {}; + const artifact = {}; + const final = duplicate.value.finalize(() => ({ + candidate: acceptedHandoff(0), + identities: [identity, artifact], + })); + expect(final).toBeDefined(); + expect( + duplicate.value.finalize(() => ({ + candidate: acceptedHandoff(0), + identities: [identity, artifact], + })) + ).toBeUndefined(); + expect(final?.capsule.consume(RELEASE_ID, 1)).toBeUndefined(); + expect(duplicate.failures).toEqual(['bundle_partial']); + + const extraIdentity = owner(); + expect( + extraIdentity.value.finalize(() => ({ candidate: handoff(0), identities: [{}] })) + ).toBeUndefined(); + expect(extraIdentity.failures).toEqual(['bundle_partial']); + + const stale = owner(); + stale.value.observeMutation(); + expect( + stale.value.finalize(() => ({ candidate: handoff(0), identities: [{}] })) + ).toBeUndefined(); + expect(stale.value.state).toBe('failed'); + expect(stale.failures).toEqual(['bundle_partial']); + }); + + it('defers semantic validation but rejects wrong identity and revision exhaustion', () => { + const nonterminal = owner(); + expect( + nonterminal.value.finalize(() => ({ + candidate: handoff(0, { + slots: [ + { + id: 'slot-1', + aliases: [], + domId: 'div-1', + gamPath: '/123/slot-1', + formats: [[300, 250]], + owner: 'trusted_server', + outcome: 'failed', + targeting: [], + targetingOwnership: [], + committedArtifact: 'none', + gptToken: null, + }, + ], + attempts: [ + { id: 'attempt-1', slotId: 'slot-1', ordinal: 1, state: 'pending', reason: null }, + ], + highWater: { + ...((handoff(0).highWater as object) ?? {}), + nextAttemptOrdinal: 2, + nextSlotRegistrationOrdinal: 2, + }, + }), + identities: [], + })) + ).toBeDefined(); + expect(nonterminal.failures).toEqual([]); + + const badIdentity = owner(); + expect( + badIdentity.value.finalize(() => ({ + candidate: handoff(0), + identities: [null as unknown as object], + })) + ).toBeUndefined(); + expect(badIdentity.failures).toEqual(['bundle_partial']); + + const exhausted = owner({ initialRevision: 4_294_967_295 }); + expect(exhausted.value.observeMutation()).toBe(false); + expect(exhausted.value.state).toBe('failed'); + expect(exhausted.failures).toEqual(['bundle_partial']); + }); + + it('requires terminal paint and the current generation before closing ingress', () => { + for (const failedGate of ['generation', 'terminal', 'paint'] as const) { + const closeIngress = vi.fn(); + const failures: string[] = []; + const value = createFirstDisplayHandoffOwner({ + releaseId: RELEASE_ID, + generation: 1, + isCurrentGeneration: () => failedGate !== 'generation', + isTerminal: () => failedGate !== 'terminal', + isPainted: () => failedGate !== 'paint', + closeIngress, + onFailure: (reason) => failures.push(reason), + }); + expect(value.finalize(() => ({ candidate: handoff(0), identities: [] }))).toBeUndefined(); + expect(closeIngress).not.toHaveBeenCalled(); + expect(failures).toEqual(['bundle_partial']); + } + }); +}); diff --git a/crates/trusted-server-js/lib/test/first_display/helpers/compact_takeover.ts b/crates/trusted-server-js/lib/test/first_display/helpers/compact_takeover.ts new file mode 100644 index 000000000..0ab9bc76e --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/helpers/compact_takeover.ts @@ -0,0 +1,191 @@ +export function failedFirstDisplayTakeover(releaseId: string) { + const projectionDigest = 'b'.repeat(64); + const integrationConfigDigest = 'c'.repeat(64); + const projection = { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'slot-1', outcome: 'failed', reason: 'internal_error' }], + }, + slots: [ + { + slot: 'slot-1', + gamUnitPath: '/123/slot-1', + divId: 'div-1', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [], + }; + return { + capture: { + captureVersion: 1, + releaseId, + generation: 1, + data: [ + projectionDigest, + integrationConfigDigest, + ['first_display'], + [['failed', 'internal_error', null, null]], + [], + [], + [], + [], + [[], 0, 0], + [0, null, 0, 0], + [2, 0, 1, 1], + [], + 1, + 2, + ], + mutationRevision: 0, + identityCount: 0, + }, + outline: { + version: 1, + releaseId, + generation: 1, + projectionDigest, + integrationConfigDigest, + slices: ['first_display'], + slotCount: 1, + outcomeCount: 1, + capabilities: [], + objectKinds: [], + }, + boot: { + abi: 1, + releaseId, + manifest: {}, + auctionProjection: projection, + integrations: {}, + creative: {}, + diagnostics: {}, + }, + } as const; +} + +export function acceptedGptFirstDisplayTakeover( + releaseId: string, + reservationId: string, + parserState: 'valid' | 'invalid' | 'absent', + gamAttributionEnabled: boolean +) { + const projectionDigest = 'b'.repeat(64); + const integrationConfigDigest = 'c'.repeat(64); + const slices = + parserState === 'absent' + ? (['first_display'] as const) + : (['first_display', 'gpt_initial'] as const); + const projection = { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'takeover-slot', outcome: 'winner', candidateId: 'AAAAAAAAAAAA' }], + }, + slots: [ + { + slot: 'takeover-slot', + gamUnitPath: '/123/takeover-slot', + divId: 'takeover-slot', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [ + { + candidateId: 'AAAAAAAAAAAA', + slot: 'takeover-slot', + provider: 'trusted', + upstreamBidId: 'bid-1', + cpm: 1, + currency: 'USD', + targeting: {}, + rendererReservationId: reservationId, + renderSource: { + type: 'adm', + version: 1, + adm: '
creative
', + width: 300, + height: 250, + }, + }, + ], + }; + const cycles = [ + { + nextCycleOrdinal: 2, + quarantines: [], + records: [ + { + ordinal: 1, + responseIdentifier: 'response-one', + seen: ['slotRequested', 'slotRenderEnded'], + state: 'completed', + }, + ], + slotId: 'takeover-slot', + token: 'gt1_1', + unknownPriorCycle: false, + }, + ]; + return { + capture: { + captureVersion: 1, + releaseId, + generation: 1, + data: [ + projectionDigest, + integrationConfigDigest, + slices, + [['accepted', null, 2, 1]], + [['takeover-slot', 'takeover-slot', 'publisher', [], 'gt1_1']], + [], + [[null, null, 'takeover-slot', 'gpt_adm', 'trusted_server', reservationId]], + parserState === 'valid' + ? [ + [ + 'gpt_initial', + [ + ['gam', gamAttributionEnabled], + ['v', 1], + ], + ], + ] + : [], + [[], 0, 0], + [1, 2, 3, 4], + [2, 0, 2, 1], + cycles, + 2, + 2, + ], + mutationRevision: 0, + identityCount: 2, + }, + outline: { + version: 1, + releaseId, + generation: 1, + projectionDigest, + integrationConfigDigest, + slices, + slotCount: 1, + outcomeCount: 1, + capabilities: [], + objectKinds: ['gpt_slot', 'dom_artifact'], + }, + boot: { + abi: 1, + releaseId, + manifest: {}, + auctionProjection: projection, + integrations: {}, + creative: {}, + diagnostics: {}, + }, + } as const; +} diff --git a/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts b/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts new file mode 100644 index 000000000..9243bffd6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts @@ -0,0 +1,36 @@ +import type { FirstDisplayApsPolicyV1 } from '../../../src/first_display/leaf/aps_protocol'; +import { createFirstDisplayApsRenderStrategy } from '../../../src/first_display/render_bridge'; +import { + createFirstDisplayRenderJournal, + type FirstDisplayRenderOwnerOptionsV1, +} from '../../../src/first_display/render_journal'; + +type TestRenderBridgeOptions = Readonly<{ + browser: FirstDisplayRenderOwnerOptionsV1[0]; + clearTimer: FirstDisplayRenderOwnerOptionsV1[1]; + createChannel: FirstDisplayRenderOwnerOptionsV1[2]; + document: FirstDisplayRenderOwnerOptionsV1[3]; + fillRandom: FirstDisplayRenderOwnerOptionsV1[4]; + now: FirstDisplayRenderOwnerOptionsV1[5]; + onNativeMutation?: NonNullable; + setTimer: FirstDisplayRenderOwnerOptionsV1[7]; + getAps: () => FirstDisplayApsPolicyV1 | undefined; +}>; + +/** Compose the production owner and APS capabilities without creating a production graph seam. */ +export function createTestFirstDisplayRenderBridge(options: TestRenderBridgeOptions) { + const { getAps, ...ownerOptions } = options; + const capabilities: FirstDisplayRenderOwnerOptionsV1 = Object.freeze([ + ownerOptions.browser, + ownerOptions.clearTimer, + ownerOptions.createChannel, + ownerOptions.document, + ownerOptions.fillRandom, + ownerOptions.now, + ownerOptions.onNativeMutation, + ownerOptions.setTimer, + ]); + const aps = getAps(); + const strategy = aps ? createFirstDisplayApsRenderStrategy(capabilities, aps) : undefined; + return createFirstDisplayRenderJournal(capabilities, strategy); +} diff --git a/crates/trusted-server-js/lib/test/first_display/projection.test.ts b/crates/trusted-server-js/lib/test/first_display/projection.test.ts new file mode 100644 index 000000000..0de9fc5b9 --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/projection.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; + +import { parseBrowserAuctionProjectionV1 } from '../../src/core/contracts/auction_projection'; +import { + acceptServerFirstDisplayBatchV1, + snapshotFirstDisplayBatchV1, +} from '../../src/first_display/leaf/projection'; + +function freezeTree(value: Value): Value { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value; + for (const child of Object.values(value as Record)) freezeTree(child); + return Object.freeze(value); +} + +function winnerFixture(options: { provider?: string; source?: 'adm' | 'aps' | 'pbs_cache' } = {}) { + const source = options.source ?? 'adm'; + const renderSource = + source === 'adm' + ? { + type: 'adm', + version: 1, + adm: '
example
', + width: 300, + height: 250, + } + : source === 'aps' + ? { + type: 'aps', + version: 1, + accountId: 'account-1', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + aaxResponse: '', + width: 300, + height: 250, + } + : { + type: 'pbs_cache', + version: 1, + cacheId: 'cache-1', + cacheHost: 'cache.example', + cachePath: '/cache', + width: 300, + height: 250, + }; + const bid = { + candidateId: 'candidate001', + slot: 'slot-1', + provider: options.provider ?? 'example', + upstreamBidId: 'upstream-1', + cpm: 1.25, + currency: 'USD', + targeting: { hb_pb: '1.25' } as Record, + ...(source === 'pbs_cache' ? {} : { rendererReservationId: `r1_${'a'.repeat(22)}` }), + renderSource, + }; + return { + version: 1, + projectionDigest: 'b'.repeat(64), + projection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'slot-1', outcome: 'winner', candidateId: 'candidate001' }], + }, + slots: [ + { + slot: 'slot-1', + gamUnitPath: '/123/example', + divId: 'slot-1', + formats: [[300, 250]], + targeting: { placement: 'article' }, + }, + ], + bids: [bid], + }, + }; +} + +describe('first-display projection snapshot', () => { + it('accepts only the already-frozen server envelope used by the production agent', () => { + const candidate = freezeTree(winnerFixture({ provider: 'prebid' })); + + expect(acceptServerFirstDisplayBatchV1(candidate)?.slice(0, 3)).toEqual([ + 'b'.repeat(64), + ['gpt', 'prebid'], + [['slot-1', 'gpt_adm']], + ]); + expect(acceptServerFirstDisplayBatchV1(winnerFixture())).toBeUndefined(); + expect( + acceptServerFirstDisplayBatchV1(freezeTree(winnerFixture({ source: 'pbs_cache' }))) + ).toBeUndefined(); + }); + + it('derives canonical protocol coverage and remains equal to the persistent ADM parser', () => { + const candidate = freezeTree(winnerFixture({ provider: 'prebid' })); + const snapshot = snapshotFirstDisplayBatchV1(candidate); + + expect(snapshot?.requiredProtocols).toEqual(['gpt', 'prebid']); + expect(snapshot?.outcomes).toEqual([{ slotId: 'slot-1', kind: 'gpt_adm' }]); + expect(snapshot?.projection).toEqual(parseBrowserAuctionProjectionV1(candidate.projection)); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot?.projection.bids[0]?.renderSource)).toBe(true); + }); + + it('derives APS plus GPT and never admits PBS Cache into the agent', () => { + const aps = snapshotFirstDisplayBatchV1(freezeTree(winnerFixture({ source: 'aps' }))); + expect(aps?.requiredProtocols).toEqual(['aps', 'gpt']); + expect(aps?.outcomes).toEqual([{ slotId: 'slot-1', kind: 'aps' }]); + + expect( + snapshotFirstDisplayBatchV1(freezeTree(winnerFixture({ source: 'pbs_cache' }))) + ).toBeUndefined(); + }); + + it('rejects duplicated summaries, mutable input, and non-data or extra fields', () => { + expect( + snapshotFirstDisplayBatchV1( + Object.freeze({ + version: 1, + projectionDigest: 'b'.repeat(64), + requiredProtocols: Object.freeze(['gpt']), + outcomes: Object.freeze([{ slotId: 'slot-1', kind: 'gpt_adm' }]), + }) + ) + ).toBeUndefined(); + expect(snapshotFirstDisplayBatchV1(winnerFixture())).toBeUndefined(); + + const extra = winnerFixture(); + Object.assign(extra.projection.slots[0]!, { extra: true }); + expect(snapshotFirstDisplayBatchV1(freezeTree(extra))).toBeUndefined(); + + const accessor = winnerFixture(); + Object.defineProperty(accessor.projection.slots[0]!, 'slot', { + enumerable: true, + get: () => 'slot-1', + }); + expect(snapshotFirstDisplayBatchV1(freezeTree(accessor))).toBeUndefined(); + }); + + it('rejects broken winner joins, unknown failures, and bounded targeting overflow', () => { + const missingBid = winnerFixture(); + missingBid.projection.bids = []; + expect(snapshotFirstDisplayBatchV1(freezeTree(missingBid))).toBeUndefined(); + + const failed = winnerFixture(); + Object.assign(failed.projection.auction, { + results: [{ slot: 'slot-1', outcome: 'failed', reason: 'unknown' }], + }); + failed.projection.bids = []; + expect(snapshotFirstDisplayBatchV1(freezeTree(failed))).toBeUndefined(); + + const targeting = winnerFixture(); + targeting.projection.bids[0]!.targeting = Object.fromEntries( + Array.from({ length: 33 }, (_, index) => [`key_${index}`, 'value']) + ); + expect(snapshotFirstDisplayBatchV1(freezeTree(targeting))).toBeUndefined(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts new file mode 100644 index 000000000..c7f1bce48 --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts @@ -0,0 +1,1014 @@ +import { describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; + +import type { FirstDisplayGptBoundCycleV1 } from '../../src/first_display/adapters/googletag'; +import { PUC_DYNAMIC_OWNER } from '../../src/kernel/contracts/puc_dynamic_owner'; + +import { createTestFirstDisplayRenderBridge } from './helpers/render_owner_composition'; + +const RESERVATION_ID = `r1_${'a'.repeat(22)}`; + +class FakePort { + public readonly addEventListener = vi.fn( + (name: string, listener: (event: { data: unknown; ports: readonly FakePort[] }) => void) => { + this.listeners.set(name, listener); + } + ); + public readonly close = vi.fn(); + public readonly postMessage = vi.fn(); + public readonly removeEventListener = vi.fn(); + public readonly start = vi.fn(); + private readonly listeners = new Map< + string, + (event: { data: unknown; ports: readonly FakePort[] }) => void + >(); + + public dispatch(data: unknown, ports: readonly FakePort[] = []): void { + const listener = this.listeners.get('message'); + if (!listener) throw new Error('expected a live port listener'); + listener({ data, ports }); + } + + public dispatchError(): void { + const listener = this.listeners.get('messageerror'); + if (!listener) throw new Error('expected a live port error listener'); + listener({ data: undefined, ports: [] }); + } +} + +function fixture(kind: 'adm' | 'aps' = 'adm') { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const element = dom.window.document.getElementById('slot-1'); + if (!(element instanceof dom.window.HTMLElement)) throw new Error('missing fixture element'); + const bid = Object.freeze({ + candidateId: 'candidate001', + slot: 'slot-1', + provider: 'example', + upstreamBidId: 'upstream-1', + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({}), + rendererReservationId: RESERVATION_ID, + renderSource: Object.freeze( + kind === 'adm' + ? { + type: 'adm' as const, + version: 1 as const, + adm: '
fictional creative
', + width: 300, + height: 250, + } + : { + type: 'aps' as const, + version: 1 as const, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe' as const, + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + } + ), + }); + const placement = Object.freeze({ + slot: 'slot-1', + gamUnitPath: '/123/example', + divId: 'slot-1', + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({}), + }); + let cycleCurrent = true; + const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze([ + bid, + element, + () => cycleCurrent, + 'trusted_server', + {}, + placement, + 'slot-1', + 'gt1_1', + ]); + return { cycle, dom, element, invalidateCycle: () => (cycleCurrent = false) }; +} + +function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) { + const value = fixture(kind); + const listeners: Array<(event: Record) => void> = []; + const target = { + addEventListener: vi.fn( + (name: string, next: (event: Record) => void, capture: boolean) => { + expect(name).toBe('message'); + expect(capture).toBe(true); + listeners.push(next); + } + ), + removeEventListener: vi.fn((_name: string, next: (event: Record) => void) => { + const index = listeners.indexOf(next); + if (index >= 0) listeners.splice(index, 1); + }), + }; + const channels: Array<{ port1: FakePort; port2: FakePort }> = []; + const timers = new Map void; delayMs: number }>>(); + let randomByte = 1; + let now = 0; + const bridge = createTestFirstDisplayRenderBridge({ + getAps: () => + kind === 'aps' + ? Object.freeze({ + version: 1 as const, + id: 'aps' as const, + publisherOrigin: 'https://publisher.example', + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', + sandbox: + 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation', + permanentSandbox: + 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation', + deadlines: Object.freeze({ + documentAcceptanceMs: 3_000 as const, + completionMs: 10_000 as const, + }), + isBootstrapNonce: (candidate: unknown): candidate is string => + typeof candidate === 'string' && /^b1_[A-Za-z0-9_-]{22}$/.test(candidate), + isRendererNonce: (candidate: unknown): candidate is string => + typeof candidate === 'string' && /^n1_[A-Za-z0-9_-]{22}$/.test(candidate), + bootstrapPolicy: () => + Object.freeze({ + creativeOrigin: 'https://creative.example', + tagType: 'iframe' as const, + }), + createRenderStrategy: () => { + throw new Error('the legacy bridge fixture already owns construction'); + }, + parseWindowMessage: (candidate: unknown) => { + if (typeof candidate !== 'string') return undefined; + const message = JSON.parse(candidate) as Record; + if (message.message === 'TS APS Bootstrap Ready') { + return Object.freeze({ + kind: 'bootstrap_ready' as const, + bootstrap: message.bootstrapNonce as string, + }); + } + if (message.message === 'TS APS Container Ready') { + return Object.freeze({ + kind: 'container_ready' as const, + bootstrap: message.bootstrapNonce as string, + renderer: message.rendererNonce as string, + }); + } + return undefined; + }, + parseDocumentMessage: (candidate: unknown, nonce: string) => { + const message = candidate as Record; + if (message.version !== 1 || message.nonce !== nonce) return undefined; + if (message.message === 'TS APS Document Accepted') { + return Object.freeze({ kind: 'document_accepted' as const }); + } + if (message.message === 'TS APS Runner Loaded') { + return Object.freeze({ kind: 'runner_loaded' as const }); + } + if (message.message === 'TS APS Render Completed') { + return Object.freeze({ kind: 'render_completed' as const }); + } + if ( + message.message === 'TS APS Render Failed' && + (message.reason === 'descriptor_invalid' || + message.reason === 'runner_no_load' || + message.reason === 'runner_failed') + ) { + return Object.freeze({ + kind: 'render_failed' as const, + reason: message.reason, + }); + } + return undefined; + }, + }) + : undefined, + now: () => now, + browser: target as unknown as Window, + clearTimer: (handle) => timers.delete(handle as object), + createChannel: () => { + const channel = { port1: new FakePort(), port2: new FakePort() }; + channels.push(channel); + return channel; + }, + document: value.dom.window.document, + fillRandom: (bytes) => { + bytes.fill(randomByte); + randomByte += 1; + }, + ...(onNativeMutation ? { onNativeMutation } : {}), + setTimer: (callback, delayMs) => { + const handle = {}; + timers.set(handle, Object.freeze({ callback, delayMs })); + return handle; + }, + }); + const terminals: string[] = []; + const terminalFacts: Array = []; + expect( + bridge.bind(value.cycle, (result, reason) => { + terminals.push(result); + terminalFacts.push([result, reason]); + }) + ).toBe(true); + const dispatch = (event: Record): void => { + if (listeners.length === 0) throw new Error('expected capture listener'); + for (const listener of [...listeners]) listener(event); + }; + const fire = (delayMs: number): void => { + const entry = [...timers.entries()].find(([, timer]) => timer.delayMs === delayMs); + if (!entry) throw new Error(`missing ${delayMs}ms timer`); + timers.delete(entry[0]); + now += delayMs; + entry[1].callback(); + }; + const fireLast = (delayMs: number): void => { + const entries = [...timers.entries()].filter(([, timer]) => timer.delayMs === delayMs); + const entry = entries[entries.length - 1]; + if (!entry) throw new Error(`missing ${delayMs}ms timer`); + timers.delete(entry[0]); + now += delayMs; + entry[1].callback(); + }; + return { + ...value, + bridge, + channels, + dispatch, + fire, + fireLast, + target, + terminalFacts, + terminals, + timers, + }; +} + +function requestEvent( + responsePort: FakePort, + options: Readonly<{ adId?: string; data?: unknown; source?: object }> = {} +) { + return { + data: + options.data ?? + JSON.stringify({ + message: 'Prebid Request', + adId: options.adId ?? RESERVATION_ID, + adServerDomain: 'ads.example', + }), + ports: [responsePort], + source: options.source ?? {}, + stopImmediatePropagation: vi.fn(), + }; +} + +function ownerRegistration(ticket: string, responsePort: FakePort, source: object) { + return { + data: JSON.stringify({ + message: 'TS Render Owner Register', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: ticket, + }), + ports: [responsePort], + source, + stopImmediatePropagation: vi.fn(), + }; +} + +function collapsedShell(h: ReturnType) { + const wrapper = h.dom.window.document.createElement('div'); + const frame = h.dom.window.document.createElement('iframe'); + wrapper.style.width = '1px'; + wrapper.style.height = '1px'; + frame.setAttribute('width', '1'); + frame.setAttribute('height', '1'); + frame.style.width = '1px'; + frame.style.height = '1px'; + wrapper.appendChild(frame); + h.dom.window.document.body.appendChild(wrapper); + return { frame, wrapper }; +} + +function registerOwner(h: ReturnType, ownerSource?: object) { + const source = ownerSource ?? {}; + const responsePort = new FakePort(); + h.dispatch(requestEvent(responsePort, { source })); + expect(h.bridge.recordGam(h.cycle, 'nonempty_gam')).toBe(true); + const outer = JSON.parse(responsePort.postMessage.mock.calls[0]?.[0] as string); + const ticket = outer.tsOwner.lifecycleTicket as string; + const registrationPort = new FakePort(); + h.dispatch(ownerRegistration(ticket, registrationPort, source)); + return { source, ticket }; +} + +function startApsDocument(h: ReturnType) { + const frame = h.element.querySelector('iframe'); + if (!frame?.contentWindow) throw new Error('expected the top-page APS frame'); + const bootstrapNonce = new URL(frame.src).hash.slice(1); + const postMessage = vi + .spyOn(frame.contentWindow, 'postMessage') + .mockImplementation(() => undefined); + h.dispatch({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + origin: 'null', + ports: [], + source: frame.contentWindow, + }); + expect(h.terminalFacts).toEqual([]); + const navigation = JSON.parse(postMessage.mock.calls[0]?.[0] as string) as Record< + string, + unknown + >; + const nonce = navigation.rendererNonce; + if (!nonce) throw new Error('expected the renderer nonce'); + const documentPort = new FakePort(); + h.dispatch({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce: nonce, + }), + origin: 'null', + ports: [documentPort], + source: frame.contentWindow, + }); + return { documentPort, frame, nonce }; +} + +function acceptPucAps(h: ReturnType): HTMLIFrameElement { + registerOwner(h); + const { documentPort, frame, nonce } = startApsDocument(h); + documentPort.dispatch({ + message: 'TS APS Document Accepted', + version: 1, + nonce, + }); + documentPort.dispatch({ + message: 'TS APS Render Completed', + version: 1, + nonce, + }); + expect(h.terminals).toEqual(['accepted']); + return frame; +} + +function bindMixedAdm(h: ReturnType) { + const element = h.dom.window.document.createElement('div'); + element.id = 'slot-2'; + h.dom.window.document.body.appendChild(element); + const reservationId = `r1_${'b'.repeat(22)}`; + const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze([ + Object.freeze({ + candidateId: 'candidate002', + slot: 'slot-2', + provider: 'example', + upstreamBidId: 'upstream-2', + cpm: 1.5, + currency: 'USD' as const, + targeting: Object.freeze({}), + rendererReservationId: reservationId, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
mixed ADM creative
', + width: 300, + height: 250, + }), + }), + element, + () => true, + 'trusted_server', + {}, + Object.freeze({ + slot: 'slot-2', + gamUnitPath: '/123/example-2', + divId: 'slot-2', + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({}), + }), + 'slot-2', + 'gt1_2', + ]); + const terminal = vi.fn(); + expect(h.bridge.bind(cycle, terminal)).toBe(true); + return { cycle, element, reservationId, terminal }; +} + +describe('bounded first-display render bridge', () => { + it('isolates interleaved APS and ADM ownership in one shared journal', () => { + const h = harness('aps'); + const adm = bindMixedAdm(h); + registerOwner(h); + + expect(h.bridge.recordGam(adm.cycle, 'gam_empty')).toBe(true); + const admFrame = adm.element.querySelector('iframe'); + expect(admFrame?.srcdoc).toContain('mixed ADM creative'); + admFrame?.dispatchEvent(new h.dom.window.Event('load')); + expect(adm.terminal).toHaveBeenCalledWith('accepted', null); + expect(h.terminals).toEqual([]); + + const { documentPort, frame: apsFrame, nonce } = startApsDocument(h); + documentPort.dispatch({ message: 'TS APS Document Accepted', version: 1, nonce }); + documentPort.dispatch({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(h.terminals).toEqual(['accepted']); + + h.bridge.sealTsAdmission(); + expect(h.bridge.closeIngress()).toBe(true); + const handoff = h.bridge.captureHandoff(); + expect( + handoff?.[0].map((artifact) => ({ + kind: artifact[3], + slotId: artifact[5], + token: artifact[6], + })) + ).toEqual([ + { kind: 'gpt_adm', slotId: 'slot-2', token: adm.reservationId }, + { kind: 'aps', slotId: 'slot-1', token: RESERVATION_ID }, + ]); + expect( + new Set(handoff?.[1].filter((entry) => entry[0] === 'reservation').map((entry) => entry[1])) + ).toEqual(new Set([adm.reservationId, RESERVATION_ID])); + expect(h.bridge.detachCommittedArtifacts()).toBe(true); + h.bridge.dispose(); + expect(admFrame?.isConnected).toBe(true); + expect(apsFrame.isConnected).toBe(true); + }); + + it('observes admitted bridge activity and terminal tombstone expiry', () => { + const mutations = vi.fn(() => true); + const h = harness('adm', mutations); + const shell = collapsedShell(h); + const owner = registerOwner(h, shell.frame.contentWindow!); + h.channels[0]?.port1.dispatch({ + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: owner.ticket, + }); + h.channels[0]?.port1.dispatch({ + message: 'TS ADM Loaded', + version: 1, + lifecycleTicket: owner.ticket, + }); + expect(h.terminals).toEqual(['accepted']); + expect(shell.frame.isConnected).toBe(true); + + mutations.mockClear(); + h.fire(3_000); + expect(mutations).toHaveBeenCalledOnce(); + }); + + it('passes native ids through and suppresses malformed recognized TS requests', () => { + const h = harness(); + const nativePort = new FakePort(); + const native = requestEvent(nativePort, { adId: `r1_${'z'.repeat(22)}` }); + h.dispatch(native); + expect(native.stopImmediatePropagation).not.toHaveBeenCalled(); + expect(nativePort.postMessage).not.toHaveBeenCalled(); + + const refusedPort = new FakePort(); + const malformed = requestEvent(refusedPort, { + data: { + message: 'Prebid Request', + adId: RESERVATION_ID, + adServerDomain: 'ads.example', + }, + }); + h.dispatch(malformed); + expect(malformed.stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(JSON.parse(refusedPort.postMessage.mock.calls[0]?.[0] as string)).toEqual({ + message: 'Prebid Response', + adId: RESERVATION_ID, + rendererVersion: '4', + tsOwner: { version: 1, status: 'refused' }, + }); + expect(refusedPort.close).toHaveBeenCalledOnce(); + }); + + it('never recognizes inherited or accessor-backed message routing fields', () => { + const h = harness(); + const read = vi.fn(() => + JSON.stringify({ + message: 'Prebid Request', + adId: RESERVATION_ID, + adServerDomain: 'ads.example', + }) + ); + const event = Object.create( + Object.defineProperty({}, 'data', { + configurable: true, + get: read, + }) + ) as Record; + event.ports = [new FakePort()]; + event.source = {}; + event.stopImmediatePropagation = vi.fn(); + + h.dispatch(event); + expect(read).not.toHaveBeenCalled(); + expect(event.stopImmediatePropagation).not.toHaveBeenCalled(); + }); + + it('joins an exact claim and nonempty GAM cycle through one-use ADM owner control', () => { + const h = harness('adm'); + const shell = collapsedShell(h); + const source = shell.frame.contentWindow!; + const responsePort = new FakePort(); + const claim = requestEvent(responsePort, { source }); + h.dispatch(claim); + expect(claim.stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(responsePort.postMessage).not.toHaveBeenCalled(); + + expect(h.bridge.recordGam(h.cycle, 'nonempty_gam')).toBe(true); + const outer = JSON.parse(responsePort.postMessage.mock.calls[0]?.[0] as string) as Record< + string, + unknown + >; + expect(outer).toMatchObject({ + message: 'Prebid Response', + adId: RESERVATION_ID, + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { version: 1, status: 'ready', kind: 'adm' }, + }); + const ticket = (outer.tsOwner as Record).lifecycleTicket; + expect(ticket).toMatch(/^t1_[A-Za-z0-9_-]{22}$/); + expect(responsePort.close).toHaveBeenCalledOnce(); + expect(shell.frame.style.width).toBe('300px'); + expect(shell.frame.style.height).toBe('250px'); + expect(shell.wrapper.style.width).toBe('300px'); + expect(shell.wrapper.style.height).toBe('250px'); + + const registrationPort = new FakePort(); + const registration = ownerRegistration(ticket as string, registrationPort, source); + h.dispatch(registration); + expect(registration.stopImmediatePropagation).toHaveBeenCalledOnce(); + const registered = JSON.parse(registrationPort.postMessage.mock.calls[0]?.[0] as string); + expect(registered).toEqual({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: ticket, + }); + expect(registrationPort.postMessage.mock.calls[0]?.[1]).toEqual([h.channels[0]?.port2]); + expect(h.channels[0]?.port1.postMessage.mock.calls[0]?.[0]).toEqual({ + message: 'TS ADM Start', + version: 1, + lifecycleTicket: ticket, + source: h.cycle[0].renderSource, + }); + + h.channels[0]?.port1.dispatch({ + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: ticket, + }); + expect(h.terminals).toEqual([]); + h.channels[0]?.port1.dispatch({ + message: 'TS ADM Loaded', + version: 1, + lifecycleTicket: ticket, + }); + expect(h.terminals).toEqual(['accepted']); + const controlCalls = h.channels[0]?.port1.postMessage.mock.calls ?? []; + expect(controlCalls[controlCalls.length - 1]?.[0]).toEqual({ + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: ticket, + outcome: 'accepted', + }); + + const replayPort = new FakePort(); + const replay = ownerRegistration(ticket as string, replayPort, source); + h.dispatch(replay); + expect(replay.stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(JSON.parse(replayPort.postMessage.mock.calls[0]?.[0] as string)).toEqual({ + message: 'TS Render Owner Refused', + adId: RESERVATION_ID, + version: 1, + }); + }); + + it('refuses delayed APS owner registration after a later physical GPT request', () => { + const h = harness('aps'); + const source = {}; + const responsePort = new FakePort(); + h.dispatch(requestEvent(responsePort, { source })); + expect(h.bridge.recordGam(h.cycle, 'nonempty_gam')).toBe(true); + const response = JSON.parse(responsePort.postMessage.mock.calls[0]?.[0] as string); + + h.invalidateCycle(); + const registrationPort = new FakePort(); + h.dispatch( + ownerRegistration(response.tsOwner.lifecycleTicket as string, registrationPort, source) + ); + + expect(h.terminals).toEqual(['failed']); + expect(h.terminalFacts).toEqual([['failed', 'slot_unresolved']]); + expect(h.element.querySelector('iframe')).toBeNull(); + }); + + it('never resizes an anchored or already-expanded PUC shell', () => { + const cases = [ + { + mutate: ({ wrapper }: ReturnType) => { + wrapper.setAttribute('data-anchor-status', 'displayed'); + }, + wrapperWidth: '1px', + }, + { + mutate: ({ wrapper }: ReturnType) => { + wrapper.style.width = '2px'; + }, + wrapperWidth: '2px', + }, + ]; + for (const { mutate, wrapperWidth } of cases) { + const h = harness('adm'); + const shell = collapsedShell(h); + mutate(shell); + const responsePort = new FakePort(); + h.dispatch(requestEvent(responsePort, { source: shell.frame.contentWindow! })); + expect(h.bridge.recordGam(h.cycle, 'nonempty_gam')).toBe(true); + expect(shell.frame.style.width).toBe('1px'); + expect(shell.wrapper.style.width).toBe(wrapperWidth); + h.bridge.dispose(); + } + }); + + it('requires APS document acceptance and completion in a hidden top-page PUC overlay', () => { + const h = harness('aps'); + h.element.innerHTML = 'publisher GAM content'; + const source = {}; + const responsePort = new FakePort(); + h.dispatch(requestEvent(responsePort, { source })); + expect(h.bridge.recordGam(h.cycle, 'nonempty_gam')).toBe(true); + const outer = JSON.parse(responsePort.postMessage.mock.calls[0]?.[0] as string); + const ticket = outer.tsOwner.lifecycleTicket as string; + const registrationPort = new FakePort(); + h.dispatch(ownerRegistration(ticket, registrationPort, source)); + + const start = h.channels[0]?.port1.postMessage.mock.calls[0]?.[0] as Record; + expect(start).toEqual({ + message: 'TS APS Top Mount Started', + version: 1, + lifecycleTicket: ticket, + }); + expect(h.channels[0]?.port1.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(h.channels).toHaveLength(1); + expect(h.element.querySelector('span')?.textContent).toBe('publisher GAM content'); + const { documentPort, frame, nonce } = startApsDocument(h); + expect(frame.parentNode).toBe(h.element); + expect(frame.style.position).toBe('absolute'); + expect(frame.style.visibility).toBe('hidden'); + expect(h.element.style.position).toBe('relative'); + + documentPort.dispatch({ + message: 'TS APS Document Accepted', + version: 1, + nonce, + }); + documentPort.dispatch({ + message: 'TS APS Runner Loaded', + version: 1, + nonce, + }); + expect(h.terminals).toEqual([]); + expect(frame.style.visibility).toBe('hidden'); + documentPort.dispatch({ + message: 'TS APS Render Completed', + version: 1, + nonce, + }); + expect(h.terminals).toEqual(['accepted']); + expect(frame.style.visibility).toBe('visible'); + expect(h.element.querySelector('span')?.textContent).toBe('publisher GAM content'); + expect(h.channels[0]?.port1.postMessage.mock.calls[1]?.[0]).toEqual({ + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: ticket, + outcome: 'accepted', + }); + expect(h.bridge.retire(h.cycle)).toBe(true); + expect(h.bridge.retire(h.cycle)).toBe(false); + expect(frame.isConnected).toBe(false); + expect(h.element.style.getPropertyValue('position')).toBe(''); + expect(h.element.querySelector('span')?.textContent).toBe('publisher GAM content'); + expect(h.terminals).toEqual(['accepted']); + }); + + it.each(['removed', 'reparented', 'host_replaced'] as const)( + 'retires an accepted APS overlay before handoff when its DOM is %s', + (mutation) => { + const h = harness('aps'); + h.element.innerHTML = 'publisher'; + const frame = acceptPucAps(h); + const movedHost = h.dom.window.document.createElement('div'); + movedHost.id = 'moved-host'; + h.dom.window.document.body.appendChild(movedHost); + + if (mutation === 'removed') frame.remove(); + if (mutation === 'reparented') movedHost.appendChild(frame); + if (mutation === 'host_replaced') { + const replacement = h.element.cloneNode(false); + h.element.replaceWith(replacement); + } + + expect(h.bridge.sweepCommittedArtifacts()).toBe(1); + expect(h.bridge.sweepCommittedArtifacts()).toBe(0); + expect(frame.isConnected).toBe(false); + expect(h.element.style.getPropertyValue('position')).toBe(''); + expect(h.element.querySelector('span')?.textContent).toBe('publisher'); + expect(h.terminals).toEqual(['accepted']); + + h.bridge.sealTsAdmission(); + expect(h.bridge.closeIngress()).toBe(true); + expect(h.bridge.captureHandoff()?.[0]).toEqual([]); + expect(h.bridge.detachCommittedArtifacts()).toBe(true); + } + ); + + it.each(['src', 'srcdoc', 'sandbox', 'frame_style', 'host_position'] as const)( + 'retires an accepted APS overlay before handoff when its %s integrity changes', + (mutation) => { + const h = harness('aps'); + h.element.innerHTML = 'publisher'; + const frame = acceptPucAps(h); + + if (mutation === 'src') frame.setAttribute('src', 'https://publisher.example/replaced'); + if (mutation === 'srcdoc') frame.srcdoc = 'Replacement'; + if (mutation === 'sandbox') frame.setAttribute('sandbox', 'allow-scripts'); + if (mutation === 'frame_style') frame.style.setProperty('visibility', 'hidden'); + if (mutation === 'host_position') h.element.style.setProperty('position', 'absolute'); + + expect(h.bridge.sweepCommittedArtifacts()).toBe(1); + expect(h.bridge.sweepCommittedArtifacts()).toBe(0); + expect(frame.isConnected).toBe(false); + expect(h.element.querySelector('span')?.textContent).toBe('publisher'); + expect(h.terminals).toEqual(['accepted']); + + h.bridge.sealTsAdmission(); + expect(h.bridge.closeIngress()).toBe(true); + expect(h.bridge.captureHandoff()?.[0]).toEqual([]); + expect(h.bridge.detachCommittedArtifacts()).toBe(true); + } + ); + + it('renders an attributable empty-GAM ADM fallback directly into the bound element', () => { + const h = harness('adm'); + expect(h.bridge.recordGam(h.cycle, 'gam_empty')).toBe(true); + const frame = h.element.querySelector('iframe'); + expect(frame).not.toBeNull(); + expect(frame?.srcdoc).toContain('fictional creative'); + expect(frame?.getAttribute('sandbox')).toBe( + 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation' + ); + expect(h.terminals).toEqual([]); + frame?.dispatchEvent(new h.dom.window.Event('load')); + expect(h.terminals).toEqual(['accepted']); + expect(h.element.contains(frame)).toBe(true); + expect(frame?.onload).toBeNull(); + expect(frame?.onerror).toBeNull(); + frame?.dispatchEvent(new h.dom.window.Event('load')); + expect(h.terminals).toEqual(['accepted']); + h.bridge.dispose(); + expect(h.element.contains(frame)).toBe(false); + }); + + it('renders an attributable empty-GAM APS fallback through the exact document channel', () => { + const h = harness('aps'); + expect(h.bridge.recordGam(h.cycle, 'gam_empty')).toBe(true); + const frame = h.element.querySelector('iframe'); + expect(frame).not.toBeNull(); + const bootstrapNonce = new URL(frame?.src ?? '').hash.slice(1); + expect(bootstrapNonce).toMatch(/^b1_[A-Za-z0-9_-]{22}$/); + expect(h.channels).toHaveLength(0); + const postMessage = vi + .spyOn(frame!.contentWindow!, 'postMessage') + .mockImplementation(() => undefined); + h.dispatch({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + origin: 'null', + ports: [], + source: frame?.contentWindow, + }); + const navigation = JSON.parse(postMessage.mock.calls[0]?.[0] as string) as Record< + string, + unknown + >; + expect(navigation).toMatchObject({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce: expect.stringMatching(/^n1_[A-Za-z0-9_-]{22}$/), + creativeOrigin: 'https://creative.example', + tagType: 'iframe', + }); + expect(navigation).not.toHaveProperty('containerUrl'); + expect(postMessage.mock.calls[0]?.slice(1)).toEqual(['*', []]); + expect(frame?.getAttribute('sandbox')).toBe( + 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation' + ); + const nonce = navigation.rendererNonce as string; + expect(nonce).toMatch(/^n1_[A-Za-z0-9_-]{22}$/); + const documentPort = new FakePort(); + h.dispatch({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce: nonce, + }), + origin: 'null', + ports: [documentPort], + source: frame?.contentWindow, + }); + expect(documentPort.postMessage).toHaveBeenCalledWith( + { + version: 1, + nonce, + publisherOrigin: 'https://publisher.example', + renderer: h.cycle[0].renderSource, + }, + [] + ); + documentPort.dispatch({ + message: 'TS APS Document Accepted', + version: 1, + nonce, + }); + documentPort.dispatch({ + message: 'TS APS Runner Loaded', + version: 1, + nonce, + }); + expect(h.terminals).toEqual([]); + documentPort.dispatch({ + message: 'TS APS Render Completed', + version: 1, + nonce, + }); + expect(h.terminals).toEqual(['accepted']); + expect(h.element.contains(frame)).toBe(true); + expect(frame?.onload).toBeNull(); + expect(frame?.onerror).toBeNull(); + }); + + it('fails closed when an APS document port reports a clone error', () => { + const h = harness('aps'); + expect(h.bridge.recordGam(h.cycle, 'gam_empty')).toBe(true); + const frame = h.element.querySelector('iframe'); + const bootstrapNonce = new URL(frame?.src ?? '').hash.slice(1); + const postMessage = vi + .spyOn(frame!.contentWindow!, 'postMessage') + .mockImplementation(() => undefined); + h.dispatch({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + origin: 'null', + ports: [], + source: frame?.contentWindow, + }); + const navigation = JSON.parse(postMessage.mock.calls[0]?.[0] as string) as Record< + string, + unknown + >; + const rendererNonce = navigation.rendererNonce; + const documentPort = new FakePort(); + h.dispatch({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, + }), + origin: 'null', + ports: [documentPort], + source: frame?.contentWindow, + }); + documentPort.dispatchError(); + expect(h.terminals).toEqual(['failed']); + expect(h.element.querySelector('iframe')).toBeNull(); + }); + + it('bounds claim waiting and disposes every listener, timer, port, and pending frame once', () => { + const timeout = harness('adm'); + expect(timeout.bridge.recordGam(timeout.cycle, 'nonempty_gam')).toBe(true); + timeout.fire(3_000); + expect(timeout.terminals).toEqual(['failed']); + expect(timeout.terminalFacts).toEqual([['failed', 'bridge_claim_timeout']]); + + const pending = harness('adm'); + expect(pending.bridge.recordGam(pending.cycle, 'gam_empty')).toBe(true); + const frame = pending.element.querySelector('iframe'); + expect(frame).not.toBeNull(); + pending.bridge.dispose(); + pending.bridge.dispose(); + expect(pending.terminals).toEqual(['cancelled']); + expect(pending.terminalFacts).toEqual([['cancelled', 'navigation_disposed']]); + expect(pending.element.contains(frame)).toBe(false); + expect(pending.target.removeEventListener).toHaveBeenCalledTimes(1); + expect(pending.target.removeEventListener).toHaveBeenCalledWith( + 'message', + expect.any(Function), + true + ); + expect(pending.timers).toHaveLength(0); + }); + + it('enforces ticket, insertion, ADM load, APS document, and APS completion deadlines', () => { + const ticket = harness('adm'); + const ticketPort = new FakePort(); + ticket.dispatch(requestEvent(ticketPort)); + expect(ticket.bridge.recordGam(ticket.cycle, 'nonempty_gam')).toBe(true); + ticket.fire(3_000); + expect(ticket.terminals).toEqual(['failed']); + expect(ticket.terminalFacts).toEqual([['failed', 'owner_registration_timeout']]); + + const insertion = harness('adm'); + registerOwner(insertion); + insertion.fire(1_000); + expect(insertion.terminals).toEqual(['failed']); + expect(insertion.terminalFacts).toEqual([['failed', 'owner_insertion_timeout']]); + + const adm = harness('adm'); + const admOwner = registerOwner(adm); + adm.channels[0]?.port1.dispatch({ + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: admOwner.ticket, + }); + adm.fire(5_000); + expect(adm.terminals).toEqual(['failed']); + expect(adm.terminalFacts).toEqual([['failed', 'adm_document_no_load']]); + + const document = harness('aps'); + registerOwner(document); + document.fireLast(3_000); + expect(document.terminals).toEqual(['failed']); + expect(document.terminalFacts).toEqual([['failed', 'renderer_document_no_load']]); + + const completion = harness('aps'); + registerOwner(completion); + const { documentPort, nonce } = startApsDocument(completion); + documentPort.dispatch({ + message: 'TS APS Document Accepted', + version: 1, + nonce, + }); + completion.fire(10_000); + expect(completion.terminals).toEqual(['failed']); + expect(completion.terminalFacts).toEqual([['failed', 'runner_failed']]); + }); + + it('seals only after every attempt is terminal and refuses later authority', () => { + const active = harness('adm'); + expect(() => active.bridge.sealTsAdmission()).toThrow('tsjs'); + + const terminal = harness('adm'); + expect(terminal.bridge.recordGam(terminal.cycle, 'gam_empty')).toBe(true); + terminal.element.querySelector('iframe')?.dispatchEvent(new terminal.dom.window.Event('load')); + expect(() => terminal.bridge.sealTsAdmission()).not.toThrow(); + expect(terminal.bridge.bind(terminal.cycle, () => undefined)).toBe(false); + }); + + it('captures and detaches an accepted direct frame without retaining provisional authority', () => { + const h = harness('adm'); + expect(h.bridge.recordGam(h.cycle, 'gam_empty')).toBe(true); + const frame = h.element.querySelector('iframe'); + frame?.dispatchEvent(new h.dom.window.Event('load')); + h.bridge.sealTsAdmission(); + + expect(h.bridge.closeIngress()).toBe(true); + expect(h.bridge.captureHandoff()).toEqual([ + [[null, null, frame, 'gpt_adm', 'trusted_server', 'slot-1', RESERVATION_ID]], + [['reservation', RESERVATION_ID, 900_000, 1]], + 0, + 2, + 1, + ]); + expect(h.bridge.detachCommittedArtifacts()).toBe(true); + expect(h.bridge.detachCommittedArtifacts()).toBe(false); + h.bridge.dispose(); + + expect(frame?.isConnected).toBe(true); + expect(h.target.removeEventListener).toHaveBeenCalledTimes(1); + expect(h.timers).toHaveLength(0); + }); +}); diff --git a/crates/trusted-server-js/lib/test/first_display/slices.test.ts b/crates/trusted-server-js/lib/test/first_display/slices.test.ts new file mode 100644 index 000000000..efcbd3de4 --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/slices.test.ts @@ -0,0 +1,1424 @@ +import { describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; + +import { + APS_INITIAL_SLICE, + CREATIVE_INITIAL_SLICE, + DATADOME_INITIAL_SLICE, + DIDOMI_INITIAL_SLICE, + GOOGLE_TAG_MANAGER_INITIAL_SLICE, + GPT_INITIAL_SLICE, + INITIAL_SLICE_DEFINITIONS, + LOCKR_INITIAL_SLICE, + OSANO_INITIAL_SLICE, + PERMUTIVE_INITIAL_SLICE, + PREBID_INITIAL_SLICE, + selectInitialSliceDefinitions, + SOURCEPOINT_INITIAL_SLICE, + TESTLIGHT_INITIAL_SLICE, +} from '../../src/first_display/composition'; +import type { + FirstDisplaySliceHost, + InitialSliceDefinition, + InitialSliceInstaller, +} from '../../src/first_display/slices/definition'; +import type { FirstDisplaySliceActivationContext } from '../../src/shared/first_display_transaction'; +import type { FirstDisplayRouteRuleV1 } from '../../src/first_display/leaf/route_guard'; +import { installDidomiInitial } from '../../src/first_display/leaf/config_guard'; +import { + installApsInitial, + type FirstDisplayApsProtocolV1, +} from '../../src/first_display/leaf/aps_protocol'; +import { + installGptInitial, + type FirstDisplayGptBatchPolicyV1, + type FirstDisplayGptProtocolV1, +} from '../../src/first_display/leaf/gpt_protocol'; +import { + installPrebidInitial, + type FirstDisplayPrebidProtocolV1, +} from '../../src/first_display/leaf/prebid_protocol'; +import { + installPermutiveInitial, + snapshotPermutiveInitialSegments, + type FirstDisplayContextRouteRuleV1, +} from '../../src/first_display/leaf/context_snapshot'; +import { + installOsanoInitial, + installSourcepointInitial, + type FirstDisplayConsentRouteRuleV1, +} from '../../src/first_display/leaf/consent_snapshot'; +import { + captureMutationObservedBindings, + createFirstDisplayParserStateCollector, + registerFirstDisplayComponent, + type FirstDisplayComponentRegistrationV1, +} from '../../src/shared/first_display_registration'; +import { createDidomiRuntime } from '../../src/integrations/didomi/module'; +import { getPermutiveSegments } from '../../src/integrations/permutive/segments'; +import { mirrorSourcepointConsent } from '../../src/integrations/sourcepoint/consent_mirror'; +import { + installRenderOwnerInitial, + type FirstDisplayRenderOwnerProtocolV1, +} from '../../src/first_display/render_journal'; + +const RELEASE_ID = 'a'.repeat(64); + +function clearAllCookies(): void { + for (const cookie of document.cookie.split(';')) { + const name = cookie.split('=')[0]?.trim(); + if (name) document.cookie = `${name}=; path=/; Max-Age=0`; + } +} + +function readCookie(name: string): string | undefined { + const prefix = `${name}=`; + return document.cookie + .split('; ') + .find((entry) => entry.startsWith(prefix)) + ?.slice(prefix.length); +} + +function componentRegistration(): FirstDisplayComponentRegistrationV1 { + return Object.freeze({ + abi: 1, + id: 'gpt_initial', + releaseId: RELEASE_ID, + order: 7, + install: () => undefined, + }); +} + +function activateInitialSlice( + definition: InitialSliceDefinition, + host: FirstDisplaySliceHost, + context: FirstDisplaySliceActivationContext +): void { + host.activate(definition.id, context.own, definition.install); +} + +describe('first-display initial slice definitions', () => { + it('captures bounded parser observations once per key in canonical slice order', () => { + const collector = createFirstDisplayParserStateCollector(); + + expect(collector.register('lockr_initial')).toBe(true); + expect(collector.observe('gpt_initial', 'protocol_version', 1)).toBe(true); + expect(collector.observe('creative_initial', 'guard_count', 1)).toBe(true); + expect(collector.observe('gpt_initial', 'protocol_version', 2)).toBe(true); + expect(collector.observe('gpt_initial', '', 3)).toBe(false); + + const snapshot = collector.snapshot(); + expect(snapshot).toEqual([ + { + sliceId: 'creative_initial', + observations: ['guard_count'], + values: [['guard_count', 1]], + }, + { + sliceId: 'gpt_initial', + observations: ['protocol_version'], + values: [['protocol_version', 2]], + }, + { sliceId: 'lockr_initial', observations: [], values: [] }, + ]); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot[0]?.values[0])).toBe(true); + }); + + it('preserves exact frozen bindings while observing each successful parser update', () => { + const events: string[] = []; + const candidate = Object.freeze({ + observe: (name: string, value: unknown) => events.push(`observe:${name}:${String(value)}`), + register: () => () => undefined, + }); + const observations: unknown[] = []; + const captured = captureMutationObservedBindings( + candidate, + () => { + events.push('mutation'); + return true; + }, + (key, value) => observations.push([key, value]) + ) as typeof candidate; + + expect(captured).not.toBe(candidate); + expect(Object.isFrozen(captured)).toBe(true); + expect(Reflect.ownKeys(captured)).toEqual(['observe', 'register']); + captured.observe('segment_count', 2); + expect(events).toEqual(['observe:segment_count:2', 'mutation']); + expect(observations).toEqual([['segment_count', 2]]); + }); + + it('returns exact protocol activation receipts while registering full protocols', () => { + const own = vi.fn(); + const register = vi.fn((_protocol: object) => () => undefined); + const observe = vi.fn(); + + const receipts = [ + installApsInitial( + Object.freeze({ + observe, + publisherOrigin: 'https://publisher.example', + register, + }), + own + ), + installGptInitial( + Object.freeze({ browser: {} as Window, observe, register }), + own, + () => + Object.freeze({ + start: () => true, + closeIngress: () => true, + captureHandoff: () => Object.freeze([]), + captureDiagnosticsHandoff: () => + Object.freeze([Object.freeze([]), Object.freeze([]), 1, 0, 0] as const), + detachCommittedSlots: () => true, + dispose: () => undefined, + }), + Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }) + ), + installPrebidInitial(Object.freeze({ observe, register }), own), + ]; + + expect(receipts).toEqual([ + [1, 'aps'], + [1, 'gpt'], + [1, 'prebid'], + ]); + for (const receipt of receipts) { + expect(Reflect.ownKeys(receipt)).toEqual(['0', '1', 'length']); + expect(Object.isFrozen(receipt)).toBe(true); + } + expect(register).toHaveBeenCalledTimes(3); + for (const [protocol] of register.mock.calls) { + expect(Reflect.ownKeys(protocol).length).toBeGreaterThan(2); + expect(Object.isFrozen(protocol)).toBe(true); + } + }); + + it('installs one source-neutral render journal and gives its slice disposer final ownership', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const removeEventListener = vi.spyOn(dom.window, 'removeEventListener'); + const release = vi.fn(); + const owned: Array<() => void> = []; + let protocol: FirstDisplayRenderOwnerProtocolV1 | undefined; + + expect( + installRenderOwnerInitial( + Object.freeze({ + observe: vi.fn(), + register: (candidate: FirstDisplayRenderOwnerProtocolV1) => { + protocol = candidate; + return release; + }, + }), + (dispose) => owned.push(dispose) + ) + ).toEqual([1, 'render_owner']); + expect(protocol).toEqual([1, 'render_owner', expect.any(Function)]); + expect(Object.isFrozen(protocol)).toBe(true); + const createRenderBridge = protocol?.[2]; + const bridge = createRenderBridge?.([ + dom.window as unknown as Window, + () => undefined, + () => { + throw new Error('unused'); + }, + dom.window.document, + () => undefined, + () => 0, + undefined, + () => ({}), + ]); + + expect(bridge).toHaveLength(10); + expect(Object.isFrozen(bridge)).toBe(true); + expect(() => + createRenderBridge?.([ + dom.window as unknown as Window, + () => undefined, + () => { + throw new Error('unused'); + }, + dom.window.document, + () => undefined, + () => 0, + undefined, + () => ({}), + ]) + ).toThrow(TypeError); + + expect(owned).toHaveLength(1); + owned[0]?.(); + expect(release).toHaveBeenCalledOnce(); + expect(removeEventListener).toHaveBeenCalledWith('message', expect.any(Function), true); + dom.window.close(); + }); + + it.each([false, true])( + 'projects the typed GAM attribution flag into the first-display GPT owner (%s)', + (gamAttributionEnabled) => { + const observe = vi.fn(); + const commands: Array<() => void> = []; + const setConfig = vi.fn(); + const browser = { + googletag: { cmd: commands, setConfig }, + } as unknown as Window; + let protocol: FirstDisplayGptProtocolV1 | undefined; + let policy: FirstDisplayGptBatchPolicyV1 | undefined; + const createBatch = vi.fn((_input: unknown, candidate: FirstDisplayGptBatchPolicyV1) => { + policy = candidate; + return Object.freeze({ + start: () => true, + closeIngress: () => true, + captureHandoff: () => Object.freeze([]), + captureDiagnosticsHandoff: () => + Object.freeze([Object.freeze([]), Object.freeze([]), 1, 0, 0] as const), + detachCommittedSlots: () => true, + dispose: () => undefined, + }); + }); + installGptInitial( + Object.freeze({ + browser, + observe, + register: (candidate: FirstDisplayGptProtocolV1) => { + protocol = candidate; + return () => undefined; + }, + }), + () => undefined, + createBatch, + Object.freeze({ gamAttributionEnabled, pageBidsEnabled: true }) + ); + + expect(protocol).toEqual([1, 'gpt', expect.any(Function)]); + expect(Object.isFrozen(protocol)).toBe(true); + const batch = protocol?.[2]({} as never); + expect(batch).toHaveLength(6); + expect(Object.isFrozen(batch)).toBe(true); + + expect(createBatch).toHaveBeenCalledOnce(); + expect(createBatch.mock.calls[0]?.[0]).toEqual({}); + expect(policy?.deadlines).toEqual({ + externalReadyMs: 10_000, + requestStartMs: 3_000, + completionMs: 10_000, + }); + expect( + policy?.requestPlan( + Object.freeze({ initialLoadDisabled: true, ownership: 'trusted_server' }) + ) + ).toEqual({ operations: ['display', 'refresh'], requestOperation: 1 }); + expect(policy?.classifyRenderEnded(Object.freeze({ isEmpty: false }))).toBe('nonempty_gam'); + expect(commands).toHaveLength(gamAttributionEnabled ? 1 : 0); + commands.splice(0).forEach((command) => command()); + expect(setConfig).toHaveBeenCalledTimes(gamAttributionEnabled ? 1 : 0); + if (gamAttributionEnabled) { + expect(setConfig).toHaveBeenCalledExactlyOnceWith({ targeting: { ts: 'true' } }); + } + expect(observe).toHaveBeenCalledWith('gam', gamAttributionEnabled); + } + ); + + it('pins the exact thirteen optional slices in build order', () => { + expect(INITIAL_SLICE_DEFINITIONS.map(({ id }) => id)).toEqual([ + 'render_owner_initial', + 'aps_initial', + 'creative_initial', + 'datadome_initial', + 'didomi_initial', + 'google_tag_manager_initial', + 'gpt_initial', + 'lockr_initial', + 'osano_initial', + 'permutive_initial', + 'sourcepoint_initial', + 'prebid_initial', + 'testlight_initial', + ]); + }); + + it('exposes only each exact initial-slice installer obligation', () => { + const events: string[] = []; + const dispose = vi.fn(); + const host = Object.freeze({ + activate: ( + id: string, + own: (callback: () => void) => void, + _install: InitialSliceInstaller + ) => { + own(dispose); + events.push(id); + }, + }); + + for (const definition of INITIAL_SLICE_DEFINITIONS) { + host.activate(definition.id, () => undefined, definition.install); + } + expect(events).toEqual(INITIAL_SLICE_DEFINITIONS.map(({ id }) => id)); + expect( + INITIAL_SLICE_DEFINITIONS.every( + (definition) => Reflect.ownKeys(definition).join(',') === 'id,install' + ) + ).toBe(true); + }); + + it('rejects unknown, duplicate, omitted-base, and misordered selections', () => { + expect( + selectInitialSliceDefinitions(['first_display', 'gpt_initial'])?.map(({ id }) => id) + ).toEqual(['gpt_initial']); + expect(selectInitialSliceDefinitions(['gpt_initial'])).toBeUndefined(); + expect( + selectInitialSliceDefinitions(['first_display', 'gpt_initial', 'gpt_initial']) + ).toBeUndefined(); + expect( + selectInitialSliceDefinitions(['first_display', 'prebid_initial', 'gpt_initial']) + ).toBeUndefined(); + expect( + selectInitialSliceDefinitions(['first_display', 'aps_initial', 'gpt_initial']) + ).toBeUndefined(); + expect( + selectInitialSliceDefinitions(['first_display', 'render_owner_initial']) + ).toBeUndefined(); + expect( + selectInitialSliceDefinitions(['first_display', 'unknown_initial' as 'gpt_initial']) + ).toBeUndefined(); + }); + + it('registers through only the bootstrap-owned sink on the exact current script', () => { + const dom = new JSDOM( + ``, + { url: 'https://publisher.example/' } + ); + const script = dom.window.document.querySelector('script') as HTMLScriptElement; + Object.defineProperty(dom.window.document, 'currentScript', { + configurable: true, + value: script, + }); + const target = {}; + const sink = vi.fn(function (this: unknown, registration: unknown, source: unknown) { + expect(this).toBe(target); + expect(registration).toBe(componentRegistrationValue); + expect(source).toBe(script); + return true; + }); + Object.defineProperty(target, '_registerFirstDisplay', { + configurable: true, + enumerable: false, + value: sink, + writable: false, + }); + Object.defineProperty(dom.window, 'tsjs', { + configurable: true, + enumerable: true, + value: target, + writable: true, + }); + const componentRegistrationValue = componentRegistration(); + + expect( + registerFirstDisplayComponent(dom.window as unknown as Window, componentRegistrationValue) + ).toBe(true); + expect(sink).toHaveBeenCalledTimes(1); + }); + + it('rejects an accessor/inherited sink and a noncanonical or detached current script', () => { + const make = () => { + const dom = new JSDOM( + ``, + { url: 'https://publisher.example/' } + ); + const script = dom.window.document.querySelector('script') as HTMLScriptElement; + Object.defineProperty(dom.window.document, 'currentScript', { + configurable: true, + value: script, + }); + return { dom, script }; + }; + + const accessor = make(); + Object.defineProperty(accessor.dom.window, 'tsjs', { + configurable: true, + value: Object.defineProperty({}, '_registerFirstDisplay', { + configurable: true, + get: vi.fn(), + }), + }); + expect( + registerFirstDisplayComponent( + accessor.dom.window as unknown as Window, + componentRegistration() + ) + ).toBe(false); + + const inherited = make(); + const prototype = Object.defineProperty({}, '_registerFirstDisplay', { + value: () => true, + }); + Object.defineProperty(inherited.dom.window, 'tsjs', { + configurable: true, + value: Object.create(prototype), + }); + expect( + registerFirstDisplayComponent( + inherited.dom.window as unknown as Window, + componentRegistration() + ) + ).toBe(false); + + const wrongSource = make(); + wrongSource.script.src = `https://publisher.example/static/tsjs=tsjs-first-display.min.js?v=${'b'.repeat(64)}&m=0041`; + Object.defineProperty(wrongSource.dom.window, 'tsjs', { + configurable: true, + value: Object.defineProperty({}, '_registerFirstDisplay', { + value: () => true, + }), + }); + expect( + registerFirstDisplayComponent( + wrongSource.dom.window as unknown as Window, + componentRegistration() + ) + ).toBe(false); + + const detached = make(); + detached.script.remove(); + Object.defineProperty(detached.dom.window, 'tsjs', { + configurable: true, + value: Object.defineProperty({}, '_registerFirstDisplay', { + value: () => true, + }), + }); + expect( + registerFirstDisplayComponent( + detached.dom.window as unknown as Window, + componentRegistration() + ) + ).toBe(false); + }); + + it('owns the initial Didomi SDK path without clobbering publisher configuration', () => { + const publisherConfig = { notice: 'publisher-owned' }; + const target = { + didomiConfig: publisherConfig, + location: { origin: 'https://publisher.example' }, + }; + const observations: Array = []; + const disposers: Array<() => void> = []; + const bindings = Object.freeze({ + config: Object.freeze({ proxyPath: '/integrations/didomi/consent/' }), + observe: (name: string, value: string) => observations.push([name, value]), + target, + }); + const host = Object.freeze({ + activate: ( + id: string, + own: (dispose: () => void) => void, + install?: InitialSliceInstaller + ) => { + expect(id).toBe('didomi_initial'); + install?.(bindings, own, undefined); + }, + }); + activateInitialSlice( + DIDOMI_INITIAL_SLICE, + host, + Object.freeze({ + own: (dispose: () => void) => disposers.push(dispose), + afterActivate: () => undefined, + }) + ); + expect(target.didomiConfig).toBe(publisherConfig); + expect(target.didomiConfig).toEqual({ + notice: 'publisher-owned', + sdkPath: 'https://publisher.example/integrations/didomi/consent/', + }); + expect(observations).toEqual([ + ['sdk_path', 'https://publisher.example/integrations/didomi/consent/'], + ]); + + disposers.reverse().forEach((dispose) => dispose()); + expect(target.didomiConfig).toEqual({ notice: 'publisher-owned' }); + }); + + it('keeps the Didomi initial config/rollback corpus equal to the persistent owner', () => { + const validCases = [ + { config: Object.freeze({ proxyPath: '/consent/' }), initial: undefined }, + { + config: Object.freeze({ proxyPath: '/consent/sdk/' }), + initial: { notice: 'publisher' }, + }, + { + config: Object.freeze({ proxyPath: '/consent/sdk/' }), + initial: { notice: 'publisher', sdkPath: 'https://publisher.example/original.js' }, + }, + ] as const; + for (const fixture of validCases) { + const persistentTarget = { + ...(fixture.initial ? { didomiConfig: { ...fixture.initial } } : {}), + location: { origin: 'https://publisher.example' }, + }; + const initialTarget = { + ...(fixture.initial ? { didomiConfig: { ...fixture.initial } } : {}), + location: { origin: 'https://publisher.example' }, + }; + const persistentDispose = createDidomiRuntime({ + started: () => undefined, + target: persistentTarget, + }).activate(fixture.config); + const initialDisposers: Array<() => void> = []; + installDidomiInitial( + Object.freeze({ + config: fixture.config, + observe: () => undefined, + target: initialTarget, + }), + (dispose) => initialDisposers.push(dispose) + ); + expect(initialTarget.didomiConfig).toEqual(persistentTarget.didomiConfig); + + persistentDispose(); + initialDisposers.reverse().forEach((dispose) => dispose()); + expect(initialTarget.didomiConfig).toEqual(persistentTarget.didomiConfig); + expect(Object.prototype.hasOwnProperty.call(initialTarget, 'didomiConfig')).toBe( + Object.prototype.hasOwnProperty.call(persistentTarget, 'didomiConfig') + ); + } + + for (const config of [ + { proxyPath: '/not-frozen/' }, + Object.freeze({ proxyPath: '//attacker.example/sdk.js' }), + Object.freeze({ proxyPath: '/consent/?publisher=1' }), + ]) { + const persistent = () => + createDidomiRuntime({ + started: () => undefined, + target: { location: { origin: 'https://publisher.example' } }, + }).activate(config); + const initial = () => + installDidomiInitial( + Object.freeze({ + config, + observe: () => undefined, + target: { location: { origin: 'https://publisher.example' } }, + }), + () => undefined + ); + expect(persistent).toThrow(); + expect(initial).toThrow(); + } + }); + + it('captures preexisting and later Testlight callbacks once without draining user work itself', () => { + const calls: string[] = []; + const first = () => calls.push('first'); + const throwing = () => { + calls.push('throwing'); + throw new Error('publisher callback failed'); + }; + const second = () => calls.push('second'); + const later = () => calls.push('later'); + const original = [first, 'invalid', throwing, second]; + const target = { testlight: { publisher: true, que: original } }; + const observations: number[] = []; + const disposers: Array<() => void> = []; + const host = Object.freeze({ + activate: ( + id: string, + own: (dispose: () => void) => void, + install?: InitialSliceInstaller + ) => { + expect(id).toBe('testlight_initial'); + install?.( + Object.freeze({ + enqueue: (callback: () => void) => callback(), + observe: (_name: string, count: number) => observations.push(count), + target, + }), + own, + undefined + ); + }, + }); + + activateInitialSlice( + TESTLIGHT_INITIAL_SLICE, + host, + Object.freeze({ + own: (dispose: () => void) => disposers.push(dispose), + afterActivate: () => undefined, + }) + ); + expect(calls).toEqual(['first', 'throwing', 'second']); + expect(original).toEqual([]); + expect(target.testlight.que).not.toBe(original); + expect(target.testlight.que.push(later)).toBe(1); + expect(calls).toEqual(['first', 'throwing', 'second', 'later']); + expect(target.testlight.que).toHaveLength(0); + expect(observations).toEqual([3, 4]); + + disposers.reverse().forEach((dispose) => dispose()); + expect(target.testlight).toEqual({ publisher: true, que: original }); + expect(original).toEqual([]); + }); + + it('registers exact DataDome and GTM route matchers with first-party path preservation', () => { + const cases = [ + { + definition: DATADOME_INITIAL_SLICE, + id: 'datadome', + accepted: [ + ['script', 'https://js.datadome.co/tags.js?x=1'], + ['preload', '//js.datadome.co/js/check'], + ], + rejected: [ + ['script', 'https://cdn.example/js.datadome.co.js'], + ['beacon', 'https://js.datadome.co/tags.js'], + ], + rewritten: 'https://publisher.example/integrations/datadome/tags.js?x=1', + }, + { + definition: GOOGLE_TAG_MANAGER_INITIAL_SLICE, + id: 'google_tag_manager', + accepted: [ + ['script', 'https://www.googletagmanager.com/gtm.js?id=GTM-1'], + ['fetch', 'https://www.google-analytics.com/g/collect?v=2'], + ['beacon', 'https://analytics.google.com/collect?v=2'], + ], + rejected: [ + ['script', 'https://googletagmanager.com/gtm.js'], + ['script', 'https://www.googletagmanager.com/ns.html'], + ], + rewritten: 'https://publisher.example/integrations/google_tag_manager/gtm.js?id=GTM-1', + }, + ] as const; + + for (const fixture of cases) { + let rule: FirstDisplayRouteRuleV1 | undefined; + const dispose = vi.fn(); + const disposers: Array<() => void> = []; + const host = Object.freeze({ + activate: ( + _id: string, + own: (release: () => void) => void, + install?: InitialSliceInstaller + ) => + install?.( + Object.freeze({ + observe: () => undefined, + origin: 'https://publisher.example', + register: (candidate: FirstDisplayRouteRuleV1) => { + rule = candidate; + return dispose; + }, + }), + own, + undefined + ), + }); + activateInitialSlice( + fixture.definition, + host, + Object.freeze({ + own: (release: () => void) => disposers.push(release), + afterActivate: () => undefined, + }) + ); + expect(rule?.id).toBe(fixture.id); + for (const [kind, url] of fixture.accepted) expect(rule?.matches(kind, url)).toBe(true); + for (const [kind, url] of fixture.rejected) expect(rule?.matches(kind, url)).toBe(false); + expect(rule?.rewrite(fixture.accepted[0][1])).toBe(fixture.rewritten); + disposers.reverse().forEach((release) => release()); + expect(dispose).toHaveBeenCalledOnce(); + } + }); + + it('owns Lockr route matching and the bounded initial SDK host rewrite', () => { + const sdk = { host: 'https://identity.loc.kr' }; + const timers: Array<() => void> = []; + const disposers: Array<() => void> = []; + let rule: FirstDisplayRouteRuleV1 | undefined; + const unregister = vi.fn(); + const observations: Array = []; + const bindings = Object.freeze({ + clearTimer: (handle: unknown) => { + const index = timers.indexOf(handle as () => void); + if (index >= 0) timers.splice(index, 1); + }, + getSdk: () => sdk, + host: 'publisher.example', + observe: (name: string, value: string | number) => observations.push([name, value]), + origin: 'https://publisher.example', + protocol: 'https:', + register: (candidate: FirstDisplayRouteRuleV1) => { + rule = candidate; + return unregister; + }, + setTimer: (callback: () => void) => { + timers.push(callback); + return callback; + }, + }); + const host = Object.freeze({ + activate: ( + _id: string, + own: (release: () => void) => void, + install?: InitialSliceInstaller + ) => install?.(bindings, own, undefined), + }); + + activateInitialSlice( + LOCKR_INITIAL_SLICE, + host, + Object.freeze({ + own: (release: () => void) => disposers.push(release), + afterActivate: () => undefined, + }) + ); + expect(rule?.matches('script', 'https://aim.loc.kr/sdk.js')).toBe(true); + expect(rule?.matches('preload', 'https://identity.loc.kr/identity-lockr.js')).toBe(true); + expect(rule?.matches('script', 'https://identity.loc.kr/other.js')).toBe(false); + expect(rule?.rewrite('https://aim.loc.kr/sdk.js')).toBe( + 'https://publisher.example/integrations/lockr/sdk' + ); + expect(sdk.host).toBe('https://publisher.example/integrations/lockr/api'); + expect(observations).toContainEqual(['sdk_host', sdk.host]); + + disposers.reverse().forEach((release) => release()); + expect(sdk.host).toBe('https://identity.loc.kr'); + expect(unregister).toHaveBeenCalledOnce(); + expect(timers).toEqual([]); + }); + + it('captures Permutive segments and owns its initial route/readiness mutations', () => { + localStorage.setItem( + 'permutive-app', + JSON.stringify({ core: { cohorts: { all: ['one', 2, false, ...Array(110).fill('x')] } } }) + ); + const sdk = { + config: { + apiHost: 'api.permutive.com', + apiProtocol: 'https', + cdnBaseUrl: 'cdn.permutive.com', + cdnProtocol: 'https', + secureSignalsApiHost: 'signals.permutive.com', + segmentSyncApiHost: 'sync.permutive.com', + }, + }; + const previous = { ...sdk.config }; + const disposers: Array<() => void> = []; + let route: FirstDisplayContextRouteRuleV1 | undefined; + const unregisterRoute = vi.fn(); + const observations: Array = []; + const bindings = Object.freeze({ + clearTimer: () => undefined, + getSdk: () => sdk, + host: 'publisher.example', + observe: (name: string, value: string | number) => observations.push([name, value]), + origin: 'https://publisher.example', + protocol: 'https:', + readStorage: (key: string) => localStorage.getItem(key), + registerRoute: (candidate: FirstDisplayContextRouteRuleV1) => { + route = candidate; + return unregisterRoute; + }, + setTimer: (_callback: () => void, _delayMs: number) => 1, + }); + const host = Object.freeze({ + activate: ( + id: string, + own: (release: () => void) => void, + install?: InitialSliceInstaller + ) => { + expect(id).toBe('permutive_initial'); + install?.(bindings, own, undefined); + }, + }); + + activateInitialSlice( + PERMUTIVE_INITIAL_SLICE, + host, + Object.freeze({ + own: (release: () => void) => disposers.push(release), + afterActivate: () => undefined, + }) + ); + + const segmentObservation = observations.find(([name]) => name === 'segments'); + expect(segmentObservation).toBeDefined(); + expect(JSON.parse(String(segmentObservation?.[1]))).toEqual(getPermutiveSegments()); + expect(JSON.parse(String(segmentObservation?.[1]))).toHaveLength(100); + expect(route?.matches('script', 'https://cdn.permutive.com/example-web.js')).toBe(true); + expect( + route?.matches('script', 'https://cdn.permutive.com.attacker.example/example-web.js') + ).toBe(false); + expect( + route?.matches('script', 'https://cdn.permutive.com@attacker.example/example-web.js') + ).toBe(false); + expect(route?.matches('script', 'https://cdn.permutive.com/example.js')).toBe(false); + expect(route?.rewrite('https://cdn.permutive.com/example-web.js')).toBe( + 'https://publisher.example/integrations/permutive/sdk' + ); + expect(sdk.config).toEqual({ + apiHost: 'publisher.example/integrations/permutive/api', + apiProtocol: 'https', + cdnBaseUrl: 'publisher.example/integrations/permutive/cdn', + cdnProtocol: 'https', + secureSignalsApiHost: 'publisher.example/integrations/permutive/secure-signal', + segmentSyncApiHost: 'publisher.example/integrations/permutive/sync', + }); + + disposers.reverse().forEach((release) => release()); + expect(sdk.config).toEqual(previous); + expect(unregisterRoute).toHaveBeenCalledOnce(); + localStorage.clear(); + }); + + it('mirrors only the one-shot Sourcepoint consent snapshot and owns its SDK route', () => { + clearAllCookies(); + localStorage.clear(); + localStorage.setItem( + '_sp_user_consent_123', + JSON.stringify({ + gppData: { gppString: 'DBABLA~BVQqAAAAAgA.QA', applicableSections: [7, 8] }, + }) + ); + expect(mirrorSourcepointConsent()).toBe(true); + const persistentCookies = document.cookie; + clearAllCookies(); + + const disposers: Array<() => void> = []; + let route: FirstDisplayConsentRouteRuleV1 | undefined; + const unregisterRoute = vi.fn(); + const bindings = Object.freeze({ + config: Object.freeze({ rewriteSdk: true }), + document, + observe: () => undefined, + origin: 'https://publisher.example', + registerRoute: (candidate: FirstDisplayConsentRouteRuleV1) => { + route = candidate; + return unregisterRoute; + }, + storage: localStorage, + }); + const host = Object.freeze({ + activate: ( + id: string, + own: (release: () => void) => void, + install?: InitialSliceInstaller + ) => { + expect(id).toBe('sourcepoint_initial'); + install?.(bindings, own, undefined); + }, + }); + + activateInitialSlice( + SOURCEPOINT_INITIAL_SLICE, + host, + Object.freeze({ + own: (release: () => void) => disposers.push(release), + afterActivate: () => undefined, + }) + ); + expect(document.cookie).toBe(persistentCookies); + expect( + route?.matches('script', 'https://cdn.privacy-mgmt.com/wrapperMessagingWithoutDetection.js') + ).toBe(true); + expect(route?.matches('script', 'https://cdn.privacy-mgmt.com.evil.example/sdk.js')).toBe( + false + ); + expect(route?.rewrite('https://cdn.privacy-mgmt.com/path/sdk.js?x=1')).toBe( + 'https://publisher.example/integrations/sourcepoint/cdn/path/sdk.js?x=1' + ); + + disposers.reverse().forEach((release) => release()); + expect(readCookie('__gpp')).toBeUndefined(); + expect(readCookie('__gpp_sid')).toBeUndefined(); + expect(readCookie('_ts_gpp_src')).toBeUndefined(); + expect(unregisterRoute).toHaveBeenCalledOnce(); + localStorage.clear(); + clearAllCookies(); + }); + + it('captures one Osano USP/GPP/TCF snapshot without installing maintenance listeners', () => { + clearAllCookies(); + const disposers: Array<() => void> = []; + const timers = new Set<() => void>(); + const target = { + addEventListener: vi.fn(), + __uspapi: ( + _command: string, + _version: number, + callback: (data: unknown, success: boolean) => void + ) => callback({ uspString: '1YN-' }, true), + __gpp: (_command: string, callback: (data: unknown, success: boolean) => void) => + callback( + { signalStatus: 'ready', gppString: 'DBABLA~BVQqAAAAAgA.QA', applicableSections: [7] }, + true + ), + __tcfapi: ( + _command: string, + _version: number, + callback: (data: unknown, success: boolean) => void + ) => callback({ eventStatus: 'tcloaded', tcString: 'consent-string' }, true), + }; + const bindings = Object.freeze({ + clearTimer: (handle: unknown) => timers.delete(handle as () => void), + document, + observe: () => undefined, + setTimer: (callback: () => void, _delayMs: number) => { + timers.add(callback); + return callback; + }, + target, + }); + const host = Object.freeze({ + activate: ( + id: string, + own: (release: () => void) => void, + install?: InitialSliceInstaller + ) => { + expect(id).toBe('osano_initial'); + install?.(bindings, own, undefined); + }, + }); + + activateInitialSlice( + OSANO_INITIAL_SLICE, + host, + Object.freeze({ + own: (release: () => void) => disposers.push(release), + afterActivate: () => undefined, + }) + ); + + expect(readCookie('us_privacy')).toBe('1YN-'); + expect(readCookie('__gpp')).toBe('DBABLA~BVQqAAAAAgA.QA'); + expect(readCookie('__gpp_sid')).toBe('7'); + expect(readCookie('euconsent-v2')).toBe('consent-string'); + expect(readCookie('_ts_consent_src')).toBe('osano'); + expect(target.addEventListener).not.toHaveBeenCalled(); + expect(timers.size).toBe(0); + + disposers.reverse().forEach((release) => release()); + expect(document.cookie).toBe(''); + }); + + it('keeps Permutive fallback parsing bounded and releases a pending readiness check', () => { + const raw = JSON.stringify({ + eventPublication: { + eventUpload: [ + ['old', { event: { properties: { segments: ['old'] } } }], + ['new', { event: { properties: { segments: [9, 'new', false] } } }], + ], + }, + }); + expect(snapshotPermutiveInitialSegments(raw)).toEqual(['9', 'new']); + expect(snapshotPermutiveInitialSegments('{bad-json')).toEqual([]); + + const timers: Array<() => void> = []; + const disposers: Array<() => void> = []; + const unregisterRoute = vi.fn(); + installPermutiveInitial( + Object.freeze({ + clearTimer: (handle: unknown) => { + const index = timers.indexOf(handle as () => void); + if (index >= 0) timers.splice(index, 1); + }, + getSdk: () => undefined, + host: 'publisher.example', + observe: () => undefined, + origin: 'https://publisher.example', + protocol: 'https:', + readStorage: () => raw, + registerRoute: () => unregisterRoute, + setTimer: (callback: () => void) => { + timers.push(callback); + return callback; + }, + }), + (release) => disposers.push(release) + ); + expect(timers).toHaveLength(1); + + disposers.reverse().forEach((release) => release()); + expect(timers).toEqual([]); + expect(unregisterRoute).toHaveBeenCalledOnce(); + }); + + it('preserves a foreign GPP owner and skips the disabled Sourcepoint SDK route', () => { + clearAllCookies(); + localStorage.clear(); + document.cookie = '__gpp=publisher-owned; path=/'; + document.cookie = '__gpp_sid=2; path=/'; + localStorage.setItem( + '_sp_user_consent_123', + JSON.stringify({ gppData: { gppString: 'sourcepoint', applicableSections: [7] } }) + ); + const registerRoute = vi.fn(); + const disposers: Array<() => void> = []; + installSourcepointInitial( + Object.freeze({ + config: Object.freeze({ rewriteSdk: false }), + document, + observe: () => undefined, + origin: 'https://publisher.example', + registerRoute, + storage: localStorage, + }), + (release) => disposers.push(release) + ); + + expect(readCookie('__gpp')).toBe('publisher-owned'); + expect(readCookie('__gpp_sid')).toBe('2'); + expect(readCookie('_ts_gpp_src')).toBeUndefined(); + expect(registerRoute).not.toHaveBeenCalled(); + disposers.reverse().forEach((release) => release()); + expect(readCookie('__gpp')).toBe('publisher-owned'); + clearAllCookies(); + localStorage.clear(); + }); + + it('cancels a pending Osano API snapshot without later work or cookie mutation', () => { + clearAllCookies(); + const timers = new Set<() => void>(); + const disposers: Array<() => void> = []; + const target = { + addEventListener: vi.fn(), + __uspapi: vi.fn(), + }; + installOsanoInitial( + Object.freeze({ + clearTimer: (handle: unknown) => timers.delete(handle as () => void), + document, + observe: () => undefined, + setTimer: (callback: () => void) => { + timers.add(callback); + return callback; + }, + target, + }), + (release) => disposers.push(release) + ); + expect(timers.size).toBe(1); + expect(target.addEventListener).not.toHaveBeenCalled(); + + disposers.reverse().forEach((release) => release()); + expect(timers.size).toBe(0); + expect(document.cookie).toBe(''); + }); + + it('installs the selected creative parser guards and owns their rollback', () => { + const observations: Array = []; + const disposers: Array<() => void> = []; + const clickHandle = Object.freeze({ dispose: vi.fn(), scan: vi.fn() }); + const imageHandle = Object.freeze({ dispose: vi.fn(), scan: vi.fn() }); + const iframeHandle = Object.freeze({ dispose: vi.fn(), scan: vi.fn() }); + const installClickGuard = vi.fn(() => clickHandle); + const installDynamicImageProxy = vi.fn(() => imageHandle); + const installDynamicIframeProxy = vi.fn(() => iframeHandle); + const bindings = Object.freeze({ + config: Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: true, + }), + document, + installClickGuard, + installDynamicIframeProxy, + installDynamicImageProxy, + observe: (name: string, value: number) => observations.push([name, value]), + }); + const host = Object.freeze({ + activate: ( + id: string, + own: (dispose: () => void) => void, + install?: InitialSliceInstaller + ) => { + expect(id).toBe('creative_initial'); + install?.(bindings, own, bindings.config); + }, + }); + + activateInitialSlice( + CREATIVE_INITIAL_SLICE, + host, + Object.freeze({ + own: (dispose: () => void) => disposers.push(dispose), + afterActivate: () => undefined, + }) + ); + expect(installClickGuard).toHaveBeenCalledOnce(); + expect(installDynamicImageProxy).toHaveBeenCalledOnce(); + expect(installDynamicIframeProxy).toHaveBeenCalledOnce(); + expect(clickHandle.scan).not.toHaveBeenCalled(); + expect(imageHandle.scan).not.toHaveBeenCalled(); + expect(iframeHandle.scan).not.toHaveBeenCalled(); + expect(observations).toEqual([['guard_count', 3]]); + + disposers.reverse().forEach((dispose) => dispose()); + expect(clickHandle.dispose).toHaveBeenCalledOnce(); + expect(imageHandle.dispose).toHaveBeenCalledOnce(); + expect(iframeHandle.dispose).toHaveBeenCalledOnce(); + }); + + it('rejects malformed creative config before installing any initial guard', () => { + for (const config of [ + { version: 1, enabled: true, clickGuard: true, renderGuard: false }, + Object.freeze({ version: 1, enabled: false, clickGuard: true, renderGuard: false }), + Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + extra: true, + }), + ]) { + const installClickGuard = vi.fn(); + const host = Object.freeze({ + activate: ( + _id: string, + own: (dispose: () => void) => void, + install?: InitialSliceInstaller + ) => + install?.( + Object.freeze({ + config, + document, + installClickGuard, + installDynamicIframeProxy: vi.fn(), + installDynamicImageProxy: vi.fn(), + observe: () => undefined, + }), + own, + config + ), + }); + expect(() => + activateInitialSlice( + CREATIVE_INITIAL_SLICE, + host, + Object.freeze({ own: () => undefined, afterActivate: () => undefined }) + ) + ).toThrow(); + expect(installClickGuard).not.toHaveBeenCalled(); + } + }); + + it('registers the closed APS nonce, policy, and document-channel protocol', () => { + const release = vi.fn(); + const disposers: Array<() => void> = []; + let protocol: FirstDisplayApsProtocolV1 | undefined; + const bindings = Object.freeze({ + observe: vi.fn(), + publisherOrigin: 'https://publisher.example', + register: (candidate: FirstDisplayApsProtocolV1) => { + protocol = candidate; + return release; + }, + }); + const host = Object.freeze({ + activate: ( + id: string, + own: (dispose: () => void) => void, + install?: InitialSliceInstaller + ) => { + expect(id).toBe('aps_initial'); + install?.(bindings, own, undefined); + }, + }); + + activateInitialSlice( + APS_INITIAL_SLICE, + host, + Object.freeze({ + own: (dispose: () => void) => disposers.push(dispose), + afterActivate: () => undefined, + }) + ); + const policy = protocol?.[2]; + expect(policy?.rendererUrl).toBe('https://publisher.example/integrations/aps/renderer/v2'); + expect(policy?.publisherOrigin).toBe('https://publisher.example'); + expect(policy?.sandbox).toBe( + 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation' + ); + expect(policy?.permanentSandbox).toBe( + 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation' + ); + expect(policy?.deadlines).toEqual({ + documentAcceptanceMs: 3_000, + completionMs: 10_000, + }); + expect(policy?.isBootstrapNonce(`b1_${'a'.repeat(22)}`)).toBe(true); + expect(policy?.isRendererNonce(`n1_${'a'.repeat(22)}`)).toBe(true); + expect(policy?.isBootstrapNonce(`n1_${'a'.repeat(22)}`)).toBe(false); + const nonce = `n1_${'b'.repeat(22)}`; + expect( + policy?.parseDocumentMessage( + Object.freeze({ message: 'TS APS Document Accepted', version: 1, nonce }), + nonce + ) + ).toEqual({ kind: 'document_accepted' }); + expect( + policy?.parseDocumentMessage( + Object.freeze({ message: 'TS APS Runner Loaded', version: 1, nonce }), + nonce + ) + ).toEqual({ kind: 'runner_loaded' }); + expect( + policy?.parseDocumentMessage( + Object.freeze({ message: 'TS APS Render Completed', version: 1, nonce }), + nonce + ) + ).toEqual({ kind: 'render_completed' }); + expect( + policy?.parseDocumentMessage( + Object.freeze({ + message: 'TS APS Render Failed', + version: 1, + nonce, + reason: 'runner_failed', + }), + nonce + ) + ).toEqual({ kind: 'render_failed', reason: 'runner_failed' }); + for (const message of [ + { message: 'TS APS Render Completed', version: 1, nonce: `n1_${'c'.repeat(22)}` }, + { message: 'TS APS Render Completed', version: 1, nonce, extra: true }, + { message: 'TS APS Render Failed', version: 1, nonce, reason: 'unknown' }, + Object.create({ message: 'TS APS Render Completed', version: 1, nonce }), + ]) { + expect(policy?.parseDocumentMessage(Object.freeze(message), nonce)).toBeUndefined(); + } + + disposers.reverse().forEach((dispose) => dispose()); + expect(release).toHaveBeenCalledOnce(); + }); + + it('registers only the attenuated GPT batch factory before any initial action', () => { + const release = vi.fn(); + const disposers: Array<() => void> = []; + let protocol: FirstDisplayGptProtocolV1 | undefined; + const bindings = Object.freeze({ + browser: {} as Window, + observe: vi.fn(), + register: (candidate: FirstDisplayGptProtocolV1) => { + protocol = candidate; + return release; + }, + }); + const host = Object.freeze({ + activate: ( + id: string, + own: (dispose: () => void) => void, + install?: InitialSliceInstaller + ) => { + expect(id).toBe('gpt_initial'); + install?.( + bindings, + own, + Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }) + ); + }, + }); + + activateInitialSlice( + GPT_INITIAL_SLICE, + host, + Object.freeze({ + own: (dispose: () => void) => disposers.push(dispose), + afterActivate: () => undefined, + }) + ); + expect(protocol).toEqual([1, 'gpt', expect.any(Function)]); + expect(Object.isFrozen(protocol)).toBe(true); + + disposers.reverse().forEach((dispose) => dispose()); + expect(release).toHaveBeenCalledOnce(); + }); + + it('registers the Prebid admission policy for the protected batch only', () => { + const release = vi.fn(); + const disposers: Array<() => void> = []; + let protocol: FirstDisplayPrebidProtocolV1 | undefined; + const bindings = Object.freeze({ + observe: vi.fn(), + register: (candidate: FirstDisplayPrebidProtocolV1) => { + protocol = candidate; + return release; + }, + }); + const host = Object.freeze({ + activate: ( + id: string, + own: (dispose: () => void) => void, + install?: InitialSliceInstaller + ) => { + expect(id).toBe('prebid_initial'); + install?.(bindings, own, undefined); + }, + }); + + activateInitialSlice( + PREBID_INITIAL_SLICE, + host, + Object.freeze({ + own: (dispose: () => void) => disposers.push(dispose), + afterActivate: () => undefined, + }) + ); + const policy = protocol?.[2]; + expect(policy).toMatchObject({ + bidderCode: 'trustedServer', + maxPendingOperations: 64, + externalReadyMs: 10_000, + admissionLeaseMs: 10_000, + renderReservationMs: 15 * 60 * 1_000, + }); + expect(policy?.normalizeEidSource(' ID5-SYNC.COM ')).toBe('id5-sync.com'); + expect(policy?.normalizeEidSource(' ')).toBeUndefined(); + const prepared = policy?.snapshotTrustedBid( + Object.freeze({ + auctionId: 'auction-1', + adUnitCode: 'slot-1', + bid: Object.freeze({ + requestId: 'request-1', + adId: `r1_${'a'.repeat(22)}`, + cpm: 1.25, + width: 300, + height: 250, + ad: '', + ttl: 300, + creativeId: 'creative-1', + netRevenue: true, + currency: 'USD', + bidderCode: 'trustedServer', + meta: Object.freeze({ + advertiserDomains: Object.freeze(['advertiser.example']), + tsAuctionId: 'auction-1', + tsBidId: 'bid-1', + }), + }), + }) + ); + expect(prepared).toBeDefined(); + expect(Object.isFrozen(prepared)).toBe(true); + expect(prepared?.bid.adId).toBe(`r1_${'a'.repeat(22)}`); + expect( + policy?.snapshotTrustedBid( + Object.freeze({ + ...prepared, + bid: Object.freeze({ ...prepared?.bid, bidderCode: 'publisherBidder' }), + }) + ) + ).toBeUndefined(); + + disposers.reverse().forEach((dispose) => dispose()); + expect(release).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts new file mode 100644 index 000000000..9db761fb3 --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts @@ -0,0 +1,442 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + coordinatePreparedFirstDisplayTakeoverV1, + createFirstDisplayHandoffOwner, + performFirstDisplayTakeoverV1, + type FinalizedFirstDisplayHandoffV1, +} from '../../src/shared/first_display_handoff'; +import { snapshotOutlinedFirstDisplayHandoffV1 } from '../../src/shared/first_display_contracts'; +import { + consumeFirstDisplayTakeoverTransport, + installFirstDisplayTakeoverTransport, +} from '../../src/shared/takeover'; + +const RELEASE_ID = 'a'.repeat(64); +const DIGEST = 'b'.repeat(64); +const CONFIG_DIGEST = 'c'.repeat(64); +const RESERVATION_ID = `r1_${'a'.repeat(22)}`; + +function handoff(revision = 0): Record { + return { + captureVersion: 1, + identityCount: 2, + version: 1, + releaseId: RELEASE_ID, + generation: 1, + projectionDigest: DIGEST, + integrationConfigDigest: CONFIG_DIGEST, + slices: ['first_display', 'gpt_initial'], + slots: [ + { + id: 'slot-1', + aliases: [], + domId: 'div-1', + gamPath: '/123/slot-1', + formats: [[300, 250]], + owner: 'trusted_server', + outcome: 'accepted', + targeting: [['hb_adid', RESERVATION_ID]], + targetingOwnership: [], + committedArtifact: 'gpt_adm', + gptToken: 'gt1_1', + }, + ], + attempts: [ + { + id: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + slotId: 'slot-1', + ordinal: 1, + state: 'accepted', + reason: null, + }, + ], + tombstones: [], + artifacts: [ + { + hostPosition: null, + hostPositionPriority: null, + slotId: 'slot-1', + kind: 'gpt_adm', + owner: 'trusted_server', + token: RESERVATION_ID, + }, + ], + parserState: [ + { + sliceId: 'gpt_initial', + observations: ['gam', 'v'], + values: [ + ['gam', false], + ['v', 1], + ], + }, + ], + gptDiagnostics: { facts: [], overflowCount: 0, dropCount: 0 }, + timing: { bidsScriptMs: 1, firstDisplayMs: 2, terminalMs: 3, paintMs: 4 }, + highWater: { + navigationAttemptPrefix: 'AAECAwQFBgc', + nextNavigationAttemptOrdinal: 2, + nextAttemptOrdinal: 2, + nextSlotRegistrationOrdinal: 2, + reservationClockEpochMs: 0, + nextReservationOrdinal: 2, + nextTicketOrdinal: 1, + }, + cycles: [ + { + slotId: 'slot-1', + token: 'gt1_1', + nextCycleOrdinal: 2, + unknownPriorCycle: false, + records: [ + { + ordinal: 1, + responseIdentifier: 'response-one', + seen: ['slotRequested', 'slotRenderEnded'], + state: 'completed', + }, + ], + quarantines: [], + }, + ], + trace: { + nextSequence: 2, + nextGlobalSlotOrdinal: 2, + slots: [ + { + slotId: 'slot-1', + impressions: 1, + bindings: [ + { + atMs: 3, + cycleOrdinal: 1, + historySequence: 1, + state: 'completed', + token: 'gt1_1', + }, + ], + }, + ], + }, + mutationRevision: revision, + }; +} + +function validateCapturedHandoff(candidate: unknown, outlineValue: unknown) { + if (typeof candidate !== 'object' || candidate === null) return undefined; + const { + captureVersion: _captureVersion, + identityCount: _identityCount, + ...full + } = candidate as Record; + return snapshotOutlinedFirstDisplayHandoffV1(full, outlineValue); +} + +function outline(): Record { + return { + version: 1, + releaseId: RELEASE_ID, + generation: 1, + projectionDigest: DIGEST, + integrationConfigDigest: CONFIG_DIGEST, + slices: ['first_display', 'gpt_initial'], + slotCount: 1, + outcomeCount: 1, + capabilities: [], + objectKinds: ['gpt_slot', 'dom_artifact'], + }; +} + +function finalized( + physicalSlot: object = {}, + artifact: object = {} +): FinalizedFirstDisplayHandoffV1 { + const owner = createFirstDisplayHandoffOwner({ + releaseId: RELEASE_ID, + generation: 1, + isCurrentGeneration: () => true, + isTerminal: () => true, + isPainted: () => true, + closeIngress: () => undefined, + onFailure: () => undefined, + }); + const value = owner.finalize(() => ({ + candidate: handoff(), + identities: [physicalSlot, artifact], + })); + if (!value) throw new Error('should finalize test handoff'); + return value; +} + +describe('atomic first-display takeover', () => { + it('uses a non-replaceable one-shot rendezvous on the runtime script', () => { + const runtimeScript = {}; + const lease = Object.freeze([]); + const claim = vi.fn(() => lease); + const release = installFirstDisplayTakeoverTransport(runtimeScript, claim as never); + + expect(release).toBeTypeOf('function'); + expect(Object.getOwnPropertyDescriptor(runtimeScript, '_firstDisplayTakeover')).toMatchObject({ + configurable: false, + enumerable: false, + writable: false, + }); + expect(() => + Object.defineProperty(runtimeScript, '_firstDisplayTakeover', { + configurable: true, + value: () => undefined, + }) + ).toThrow(); + + const transport = consumeFirstDisplayTakeoverTransport(runtimeScript); + expect(transport.status).toBe('accepted'); + if (transport.status !== 'accepted') throw new Error('should accept transport'); + expect(transport.claim({}, (() => undefined) as never)).toBe(lease); + expect(transport.claim({}, (() => undefined) as never)).toBeUndefined(); + expect(claim).toHaveBeenCalledOnce(); + }); + + it('binds the exact handoff and one-use identities to the prepared persistent barrier', () => { + const physicalSlot = {}; + const artifact = {}; + const events: string[] = []; + let adoption: unknown; + const prepared = Object.freeze({ + validateHandoff: validateCapturedHandoff, + activate: (candidate?: unknown) => { + adoption = candidate; + events.push('activate'); + }, + commit: () => events.push('commit'), + rollback: () => events.push('rollback'), + }); + + expect( + coordinatePreparedFirstDisplayTakeoverV1({ + prepared, + finalized: finalized(physicalSlot, artifact), + outline: outline(), + isCurrentGeneration: () => true, + authenticateRuntimeScript: () => true, + currentMutationRevision: () => 0, + quiesceAgent: () => events.push('quiesce'), + detachCommittedArtifacts: () => events.push('detach'), + disposeAgent: () => events.push('dispose'), + onFailure: () => events.push('fallback'), + }) + ).toBe(true); + expect(adoption).toMatchObject({ + version: 1, + adoptInitialDisplay: true, + identities: [physicalSlot, artifact], + handoff: { releaseId: RELEASE_ID, generation: 1 }, + }); + expect(Object.isFrozen(adoption)).toBe(true); + expect(events).toEqual(['quiesce', 'detach', 'dispose', 'activate', 'commit']); + }); + + it('transfers one-use identities and commits in the exact non-yielding order', () => { + const events: string[] = []; + const physicalSlot = {}; + const artifact = {}; + const result = performFirstDisplayTakeoverV1({ + validateHandoff: validateCapturedHandoff, + finalized: finalized(physicalSlot, artifact), + outline: outline(), + isCurrentGeneration: () => true, + authenticateRuntimeScript: () => true, + currentMutationRevision: () => 0, + quiesceAgent: () => events.push('quiesce-agent'), + detachCommittedArtifacts: () => events.push('detach-artifacts'), + disposeAgent: () => events.push('dispose-agent'), + activatePersistent: (_snapshot, identities, own) => { + expect(identities).toEqual([physicalSlot, artifact]); + own(() => events.push('rollback-persistent')); + events.push('activate-persistent'); + }, + commitPersistent: () => events.push('commit-persistent'), + onFailure: () => events.push('fallback'), + }); + + expect(result).toBe(true); + expect(events).toEqual([ + 'quiesce-agent', + 'detach-artifacts', + 'dispose-agent', + 'activate-persistent', + 'commit-persistent', + ]); + }); + + it('rolls back partial persistent effects without resurrecting the agent', () => { + const events: string[] = []; + const result = performFirstDisplayTakeoverV1({ + validateHandoff: validateCapturedHandoff, + finalized: finalized(), + outline: outline(), + isCurrentGeneration: () => true, + authenticateRuntimeScript: () => true, + currentMutationRevision: () => 0, + quiesceAgent: () => events.push('quiesce-agent'), + detachCommittedArtifacts: () => events.push('detach-artifacts'), + disposeAgent: () => events.push('dispose-agent'), + activatePersistent: (_snapshot, _identities, own) => { + own(() => events.push('rollback-a')); + own(() => events.push('rollback-b')); + throw new Error('activation failed'); + }, + commitPersistent: () => events.push('commit-persistent'), + onFailure: () => events.push('fallback'), + }); + + expect(result).toBe(false); + expect(events).toEqual([ + 'quiesce-agent', + 'detach-artifacts', + 'dispose-agent', + 'rollback-b', + 'rollback-a', + 'fallback', + ]); + }); + + it('rejects a mutation during quiesce or persistent activation', () => { + for (const phase of ['quiesce', 'activate'] as const) { + let revision = 0; + const events: string[] = []; + const result = performFirstDisplayTakeoverV1({ + validateHandoff: validateCapturedHandoff, + finalized: finalized(), + outline: outline(), + isCurrentGeneration: () => true, + authenticateRuntimeScript: () => true, + currentMutationRevision: () => revision, + quiesceAgent: () => { + events.push('quiesce'); + if (phase === 'quiesce') revision += 1; + }, + detachCommittedArtifacts: () => events.push('detach'), + disposeAgent: () => events.push('dispose'), + activatePersistent: (_snapshot, _identities, own) => { + own(() => events.push('rollback')); + if (phase === 'activate') revision += 1; + }, + commitPersistent: () => events.push('commit'), + onFailure: () => events.push('fallback'), + }); + expect(result).toBe(false); + expect(events).not.toContain('commit'); + expect(events[events.length - 1]).toBe('fallback'); + } + }); + + it('revalidates runtime authentication immediately before persistent commit', () => { + let authenticated = true; + const events: string[] = []; + expect( + performFirstDisplayTakeoverV1({ + validateHandoff: validateCapturedHandoff, + finalized: finalized(), + outline: outline(), + isCurrentGeneration: () => true, + authenticateRuntimeScript: () => authenticated, + currentMutationRevision: () => 0, + quiesceAgent: () => events.push('quiesce'), + detachCommittedArtifacts: () => events.push('detach'), + disposeAgent: () => events.push('dispose'), + activatePersistent: (_snapshot, _identities, own) => { + own(() => events.push('rollback')); + events.push('activate'); + authenticated = false; + }, + commitPersistent: () => events.push('commit'), + onFailure: () => events.push('fallback'), + }) + ).toBe(false); + expect(events).toEqual(['quiesce', 'detach', 'dispose', 'activate', 'rollback', 'fallback']); + }); + + it('fails before quiesce for a stale outline, generation, or runtime script', () => { + for (const failure of ['outline', 'generation', 'script'] as const) { + const events: string[] = []; + const candidate = outline(); + if (failure === 'outline') candidate.projectionDigest = 'c'.repeat(64); + expect( + performFirstDisplayTakeoverV1({ + validateHandoff: validateCapturedHandoff, + finalized: finalized(), + outline: candidate, + isCurrentGeneration: () => failure !== 'generation', + authenticateRuntimeScript: () => failure !== 'script', + currentMutationRevision: () => 0, + quiesceAgent: () => events.push('quiesce'), + detachCommittedArtifacts: () => events.push('detach'), + disposeAgent: () => events.push('dispose'), + activatePersistent: () => events.push('activate'), + commitPersistent: () => events.push('commit'), + onFailure: () => events.push('fallback'), + }) + ).toBe(false); + expect(events).toEqual(['fallback']); + } + }); + + it('performs full semantic handoff validation at takeover before any owner effect', () => { + const owner = createFirstDisplayHandoffOwner({ + releaseId: RELEASE_ID, + generation: 1, + isCurrentGeneration: () => true, + isTerminal: () => true, + isPainted: () => true, + closeIngress: () => undefined, + onFailure: () => undefined, + }); + const candidate = handoff(); + candidate.trace = { ...(candidate.trace as object), nextSequence: 1 }; + const sealed = owner.finalize(() => ({ candidate, identities: [{}, {}] })); + expect(sealed).toBeDefined(); + + const events: string[] = []; + expect( + performFirstDisplayTakeoverV1({ + validateHandoff: validateCapturedHandoff, + finalized: sealed!, + outline: outline(), + isCurrentGeneration: () => true, + authenticateRuntimeScript: () => true, + currentMutationRevision: () => 0, + quiesceAgent: () => events.push('quiesce'), + detachCommittedArtifacts: () => events.push('detach'), + disposeAgent: () => events.push('dispose'), + activatePersistent: () => events.push('activate'), + commitPersistent: () => events.push('commit'), + onFailure: () => events.push('fallback'), + }) + ).toBe(false); + expect(events).toEqual(['fallback']); + }); + + it('rejects an outline that did not prepare every transferred object kind', () => { + for (const objectKinds of [[], ['gpt_slot'], ['dom_artifact']] as const) { + const events: string[] = []; + expect( + performFirstDisplayTakeoverV1({ + validateHandoff: validateCapturedHandoff, + finalized: finalized(), + outline: { ...outline(), objectKinds }, + isCurrentGeneration: () => true, + authenticateRuntimeScript: () => true, + currentMutationRevision: () => 0, + quiesceAgent: () => events.push('quiesce'), + detachCommittedArtifacts: () => events.push('detach'), + disposeAgent: () => events.push('dispose'), + activatePersistent: () => events.push('activate'), + commitPersistent: () => events.push('commit'), + onFailure: () => events.push('fallback'), + }) + ).toBe(false); + expect(events).toEqual(['fallback']); + } + }); +}); diff --git a/crates/trusted-server-js/lib/test/first_display/transaction.test.ts b/crates/trusted-server-js/lib/test/first_display/transaction.test.ts new file mode 100644 index 000000000..f9b2cb62b --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/transaction.test.ts @@ -0,0 +1,250 @@ +import { JSDOM } from 'jsdom'; +import { describe, expect, it, vi } from 'vitest'; + +import { createFirstDisplayTransaction } from '../../src/shared/first_display_transaction'; + +const RELEASE_ID = 'a'.repeat(64); + +function documentWithScript(): { document: Document; script: HTMLScriptElement } { + const dom = new JSDOM( + '', + { url: 'https://publisher.example/' } + ); + const document = dom.window.document; + const script = document.querySelector('script') as HTMLScriptElement; + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return { document, script }; +} + +function registration( + id: string, + order: number, + activate: (own: (dispose: () => void) => void) => void = () => undefined +): Record { + return { + abi: 1, + id, + releaseId: RELEASE_ID, + generation: 7, + order, + prepare: () => ({ + activate: ({ own }: { own: (dispose: () => void) => void }) => activate(own), + }), + }; +} + +describe('release-private first-display transaction', () => { + it('accepts exact ordered registrations and activates in one synchronous transaction', () => { + const { document, script } = documentWithScript(); + const events: string[] = []; + const transaction = createFirstDisplayTransaction({ + document, + script, + releaseId: RELEASE_ID, + generation: 7, + expectedSliceIds: ['first_display', 'gpt_initial'], + isCurrentGeneration: () => true, + }); + + expect( + transaction.register( + registration('first_display', 1, (own) => { + own(() => events.push('dispose-base')); + events.push('activate-base'); + }) + ) + ).toBe(true); + expect( + transaction.register( + registration('gpt_initial', 8, (own) => { + own(() => events.push('dispose-gpt')); + events.push('activate-gpt'); + }) + ) + ).toBe(true); + expect(transaction.activate()).toBe(true); + expect(events).toEqual(['activate-base', 'activate-gpt']); + transaction.dispose(); + expect(events).toEqual(['activate-base', 'activate-gpt', 'dispose-gpt', 'dispose-base']); + }); + + it('starts the responsible action only after every selected slice has activated', () => { + const { document, script } = documentWithScript(); + const events: string[] = []; + const transaction = createFirstDisplayTransaction({ + document, + script, + releaseId: RELEASE_ID, + generation: 7, + expectedSliceIds: ['first_display', 'gpt_initial'], + isCurrentGeneration: () => true, + }); + transaction.register({ + ...registration('first_display', 1), + prepare: () => ({ + activate: ({ afterActivate }: { afterActivate: (callback: () => void) => void }) => { + events.push('activate-base'); + afterActivate(() => events.push('start-action')); + }, + }), + }); + transaction.register( + registration('gpt_initial', 8, () => { + events.push('activate-gpt'); + }) + ); + + expect(transaction.activate()).toBe(true); + expect(events).toEqual(['activate-base', 'activate-gpt', 'start-action']); + }); + + it('rejects unknown, duplicate, omitted, misordered, late, wrong-release, and accessor registrations', () => { + const make = () => { + const { document, script } = documentWithScript(); + return createFirstDisplayTransaction({ + document, + script, + releaseId: RELEASE_ID, + generation: 7, + expectedSliceIds: ['first_display', 'gpt_initial'], + isCurrentGeneration: () => true, + }); + }; + + expect(make().register(registration('unknown_initial', 1))).toBe(false); + const duplicate = make(); + expect(duplicate.register(registration('first_display', 1))).toBe(true); + expect(duplicate.register(registration('first_display', 1))).toBe(false); + const omitted = make(); + expect(omitted.register(registration('first_display', 1))).toBe(true); + expect(omitted.activate()).toBe(false); + expect(make().register(registration('gpt_initial', 8))).toBe(false); + expect( + make().register({ ...registration('first_display', 1), releaseId: 'b'.repeat(64) }) + ).toBe(false); + const accessor = registration('first_display', 1); + Object.defineProperty(accessor, 'prepare', { enumerable: true, get: vi.fn() }); + expect(make().register(accessor)).toBe(false); + const late = make(); + expect(late.register(registration('first_display', 1))).toBe(true); + expect(late.register(registration('gpt_initial', 8))).toBe(true); + expect(late.activate()).toBe(true); + expect(late.register(registration('gpt_initial', 8))).toBe(false); + }); + + it('authenticates the exact parser-inserted current script and current generation', () => { + const { document, script } = documentWithScript(); + const replaced = document.createElement('script'); + document.head.append(replaced); + const wrongScript = createFirstDisplayTransaction({ + document, + script: replaced, + releaseId: RELEASE_ID, + generation: 7, + expectedSliceIds: ['first_display'], + isCurrentGeneration: () => true, + }); + expect(wrongScript.register(registration('first_display', 1))).toBe(false); + + const stale = createFirstDisplayTransaction({ + document, + script, + releaseId: RELEASE_ID, + generation: 7, + expectedSliceIds: ['first_display'], + isCurrentGeneration: () => false, + }); + expect(stale.register(registration('first_display', 1))).toBe(false); + }); + + it('rolls back every owned effect in reverse order after an activation failure', () => { + const { document, script } = documentWithScript(); + const events: string[] = []; + const transaction = createFirstDisplayTransaction({ + document, + script, + releaseId: RELEASE_ID, + generation: 7, + expectedSliceIds: ['first_display', 'gpt_initial'], + isCurrentGeneration: () => true, + }); + transaction.register( + registration('first_display', 1, (own) => { + own(() => events.push('dispose-a')); + own(() => events.push('dispose-b')); + }) + ); + transaction.register( + registration('gpt_initial', 8, (own) => { + own(() => events.push('dispose-c')); + throw new Error('boom'); + }) + ); + + expect(transaction.activate()).toBe(false); + expect(events).toEqual(['dispose-c', 'dispose-b', 'dispose-a']); + expect(transaction.state).toBe('failed'); + }); + + it('cannot continue activation or resurrect after a slice disposes reentrantly', () => { + const { document, script } = documentWithScript(); + const events: string[] = []; + const transaction = createFirstDisplayTransaction({ + document, + script, + releaseId: RELEASE_ID, + generation: 7, + expectedSliceIds: ['first_display', 'gpt_initial'], + isCurrentGeneration: () => true, + }); + transaction.register( + registration('first_display', 1, (own) => { + own(() => events.push('dispose-base')); + events.push('activate-base'); + transaction.dispose(); + }) + ); + transaction.register( + registration('gpt_initial', 8, () => { + events.push('activate-gpt'); + }) + ); + + expect(transaction.activate()).toBe(false); + expect(transaction.state).toBe('disposed'); + expect(events).toEqual(['activate-base', 'dispose-base']); + }); + + it('removes each disposer before invoking it during recursive rollback', () => { + const { document, script } = documentWithScript(); + const events: string[] = []; + const transaction = createFirstDisplayTransaction({ + document, + script, + releaseId: RELEASE_ID, + generation: 7, + expectedSliceIds: ['first_display', 'gpt_initial'], + isCurrentGeneration: () => true, + }); + transaction.register( + registration('first_display', 1, (own) => { + own(() => events.push('dispose-a')); + own(() => { + events.push('dispose-b'); + transaction.dispose(); + }); + }) + ); + transaction.register( + registration('gpt_initial', 8, () => { + throw new Error('boom'); + }) + ); + + expect(transaction.activate()).toBe(false); + expect(transaction.state).toBe('disposed'); + expect(events).toEqual(['dispose-b', 'dispose-a']); + }); +}); diff --git a/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json b/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json new file mode 100644 index 000000000..b782f99f4 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json @@ -0,0 +1,511 @@ +{ + "schemaVersion": 1, + "publisherOrigin": "https://publisher.example", + "baseDescriptor": { + "type": "aps", + "version": 1, + "accountId": "example-account-id", + "bidId": "fictional-selected-bid-id", + "creativeId": "fictional-creative-id", + "tagType": "iframe", + "creativeUrl": "https://creative.example/render", + "width": 300, + "height": 250 + }, + "vectors": [ + { + "id": "valid-complete", + "expected": "accepted", + "operation": { + "kind": "none" + } + }, + { + "id": "valid-without-optional-creative-id", + "expected": "accepted", + "operation": { + "kind": "descriptor-delete", + "field": "creativeId" + } + }, + { + "id": "missing-required-account-id", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-delete", + "field": "accountId" + } + }, + { + "id": "unknown-descriptor-key", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "adm", + "value": "
forbidden
" + } + }, + { + "id": "account-id-utf8-byte-limit", + "expected": "accepted", + "operation": { + "kind": "descriptor-repeat", + "field": "accountId", + "unit": "é", + "count": 512 + } + }, + { + "id": "account-id-utf8-byte-over-limit", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-repeat", + "field": "accountId", + "unit": "é", + "count": 512, + "suffix": "x" + } + }, + { + "id": "creative-id-utf8-byte-limit", + "expected": "accepted", + "operation": { + "kind": "descriptor-repeat", + "field": "creativeId", + "unit": "é", + "count": 512 + } + }, + { + "id": "creative-id-utf8-byte-over-limit", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-repeat", + "field": "creativeId", + "unit": "é", + "count": 512, + "suffix": "x" + } + }, + { + "id": "bid-id-utf8-byte-limit", + "expected": "accepted", + "operation": { + "kind": "bid-id-repeat", + "unit": "é", + "count": 32 + } + }, + { + "id": "bid-id-utf8-byte-over-limit", + "expected": "descriptor_invalid", + "operation": { + "kind": "bid-id-repeat", + "unit": "é", + "count": 32, + "suffix": "x" + } + }, + { + "id": "bid-id-nul", + "expected": "descriptor_invalid", + "operation": { + "kind": "bid-id-repeat", + "unit": "bid\u0000id", + "count": 1 + } + }, + { + "id": "bid-id-ascii-control", + "expected": "descriptor_invalid", + "operation": { + "kind": "bid-id-repeat", + "unit": "bid\n-id", + "count": 1 + } + }, + { + "id": "width-zero", + "expected": "invalid_dimensions", + "operation": { + "kind": "dimension", + "field": "width", + "value": 0 + } + }, + { + "id": "height-negative", + "expected": "invalid_dimensions", + "operation": { + "kind": "dimension", + "field": "height", + "value": -1 + } + }, + { + "id": "width-fractional", + "expected": "invalid_dimensions", + "operation": { + "kind": "dimension", + "field": "width", + "value": 1.5 + } + }, + { + "id": "height-wrong-type", + "expected": "invalid_dimensions", + "operation": { + "kind": "dimension", + "field": "height", + "value": "250" + } + }, + { + "id": "dimensions-minimum", + "expected": "accepted", + "operation": { + "kind": "dimensions", + "width": 1, + "height": 1 + } + }, + { + "id": "dimensions-maximum", + "expected": "accepted", + "operation": { + "kind": "dimensions", + "width": 4096, + "height": 4096 + } + }, + { + "id": "width-over-maximum", + "expected": "dimensions_out_of_range", + "operation": { + "kind": "dimension", + "field": "width", + "value": 4097 + } + }, + { + "id": "height-over-maximum", + "expected": "dimensions_out_of_range", + "operation": { + "kind": "dimension", + "field": "height", + "value": 4097 + } + }, + { + "id": "creative-url-http", + "expected": "descriptor_invalid", + "operation": { + "kind": "creative-url", + "value": "http://creative.example/render" + } + }, + { + "id": "creative-url-credentials", + "expected": "descriptor_invalid", + "operation": { + "kind": "creative-url", + "value": "https://user:password@creative.example/render" + } + }, + { + "id": "creative-url-publisher-origin", + "expected": "descriptor_invalid", + "operation": { + "kind": "creative-url", + "value": "https://publisher.example/render" + } + }, + { + "id": "creative-url-byte-limit", + "expected": "accepted", + "operation": { + "kind": "creative-url-bytes", + "bytes": 4096 + } + }, + { + "id": "creative-url-byte-over-limit", + "expected": "descriptor_invalid", + "operation": { + "kind": "creative-url-bytes", + "bytes": 4097 + } + }, + { + "id": "aax-empty", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-literal", + "value": "" + } + }, + { + "id": "aax-invalid-alphabet", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-literal", + "value": "not-base64" + } + }, + { + "id": "aax-missing-padding", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-literal", + "value": "e30" + } + }, + { + "id": "aax-noncanonical-trailing-bits", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-literal", + "value": "Zh==" + } + }, + { + "id": "aax-invalid-utf8", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-bytes", + "values": [195, 40] + } + }, + { + "id": "aax-malformed-json", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-raw-json", + "value": "{not json}" + } + }, + { + "id": "aax-decoded-byte-limit", + "expected": "accepted", + "operation": { + "kind": "aax-decoded-bytes", + "bytes": 262144 + } + }, + { + "id": "aax-decoded-byte-over-limit", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-decoded-bytes", + "bytes": 262145 + } + }, + { + "id": "envelope-unknown-root-key", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["forbidden"], + "value": true + } + }, + { + "id": "envelope-missing-seatbid", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-delete", + "path": ["seatbid"] + } + }, + { + "id": "envelope-zero-seats", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid"], + "value": [] + } + }, + { + "id": "envelope-two-seats", + "expected": "descriptor_invalid", + "operation": { + "kind": "duplicate-seat" + } + }, + { + "id": "envelope-unknown-seat-key", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid", 0, "seat"], + "value": "forbidden" + } + }, + { + "id": "envelope-missing-bid-array", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-delete", + "path": ["seatbid", 0, "bid"] + } + }, + { + "id": "envelope-zero-bids", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid", 0, "bid"], + "value": [] + } + }, + { + "id": "envelope-two-bids", + "expected": "descriptor_invalid", + "operation": { + "kind": "duplicate-bid" + } + }, + { + "id": "envelope-unknown-bid-key", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid", 0, "bid", 0, "adm"], + "value": "
forbidden
" + } + }, + { + "id": "envelope-missing-ext", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-delete", + "path": ["seatbid", 0, "bid", 0, "ext"] + } + }, + { + "id": "envelope-unknown-ext-key", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid", 0, "bid", 0, "ext", "forbidden"], + "value": true + } + }, + { + "id": "envelope-missing-tagtype", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-delete", + "path": ["seatbid", 0, "bid", 0, "ext", "tagtype"] + } + }, + { + "id": "bid-id-disagreement", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "bidId", + "value": "different-bid-id" + } + }, + { + "id": "width-disagreement", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "width", + "value": 728 + } + }, + { + "id": "height-disagreement", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "height", + "value": 90 + } + }, + { + "id": "creative-url-disagreement", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "creativeUrl", + "value": "https://different.example/render" + } + }, + { + "id": "tag-type-disagreement", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "tagType", + "value": "script" + } + }, + { + "id": "price-negative", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid", 0, "bid", 0, "price"], + "value": -0.01 + } + }, + { + "id": "price-wrong-type", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid", 0, "bid", 0, "price"], + "value": "1.23" + } + }, + { + "id": "price-nonfinite", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-raw-price", + "value": "1e400" + } + }, + { + "id": "unknown-descriptor-type", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "type", + "value": "renderer" + } + }, + { + "id": "equivalent-decimal-version", + "expected": "accepted", + "operation": { + "kind": "descriptor-set", + "field": "version", + "value": 1.0 + } + }, + { + "id": "unknown-descriptor-version", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "version", + "value": 2 + } + }, + { + "id": "unknown-tag-type", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "tagType", + "value": "video" + } + } + ] +} diff --git a/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json b/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json new file mode 100644 index 000000000..4fe6c0445 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://iabtechlab.com/trusted-server/aps-renderer-v1.schema.json", + "$comment": "The x-* semantic markers are documentation only; scripts/generate-aps-renderer-contract.mjs hard-codes these checks and does not read marker values, so editing a marker does not change enforcement.", + "title": "Trusted Server APS renderer descriptor version 1", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "version", + "accountId", + "bidId", + "tagType", + "creativeUrl", + "width", + "height", + "aaxResponse" + ], + "properties": { + "type": { + "const": "aps" + }, + "version": { + "const": 1 + }, + "accountId": { + "type": "string", + "minLength": 1, + "x-utf8MaxBytes": 1024 + }, + "bidId": { + "type": "string", + "minLength": 1, + "x-utf8MaxBytes": 64, + "x-forbidNulAndAsciiControl": true + }, + "creativeId": { + "type": "string", + "minLength": 1, + "x-utf8MaxBytes": 1024 + }, + "tagType": { + "enum": ["iframe", "script"] + }, + "creativeUrl": { + "type": "string", + "format": "uri", + "x-utf8MaxBytes": 4096, + "x-requiredScheme": "https", + "x-forbidCredentials": true, + "x-forbidPublisherOrigin": true + }, + "width": { + "type": "integer", + "minimum": 1, + "maximum": 4096 + }, + "height": { + "type": "integer", + "minimum": 1, + "maximum": 4096 + }, + "aaxResponse": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", + "x-canonicalStandardBase64": true, + "x-decodedMaxBytes": 262144 + } + }, + "x-envelope": { + "rootKeys": ["seatbid"], + "seatCount": 1, + "seatKeys": ["bid"], + "bidCount": 1, + "bidKeys": ["ext", "h", "id", "price", "w"], + "extKeys": ["creativeurl", "tagtype"], + "price": { + "finite": true, + "minimum": 0 + }, + "duplicatedFields": { + "bidId": ["seatbid", 0, "bid", 0, "id"], + "width": ["seatbid", 0, "bid", 0, "w"], + "height": ["seatbid", 0, "bid", 0, "h"], + "creativeUrl": ["seatbid", 0, "bid", 0, "ext", "creativeurl"], + "tagType": ["seatbid", 0, "bid", 0, "ext", "tagtype"] + } + } +} diff --git a/crates/trusted-server-js/lib/test/fixtures/contracts/current-main-concept-audit.json b/crates/trusted-server-js/lib/test/fixtures/contracts/current-main-concept-audit.json new file mode 100644 index 000000000..ca74daef0 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/contracts/current-main-concept-audit.json @@ -0,0 +1,581 @@ +{ + "version": 2, + "historicalMain": { + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "rows": [ + { + "id": "RCJ-CORE-01", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/src/core/auction.ts", + "crates/trusted-server-js/lib/src/core/render.ts", + "crates/trusted-server-js/lib/src/core/request.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/core/auction.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/core/auction.test.ts test/core/request.test.ts test/core/render.test.ts", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-CORE-02", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/src/core/config.ts", + "crates/trusted-server-js/lib/src/core/index.ts", + "crates/trusted-server-js/lib/src/core/log.ts", + "crates/trusted-server-js/lib/src/core/registry.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/core/index.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/core/index.test.ts test/core/config.test.ts test/core/registry.test.ts", + "result": "pass", + "disposition": "supersede" + }, + { + "id": "RCJ-BOOT-01", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": [ + "crates/trusted-server-core/src/html_processor.rs", + "crates/trusted-server-js/lib/src/integrations/gpt/index.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/gpt_bootstrap.test.ts test/integrations/gpt/schedule_initial_ad_init.test.ts", + "result": "pass", + "disposition": "supersede" + }, + { + "id": "RCJ-TRACE-01", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "implementation-gap", + "ownerPaths": ["crates/trusted-server-js/lib/src/core/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "command": "node --test --test-name-pattern='RCJ-TRACE-01' crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "result": "fail", + "disposition": "supersede" + }, + { + "id": "RCJ-GPT-01", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/ad_init.test.ts", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-GPT-02", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/ad_init.test.ts", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-GPT-03", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/index.test.ts", + "result": "pass", + "disposition": "preserve" + }, + { + "id": "RCJ-GPT-04", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "implementation-gap", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "command": "node --test --test-name-pattern='RCJ-GPT-04' crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "result": "fail", + "disposition": "rebuild" + }, + { + "id": "RCJ-PREBID-01", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/build-prebid-external.mjs", + "crates/trusted-server-js/lib/src/integrations/prebid/index.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/build-prebid-external.test.mjs test/prebid-artifact-integration.test.mjs", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-PREBID-02", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/prebid/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid/index.test.ts", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-PREBID-03", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/build-prebid-external.mjs", + "crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid/user_id_modules.test.ts", + "result": "pass", + "disposition": "preserve" + }, + { + "id": "RCJ-PREBID-04", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/prebid/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid/index.test.ts", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-APS-01", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": [ + "crates/trusted-server-core/src/integrations/aps.rs", + "crates/trusted-server-js/lib/src/integrations/aps/render.ts" + ], + "testPath": "crates/trusted-server-core/src/integrations/aps.rs", + "command": "cargo test-fastly integrations::aps::tests", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-APS-02", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/src/integrations/aps/render.ts", + "crates/trusted-server-js/lib/src/integrations/gpt/index.ts", + "crates/trusted-server-js/lib/src/integrations/prebid/index.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/ad_init.test.ts", + "result": "pass", + "disposition": "supersede" + }, + { + "id": "RCJ-APS-03", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "implementation-gap", + "ownerPaths": [ + "crates/trusted-server-core/src/integrations/aps.rs", + "crates/trusted-server-js/lib/src/integrations/aps/render.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "command": "node --test --test-name-pattern='RCJ-APS-03' crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "result": "fail", + "disposition": "rebuild" + }, + { + "id": "RCJ-APS-04", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "implementation-gap", + "ownerPaths": [ + "crates/trusted-server-core/src/integrations/aps.rs", + "crates/trusted-server-js/lib/src/integrations/aps/render.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "command": "node --test --test-name-pattern='RCJ-APS-04' crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "result": "fail", + "disposition": "preserve" + }, + { + "id": "RCJ-CREATIVE-01", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": ["crates/trusted-server-core/src/publisher.rs"], + "testPath": "crates/trusted-server-core/src/publisher.rs", + "command": "cargo test-fastly build_bid_map_", + "result": "pass", + "disposition": "preserve" + }, + { + "id": "RCJ-CREATIVE-02", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/src/integrations/creative/click.ts", + "crates/trusted-server-js/lib/src/shared/origin.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/integrations/creative/click.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/creative/click.test.ts test/integrations/creative/image.test.ts test/integrations/creative/iframe.test.ts", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-CREATIVE-03", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/creative/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/creative/image.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/creative/click.test.ts test/integrations/creative/image.test.ts test/integrations/creative/iframe.test.ts", + "result": "pass", + "disposition": "supersede" + }, + { + "id": "RCJ-DIAG-01", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt_diagnostics", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-INT-01", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": [ + "crates/trusted-server-core/src/integrations/testlight.rs", + "crates/trusted-server-js/lib/src/integrations/datadome/script_guard.ts", + "crates/trusted-server-js/lib/src/integrations/didomi/index.ts", + "crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts", + "crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts", + "crates/trusted-server-js/lib/src/integrations/osano/index.ts", + "crates/trusted-server-js/lib/src/integrations/permutive/segments.ts", + "crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts", + "command": "cargo test-fastly integrations::testlight && npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/datadome/script_guard.test.ts test/integrations/didomi/index.test.ts test/integrations/google_tag_manager/script_guard.test.ts test/integrations/lockr/script_guard.test.ts test/integrations/osano/index.test.ts test/integrations/permutive/segments.test.ts test/integrations/sourcepoint/index.test.ts", + "result": "pass", + "disposition": "preserve" + }, + { + "id": "RCJ-INT-02", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "main-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/src/shared/async.ts", + "crates/trusted-server-js/lib/src/shared/beacon_guard.ts", + "crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts", + "crates/trusted-server-js/lib/src/shared/origin.ts", + "crates/trusted-server-js/lib/src/shared/scheduler.ts", + "crates/trusted-server-js/lib/src/shared/script_guard.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/shared", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-QUAL-01", + "mainSha": "f6a2fb85ce623bf8a574e3941e1ee349acc3412d", + "classification": "implementation-gap", + "ownerPaths": [ + "crates/trusted-server-js/lib/eslint.config.js", + "crates/trusted-server-js/lib/package.json", + "crates/trusted-server-js/lib/vitest.config.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "command": "node --test --test-name-pattern='RCJ-QUAL-01' crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "result": "fail", + "disposition": "preserve" + } + ] + }, + "rcBaseline": { + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "rows": [ + { + "id": "RCJ-CORE-01", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/src/core/auction.ts", + "crates/trusted-server-js/lib/src/core/render.ts", + "crates/trusted-server-js/lib/src/core/request.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/core/auction.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/core/auction.test.ts test/core/request.test.ts test/core/render.test.ts", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-CORE-02", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/src/core/config.ts", + "crates/trusted-server-js/lib/src/core/index.ts", + "crates/trusted-server-js/lib/src/core/log.ts", + "crates/trusted-server-js/lib/src/core/registry.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/core/index.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/core/index.test.ts test/core/config.test.ts test/core/registry.test.ts", + "result": "pass", + "disposition": "supersede" + }, + { + "id": "RCJ-BOOT-01", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": [ + "crates/trusted-server-core/src/html_processor.rs", + "crates/trusted-server-js/lib/src/integrations/gpt/index.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/gpt_bootstrap.test.ts test/integrations/gpt/schedule_initial_ad_init.test.ts", + "result": "pass", + "disposition": "supersede" + }, + { + "id": "RCJ-TRACE-01", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "implementation-gap", + "ownerPaths": ["crates/trusted-server-js/lib/src/core/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "command": "TSJS_CONCEPT_PROOF_LIB_ROOT=\"$TSJS_RC_BASELINE_ROOT/crates/trusted-server-js/lib\" node --test --test-name-pattern='RCJ-TRACE-01' crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "result": "fail", + "disposition": "supersede" + }, + { + "id": "RCJ-GPT-01", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/ad_init.test.ts", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-GPT-02", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/ad_init.test.ts", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-GPT-03", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/index.test.ts", + "result": "pass", + "disposition": "preserve" + }, + { + "id": "RCJ-GPT-04", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "command": "TSJS_CONCEPT_PROOF_LIB_ROOT=\"$TSJS_RC_BASELINE_ROOT/crates/trusted-server-js/lib\" node --test --test-name-pattern='RCJ-GPT-04' crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-PREBID-01", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/build-prebid-external.mjs", + "crates/trusted-server-js/lib/src/integrations/prebid/index.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/build-prebid-external.test.mjs test/prebid-artifact-integration.test.mjs", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-PREBID-02", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/prebid/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid/index.test.ts", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-PREBID-03", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/build-prebid-external.mjs", + "crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid/user_id_modules.test.ts", + "result": "pass", + "disposition": "preserve" + }, + { + "id": "RCJ-PREBID-04", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/prebid/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid/index.test.ts", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-APS-01", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": [ + "crates/trusted-server-core/src/integrations/aps.rs", + "crates/trusted-server-js/lib/src/integrations/aps/render.ts" + ], + "testPath": "crates/trusted-server-core/src/integrations/aps.rs", + "command": "cargo test --package trusted-server-core --target \"$(rustc -vV | sed -n 's/^host: //p')\" integrations::aps::tests", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-APS-02", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/src/integrations/aps/render.ts", + "crates/trusted-server-js/lib/src/integrations/gpt/index.ts", + "crates/trusted-server-js/lib/src/integrations/prebid/index.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/ad_init.test.ts", + "result": "pass", + "disposition": "supersede" + }, + { + "id": "RCJ-APS-03", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "implementation-gap", + "ownerPaths": [ + "crates/trusted-server-core/src/integrations/aps.rs", + "crates/trusted-server-js/lib/src/integrations/aps/render.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "command": "TSJS_CONCEPT_PROOF_LIB_ROOT=\"$TSJS_RC_BASELINE_ROOT/crates/trusted-server-js/lib\" node --test --test-name-pattern='RCJ-APS-03' crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "result": "fail", + "disposition": "rebuild" + }, + { + "id": "RCJ-APS-04", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "implementation-gap", + "ownerPaths": [ + "crates/trusted-server-core/src/integrations/aps.rs", + "crates/trusted-server-js/lib/src/integrations/aps/render.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "command": "TSJS_CONCEPT_PROOF_LIB_ROOT=\"$TSJS_RC_BASELINE_ROOT/crates/trusted-server-js/lib\" node --test --test-name-pattern='RCJ-APS-04' crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "result": "fail", + "disposition": "preserve" + }, + { + "id": "RCJ-CREATIVE-01", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": ["crates/trusted-server-core/src/publisher.rs"], + "testPath": "crates/trusted-server-core/src/publisher.rs", + "command": "cargo test --package trusted-server-core --target \"$(rustc -vV | sed -n 's/^host: //p')\" build_bid_map_", + "result": "pass", + "disposition": "preserve" + }, + { + "id": "RCJ-CREATIVE-02", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/src/integrations/creative/click.ts", + "crates/trusted-server-js/lib/src/shared/origin.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/integrations/creative/click.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/creative/click.test.ts test/integrations/creative/image.test.ts test/integrations/creative/iframe.test.ts", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-CREATIVE-03", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/creative/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/creative/image.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/creative/click.test.ts test/integrations/creative/image.test.ts test/integrations/creative/iframe.test.ts", + "result": "pass", + "disposition": "supersede" + }, + { + "id": "RCJ-DIAG-01", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts"], + "testPath": "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt_diagnostics", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-INT-01", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": [ + "crates/trusted-server-core/src/integrations/testlight.rs", + "crates/trusted-server-js/lib/src/integrations/datadome/script_guard.ts", + "crates/trusted-server-js/lib/src/integrations/didomi/index.ts", + "crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts", + "crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts", + "crates/trusted-server-js/lib/src/integrations/osano/index.ts", + "crates/trusted-server-js/lib/src/integrations/permutive/segments.ts", + "crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts", + "command": "cargo test --package trusted-server-core --target \"$(rustc -vV | sed -n 's/^host: //p')\" integrations::testlight && npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/datadome/script_guard.test.ts test/integrations/didomi/index.test.ts test/integrations/google_tag_manager/script_guard.test.ts test/integrations/lockr/script_guard.test.ts test/integrations/osano/index.test.ts test/integrations/permutive/segments.test.ts test/integrations/sourcepoint/index.test.ts", + "result": "pass", + "disposition": "preserve" + }, + { + "id": "RCJ-INT-02", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "baseline-owned", + "ownerPaths": [ + "crates/trusted-server-js/lib/src/shared/async.ts", + "crates/trusted-server-js/lib/src/shared/beacon_guard.ts", + "crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts", + "crates/trusted-server-js/lib/src/shared/origin.ts", + "crates/trusted-server-js/lib/src/shared/scheduler.ts", + "crates/trusted-server-js/lib/src/shared/script_guard.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts", + "command": "npm --prefix crates/trusted-server-js/lib test -- --run test/shared", + "result": "pass", + "disposition": "rebuild" + }, + { + "id": "RCJ-QUAL-01", + "baselineSha": "07dfc1c6dddf69345ded17bd2d40a3d01bb39bcf", + "classification": "implementation-gap", + "ownerPaths": [ + "crates/trusted-server-js/lib/eslint.config.js", + "crates/trusted-server-js/lib/package.json", + "crates/trusted-server-js/lib/vitest.config.ts" + ], + "testPath": "crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "command": "TSJS_CONCEPT_PROOF_LIB_ROOT=\"$TSJS_RC_BASELINE_ROOT/crates/trusted-server-js/lib\" node --test --test-name-pattern='RCJ-QUAL-01' crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", + "result": "fail", + "disposition": "preserve" + } + ] + } +} diff --git a/crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json b/crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json new file mode 100644 index 000000000..418c2cfa2 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json @@ -0,0 +1,39 @@ +[ + { "name": "negative zero", "input": "{\"n\":-0.0}", "expected": "{\"n\":0}" }, + { "name": "integral float", "input": "{\"n\":1.0}", "expected": "{\"n\":1}" }, + { + "name": "upper decimal threshold", + "input": "{\"n\":1e20}", + "expected": "{\"n\":100000000000000000000}" + }, + { + "name": "upper exponent threshold", + "input": "{\"n\":1e21}", + "expected": "{\"n\":1e+21}" + }, + { + "name": "lower decimal threshold", + "input": "{\"n\":1e-6}", + "expected": "{\"n\":0.000001}" + }, + { + "name": "lower exponent threshold", + "input": "{\"n\":1e-7}", + "expected": "{\"n\":1e-7}" + }, + { + "name": "binary64 integer rounding", + "input": "{\"n\":9007199254740993}", + "expected": "{\"n\":9007199254740992}" + }, + { + "name": "integer index enumeration", + "input": "{\"a\":1,\"10\":10,\"2\":2,\"01\":1}", + "expected": "{\"2\":2,\"10\":10,\"a\":1,\"01\":1}" + }, + { + "name": "nested number and key ordering", + "input": "{\"values\":[-0.0,1e20,1e-7],\"3\":{\"20\":20,\"4\":4}}", + "expected": "{\"3\":{\"4\":4,\"20\":20},\"values\":[0,100000000000000000000,1e-7]}" + } +] diff --git a/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json b/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json new file mode 100644 index 000000000..a67a96c5d --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json @@ -0,0 +1,2221 @@ +{ + "schemaVersion": 1, + "mode": "baseline", + "source": { + "ref": "spec/aps-tsjs-resilience-design", + "sha": "88f1432e33f310a202177feb656eba21ff0173de" + }, + "environment": { + "node": "v24.12.0", + "npm": "11.6.2", + "typescript": "5.9.3", + "chromium": "145.0.7632.6", + "ciMachineClass": "github-hosted:ubuntu-24.04", + "fixture": "tsjs-core-placeholder-v1" + }, + "sampling": { + "warmups": 5, + "samples": 50, + "percentile": 90 + }, + "bundles": { + "minimal": { + "files": [ + "tsjs-core.js" + ], + "rawBytes": 23317, + "gzipBytes": 8687, + "brotliBytes": 7686, + "sha256": "1e027cfb238cb6eed090b7addcdba5042059737cb319fbdef1b50e286689b851" + }, + "reference": { + "files": [ + "tsjs-core.js", + "tsjs-creative.js", + "tsjs-gpt.js", + "tsjs-prebid.js", + "tsjs-datadome.js" + ], + "rawBytes": 113756, + "gzipBytes": 35163, + "brotliBytes": 26051, + "sha256": "232b734406d9baec8f244d5ab1501535d0296d9f1e0d87e30fc9e30b6c96d204" + }, + "maximal": { + "files": [ + "tsjs-core.js", + "tsjs-creative.js", + "tsjs-datadome.js", + "tsjs-didomi.js", + "tsjs-google_tag_manager.js", + "tsjs-gpt.js", + "tsjs-gpt_diagnostics.js", + "tsjs-lockr.js", + "tsjs-osano.js", + "tsjs-permutive.js", + "tsjs-prebid.js", + "tsjs-sourcepoint.js", + "tsjs-testlight.js" + ], + "rawBytes": 187224, + "gzipBytes": 53799, + "brotliBytes": 37790, + "sha256": "ce5fa29ba8914ad7074221ae777b12c0f496c00b19c42171e42a3d6005c378d3" + } + }, + "performance": { + "bootToFirstDisplayMs": { + "samples": [ + 23, + 24.099999999976717, + 23.20000000001164, + 25.100000000034925, + 26.699999999953434, + 25.20000000001164, + 24.79999999998836, + 22.900000000023283, + 23.79999999998836, + 26.899999999965075, + 26, + 26.300000000046566, + 22.20000000001164, + 25.29999999998836, + 24.100000000034925, + 23.099999999976717, + 25, + 25.70000000001164, + 24.70000000001164, + 23.599999999976717, + 24.199999999953434, + 25.400000000023283, + 26.70000000001164, + 24.20000000001164, + 24.5, + 23.5, + 24.79999999998836, + 23.79999999998836, + 26, + 25.5, + 22.599999999976717, + 24.70000000001164, + 24.79999999998836, + 24.400000000023283, + 25, + 24.899999999965075, + 23.5, + 23.400000000023283, + 24.099999999976717, + 24.79999999998836, + 23.29999999998836, + 24, + 23.5, + 27.20000000001164, + 25.899999999965075, + 23.5, + 24.20000000001164, + 22.5, + 23.599999999976717, + 25 + ], + "p90": 26 + }, + "retainedHeapBytes": { + "afterBoot": 1208816, + "afterFirstRender": 1212016, + "afterRefresh": 1212016, + "afterSpaNavigation": 1219472 + } + }, + "evidence": { + "evidenceId": "aps-tsjs-baseline-88f1432e33f310a202177feb656eba21ff0173de", + "workflowRunId": 31074816129 + }, + "roleCorrectTransfer": { + "schemaVersion": 1, + "source": { + "ref": "spec/aps-tsjs-resilience-design", + "sha": "4e2c307923b838716d95e2feeebb994a37bb8025" + }, + "originalTopLevelSha256": "53f762603ad49239f1756171440be422e190cc231efafc56cf37a11e1a38ddf4", + "tools": { + "node": "v24.12.0", + "npm": "11.6.2", + "typescript": "6.0.3", + "vite": "8.2.1", + "esbuild": "0.28.1", + "packageLockSha256": "083ef76165b0518a3157d56dc4fd2697c8ab67ebf43efad1e9f9b06c2edafad5" + }, + "compression": { + "concatenationSeparator": ";\n", + "gzip": { + "implementation": "node:zlib.gzipSync", + "version": "zlib 1.3.1-470d3a2", + "level": 9, + "mtime": 0 + }, + "brotli": { + "implementation": "node:zlib.brotliCompressSync", + "version": "brotli 1.1.0", + "mode": "text", + "quality": 11, + "sizeHint": "input-bytes" + } + }, + "release": { + "version": 1, + "releaseId": "adb06ad34897d0601c1119bf4f193c8175ab32cfe24a718fdc1da89f081c1104", + "artifacts": [ + { + "id": "bootstrap", + "role": "bootstrap", + "phase": null, + "trigger": null, + "inputs": [], + "outputs": [], + "file": "gpt-bootstrap-fallback.js", + "bytes": 42399, + "hash": "6527456d289006e48ed527d897992b0cc0598498f09ca6cacec743ddb4d4915a" + }, + { + "id": "core", + "role": "core", + "phase": null, + "trigger": null, + "inputs": [], + "outputs": [ + "runtime.v1" + ], + "file": "tsjs-core.js", + "bytes": 85581, + "hash": "da8246724c005eb9db51e3c610f5de712583b706b11eea54257a5fe119944515" + }, + { + "id": "render_runtime", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [ + "slots.v1", + "auction.v1", + "render.v1", + "messages.v1", + "trace.v1", + "trace.presentation.v1", + "direct.v1" + ], + "file": "tsjs-render_runtime.js", + "bytes": 166996, + "hash": "77a0a1510a35eb77012fffb8f75c7268d05b7e9743f89b532a254484dbfd90d7" + }, + { + "id": "aps", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1", + "slots.v1", + "render.v1", + "messages.v1", + "trace.v1" + ], + "outputs": [ + "aps.v1" + ], + "file": "tsjs-aps.js", + "bytes": 16782, + "hash": "b389cb85572c2a20c36112e079e67558eb2931ff15a2ae44d07f621c2b9c85e1" + }, + { + "id": "creative", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [], + "file": "tsjs-creative.js", + "bytes": 22226, + "hash": "41d3b46e1a95400ba23c7c77d82aacc9d34d43a81ba9d2b1cc80313a49465a6d" + }, + { + "id": "datadome", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [], + "file": "tsjs-datadome.js", + "bytes": 23375, + "hash": "f847c3264415bdbd3d7a87a0c1254b7c7c7895dcad62ac61ef94728485e87d3c" + }, + { + "id": "didomi", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [], + "file": "tsjs-didomi.js", + "bytes": 15489, + "hash": "87dbbb6e7d7ba3f65497eaacca1a38c31b8b4603d15cc8f53ec42b0eb7673763" + }, + { + "id": "google_tag_manager", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [], + "file": "tsjs-google_tag_manager.js", + "bytes": 26343, + "hash": "07eeb0087257f17ad219527af1d76d5726452dc2856022e2ee98fbed97a609fc" + }, + { + "id": "gpt", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1", + "slots.v1", + "auction.v1", + "render.v1", + "messages.v1", + "trace.v1" + ], + "outputs": [ + "gpt.v1", + "gpt.events.v1", + "pbs_cache.baseline.v1" + ], + "file": "tsjs-gpt.js", + "bytes": 180256, + "hash": "9572f7fd8cdbca00e562591a0306de1a914e1394bbadf384eb08a64ee446a064" + }, + { + "id": "gpt_diagnostics", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1", + "gpt.events.v1" + ], + "outputs": [ + "gpt_diag.v1" + ], + "file": "tsjs-gpt_diagnostics.js", + "bytes": 29975, + "hash": "dc5e1946fd5badd0ad6554bf38cf916e37e7ec30403450a6f89e8f2cdc8785db" + }, + { + "id": "lockr", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [], + "file": "tsjs-lockr.js", + "bytes": 24031, + "hash": "9c78aca92b0e372dc31d714f71d87b0563ffa06a87c292b8899aadbb23f33f1e" + }, + { + "id": "osano_consent", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [ + "osano_consent.v1" + ], + "file": "tsjs-osano_consent.js", + "bytes": 10511, + "hash": "f72879b5f40d8929a5d82f78d37be4304ddbf1616cde777e501b47b81381940c" + }, + { + "id": "permutive_context", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [ + "permutive_context.v1" + ], + "file": "tsjs-permutive_context.js", + "bytes": 17818, + "hash": "55f074193ae0c2250d6a87d0caa4fae7fd927c328b8bb48693814020a93af29c" + }, + { + "id": "sourcepoint_consent", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [ + "sourcepoint_consent.v1" + ], + "file": "tsjs-sourcepoint_consent.js", + "bytes": 18789, + "hash": "2d00eb50fd7996004148778cd42a2bd2f12c37d6d5a8593bacf0dba4d7e9c88a" + }, + { + "id": "prebid", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1", + "slots.v1", + "render.v1", + "messages.v1", + "aps.v1?aps" + ], + "outputs": [ + "prebid.v1" + ], + "file": "tsjs-prebid.js", + "bytes": 36716, + "hash": "d1a7cf9b9a864711b4e1f01e25a088b388bd02a33227b88230a6b0e43c358846" + }, + { + "id": "testlight", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [], + "file": "tsjs-testlight.js", + "bytes": 16232, + "hash": "53b8915eb544313ee41a84a096c1e1d7a7a5475cf5165cef6fc0ee238f15d752" + }, + { + "id": "diagnostics_presentation", + "role": "integration", + "phase": "deferred", + "trigger": "first_display_or_idle", + "inputs": [ + "runtime.v1", + "trace.presentation.v1", + "gpt_diag.v1?gpt_diagnostics_active" + ], + "outputs": [], + "file": "tsjs-diagnostics_presentation.js", + "bytes": 39944, + "hash": "15b4c83f7baf222bfac34ed02f1b85e69dff58c0004b53ce80e7a36325a490d2" + }, + { + "id": "gpt_later", + "role": "integration", + "phase": "deferred", + "trigger": "first_display_or_idle", + "inputs": [ + "runtime.v1", + "slots.v1", + "auction.v1", + "render.v1", + "gpt.v1", + "trace.v1" + ], + "outputs": [], + "file": "tsjs-gpt_later.js", + "bytes": 5420, + "hash": "7c870dd3c623f2fd468327fbcdc79dd3a0ab3a99001bfb0d99542dc24a7f3af6" + }, + { + "id": "osano_lifecycle", + "role": "integration", + "phase": "deferred", + "trigger": "first_display_or_idle", + "inputs": [ + "runtime.v1", + "osano_consent.v1" + ], + "outputs": [], + "file": "tsjs-osano_lifecycle.js", + "bytes": 3663, + "hash": "38fb9af941cfbe6b1808144277f05f360b0f9278c87220045534b031fa30b845" + }, + { + "id": "permutive_lifecycle", + "role": "integration", + "phase": "deferred", + "trigger": "first_display_or_idle", + "inputs": [ + "runtime.v1", + "permutive_context.v1" + ], + "outputs": [], + "file": "tsjs-permutive_lifecycle.js", + "bytes": 3675, + "hash": "7182fe24f7d6cee7e7c0621ac30eee069fe959bdc64d0c3d381b5e9f89d73740" + }, + { + "id": "prebid_later", + "role": "integration", + "phase": "deferred", + "trigger": "first_display_or_idle", + "inputs": [ + "runtime.v1", + "slots.v1", + "gpt.v1", + "prebid.v1" + ], + "outputs": [], + "file": "tsjs-prebid_later.js", + "bytes": 11160, + "hash": "c2d8695d1a2e26039d760f86566840b167a2cd899783528d7d9dfdb3f065bff4" + }, + { + "id": "sourcepoint_lifecycle", + "role": "integration", + "phase": "deferred", + "trigger": "first_display_or_idle", + "inputs": [ + "runtime.v1", + "sourcepoint_consent.v1" + ], + "outputs": [], + "file": "tsjs-sourcepoint_lifecycle.js", + "bytes": 3681, + "hash": "eca9fc0410f214ffc8268cdbf682f7ddeed20c7aa0ad704d34d08b72a3900ddd" + } + ] + }, + "sourceOwners": { + "src/adapters/googletag.ts": [ + "gpt" + ], + "src/adapters/messaging.ts": [ + "render_runtime", + "gpt" + ], + "src/adapters/prebid.ts": [ + "prebid" + ], + "src/composition/browser.ts": [ + "core" + ], + "src/composition/index.ts": [ + "core" + ], + "src/core/auction.ts": [ + "render_runtime" + ], + "src/core/config.ts": [ + "bootstrap", + "core", + "render_runtime", + "gpt" + ], + "src/core/contracts/aps_renderer.ts": [ + "bootstrap", + "core", + "render_runtime", + "aps", + "gpt" + ], + "src/core/contracts/auction_projection.ts": [ + "bootstrap", + "core", + "render_runtime", + "gpt", + "prebid", + "prebid_later" + ], + "src/core/contracts/generated/renderer_validator_v1.ts": [ + "bootstrap", + "core", + "render_runtime", + "aps", + "gpt" + ], + "src/core/contracts/request_ads.ts": [ + "bootstrap", + "core", + "render_runtime" + ], + "src/core/index.ts": [ + "core" + ], + "src/core/log.ts": [ + "bootstrap", + "core", + "render_runtime", + "creative", + "datadome", + "didomi", + "google_tag_manager", + "gpt", + "gpt_diagnostics", + "lockr", + "osano_consent", + "permutive_context", + "sourcepoint_consent", + "testlight", + "diagnostics_presentation" + ], + "src/core/queue.ts": [ + "bootstrap", + "core" + ], + "src/core/registry.ts": [ + "bootstrap", + "core", + "render_runtime" + ], + "src/core/release.ts": [ + "bootstrap", + "core", + "render_runtime", + "aps", + "creative", + "datadome", + "didomi", + "google_tag_manager", + "gpt", + "gpt_diagnostics", + "lockr", + "osano_consent", + "permutive_context", + "sourcepoint_consent", + "prebid", + "testlight", + "diagnostics_presentation", + "gpt_later", + "osano_lifecycle", + "permutive_lifecycle", + "prebid_later", + "sourcepoint_lifecycle" + ], + "src/core/render.ts": [ + "render_runtime", + "gpt" + ], + "src/core/styles/normalize.css?inline": [ + "render_runtime", + "gpt" + ], + "src/core/templates/iframe.html?raw": [ + "render_runtime", + "gpt" + ], + "src/core/trace.ts": [ + "render_runtime", + "gpt_diagnostics" + ], + "src/integrations/aps/index.ts": [ + "aps" + ], + "src/integrations/aps/module.ts": [ + "aps" + ], + "src/integrations/aps/render.ts": [ + "aps" + ], + "src/integrations/creative/click.ts": [ + "creative" + ], + "src/integrations/creative/dynamic_src_guard.ts": [ + "creative" + ], + "src/integrations/creative/iframe.ts": [ + "creative" + ], + "src/integrations/creative/image.ts": [ + "creative" + ], + "src/integrations/creative/index.ts": [ + "creative" + ], + "src/integrations/creative/module.ts": [ + "creative" + ], + "src/integrations/creative/proxy_sign.ts": [ + "creative" + ], + "src/integrations/creative/startup.ts": [ + "creative" + ], + "src/integrations/datadome/index.ts": [ + "datadome" + ], + "src/integrations/datadome/module.ts": [ + "datadome" + ], + "src/integrations/datadome/script_guard.ts": [ + "datadome" + ], + "src/integrations/didomi/index.ts": [ + "didomi" + ], + "src/integrations/didomi/module.ts": [ + "didomi" + ], + "src/integrations/google_tag_manager/index.ts": [ + "google_tag_manager" + ], + "src/integrations/google_tag_manager/module.ts": [ + "google_tag_manager" + ], + "src/integrations/google_tag_manager/script_guard.ts": [ + "google_tag_manager" + ], + "src/integrations/gpt/bootstrap_fallback.ts": [ + "bootstrap" + ], + "src/integrations/gpt/diagnostics_facts.ts": [ + "gpt" + ], + "src/integrations/gpt/index.ts": [ + "gpt" + ], + "src/integrations/gpt/later.ts": [ + "gpt_later" + ], + "src/integrations/gpt/module.ts": [ + "gpt" + ], + "src/integrations/gpt/script_guard.ts": [ + "gpt" + ], + "src/integrations/gpt/startup.ts": [ + "gpt" + ], + "src/integrations/gpt_diagnostics/badges.ts": [ + "diagnostics_presentation" + ], + "src/integrations/gpt_diagnostics/binding.ts": [ + "diagnostics_presentation" + ], + "src/integrations/gpt_diagnostics/data_api.ts": [ + "gpt_diagnostics" + ], + "src/integrations/gpt_diagnostics/exhaustive.ts": [ + "diagnostics_presentation" + ], + "src/integrations/gpt_diagnostics/index.ts": [ + "gpt_diagnostics" + ], + "src/integrations/gpt_diagnostics/module.ts": [ + "gpt_diagnostics" + ], + "src/integrations/gpt_diagnostics/observer.ts": [ + "gpt_diagnostics" + ], + "src/integrations/gpt_diagnostics/overlay.ts": [ + "diagnostics_presentation" + ], + "src/integrations/gpt_diagnostics/presentation.ts": [ + "diagnostics_presentation" + ], + "src/integrations/gpt_diagnostics/store.ts": [ + "gpt_diagnostics" + ], + "src/integrations/lockr/index.ts": [ + "lockr" + ], + "src/integrations/lockr/module.ts": [ + "lockr" + ], + "src/integrations/lockr/script_guard.ts": [ + "lockr" + ], + "src/integrations/osano/consent.ts": [ + "osano_consent" + ], + "src/integrations/osano/consent_mirror.ts": [ + "osano_consent" + ], + "src/integrations/osano/lifecycle.ts": [ + "osano_lifecycle" + ], + "src/integrations/osano/module.ts": [ + "osano_consent" + ], + "src/integrations/permutive/context.ts": [ + "permutive_context" + ], + "src/integrations/permutive/lifecycle.ts": [ + "permutive_lifecycle" + ], + "src/integrations/permutive/module.ts": [ + "permutive_context" + ], + "src/integrations/permutive/script_guard.ts": [ + "permutive_context" + ], + "src/integrations/permutive/segments.ts": [ + "permutive_context" + ], + "src/integrations/prebid/index.ts": [ + "prebid" + ], + "src/integrations/prebid/later.ts": [ + "prebid_later" + ], + "src/integrations/prebid/module.ts": [ + "prebid" + ], + "src/integrations/prebid/refresh.ts": [ + "prebid", + "prebid_later" + ], + "src/integrations/prebid/startup.ts": [ + "prebid" + ], + "src/integrations/render_runtime/index.ts": [ + "render_runtime" + ], + "src/integrations/render_runtime/module.ts": [ + "render_runtime" + ], + "src/integrations/sourcepoint/consent.ts": [ + "sourcepoint_consent" + ], + "src/integrations/sourcepoint/consent_mirror.ts": [ + "sourcepoint_consent" + ], + "src/integrations/sourcepoint/lifecycle.ts": [ + "sourcepoint_lifecycle" + ], + "src/integrations/sourcepoint/module.ts": [ + "sourcepoint_consent" + ], + "src/integrations/sourcepoint/script_guard.ts": [ + "sourcepoint_consent" + ], + "src/integrations/testlight/index.ts": [ + "testlight" + ], + "src/integrations/testlight/module.ts": [ + "testlight" + ], + "src/kernel/diagnostics.ts": [ + "render_runtime" + ], + "src/kernel/disposable.ts": [ + "core", + "render_runtime", + "gpt" + ], + "src/kernel/fallback.ts": [ + "bootstrap", + "core" + ], + "src/kernel/identity.ts": [ + "render_runtime", + "gpt" + ], + "src/kernel/integration_registry.ts": [ + "core" + ], + "src/kernel/lifecycle_module.ts": [ + "datadome", + "didomi", + "google_tag_manager", + "lockr", + "testlight" + ], + "src/kernel/phase_loader.ts": [ + "core" + ], + "src/kernel/release_catalog.ts": [ + "bootstrap", + "core", + "render_runtime", + "datadome", + "didomi", + "google_tag_manager", + "lockr", + "testlight" + ], + "src/kernel/runtime.ts": [ + "core" + ], + "src/kernel/sessions.ts": [ + "render_runtime" + ], + "src/services/auction_batch.ts": [ + "render_runtime" + ], + "src/services/context.ts": [ + "render_runtime" + ], + "src/services/projections.ts": [ + "render_runtime", + "gpt" + ], + "src/services/puc_bridge.ts": [ + "gpt" + ], + "src/services/render.ts": [ + "render_runtime" + ], + "src/services/reservations.ts": [ + "render_runtime" + ], + "src/services/slots.ts": [ + "gpt" + ], + "src/services/targeting.ts": [ + "gpt" + ], + "src/shared/async.ts": [ + "creative" + ], + "src/shared/beacon_guard.ts": [ + "google_tag_manager" + ], + "src/shared/dom_insertion_dispatcher.ts": [ + "datadome", + "google_tag_manager", + "gpt", + "lockr", + "permutive_context", + "sourcepoint_consent" + ], + "src/shared/globals.ts": [ + "creative" + ], + "src/shared/origin.ts": [ + "render_runtime", + "creative" + ], + "src/shared/realm.ts": [ + "gpt_diagnostics", + "diagnostics_presentation" + ], + "src/shared/scheduler.ts": [ + "creative" + ], + "src/shared/script_guard.ts": [ + "datadome", + "google_tag_manager", + "gpt", + "lockr", + "permutive_context", + "sourcepoint_consent" + ] + }, + "sets": { + "bootstrap": { + "artifactIds": [ + "bootstrap" + ], + "files": [ + "gpt-bootstrap-fallback.js" + ], + "rawBytes": 42399, + "gzipBytes": 11870, + "brotliBytes": 10537, + "sha256": "6527456d289006e48ed527d897992b0cc0598498f09ca6cacec743ddb4d4915a" + }, + "minimal": { + "artifactIds": [ + "core", + "render_runtime" + ], + "files": [ + "tsjs-core.js", + "tsjs-render_runtime.js" + ], + "rawBytes": 252579, + "gzipBytes": 67933, + "brotliBytes": 51467, + "sha256": "99da81c445b54060b57a64ff65337008d98b1e597e0006cdbd3bab584d9b6f1e" + }, + "reference": { + "artifactIds": [ + "core", + "render_runtime", + "creative", + "gpt", + "prebid", + "datadome" + ], + "files": [ + "tsjs-core.js", + "tsjs-render_runtime.js", + "tsjs-creative.js", + "tsjs-gpt.js", + "tsjs-prebid.js", + "tsjs-datadome.js" + ], + "rawBytes": 515160, + "gzipBytes": 140684, + "brotliBytes": 100577, + "sha256": "3d3ec6b4d11555ee2906c4dc1532cc7e716f4914089667754a1c06f2a1877c8a" + }, + "maximal": { + "artifactIds": [ + "core", + "render_runtime", + "aps", + "creative", + "datadome", + "didomi", + "google_tag_manager", + "gpt", + "gpt_diagnostics", + "lockr", + "osano_consent", + "permutive_context", + "sourcepoint_consent", + "prebid", + "testlight", + "diagnostics_presentation", + "gpt_later", + "osano_lifecycle", + "permutive_lifecycle", + "prebid_later", + "sourcepoint_lifecycle" + ], + "files": [ + "tsjs-core.js", + "tsjs-render_runtime.js", + "tsjs-aps.js", + "tsjs-creative.js", + "tsjs-datadome.js", + "tsjs-didomi.js", + "tsjs-google_tag_manager.js", + "tsjs-gpt.js", + "tsjs-gpt_diagnostics.js", + "tsjs-lockr.js", + "tsjs-osano_consent.js", + "tsjs-permutive_context.js", + "tsjs-sourcepoint_consent.js", + "tsjs-prebid.js", + "tsjs-testlight.js", + "tsjs-diagnostics_presentation.js", + "tsjs-gpt_later.js", + "tsjs-osano_lifecycle.js", + "tsjs-permutive_lifecycle.js", + "tsjs-prebid_later.js", + "tsjs-sourcepoint_lifecycle.js" + ], + "rawBytes": 758703, + "gzipBytes": 188753, + "brotliBytes": 128205, + "sha256": "67310e0c47fea3011ba193ed79c03e6e50c574a3704c35eb636114cd3a3b88f9" + } + } + }, + "reviewRemediationTransfer": { + "schemaVersion": 1, + "source": { + "ref": "spec/aps-tsjs-resilience-design", + "sha": "91b3533ae7c07e03fa77441e0d94f27e31965d9e" + }, + "originalTopLevelSha256": "53f762603ad49239f1756171440be422e190cc231efafc56cf37a11e1a38ddf4", + "roleCorrectTransferSha256": "f1d73d517e4888ef4dc3a84b34166e9aeb6a2bde99dec1c835f151f4e070f64a", + "tools": { + "node": "v24.12.0", + "npm": "11.6.2", + "typescript": "6.0.3", + "vite": "8.2.1", + "esbuild": "0.28.1", + "packageLockSha256": "083ef76165b0518a3157d56dc4fd2697c8ab67ebf43efad1e9f9b06c2edafad5" + }, + "compression": { + "concatenationSeparator": ";\n", + "gzip": { + "implementation": "node:zlib.gzipSync", + "version": "zlib 1.3.1-470d3a2", + "level": 9, + "mtime": 0 + }, + "brotli": { + "implementation": "node:zlib.brotliCompressSync", + "version": "brotli 1.1.0", + "mode": "text", + "quality": 11, + "sizeHint": "input-bytes" + } + }, + "release": { + "version": 1, + "releaseId": "da6237db414516d27426d1ae0be03c024274dd0e869a335fb3c00bbc52afc0bd", + "artifacts": [ + { + "id": "bootstrap", + "role": "bootstrap", + "phase": null, + "trigger": null, + "inputs": [], + "outputs": [], + "file": "gpt-bootstrap-fallback.js", + "bytes": 42838, + "hash": "cfc25ea32119a0193bb2ace190b61be255b488157ca8ceef64347676d10193d9" + }, + { + "id": "core", + "role": "core", + "phase": null, + "trigger": null, + "inputs": [], + "outputs": [ + "runtime.v1" + ], + "file": "tsjs-core.js", + "bytes": 217966, + "hash": "b5bdbe0ac89d0d0d1fe29f522ae58aa23fc443597a21d096e4ee63c33daad248" + }, + { + "id": "render_runtime", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [ + "slots.v1", + "auction.v1", + "render.v1", + "messages.v1", + "trace.v1", + "trace.presentation.v1", + "direct.v1" + ], + "file": "tsjs-render_runtime.js", + "bytes": 74, + "hash": "c803f320271dbd668bbf23328fd1769331c0579f6bbbb137027e30bea26b9a7e" + }, + { + "id": "aps", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1", + "slots.v1", + "render.v1", + "messages.v1", + "trace.v1" + ], + "outputs": [ + "aps.v1" + ], + "file": "tsjs-aps.js", + "bytes": 16782, + "hash": "f6747dee35a9502ca15f3390b47c400e4b1be0ce83c25bd57c7294a6b97c5d8b" + }, + { + "id": "creative", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [], + "file": "tsjs-creative.js", + "bytes": 22226, + "hash": "5703db52100117da3b2304734957db9327903e2ee70371e9150a19553ac2f894" + }, + { + "id": "datadome", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [], + "file": "tsjs-datadome.js", + "bytes": 23375, + "hash": "95abf1300724721c0ad4ec82098e71e25afbc79c28d89888e4b9c3a86993e0c8" + }, + { + "id": "didomi", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [], + "file": "tsjs-didomi.js", + "bytes": 15489, + "hash": "c631026551448ca2c59c669ae05842da2a8b29ff49da08e9ace760d240cc24d5" + }, + { + "id": "google_tag_manager", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [], + "file": "tsjs-google_tag_manager.js", + "bytes": 26343, + "hash": "ba91a352dc9658a33e58ee4c742d91ef7ad37cc77685e32b9804d879042a9e45" + }, + { + "id": "gpt", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1", + "slots.v1", + "auction.v1", + "render.v1", + "messages.v1", + "trace.v1" + ], + "outputs": [ + "gpt.v1", + "gpt.events.v1", + "pbs_cache.baseline.v1" + ], + "file": "tsjs-gpt.js", + "bytes": 154575, + "hash": "710640b3dfdb0779bba6444da66c9b2245c011686a6c42e5542022c6dab8742b" + }, + { + "id": "gpt_diagnostics", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1", + "gpt.events.v1" + ], + "outputs": [ + "gpt_diag.v1" + ], + "file": "tsjs-gpt_diagnostics.js", + "bytes": 29975, + "hash": "6703f7ab1fe6ed1ff9a90f1d3511ff858f75170dca7ec6a6f686f2c59004869d" + }, + { + "id": "lockr", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [], + "file": "tsjs-lockr.js", + "bytes": 24031, + "hash": "19952a6888923f35852ec8e76bea525082874a36d3fda3e801311a6f6dde49ed" + }, + { + "id": "osano_consent", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [ + "osano_consent.v1" + ], + "file": "tsjs-osano_consent.js", + "bytes": 10511, + "hash": "aad62189a6d5018f90c72e68593adeab7a9dd3d8d87649032a4a5499135da99a" + }, + { + "id": "permutive_context", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [ + "permutive_context.v1" + ], + "file": "tsjs-permutive_context.js", + "bytes": 17818, + "hash": "bab701c0ef96bc68fce41afefa7f49be114a139b92f6eee62f48c07f7a19f6e2" + }, + { + "id": "sourcepoint_consent", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [ + "sourcepoint_consent.v1" + ], + "file": "tsjs-sourcepoint_consent.js", + "bytes": 18789, + "hash": "9378603d6e43c77c3e3975c9dfb1c4229bbd9b0d63bc7ab74dc303492a248eb9" + }, + { + "id": "prebid", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1", + "slots.v1", + "render.v1", + "messages.v1", + "aps.v1?aps" + ], + "outputs": [ + "prebid.v1" + ], + "file": "tsjs-prebid.js", + "bytes": 36569, + "hash": "9492a7e41aa4ebe484fbbd2fac6681c7080437d1ed7cfaefc4120a3270967763" + }, + { + "id": "testlight", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": [ + "runtime.v1" + ], + "outputs": [], + "file": "tsjs-testlight.js", + "bytes": 16232, + "hash": "e788888a96b102adc9444030ab43a152e4fec375134ba5135961c11f4fd9b947" + }, + { + "id": "diagnostics_presentation", + "role": "integration", + "phase": "deferred", + "trigger": "first_display_or_idle", + "inputs": [ + "runtime.v1", + "trace.presentation.v1", + "gpt_diag.v1?gpt_diagnostics_active" + ], + "outputs": [], + "file": "tsjs-diagnostics_presentation.js", + "bytes": 39926, + "hash": "3f0735bba5a334e4638c48dae6fbd51db4db769e74a0251775eda0362b739fab" + }, + { + "id": "gpt_later", + "role": "integration", + "phase": "deferred", + "trigger": "first_display_or_idle", + "inputs": [ + "runtime.v1", + "slots.v1", + "auction.v1", + "render.v1", + "gpt.v1", + "trace.v1" + ], + "outputs": [], + "file": "tsjs-gpt_later.js", + "bytes": 5420, + "hash": "20ff6c4178fc7c7346f634027476b14435905b31fbbc70261b73dcc37c7ccd37" + }, + { + "id": "osano_lifecycle", + "role": "integration", + "phase": "deferred", + "trigger": "first_display_or_idle", + "inputs": [ + "runtime.v1", + "osano_consent.v1" + ], + "outputs": [], + "file": "tsjs-osano_lifecycle.js", + "bytes": 3663, + "hash": "39878189d45b8472047969a9ea5faa83e9f464c95bac32ce97646075f6d8dff4" + }, + { + "id": "permutive_lifecycle", + "role": "integration", + "phase": "deferred", + "trigger": "first_display_or_idle", + "inputs": [ + "runtime.v1", + "permutive_context.v1" + ], + "outputs": [], + "file": "tsjs-permutive_lifecycle.js", + "bytes": 3675, + "hash": "9b4049d8054bc0b5f2cd0d6824532ed15d004ffd592a734d4ec4229e09f5c49b" + }, + { + "id": "prebid_later", + "role": "integration", + "phase": "deferred", + "trigger": "first_display_or_idle", + "inputs": [ + "runtime.v1", + "slots.v1", + "gpt.v1", + "prebid.v1" + ], + "outputs": [], + "file": "tsjs-prebid_later.js", + "bytes": 11160, + "hash": "92c5f09add1a39cca6431d758927f9245e2c211af15536f9bac8ab11d9380f66" + }, + { + "id": "sourcepoint_lifecycle", + "role": "integration", + "phase": "deferred", + "trigger": "first_display_or_idle", + "inputs": [ + "runtime.v1", + "sourcepoint_consent.v1" + ], + "outputs": [], + "file": "tsjs-sourcepoint_lifecycle.js", + "bytes": 3681, + "hash": "31ecc5a68e7602f7222c8efe6001b64427d770101a3ddb31610d13ad49776efd" + } + ] + }, + "sourceOwners": { + "src/core/log.ts": [ + "bootstrap", + "core", + "creative", + "datadome", + "didomi", + "google_tag_manager", + "gpt", + "gpt_diagnostics", + "lockr", + "osano_consent", + "permutive_context", + "sourcepoint_consent", + "testlight", + "diagnostics_presentation" + ], + "src/core/queue.ts": [ + "bootstrap", + "core" + ], + "src/core/release.ts": [ + "bootstrap", + "core", + "aps", + "creative", + "datadome", + "didomi", + "google_tag_manager", + "gpt", + "gpt_diagnostics", + "lockr", + "osano_consent", + "permutive_context", + "sourcepoint_consent", + "prebid", + "testlight", + "diagnostics_presentation", + "gpt_later", + "osano_lifecycle", + "permutive_lifecycle", + "prebid_later", + "sourcepoint_lifecycle" + ], + "src/core/config.ts": [ + "bootstrap", + "core" + ], + "src/core/contracts/generated/renderer_validator_v1.ts": [ + "bootstrap", + "core", + "aps" + ], + "src/core/contracts/aps_renderer.ts": [ + "bootstrap", + "core", + "aps" + ], + "src/core/contracts/auction_projection.ts": [ + "bootstrap", + "core", + "prebid", + "prebid_later" + ], + "src/core/contracts/request_ads.ts": [ + "bootstrap", + "core" + ], + "src/core/registry.ts": [ + "bootstrap", + "core" + ], + "src/kernel/release_catalog.ts": [ + "bootstrap", + "core", + "datadome", + "didomi", + "google_tag_manager", + "lockr", + "testlight" + ], + "src/kernel/fallback.ts": [ + "bootstrap", + "core" + ], + "src/integrations/gpt/bootstrap_fallback.ts": [ + "bootstrap" + ], + "src/core/index.ts": [ + "core" + ], + "src/kernel/contracts/message_protocol.ts": [ + "core", + "gpt" + ], + "src/adapters/messaging.ts": [ + "core" + ], + "src/core/auction.ts": [ + "core" + ], + "src/core/styles/normalize.css?inline": [ + "core" + ], + "src/core/templates/iframe.html?raw": [ + "core" + ], + "src/core/render.ts": [ + "core" + ], + "src/core/trace.ts": [ + "core", + "gpt_diagnostics" + ], + "src/kernel/disposable.ts": [ + "core", + "gpt" + ], + "src/kernel/identity.ts": [ + "core" + ], + "src/kernel/diagnostics.ts": [ + "core" + ], + "src/kernel/sessions.ts": [ + "core" + ], + "src/services/auction_batch.ts": [ + "core" + ], + "src/services/context.ts": [ + "core" + ], + "src/services/projections.ts": [ + "core" + ], + "src/services/reservations.ts": [ + "core" + ], + "src/services/render.ts": [ + "core" + ], + "src/shared/origin.ts": [ + "core", + "creative" + ], + "src/integrations/render_runtime/module.ts": [ + "core" + ], + "src/kernel/integration_registry.ts": [ + "core" + ], + "src/kernel/phase_loader.ts": [ + "core" + ], + "src/kernel/runtime.ts": [ + "core" + ], + "src/composition/browser.ts": [ + "core" + ], + "src/composition/critical_transport.ts": [ + "core" + ], + "src/integrations/render_runtime/transport_marker.ts": [ + "render_runtime" + ], + "src/integrations/aps/render.ts": [ + "aps" + ], + "src/integrations/aps/module.ts": [ + "aps" + ], + "src/integrations/aps/index.ts": [ + "aps" + ], + "src/shared/globals.ts": [ + "creative" + ], + "src/shared/async.ts": [ + "creative" + ], + "src/shared/scheduler.ts": [ + "creative" + ], + "src/integrations/creative/click.ts": [ + "creative" + ], + "src/integrations/creative/dynamic_src_guard.ts": [ + "creative" + ], + "src/integrations/creative/proxy_sign.ts": [ + "creative" + ], + "src/integrations/creative/iframe.ts": [ + "creative" + ], + "src/integrations/creative/image.ts": [ + "creative" + ], + "src/integrations/creative/startup.ts": [ + "creative" + ], + "src/integrations/creative/module.ts": [ + "creative" + ], + "src/integrations/creative/index.ts": [ + "creative" + ], + "src/kernel/lifecycle_module.ts": [ + "datadome", + "didomi", + "google_tag_manager", + "lockr", + "testlight" + ], + "src/shared/dom_insertion_dispatcher.ts": [ + "datadome", + "google_tag_manager", + "gpt", + "lockr", + "permutive_context", + "sourcepoint_consent" + ], + "src/shared/script_guard.ts": [ + "datadome", + "google_tag_manager", + "gpt", + "lockr", + "permutive_context", + "sourcepoint_consent" + ], + "src/integrations/datadome/script_guard.ts": [ + "datadome" + ], + "src/integrations/datadome/module.ts": [ + "datadome" + ], + "src/integrations/datadome/index.ts": [ + "datadome" + ], + "src/integrations/didomi/module.ts": [ + "didomi" + ], + "src/integrations/didomi/index.ts": [ + "didomi" + ], + "src/shared/beacon_guard.ts": [ + "google_tag_manager" + ], + "src/integrations/google_tag_manager/script_guard.ts": [ + "google_tag_manager" + ], + "src/integrations/google_tag_manager/module.ts": [ + "google_tag_manager" + ], + "src/integrations/google_tag_manager/index.ts": [ + "google_tag_manager" + ], + "src/adapters/googletag.ts": [ + "gpt" + ], + "src/core/puc_shell.ts": [ + "gpt" + ], + "src/services/puc_bridge.ts": [ + "gpt" + ], + "src/services/slots.ts": [ + "gpt" + ], + "src/services/targeting.ts": [ + "gpt" + ], + "src/integrations/gpt/diagnostics_facts.ts": [ + "gpt" + ], + "src/integrations/gpt/script_guard.ts": [ + "gpt" + ], + "src/integrations/gpt/startup.ts": [ + "gpt" + ], + "src/integrations/gpt/module.ts": [ + "gpt" + ], + "src/integrations/gpt/index.ts": [ + "gpt" + ], + "src/shared/realm.ts": [ + "gpt_diagnostics", + "diagnostics_presentation" + ], + "src/integrations/gpt_diagnostics/data_api.ts": [ + "gpt_diagnostics" + ], + "src/integrations/gpt_diagnostics/observer.ts": [ + "gpt_diagnostics" + ], + "src/integrations/gpt_diagnostics/store.ts": [ + "gpt_diagnostics" + ], + "src/integrations/gpt_diagnostics/module.ts": [ + "gpt_diagnostics" + ], + "src/integrations/gpt_diagnostics/index.ts": [ + "gpt_diagnostics" + ], + "src/integrations/lockr/script_guard.ts": [ + "lockr" + ], + "src/integrations/lockr/module.ts": [ + "lockr" + ], + "src/integrations/lockr/index.ts": [ + "lockr" + ], + "src/integrations/osano/consent_mirror.ts": [ + "osano_consent" + ], + "src/integrations/osano/module.ts": [ + "osano_consent" + ], + "src/integrations/osano/consent.ts": [ + "osano_consent" + ], + "src/integrations/permutive/script_guard.ts": [ + "permutive_context" + ], + "src/integrations/permutive/segments.ts": [ + "permutive_context" + ], + "src/integrations/permutive/module.ts": [ + "permutive_context" + ], + "src/integrations/permutive/context.ts": [ + "permutive_context" + ], + "src/integrations/sourcepoint/consent_mirror.ts": [ + "sourcepoint_consent" + ], + "src/integrations/sourcepoint/script_guard.ts": [ + "sourcepoint_consent" + ], + "src/integrations/sourcepoint/module.ts": [ + "sourcepoint_consent" + ], + "src/integrations/sourcepoint/consent.ts": [ + "sourcepoint_consent" + ], + "src/adapters/prebid.ts": [ + "prebid" + ], + "src/integrations/prebid/startup.ts": [ + "prebid" + ], + "src/integrations/prebid/module.ts": [ + "prebid" + ], + "src/integrations/prebid/index.ts": [ + "prebid" + ], + "src/integrations/testlight/module.ts": [ + "testlight" + ], + "src/integrations/testlight/index.ts": [ + "testlight" + ], + "src/integrations/gpt_diagnostics/exhaustive.ts": [ + "diagnostics_presentation" + ], + "src/integrations/gpt_diagnostics/badges.ts": [ + "diagnostics_presentation" + ], + "src/integrations/gpt_diagnostics/binding.ts": [ + "diagnostics_presentation" + ], + "src/integrations/gpt_diagnostics/overlay.ts": [ + "diagnostics_presentation" + ], + "src/integrations/gpt_diagnostics/presentation.ts": [ + "diagnostics_presentation" + ], + "src/integrations/gpt/later.ts": [ + "gpt_later" + ], + "src/integrations/osano/lifecycle.ts": [ + "osano_lifecycle" + ], + "src/integrations/permutive/lifecycle.ts": [ + "permutive_lifecycle" + ], + "src/integrations/prebid/refresh.ts": [ + "prebid_later" + ], + "src/integrations/prebid/later.ts": [ + "prebid_later" + ], + "src/integrations/sourcepoint/lifecycle.ts": [ + "sourcepoint_lifecycle" + ] + }, + "logicalProviderSources": { + "core": [ + "src/kernel/runtime.ts" + ], + "render_runtime": [ + "src/integrations/render_runtime/module.ts", + "src/services/render.ts" + ], + "gpt": [ + "src/integrations/gpt/module.ts", + "src/integrations/gpt/startup.ts" + ], + "gpt_diagnostics": [ + "src/integrations/gpt_diagnostics/store.ts" + ], + "osano_consent": [ + "src/integrations/osano/consent.ts" + ], + "prebid": [ + "src/integrations/prebid/module.ts", + "src/integrations/prebid/startup.ts" + ], + "sourcepoint_consent": [ + "src/integrations/sourcepoint/consent.ts" + ] + }, + "physicalMarkerOwners": { + "render_runtime": "core" + }, + "graphReport": { + "largestContributions": [ + { + "artifact": "gpt", + "source": "src/services/slots.ts", + "renderedBytes": 94703 + }, + { + "artifact": "core", + "source": "src/services/render.ts", + "renderedBytes": 79493 + }, + { + "artifact": "gpt", + "source": "src/adapters/googletag.ts", + "renderedBytes": 72088 + }, + { + "artifact": "gpt", + "source": "src/services/puc_bridge.ts", + "renderedBytes": 71834 + }, + { + "artifact": "prebid", + "source": "src/adapters/prebid.ts", + "renderedBytes": 40422 + }, + { + "artifact": "core", + "source": "src/adapters/messaging.ts", + "renderedBytes": 35826 + }, + { + "artifact": "gpt", + "source": "src/integrations/gpt/module.ts", + "renderedBytes": 33449 + }, + { + "artifact": "core", + "source": "src/kernel/integration_registry.ts", + "renderedBytes": 31919 + }, + { + "artifact": "core", + "source": "src/services/reservations.ts", + "renderedBytes": 31726 + }, + { + "artifact": "gpt_diagnostics", + "source": "src/integrations/gpt_diagnostics/store.ts", + "renderedBytes": 28251 + }, + { + "artifact": "prebid", + "source": "src/integrations/prebid/module.ts", + "renderedBytes": 24292 + }, + { + "artifact": "diagnostics_presentation", + "source": "src/integrations/gpt_diagnostics/overlay.ts", + "renderedBytes": 23379 + }, + { + "artifact": "core", + "source": "src/integrations/render_runtime/module.ts", + "renderedBytes": 23342 + }, + { + "artifact": "core", + "source": "src/core/trace.ts", + "renderedBytes": 20652 + }, + { + "artifact": "aps", + "source": "src/integrations/aps/render.ts", + "renderedBytes": 19831 + }, + { + "artifact": "core", + "source": "src/kernel/runtime.ts", + "renderedBytes": 19173 + }, + { + "artifact": "core", + "source": "src/core/registry.ts", + "renderedBytes": 18979 + }, + { + "artifact": "core", + "source": "src/core/contracts/auction_projection.ts", + "renderedBytes": 18781 + }, + { + "artifact": "bootstrap", + "source": "src/core/contracts/auction_projection.ts", + "renderedBytes": 18747 + }, + { + "artifact": "diagnostics_presentation", + "source": "src/integrations/gpt_diagnostics/presentation.ts", + "renderedBytes": 16668 + } + ], + "repeatedAttributions": [ + { + "source": "src/core/config.ts", + "owners": [ + "bootstrap", + "core" + ] + }, + { + "source": "src/core/contracts/aps_renderer.ts", + "owners": [ + "bootstrap", + "core", + "aps" + ] + }, + { + "source": "src/core/contracts/auction_projection.ts", + "owners": [ + "bootstrap", + "core", + "prebid", + "prebid_later" + ] + }, + { + "source": "src/core/contracts/generated/renderer_validator_v1.ts", + "owners": [ + "bootstrap", + "core", + "aps" + ] + }, + { + "source": "src/core/contracts/request_ads.ts", + "owners": [ + "bootstrap", + "core" + ] + }, + { + "source": "src/core/log.ts", + "owners": [ + "bootstrap", + "core", + "creative", + "datadome", + "didomi", + "google_tag_manager", + "gpt", + "gpt_diagnostics", + "lockr", + "osano_consent", + "permutive_context", + "sourcepoint_consent", + "testlight", + "diagnostics_presentation" + ] + }, + { + "source": "src/core/queue.ts", + "owners": [ + "bootstrap", + "core" + ] + }, + { + "source": "src/core/registry.ts", + "owners": [ + "bootstrap", + "core" + ] + }, + { + "source": "src/core/release.ts", + "owners": [ + "bootstrap", + "core", + "aps", + "creative", + "datadome", + "didomi", + "google_tag_manager", + "gpt", + "gpt_diagnostics", + "lockr", + "osano_consent", + "permutive_context", + "sourcepoint_consent", + "prebid", + "testlight", + "diagnostics_presentation", + "gpt_later", + "osano_lifecycle", + "permutive_lifecycle", + "prebid_later", + "sourcepoint_lifecycle" + ] + }, + { + "source": "src/core/trace.ts", + "owners": [ + "core", + "gpt_diagnostics" + ] + }, + { + "source": "src/kernel/contracts/message_protocol.ts", + "owners": [ + "core", + "gpt" + ] + }, + { + "source": "src/kernel/disposable.ts", + "owners": [ + "core", + "gpt" + ] + }, + { + "source": "src/kernel/fallback.ts", + "owners": [ + "bootstrap", + "core" + ] + }, + { + "source": "src/kernel/lifecycle_module.ts", + "owners": [ + "datadome", + "didomi", + "google_tag_manager", + "lockr", + "testlight" + ] + }, + { + "source": "src/kernel/release_catalog.ts", + "owners": [ + "bootstrap", + "core", + "datadome", + "didomi", + "google_tag_manager", + "lockr", + "testlight" + ] + }, + { + "source": "src/shared/dom_insertion_dispatcher.ts", + "owners": [ + "datadome", + "google_tag_manager", + "gpt", + "lockr", + "permutive_context", + "sourcepoint_consent" + ] + }, + { + "source": "src/shared/origin.ts", + "owners": [ + "core", + "creative" + ] + }, + { + "source": "src/shared/realm.ts", + "owners": [ + "gpt_diagnostics", + "diagnostics_presentation" + ] + }, + { + "source": "src/shared/script_guard.ts", + "owners": [ + "datadome", + "google_tag_manager", + "gpt", + "lockr", + "permutive_context", + "sourcepoint_consent" + ] + } + ] + }, + "sets": { + "bootstrap": { + "artifactIds": [ + "bootstrap" + ], + "files": [ + "gpt-bootstrap-fallback.js" + ], + "rawBytes": 42838, + "gzipBytes": 12000, + "brotliBytes": 10652, + "sha256": "cfc25ea32119a0193bb2ace190b61be255b488157ca8ceef64347676d10193d9" + }, + "minimal": { + "artifactIds": [ + "core", + "render_runtime" + ], + "files": [ + "tsjs-core.js", + "tsjs-render_runtime.js" + ], + "rawBytes": 218042, + "gzipBytes": 58973, + "brotliBytes": 49660, + "sha256": "e015d3147557b8084ba42ea9a0b625c28950c4fbbd09322666404b883fabf974" + }, + "reference": { + "artifactIds": [ + "core", + "render_runtime", + "creative", + "gpt", + "prebid", + "datadome" + ], + "files": [ + "tsjs-core.js", + "tsjs-render_runtime.js", + "tsjs-creative.js", + "tsjs-gpt.js", + "tsjs-prebid.js", + "tsjs-datadome.js" + ], + "rawBytes": 454795, + "gzipBytes": 124552, + "brotliBytes": 96460, + "sha256": "b7ee65c7fddc813e895840d92d10c14e01d1f0ca09d95c9d2a8654f33ba57e52" + }, + "maximal": { + "artifactIds": [ + "core", + "render_runtime", + "aps", + "creative", + "datadome", + "didomi", + "google_tag_manager", + "gpt", + "gpt_diagnostics", + "lockr", + "osano_consent", + "permutive_context", + "sourcepoint_consent", + "prebid", + "testlight", + "diagnostics_presentation", + "gpt_later", + "osano_lifecycle", + "permutive_lifecycle", + "prebid_later", + "sourcepoint_lifecycle" + ], + "files": [ + "tsjs-core.js", + "tsjs-render_runtime.js", + "tsjs-aps.js", + "tsjs-creative.js", + "tsjs-datadome.js", + "tsjs-didomi.js", + "tsjs-google_tag_manager.js", + "tsjs-gpt.js", + "tsjs-gpt_diagnostics.js", + "tsjs-lockr.js", + "tsjs-osano_consent.js", + "tsjs-permutive_context.js", + "tsjs-sourcepoint_consent.js", + "tsjs-prebid.js", + "tsjs-testlight.js", + "tsjs-diagnostics_presentation.js", + "tsjs-gpt_later.js", + "tsjs-osano_lifecycle.js", + "tsjs-permutive_lifecycle.js", + "tsjs-prebid_later.js", + "tsjs-sourcepoint_lifecycle.js" + ], + "rawBytes": 698320, + "gzipBytes": 172718, + "brotliBytes": 124679, + "sha256": "9a2c29b5e7054ca4bcfa49ca0493a8de5b75a1891739b6febaaa4c7aa3b5d99c" + } + } + } +} diff --git a/crates/trusted-server-js/lib/test/helpers/legacy_gpt_registration.ts b/crates/trusted-server-js/lib/test/helpers/legacy_gpt_registration.ts new file mode 100644 index 000000000..f6a6e2675 --- /dev/null +++ b/crates/trusted-server-js/lib/test/helpers/legacy_gpt_registration.ts @@ -0,0 +1,84 @@ +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../src/kernel/integration_registry'; +import { installGptGuard, resetGuardState } from '../../src/integrations/gpt/script_guard'; + +interface TestGptRuntime { + readonly activate: () => () => void; + readonly start: (config: unknown) => void; +} + +function recursivelyFrozen(candidate: unknown, seen = new Set()): boolean { + if (candidate === null || (typeof candidate !== 'object' && typeof candidate !== 'function')) { + return typeof candidate !== 'number' || Number.isFinite(candidate); + } + if (seen.has(candidate) || !Object.isFrozen(candidate)) return false; + const prototype = Object.getPrototypeOf(candidate); + if ( + prototype !== Object.prototype && + prototype !== null && + !(Array.isArray(candidate) && prototype === Array.prototype) + ) { + return false; + } + seen.add(candidate); + try { + return Reflect.ownKeys(candidate).every((key) => { + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + return ( + descriptor !== undefined && + 'value' in descriptor && + recursivelyFrozen(descriptor.value, seen) + ); + }); + } catch { + return false; + } +} + +function runtime(interfaces: Readonly>): TestGptRuntime | undefined { + const candidate = interfaces['gpt']; + if ( + typeof candidate !== 'object' || + candidate === null || + !Object.isFrozen(candidate) || + typeof (candidate as TestGptRuntime).activate !== 'function' || + typeof (candidate as TestGptRuntime).start !== 'function' + ) { + return undefined; + } + return candidate as TestGptRuntime; +} + +/** Legacy composition seam retained only in tests; never reachable from a shipped entry point. */ +export function createLegacyGptRegistrationForTest(releaseId: string): IntegrationRegistration { + const prepare = ({ config, interfaces }: IntegrationPrepareContext) => { + if (!recursivelyFrozen(config)) throw new TypeError('GPT integration config is invalid'); + const preparedRuntime = runtime(interfaces); + if (!preparedRuntime) throw new TypeError('GPT integration runtime is unavailable'); + return Object.freeze({ + activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { + onDispose(resetGuardState); + const releaseHolder: { current?: () => void } = {}; + onDispose(() => releaseHolder.current?.()); + const release = preparedRuntime.activate(); + if (typeof release !== 'function') { + throw new TypeError('GPT integration activation disposer is unavailable'); + } + releaseHolder.current = release; + installGptGuard(); + afterCommit(() => preparedRuntime.start(config)); + }, + }); + }; + return Object.freeze({ + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId, + prepareSync: prepare, + prepare, + }); +} diff --git a/crates/trusted-server-js/lib/test/integrations/aps/documents.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/documents.test.ts new file mode 100644 index 000000000..2f091e8d5 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/aps/documents.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it, vi } from 'vitest'; + +import envelopeFixture from '../../fixtures/aps-renderer-v1.json'; +import type { ApsRendererV1 } from '../../../src/core/types'; +import { parseApsDocumentEnvelopeV1 } from '../../../src/core/contracts/aps_renderer'; +import { + APS_IFRAME_INNER_CSP, + APS_IFRAME_OUTER_CSP, + APS_PERMANENT_SANDBOX, + APS_SCRIPT_INNER_CSP, + APS_SCRIPT_OUTER_CSP, + MAX_APS_CONTAINER_DOCUMENT_BYTES, + MAX_APS_INNER_DOCUMENT_BYTES, + generateApsDataDocumentsV1, +} from '../../../src/integrations/aps/documents'; + +const publisherOrigin = 'https://publisher.example'; +const creativeOrigin = 'https://creative.example'; +const bootstrapNonce = `b1_${'b'.repeat(22)}`; +const rendererNonce = `n1_${'n'.repeat(22)}`; + +function encodeEnvelope(value: unknown): string { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function renderer(tagType: 'iframe' | 'script' = 'iframe'): ApsRendererV1 { + const envelope = structuredClone(envelopeFixture); + envelope.seatbid[0]!.bid[0]!.ext.tagtype = tagType; + const bid = envelope.seatbid[0]!.bid[0]!; + return { + type: 'aps', + version: 1, + accountId: 'document-account', + bidId: bid.id, + creativeId: 'document-creative', + tagType, + creativeUrl: bid.ext.creativeurl, + width: bid.w, + height: bid.h, + aaxResponse: encodeEnvelope(envelope), + }; +} + +function decodeDataDocument(url: string, nonce: string): string { + const prefix = 'data:text/html;charset=utf-8,'; + const suffix = `#${nonce}`; + expect(url.startsWith(prefix)).toBe(true); + expect(url.endsWith(suffix)).toBe(true); + return decodeURIComponent(url.slice(prefix.length, -suffix.length)); +} + +function inlineScript(documentSource: string): string { + const start = documentSource.indexOf(''); + if (start < 0 || end <= start) throw new Error('document should contain one inline script'); + return documentSource.slice(start + '' }] }, + { bid: [{ ...envelope.seatbid[0]!.bid[0]!, adm: '' }] }, ], }, ], [ 'notification', - { seatbid: [{ bid: [{ ...envelope.seatbid[0].bid[0], nurl: 'https://notify.example' }] }] }, + { seatbid: [{ bid: [{ ...envelope.seatbid[0]!.bid[0]!, nurl: 'https://notify.example' }] }] }, ], [ 'unknown extension', @@ -132,8 +366,8 @@ describe('APS renderer validation', () => { { bid: [ { - ...envelope.seatbid[0].bid[0], - ext: { ...envelope.seatbid[0].bid[0].ext, userSyncs: [] }, + ...envelope.seatbid[0]!.bid[0]!, + ext: { ...envelope.seatbid[0]!.bid[0]!.ext, userSyncs: [] }, }, ], }, @@ -171,8 +405,8 @@ describe('APS renderer validation', () => { const canonical = encodeBytes(new TextEncoder().encode(`${JSON.stringify(envelope)} `)); const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; const finalDataIndex = canonical.length - 3; - const canonicalIndex = alphabet.indexOf(canonical[finalDataIndex]); - const nonCanonical = `${canonical.slice(0, finalDataIndex)}${alphabet[canonicalIndex + 1]}==`; + const canonicalIndex = alphabet.indexOf(canonical[finalDataIndex]!); + const nonCanonical = `${canonical.slice(0, finalDataIndex)}${alphabet[canonicalIndex + 1]!}==`; expect(atob(nonCanonical)).toBe(atob(canonical)); expect(validateApsRenderer(descriptor({ aaxResponse: canonical }))).toBeDefined(); @@ -185,7 +419,7 @@ describe('APS renderer validation', () => { `${window.location.origin}/creative`, ])('rejects an unsafe creative URL', (creativeUrl) => { const invalidEnvelope = structuredClone(envelope); - invalidEnvelope.seatbid[0].bid[0].ext.creativeurl = creativeUrl; + invalidEnvelope.seatbid[0]!.bid[0]!.ext.creativeurl = creativeUrl; expect( validateApsRenderer(descriptor({ creativeUrl, aaxResponse: encodeEnvelope(invalidEnvelope) })) ).toBeUndefined(); @@ -210,9 +444,9 @@ describe('APS renderer validation', () => { const atLimit = `${prefix}${'a'.repeat(4096 - prefix.length)}`; const overLimit = `${atLimit}x`; const atLimitEnvelope = structuredClone(envelope); - atLimitEnvelope.seatbid[0].bid[0].ext.creativeurl = atLimit; + atLimitEnvelope.seatbid[0]!.bid[0]!.ext.creativeurl = atLimit; const overLimitEnvelope = structuredClone(envelope); - overLimitEnvelope.seatbid[0].bid[0].ext.creativeurl = overLimit; + overLimitEnvelope.seatbid[0]!.bid[0]!.ext.creativeurl = overLimit; expect( validateApsRenderer( @@ -239,568 +473,3 @@ describe('APS renderer validation', () => { ).toBeUndefined(); }); }); - -describe('Prebid APS renderer registry', () => { - afterEach(() => { - delete window.tsjs; - }); - - it('bounds entries and evicts the oldest capability', () => { - for (let index = 0; index <= 256; index += 1) { - expect( - registerApsPrebidRenderer(`prebid-${index}`, 'fictional-slot', descriptor(), 300, { - markUsed: vi.fn(), - }) - ).toBe(true); - } - - expect(Object.keys(window.tsjs?.apsPrebidRenderers ?? {})).toHaveLength(256); - expect(getApsPrebidRenderer('prebid-0')).toBeUndefined(); - expect(getApsPrebidRenderer('prebid-256')).toEqual( - expect.objectContaining({ adUnitCode: 'fictional-slot', renderer: descriptor() }) - ); - }); - - it('rejects unsafe Prebid IDs and invalid descriptors', () => { - const lifecycle = { markUsed: vi.fn() }; - expect( - registerApsPrebidRenderer('__proto__', 'fictional-slot', descriptor(), 300, lifecycle) - ).toBe(false); - expect( - registerApsPrebidRenderer( - 'safe-prebid-id', - 'fictional-slot', - descriptor({ aaxResponse: 'invalid' }), - 300, - lifecycle - ) - ).toBe(false); - expect(window.tsjs?.apsPrebidRenderers).toBeUndefined(); - }); -}); - -describe('APS rendering-mode authorization', () => { - it('ignores mode markers and duplicate script tags injected after module initialization', () => { - document.body.innerHTML = '
'; - document.head.insertAdjacentHTML( - 'beforeend', - '' + - '' - ); - const trustedServer = vi.fn(() => true); - - expect( - dispatchDefaultApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer, - }) - ).toBe(true); - expect(trustedServer).toHaveBeenCalledOnce(); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - - document.head - .querySelectorAll( - 'meta[name="trusted-server-aps-rendering-mode"], script[data-ts-aps-rendering-mode]' - ) - .forEach((element) => element.remove()); - document.body.innerHTML = ''; - }); -}); - -describe('publisher-native APS runner contract tests', () => { - let dispatchApsRendering: typeof dispatchDefaultApsRendering; - - beforeEach(async () => { - vi.resetModules(); - document.body.innerHTML = '
existing
'; - const publisherScript = document.createElement('script'); - publisherScript.setAttribute(APS_RENDERING_MODE_ATTRIBUTE_NAME, 'publisher_native'); - const currentScriptSpy = vi - .spyOn(document, 'currentScript', 'get') - .mockReturnValue(publisherScript); - ({ dispatchApsRendering } = await import('../../../src/integrations/aps/render')); - currentScriptSpy.mockRestore(); - }); - - afterEach(() => { - delete window.tsjs; - vi.restoreAllMocks(); - document.body.innerHTML = ''; - }); - - it('queues the exact selected response for the fixed APS runner and commits on load', async () => { - const trustedServer = vi.fn(() => true); - const accepted = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer, - }); - const slot = document.getElementById('fictional-slot')!; - const frame = slot.querySelector('iframe')!; - const { runner, event } = nativeRunnerState(frame); - - expect(frame.getAttribute('sandbox')).toBeNull(); - expect(frame.style.display).toBe('none'); - expect(runner.src).toBe(APS_PREBID_CREATIVE_RUNNER_URL); - expect(event.type).toBe('prebid/creative/render'); - expect(event.detail).toEqual({ - aaxResponse: descriptor().aaxResponse, - seatBidId: descriptor().bidId, - }); - expect(slot.querySelector('span')).not.toBeNull(); - expect(trustedServer).not.toHaveBeenCalled(); - - const runnerDocument = frame.contentDocument!; - expect(runnerDocument.querySelector('meta[name="referrer"]')?.getAttribute('content')).toBe( - 'no-referrer' - ); - expect(runnerDocument.documentElement.style.margin).toBe('0px'); - expect(runnerDocument.documentElement.style.padding).toBe('0px'); - expect(runnerDocument.body.style.margin).toBe('0px'); - expect(runnerDocument.body.style.padding).toBe('0px'); - const creativeFrame = runnerDocument.createElement('iframe'); - runnerDocument.body.appendChild(creativeFrame); - await vi.waitFor(() => expect(creativeFrame.style.display).toBe('block')); - - runner.dispatchEvent(new Event('load')); - await expect(accepted).resolves.toBe(true); - expect(slot.querySelector('span')).toBeNull(); - expect(frame.style.display).toBe(''); - }); - - it('fails closed when the runner fails without clearing publisher content', async () => { - const trustedServer = vi.fn(() => true); - const accepted = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer, - }); - const frame = document.querySelector('#fictional-slot iframe')!; - const { runner } = nativeRunnerState(frame); - - runner.dispatchEvent(new Event('error')); - - await expect(accepted).resolves.toBe(false); - expect(trustedServer).not.toHaveBeenCalled(); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - }); - - it('cancels a pending runner when a newer dispatch replaces it', async () => { - const first = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer: () => true, - }); - const firstFrame = document.querySelector('#fictional-slot iframe')!; - - const second = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer: () => true, - }); - const secondFrame = document.querySelector('#fictional-slot iframe')!; - - expect(firstFrame.isConnected).toBe(false); - expect(secondFrame).not.toBe(firstFrame); - await expect(first).resolves.toBe(false); - nativeRunnerState(secondFrame).runner.dispatchEvent(new Event('load')); - await expect(second).resolves.toBe(true); - }); - - it('lets an invalid replacement cancel an older pending runner', async () => { - const first = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer: () => true, - }); - const second = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor({ aaxResponse: 'invalid' }), - trustedServer: () => true, - }); - - expect(second).toBe(false); - await expect(first).resolves.toBe(false); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - }); - - it('resolves a logical GPT slot through the injected div mapping', async () => { - document.body.innerHTML = '
existing
'; - window.tsjs = { divToSlotId: { 'div-header': 'homepage_header' } } as typeof window.tsjs; - - const accepted = dispatchApsRendering({ - slotId: 'homepage_header', - renderer: descriptor(), - trustedServer: () => true, - }); - const frame = document.querySelector('#div-header iframe')!; - nativeRunnerState(frame).runner.dispatchEvent(new Event('load')); - - await expect(accepted).resolves.toBe(true); - expect(document.querySelector('#div-header span')).toBeNull(); - }); - - it('renders inside the inner slot when Prebid uses its container ID', async () => { - document.body.innerHTML = - '
'; - const source = document.querySelector('#div-header > iframe')!.contentWindow; - - const accepted = dispatchApsRendering({ - slotId: 'div-header-container', - renderer: descriptor(), - source, - trustedServer: () => true, - }); - const frame = Array.from( - document.querySelectorAll('#div-header > iframe') - ).find((candidate) => candidate.title === 'Ad content')!; - nativeRunnerState(frame).runner.dispatchEvent(new Event('load')); - - await expect(accepted).resolves.toBe(true); - expect(document.getElementById('div-header-container')).not.toBeNull(); - expect(document.getElementById('div-header')).not.toBeNull(); - expect(document.querySelectorAll('#div-header > iframe')).toHaveLength(1); - }); - - it('uses the requesting frame to resolve a dynamic slot prefix', async () => { - document.body.innerHTML = - '
' + - '
'; - const source = document.querySelector( - '#div-header-second > iframe' - )!.contentWindow; - - const accepted = dispatchApsRendering({ - slotId: 'div-header-', - renderer: descriptor(), - source, - trustedServer: () => true, - }); - const frame = Array.from( - document.querySelectorAll('#div-header-second > iframe') - ).find((candidate) => candidate.title === 'Ad content')!; - nativeRunnerState(frame).runner.dispatchEvent(new Event('load')); - - await expect(accepted).resolves.toBe(true); - expect(document.querySelector('#div-header-first > iframe')).not.toBeNull(); - expect(document.querySelectorAll('#div-header-second > iframe')).toHaveLength(1); - }); - - it('contains throwing publisher slot mappings without falling back', async () => { - const tsjs = {} as NonNullable; - Object.defineProperty(tsjs, 'divToSlotId', { - get: () => { - throw new Error('fictional mapping lookup failure'); - }, - }); - window.tsjs = tsjs; - const trustedServer = vi.fn(() => true); - - await expect( - dispatchApsRendering({ - slotId: 'logical-slot', - renderer: descriptor(), - trustedServer, - }) - ).resolves.toBe(false); - expect(trustedServer).not.toHaveBeenCalled(); - expect(document.querySelector('iframe')).toBeNull(); - }); - - it('times out an unacknowledged runner without clearing publisher content', async () => { - vi.useFakeTimers(); - try { - const result = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer: () => true, - }); - await vi.advanceTimersByTimeAsync(APS_NATIVE_RENDERER_TIMEOUT_MS); - - await expect(result).resolves.toBe(false); - expect(vi.getTimerCount()).toBe(0); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - } finally { - vi.useRealTimers(); - } - }); -}); - -describe('direct APS rendering', () => { - beforeEach(() => { - document.body.innerHTML = '
existing
'; - }); - - afterEach(() => { - vi.restoreAllMocks(); - document.body.innerHTML = ''; - }); - - it('keeps a valid default frame when an invalid replacement is rejected', () => { - const trustedServer = (renderer: ApsRendererV1): boolean => - renderApsCreative({ slotId: 'fictional-slot', renderer }); - expect( - dispatchDefaultApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer, - }) - ).toBe(true); - - const slot = document.getElementById('fictional-slot')!; - const iframe = slot.querySelector('iframe')!; - const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); - iframe.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0][0] as { nonce: string }; - - expect( - dispatchDefaultApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor({ aaxResponse: 'invalid' }), - trustedServer, - }) - ).toBe(false); - expect(iframe.isConnected).toBe(true); - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: iframe.contentWindow, - }) - ); - expect(slot.querySelector('span')).toBeNull(); - expect(iframe.style.display).toBe(''); - }); - - it('loads the static route with a fragment-bound 128-bit nonce and opaque sandbox', () => { - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - - const slot = document.getElementById('fictional-slot')!; - const iframe = slot.querySelector('iframe')!; - const existing = slot.querySelector('span'); - expect(existing).not.toBeNull(); - expect(iframe.src).toMatch(/\/integrations\/aps\/renderer#tsaps=[A-Za-z0-9_-]{22}$/); - expect(iframe.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); - expect(iframe.getAttribute('sandbox')).not.toContain('allow-same-origin'); - expect(iframe.srcdoc).toBe(''); - - const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); - iframe.dispatchEvent(new Event('load')); - - expect(slot.querySelector('span')).not.toBeNull(); - expect(iframe.style.display).toBe('none'); - expect(postMessage).toHaveBeenCalledTimes(1); - expect(postMessage).toHaveBeenCalledWith( - { - nonce: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/), - renderer: descriptor(), - }, - '*' - ); - - const message = postMessage.mock.calls[0][0] as { nonce: string }; - window.dispatchEvent( - new MessageEvent('message', { - data: { - message: 'trusted-server/aps/renderer-ready', - nonce: `wrong-${message.nonce}`, - }, - source: iframe.contentWindow, - }) - ); - expect(slot.querySelector('span')).not.toBeNull(); - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: message.nonce }, - source: iframe.contentWindow, - }) - ); - expect(slot.querySelector('span')).toBeNull(); - expect(iframe.style.display).toBe(''); - }); - - it('rejects a ready message with the correct nonce from a foreign window', () => { - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - - const slot = document.getElementById('fictional-slot')!; - const rendererFrame = slot.querySelector('iframe')!; - const postMessage = vi.spyOn(rendererFrame.contentWindow!, 'postMessage'); - rendererFrame.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0][0] as { nonce: string }; - const foreignFrame = document.createElement('iframe'); - document.body.appendChild(foreignFrame); - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: foreignFrame.contentWindow, - }) - ); - - expect(slot.querySelector('span')).not.toBeNull(); - expect(rendererFrame.style.display).toBe('none'); - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: rendererFrame.contentWindow, - }) - ); - - expect(slot.querySelector('span')).toBeNull(); - expect(rendererFrame.style.display).toBe(''); - }); - - it('leaves existing slot content intact when validation or loading fails', () => { - expect( - renderApsCreative({ - slotId: 'fictional-slot', - renderer: descriptor({ aaxResponse: 'invalid' }), - }) - ).toBe(false); - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const iframe = document.querySelector('#fictional-slot iframe')!; - iframe.dispatchEvent(new Event('error')); - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - }); - - it('removes an unacknowledged frame without clearing publisher content', () => { - vi.useFakeTimers(); - try { - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const iframe = document.querySelector('#fictional-slot iframe')!; - iframe.dispatchEvent(new Event('load')); - - vi.advanceTimersByTime(10_000); - - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - } finally { - vi.useRealTimers(); - } - }); - - it('immediately cancels a superseded pending frame and its timeout', () => { - vi.useFakeTimers(); - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - try { - const baselineTimers = vi.getTimerCount(); - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const firstFrame = document.querySelector('#fictional-slot iframe')!; - const firstPostMessage = vi.spyOn(firstFrame.contentWindow!, 'postMessage'); - firstFrame.dispatchEvent(new Event('load')); - const firstSent = firstPostMessage.mock.calls[0][0] as { nonce: string }; - const timersAfterFirst = vi.getTimerCount(); - expect(timersAfterFirst).toBeGreaterThan(baselineTimers); - - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const secondFrame = document.querySelector('#fictional-slot iframe')!; - expect(firstFrame.isConnected).toBe(false); - expect(vi.getTimerCount()).toBe(timersAfterFirst); - const postMessage = vi.spyOn(secondFrame.contentWindow!, 'postMessage'); - secondFrame.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: firstSent.nonce }, - source: firstFrame.contentWindow, - }) - ); - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - expect((secondFrame as HTMLIFrameElement).style.display).toBe('none'); - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: secondFrame.contentWindow, - }) - ); - - vi.advanceTimersByTime(10_000); - expect(warnSpy).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); -}); - -describe('Universal Creative APS source', () => { - it('uses the deployed dynamic renderer protocol and only creates the opaque route frame', () => { - expect(APS_UNIVERSAL_CREATIVE_RENDERER_VERSION).toBeGreaterThanOrEqual(4); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain('window.render=function'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain('d&&d.apsRenderer'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain('d&&d.rendererUrl'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain(APS_RENDERER_PATH); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain(APS_RENDERER_SANDBOX); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('allow-same-origin'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('srcdoc'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('document.write'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('creativeUrl'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('aaxResponse'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('example-account-id'); - }); - - it('computes an absolute renderer URL from the publisher origin', () => { - expect(apsRendererUrl()).toBe(new URL(APS_RENDERER_PATH, window.location.origin).href); - expect(apsRendererUrl('http://publisher.example')).toBe( - `http://publisher.example${APS_RENDERER_PATH}` - ); - expect(apsRendererUrl('not an origin')).toBeUndefined(); - }); - - it('creates the opaque route frame and resolves only after the bound acknowledgement', async () => { - const dynamicWindow = window as unknown as { - render?: (data: Record, helper: unknown, target: Window) => Promise; - }; - window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); - - try { - const renderer = descriptor(); - const rendered = dynamicWindow.render!( - { - apsRenderer: renderer, - rendererUrl: apsRendererUrl(), - }, - undefined, - window - ); - const iframe = document.body.querySelector('iframe')!; - expect(iframe.src).toMatch(/\/integrations\/aps\/renderer#tsaps=[A-Za-z0-9_-]{22}$/); - expect(iframe.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); - expect(iframe.getAttribute('sandbox')).not.toContain('allow-same-origin'); - - const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); - iframe.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0][0] as { nonce: string; renderer: ApsRendererV1 }; - expect(sent.renderer).toEqual(renderer); - - let settled = false; - void rendered.then(() => { - settled = true; - }); - await Promise.resolve(); - expect(settled).toBe(false); - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: iframe.contentWindow, - }) - ); - await expect(rendered).resolves.toBeUndefined(); - } finally { - delete dynamicWindow.render; - document.body.innerHTML = ''; - } - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts index 85a231553..024f24475 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts @@ -1,6 +1,12 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { FIRST_PARTY_CLICK, MUTATED_CLICK, PROXY_RESPONSE, importCreativeModule } from './helpers'; +import { + FIRST_PARTY_CLICK, + MUTATED_CLICK, + PROXY_RESPONSE, + activateCreativeRuntime, + disposeImportedCreativeModule, +} from './helpers'; const ORIGINAL_FETCH = global.fetch; @@ -11,15 +17,47 @@ const REBUILD_PREFIX = absolute('/first-party/proxy-rebuild?'); describe('creative/click.ts', () => { beforeEach(() => { + disposeImportedCreativeModule(); vi.resetModules(); document.body.innerHTML = ''; }); afterEach(() => { + disposeImportedCreativeModule(); global.fetch = ORIGINAL_FETCH; vi.useRealTimers(); }); + it('owns click listeners and defers the baseline scan until requested', async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ href: PROXY_RESPONSE }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', MUTATED_CLICK); + document.body.appendChild(anchor); + const removeEventListener = vi.spyOn(document, 'removeEventListener'); + const { installClickGuard } = await import('../../../src/integrations/creative/click'); + + const handle = installClickGuard(false); + await Promise.resolve(); + await vi.runAllTimersAsync(); + expect(fetchMock).not.toHaveBeenCalled(); + + handle.scan(); + await Promise.resolve(); + await vi.runAllTimersAsync(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + handle.dispose(); + handle.dispose(); + expect(removeEventListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1); + expect(removeEventListener.mock.calls.filter(([type]) => type === 'auxclick')).toHaveLength(1); + }); + it('repairs anchors via proxy rebuild fallback when fetch is unavailable', async () => { vi.useFakeTimers(); global.fetch = undefined as unknown as typeof fetch; @@ -29,7 +67,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', FIRST_PARTY_CLICK); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); anchor.setAttribute('href', MUTATED_CLICK); @@ -56,7 +94,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', FIRST_PARTY_CLICK); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); anchor.setAttribute('href', MUTATED_CLICK); @@ -64,7 +102,7 @@ describe('creative/click.ts', () => { await vi.runAllTimersAsync(); expect(fetchMock).toHaveBeenCalled(); - const call = fetchMock.mock.calls[0]; + const call = fetchMock.mock.calls[0]!; expect(call[0]).toBe('/first-party/proxy-rebuild'); const payload = JSON.parse(call[1]?.body as string); expect(payload).toEqual({ @@ -74,37 +112,27 @@ describe('creative/click.ts', () => { }); expect(anchor.getAttribute('href')).toBe(absolute(PROXY_RESPONSE)); - // data-tsclick keeps the server's root-relative shape: it is echoed back as - // the rebuild payload's `tsclick`, which the server parses as a click path. expect(anchor.getAttribute('data-tsclick')).toBe(PROXY_RESPONSE); }); it('sends a root-relative tsclick on a second rebuild after a successful one', async () => { - // Regression: persisting an absolute canonical click made the next rebuild - // POST a value the server rejects as an invalid click path, so the second - // mutation was silently lost on non-opaque consumers. vi.useFakeTimers(); - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ href: PROXY_RESPONSE }), }); global.fetch = fetchMock as unknown as typeof fetch; - const anchor = document.createElement('a'); anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); anchor.setAttribute('href', FIRST_PARTY_CLICK); document.body.appendChild(anchor); - await importCreativeModule(); - + await activateCreativeRuntime(); anchor.setAttribute('href', MUTATED_CLICK); await Promise.resolve(); await vi.runAllTimersAsync(); - expect(anchor.getAttribute('data-tsclick')).toBe(PROXY_RESPONSE); - // A second mutation now diffs against the rebuilt canonical click. anchor.setAttribute('href', 'https://example.com/landing?baz=3'); await Promise.resolve(); await vi.runAllTimersAsync(); @@ -113,11 +141,9 @@ describe('creative/click.ts', () => { const payloads = fetchMock.mock.calls.map( (call) => JSON.parse(call[1]?.body as string) as { tsclick: string } ); - // Every payload must carry the server's root-relative click form: an - // absolute one is rejected as an invalid click path. - for (const payload of payloads) { - expect(payload.tsclick.startsWith('/first-party/click?')).toBe(true); - } + expect(payloads.every((payload) => payload.tsclick.startsWith('/first-party/click?'))).toBe( + true + ); expect(payloads.some((payload) => payload.tsclick === PROXY_RESPONSE)).toBe(true); }); @@ -142,7 +168,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', FIRST_PARTY_CLICK); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); anchor.setAttribute('href', MUTATED_CLICK); @@ -182,7 +208,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', FIRST_PARTY_CLICK); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); anchor.setAttribute('href', MUTATED_CLICK); await Promise.resolve(); @@ -231,7 +257,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('target', '_blank'); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); // Wave 1: creative mutates the link, observer repairs it. anchor.setAttribute('href', MUTATED_CLICK); @@ -247,7 +273,7 @@ describe('creative/click.ts', () => { await vi.runAllTimersAsync(); expect(openMock).toHaveBeenCalled(); - const navigated = String(openMock.mock.calls[0][0]); + const navigated = String(openMock.mock.calls[0]![0]); expect(navigated.startsWith(REBUILD_PREFIX)).toBe(true); expect(navigated).toContain('add=%7B%22bar%22%3A%222%22%7D'); expect(navigated).not.toBe(absolute(FIRST_PARTY_CLICK)); @@ -261,17 +287,62 @@ describe('creative/click.ts', () => { } }); - it('navigates an over-long rebuild through a form POST instead of a GET URL', async () => { - // Fastly rejects request URLs over 8192 bytes before the handler runs, and - // a signed click with many tracking params exceeds that once nested in - // another query string. A form body has no such bound, and a submission is - // a navigation — so it is not blocked by CORS from the opaque origin. + it('does not reuse an opaque rebuild from a disposed guard generation', async () => { vi.useFakeTimers(); - + const nextClick = + '/first-party/click?tsurl=https%3A%2F%2Fexample.com%2Fnext&wave=2&tstoken=nexttoken'; const originDescriptor = Object.getOwnPropertyDescriptor(window, 'origin'); Object.defineProperty(window, 'origin', { value: 'null', configurable: true }); global.fetch = undefined as unknown as typeof fetch; + const openMock = vi.fn(); + const originalOpen = window.open; + window.open = openMock as unknown as typeof window.open; + let firstGeneration: { dispose(): void; scan(): void } | undefined; + let secondGeneration: { dispose(): void; scan(): void } | undefined; + + try { + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', MUTATED_CLICK); + anchor.setAttribute('target', '_blank'); + document.body.appendChild(anchor); + const { installClickGuard } = await import('../../../src/integrations/creative/click'); + firstGeneration = installClickGuard(false); + firstGeneration.scan(); + await Promise.resolve(); + await vi.runAllTimersAsync(); + const firstFallback = anchor.getAttribute('href') ?? ''; + expect(firstFallback.startsWith(REBUILD_PREFIX)).toBe(true); + + firstGeneration.dispose(); + anchor.setAttribute('data-tsclick', nextClick); + expect(anchor.getAttribute('href')).toBe(firstFallback); + + secondGeneration = installClickGuard(false); + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(openMock).toHaveBeenCalledWith(absolute(nextClick), '_blank', 'noopener,noreferrer'); + expect(openMock).not.toHaveBeenCalledWith(firstFallback, '_blank', 'noopener,noreferrer'); + } finally { + secondGeneration?.dispose(); + firstGeneration?.dispose(); + window.open = originalOpen; + if (originDescriptor) { + Object.defineProperty(window, 'origin', originDescriptor); + } else { + delete (window as { origin?: string }).origin; + } + } + }); + + it('navigates an over-long opaque-origin rebuild through a form POST', async () => { + vi.useFakeTimers(); + const originDescriptor = Object.getOwnPropertyDescriptor(window, 'origin'); + Object.defineProperty(window, 'origin', { value: 'null', configurable: true }); + global.fetch = undefined as unknown as typeof fetch; const submits: HTMLFormElement[] = []; const originalSubmit = HTMLFormElement.prototype.submit; HTMLFormElement.prototype.submit = function patched(this: HTMLFormElement) { @@ -279,7 +350,6 @@ describe('creative/click.ts', () => { }; try { - // A signed click long enough that the nested rebuild URL crosses the cap. const filler = 'a'.repeat(6800); const longClick = `/first-party/click?tsurl=https%3A%2F%2Fexample.com%2Flanding&foo=1&pad=${filler}&tstoken=token123`; const anchor = document.createElement('a'); @@ -287,24 +357,21 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', longClick); document.body.appendChild(anchor); - await importCreativeModule(); - + await activateCreativeRuntime(); anchor.setAttribute('href', `https://example.com/landing?pad=${filler}&bar=2`); await Promise.resolve(); await vi.runAllTimersAsync(); - anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await Promise.resolve(); await vi.runAllTimersAsync(); expect(submits.length).toBeGreaterThan(0); const form = submits[0]; - expect(form.method.toLowerCase()).toBe('post'); - expect(form.action).toContain('/first-party/proxy-rebuild'); - const tsclick = form.querySelector('input[name="tsclick"]') as HTMLInputElement | null; - expect(tsclick?.value).toBe(longClick); - const add = form.querySelector('input[name="add"]') as HTMLInputElement | null; - expect(add?.value).toContain('bar'); + expect(form?.method.toLowerCase()).toBe('post'); + expect(form?.action).toContain('/first-party/proxy-rebuild'); + expect(form?.rel).toBe('noopener noreferrer'); + expect(form?.querySelector('input[name="tsclick"]')?.value).toBe(longClick); + expect(form?.querySelector('input[name="add"]')?.value).toContain('bar'); } finally { HTMLFormElement.prototype.submit = originalSubmit; if (originDescriptor) { @@ -315,6 +382,38 @@ describe('creative/click.ts', () => { } }); + it('does not form-submit a long non-rebuild URL containing the rebuild path', async () => { + vi.useFakeTimers(); + const submits: HTMLFormElement[] = []; + const originalSubmit = HTMLFormElement.prototype.submit; + HTMLFormElement.prototype.submit = function patched(this: HTMLFormElement) { + submits.push(this); + }; + const openMock = vi.fn(); + const originalOpen = window.open; + window.open = openMock as unknown as typeof window.open; + + try { + const targetUrl = `/first-party/landing?next=/first-party/proxy-rebuild&tsclick=fictional&pad=${'a'.repeat(7000)}`; + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', targetUrl); + anchor.setAttribute('href', targetUrl); + anchor.setAttribute('target', '_blank'); + document.body.appendChild(anchor); + + await activateCreativeRuntime(); + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(submits).toHaveLength(0); + expect(openMock).toHaveBeenCalledWith(absolute(targetUrl), '_blank', 'noopener,noreferrer'); + } finally { + HTMLFormElement.prototype.submit = originalSubmit; + window.open = originalOpen; + } + }); + it('refuses to navigate to or persist non-http(s) URLs', async () => { // The guard reads creative-controlled attributes; a javascript: value must // never reach location.href or an href write. @@ -326,7 +425,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', 'javascript:evil()'); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await Promise.resolve(); @@ -337,4 +436,96 @@ describe('creative/click.ts', () => { // unhandled navigation error is the assertion that location.href was // never assigned the javascript: URL. }); + + it.each([ + 'https://user@example.com/landing', + 'https://:password@example.com/landing', + 'https://%75ser:%70assword@example.com/landing', + ])('refuses a credential-bearing navigation URL: %s', async (targetUrl) => { + vi.useFakeTimers(); + const openMock = vi.fn(); + const originalOpen = window.open; + window.open = openMock as unknown as typeof window.open; + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', targetUrl); + anchor.setAttribute('href', targetUrl); + anchor.setAttribute('target', '_blank'); + document.body.appendChild(anchor); + + try { + await activateCreativeRuntime(); + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(openMock).not.toHaveBeenCalled(); + expect(anchor.getAttribute('href')).toBe(targetUrl); + } finally { + window.open = originalOpen; + } + }); + + it.each([ + ['absolute', 'https://example.com/landing?campaign=fictional'], + ['root-relative', '/first-party/landing?campaign=fictional'], + ])('preserves valid %s HTTP(S) navigation', async (_caseName, targetUrl) => { + vi.useFakeTimers(); + const openMock = vi.fn(); + const originalOpen = window.open; + window.open = openMock as unknown as typeof window.open; + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', targetUrl); + anchor.setAttribute('href', targetUrl); + anchor.setAttribute('target', '_blank'); + document.body.appendChild(anchor); + + try { + await activateCreativeRuntime(); + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(openMock).toHaveBeenCalledWith(absolute(targetUrl), '_blank', 'noopener,noreferrer'); + } finally { + window.open = originalOpen; + } + }); + + it.each(['success', 'error'] as const)( + 'does not persist a late proxy-rebuild %s after disposal', + async (outcome) => { + let resolveFetch: ((response: Response) => void) | undefined; + let rejectFetch: ((reason: unknown) => void) | undefined; + global.fetch = vi.fn( + () => + new Promise((resolve, reject) => { + resolveFetch = resolve; + rejectFetch = reject; + }) + ); + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', MUTATED_CLICK); + document.body.appendChild(anchor); + const { installClickGuard } = await import('../../../src/integrations/creative/click'); + const handle = installClickGuard(false); + + handle.scan(); + await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)); + handle.dispose(); + if (outcome === 'success') { + resolveFetch?.({ + ok: true, + json: async () => ({ href: PROXY_RESPONSE }), + } as Response); + } else { + rejectFetch?.(new Error('fictional late proxy failure')); + } + await Promise.resolve(); + await Promise.resolve(); + + expect(anchor.getAttribute('href')).toBe(MUTATED_CLICK); + expect(anchor.getAttribute('data-tsclick')).toBe(FIRST_PARTY_CLICK); + } + ); }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts b/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts index 176a5e5ab..8e86675fd 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts @@ -17,19 +17,43 @@ export const MUTATED_CLICK = 'https://example.com/landing?bar=2'; export const PROXY_RESPONSE = '/first-party/click?tsurl=https%3A%2F%2Fexample.com%2Flanding&bar=2&tstoken=newtoken'; -import type { TsCreativeConfig } from '../../../src/shared/globals'; +import type { CreativeBootV1 } from '../../../src/core/types'; -export async function importCreativeModule(config?: TsCreativeConfig): Promise { - const globalRef = globalThis as { - __ts_creative_installed?: boolean; - tsCreativeConfig?: TsCreativeConfig; - }; - delete globalRef.__ts_creative_installed; - if (config) { - globalRef.tsCreativeConfig = config; - } - await import('../../../src/integrations/creative/index'); - if (config) { - delete globalRef.tsCreativeConfig; - } +let disposeLastImportedCreative: (() => void) | undefined; + +export function disposeImportedCreativeModule(): void { + const dispose = disposeLastImportedCreative; + disposeLastImportedCreative = undefined; + dispose?.(); +} + +export async function activateCreativeRuntime( + config: Partial> = {} +): Promise { + disposeImportedCreativeModule(); + const [ + { installClickGuard }, + { installDynamicIframeProxy }, + { installDynamicImageProxy }, + startup, + ] = await Promise.all([ + import('../../../src/integrations/creative/click'), + import('../../../src/integrations/creative/iframe'), + import('../../../src/integrations/creative/image'), + import('../../../src/integrations/creative/startup'), + ]); + const boot = Object.freeze({ + version: 1 as const, + enabled: true, + clickGuard: config.clickGuard ?? true, + renderGuard: config.renderGuard ?? false, + }); + const runtime = startup.createCreativeStartup({ + document, + installClickGuard: () => installClickGuard(false), + installDynamicIframeProxy: () => installDynamicIframeProxy(false), + installDynamicImageProxy: () => installDynamicImageProxy(false), + }); + disposeLastImportedCreative = runtime.activate(boot); + runtime.start(boot); } diff --git a/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts index f4b985532..b9cac6939 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts @@ -1,16 +1,18 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { importCreativeModule, waitForExpect } from './helpers'; +import { activateCreativeRuntime, disposeImportedCreativeModule, waitForExpect } from './helpers'; describe('creative/iframe.ts', () => { const ORIGINAL_FETCH = global.fetch; beforeEach(() => { + disposeImportedCreativeModule(); vi.resetModules(); document.body.innerHTML = ''; }); afterEach(() => { + disposeImportedCreativeModule(); global.fetch = ORIGINAL_FETCH; }); @@ -23,7 +25,7 @@ describe('creative/iframe.ts', () => { }); global.fetch = fetchMock as unknown as typeof fetch; - await importCreativeModule({ renderGuard: true }); + await activateCreativeRuntime({ renderGuard: true }); const iframe = document.createElement('iframe'); iframe.src = 'https://frame.example/widget.html?cb=1'; @@ -45,7 +47,7 @@ describe('creative/iframe.ts', () => { }); global.fetch = fetchMock as unknown as typeof fetch; - await importCreativeModule({ renderGuard: true }); + await activateCreativeRuntime({ renderGuard: true }); const iframe = document.createElement('iframe'); iframe.src = 'https://blocked.example.com/rejected.html'; @@ -61,7 +63,7 @@ describe('creative/iframe.ts', () => { const fetchMock = vi.fn().mockRejectedValue(new Error('network')); global.fetch = fetchMock as unknown as typeof fetch; - await importCreativeModule({ renderGuard: true }); + await activateCreativeRuntime({ renderGuard: true }); const iframe = document.createElement('iframe'); iframe.src = 'https://frame.example/fallback.html'; @@ -71,4 +73,24 @@ describe('creative/iframe.ts', () => { expect(iframe.src).toContain('https://frame.example/fallback.html'); }); }); + + it('cancels queued and future iframe rewrites on disposal', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ href: '/first-party/proxy?tsurl=iframe&tstoken=token&tsexp=1' }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + const { installDynamicIframeProxy } = await import('../../../src/integrations/creative/iframe'); + const handle = installDynamicIframeProxy(false); + const iframe = document.createElement('iframe'); + iframe.setAttribute('src', 'https://frame.example/queued.html'); + + handle.dispose(); + await Promise.resolve(); + iframe.setAttribute('src', 'https://frame.example/later.html'); + await Promise.resolve(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(iframe.src).toContain('https://frame.example/later.html'); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts index 7459d0a11..01cbac0d7 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts @@ -1,16 +1,18 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { importCreativeModule, waitForExpect } from './helpers'; +import { activateCreativeRuntime, disposeImportedCreativeModule, waitForExpect } from './helpers'; const ORIGINAL_FETCH = global.fetch; describe('creative/image.ts', () => { beforeEach(() => { + disposeImportedCreativeModule(); vi.resetModules(); document.body.innerHTML = ''; }); afterEach(() => { + disposeImportedCreativeModule(); global.fetch = ORIGINAL_FETCH; }); @@ -23,7 +25,7 @@ describe('creative/image.ts', () => { }); global.fetch = fetchMock as unknown as typeof fetch; - await importCreativeModule({ renderGuard: true }); + await activateCreativeRuntime({ renderGuard: true }); const img = new Image(); img.src = 'https://img.example/pixel.gif?cb=1'; @@ -45,7 +47,7 @@ describe('creative/image.ts', () => { }); global.fetch = fetchMock as unknown as typeof fetch; - await importCreativeModule({ renderGuard: true }); + await activateCreativeRuntime({ renderGuard: true }); const img = new Image(); img.src = '/existing.png'; @@ -62,7 +64,7 @@ describe('creative/image.ts', () => { const fetchMock = vi.fn().mockRejectedValue(new Error('network')); global.fetch = fetchMock as unknown as typeof fetch; - await importCreativeModule({ renderGuard: true }); + await activateCreativeRuntime({ renderGuard: true }); const img = new Image(); img.src = 'https://img.example/fallback.png'; @@ -72,4 +74,30 @@ describe('creative/image.ts', () => { expect(img.src).toContain('https://img.example/fallback.png'); }); }); + + it('defers the baseline scan and restores only its exact hooks on disposal', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ href: '/first-party/proxy?tsurl=image&tstoken=token&tsexp=1' }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + const image = document.createElement('img'); + image.setAttribute('src', 'https://img.example/preexisting.png'); + document.body.appendChild(image); + const baselineSrc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src'); + const baselineSetAttribute = HTMLImageElement.prototype.setAttribute; + const { installDynamicImageProxy } = await import('../../../src/integrations/creative/image'); + + const handle = installDynamicImageProxy(false); + await Promise.resolve(); + expect(fetchMock).not.toHaveBeenCalled(); + + handle.scan(); + await waitForExpect(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + handle.dispose(); + handle.dispose(); + + expect(Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')).toEqual(baselineSrc); + expect(HTMLImageElement.prototype.setAttribute).toBe(baselineSetAttribute); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts new file mode 100644 index 000000000..cbae6e301 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts @@ -0,0 +1,364 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const ownedGuards = vi.hoisted(() => ({ + installClick: vi.fn(), + installIframe: vi.fn(), + installImage: vi.fn(), +})); + +vi.mock('../../../src/integrations/creative/click', () => ({ + installClickGuard: ownedGuards.installClick, +})); +vi.mock('../../../src/integrations/creative/iframe', () => ({ + installDynamicIframeProxy: ownedGuards.installIframe, +})); +vi.mock('../../../src/integrations/creative/image', () => ({ + installDynamicImageProxy: ownedGuards.installImage, +})); + +import { createCreativeIntegrationRegistration } from '../../../src/integrations/creative/module'; +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, + type IntegrationRegistration, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +function manifest(ids: readonly string[]) { + return { + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => ({ id, phase: 'takeover' as const })), + }; +} + +function catalog(ids: readonly string[]) { + return Object.freeze( + ids.map((id) => + Object.freeze({ + id, + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze(id === 'creative' ? ['runtime.v1'] : []), + provides: Object.freeze([]), + }) + ) + ); +} + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +function registration( + id: string, + prepare: IntegrationRegistration['prepare'] +): IntegrationRegistration { + return Object.freeze({ + abi: 1, + id, + phase: 'takeover', + releaseId: RELEASE_ID, + prepareSync: () => Object.freeze({ activate: () => undefined }), + prepare, + }); +} + +function runtimeCapability() { + return Object.freeze({ document }); +} + +function guard(name: string, order: string[]) { + return Object.freeze({ + dispose: vi.fn(() => order.push(`dispose:${name}`)), + scan: vi.fn(() => order.push(`scan:${name}`)), + }); +} + +describe('transactional creative integration module', () => { + beforeEach(() => { + ownedGuards.installClick.mockReset(); + ownedGuards.installIframe.mockReset(); + ownedGuards.installImage.mockReset(); + }); + + it('prepares inertly, activates reversible guards, and scans only after commit', async () => { + const config = Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: true, + }); + const order: string[] = []; + const click = guard('click', order); + const image = guard('image', order); + const iframe = guard('iframe', order); + ownedGuards.installClick.mockImplementation(() => { + order.push('install:click'); + return click; + }); + ownedGuards.installImage.mockImplementation(() => { + order.push('install:image'); + return image; + }); + ownedGuards.installIframe.mockImplementation(() => { + order.push('install:iframe'); + return iframe; + }); + let finishPreparation: (() => void) | undefined; + const preparationGate = new Promise((resolve) => { + finishPreparation = resolve; + }); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative', 'gate']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative', 'gate']), + catalog: catalog(['creative', 'gate']), + startedAtMs: 0, + now: () => 0, + runtimeCapability: runtimeCapability(), + getBindings: () => ({ + config, + interfaces: Object.freeze({}), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + registry.register( + registration('gate', async () => { + order.push('gate:prepare'); + await preparationGate; + return Object.freeze({ activate: () => order.push('gate:activate') }); + }) + ); + + const installing = registry.install(callbacks(order)); + await vi.waitFor(() => expect(order).toEqual(['gate:prepare'])); + expect(ownedGuards.installClick).not.toHaveBeenCalled(); + + finishPreparation?.(); + const result = await installing; + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'gate:prepare', + 'core', + 'install:click', + 'install:image', + 'install:iframe', + 'gate:activate', + 'publish', + 'scan:click', + 'scan:image', + 'scan:iframe', + 'drain', + ]); + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(order.slice(-3)).toEqual(['dispose:iframe', 'dispose:image', 'dispose:click']); + }); + + it('performs no runtime work when enabled with both guards false', async () => { + const config = Object.freeze({ + version: 1 as const, + enabled: true, + clickGuard: false, + renderGuard: false, + }); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative']), + catalog: catalog(['creative']), + startedAtMs: 0, + now: () => 0, + runtimeCapability: runtimeCapability(), + getBindings: () => ({ + config, + interfaces: Object.freeze({}), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ state: 'kernel' }); + expect(ownedGuards.installClick).not.toHaveBeenCalled(); + expect(ownedGuards.installImage).not.toHaveBeenCalled(); + expect(ownedGuards.installIframe).not.toHaveBeenCalled(); + }); + + it('unwinds creative activation before a later module failure', async () => { + const order: string[] = []; + const click = guard('click', order); + ownedGuards.installClick.mockReturnValue(click); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative', 'broken']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative', 'broken']), + catalog: catalog(['creative', 'broken']), + startedAtMs: 0, + now: () => 0, + runtimeCapability: runtimeCapability(), + getBindings: () => ({ + config: Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }), + interfaces: Object.freeze({}), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + registry.register( + registration('broken', () => ({ + activate: () => { + throw new Error('fictional creative peer failure'); + }, + })) + ); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(click.dispose).toHaveBeenCalledTimes(1); + expect(click.scan).not.toHaveBeenCalled(); + }); + + it.each([ + ['missing field', Object.freeze({ version: 1, enabled: true, clickGuard: true })], + [ + 'unknown field', + Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + extra: true, + }), + ], + [ + 'accessor', + Object.freeze( + Object.defineProperty({ version: 1, enabled: true, clickGuard: true }, 'renderGuard', { + enumerable: true, + get: () => false, + }) + ), + ], + [ + 'non-plain object', + Object.freeze( + Object.assign(Object.create({ inherited: true }) as object, { + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }) + ), + ], + ['mutable object', { version: 1, enabled: true, clickGuard: true, renderGuard: false }], + [ + 'disabled click guard', + Object.freeze({ version: 1, enabled: false, clickGuard: true, renderGuard: false }), + ], + [ + 'disabled render guard', + Object.freeze({ version: 1, enabled: false, clickGuard: false, renderGuard: true }), + ], + ])('rejects %s configuration during inert preparation', async (_caseName, config) => { + const activate = vi.fn(); + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative']), + catalog: catalog(['creative']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ creative: Object.freeze({ activate, start }) }), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + }); + + it('fails preparation without effects when composition omits runtime.v1', async () => { + const registry = createIntegrationRegistry({ + manifest: manifest(['creative']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative']), + catalog: catalog(['creative']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }), + interfaces: Object.freeze({}), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + }); + + it('contains a post-commit guard scan failure inside the creative module', async () => { + const runtimeFailures: unknown[] = []; + const order: string[] = []; + const click = guard('click', order); + vi.mocked(click.scan).mockImplementation(() => { + throw new Error('fictional creative scan failure'); + }); + ownedGuards.installClick.mockReturnValue(click); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative']), + catalog: catalog(['creative']), + startedAtMs: 0, + now: () => 0, + runtimeCapability: runtimeCapability(), + onRuntimeFailure: (failure) => runtimeFailures.push(failure), + getBindings: () => ({ + config: Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }), + interfaces: Object.freeze({}), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'kernel', + runtimeFailures: [], + }); + expect(click.scan).toHaveBeenCalledOnce(); + expect(runtimeFailures).toEqual([]); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts new file mode 100644 index 000000000..6e1b8bcb5 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FIRST_PARTY_CLICK, MUTATED_CLICK, waitForExpect } from './helpers'; + +const ORIGINAL_FETCH = global.fetch; + +describe('creative guard ownership', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + }); + + afterEach(() => { + global.fetch = ORIGINAL_FETCH; + vi.useRealTimers(); + }); + + it('defers the click scan and releases its observer and capture listeners', async () => { + vi.useFakeTimers(); + global.fetch = undefined as unknown as typeof fetch; + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', MUTATED_CLICK); + document.body.appendChild(anchor); + const { installClickGuard } = await import('../../../src/integrations/creative/click'); + + const guard = installClickGuard(false); + await Promise.resolve(); + await vi.runAllTimersAsync(); + expect(anchor.getAttribute('href')).toBe(MUTATED_CLICK); + + guard.scan(); + await Promise.resolve(); + await vi.runAllTimersAsync(); + expect(anchor.getAttribute('href')).toContain('/first-party/proxy-rebuild?'); + + guard.dispose(); + guard.dispose(); + anchor.setAttribute('href', MUTATED_CLICK); + const click = new MouseEvent('click', { bubbles: true, cancelable: true }); + anchor.dispatchEvent(click); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(click.defaultPrevented).toBe(false); + expect(anchor.getAttribute('href')).toBe(MUTATED_CLICK); + }); + + it('defers image scans, cancels late signing, and compare-restores owned hooks', async () => { + let resolveSigning: ((value: unknown) => void) | undefined; + const fetchMock = vi.fn( + () => + new Promise((resolve) => { + resolveSigning = resolve; + }) + ); + global.fetch = fetchMock as unknown as typeof fetch; + const image = document.createElement('img'); + image.setAttribute('src', 'https://img.example/existing.gif'); + document.body.appendChild(image); + const descriptorBefore = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src'); + const { installDynamicImageProxy } = await import('../../../src/integrations/creative/image'); + + const guard = installDynamicImageProxy(false); + expect(fetchMock).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')).not.toEqual( + descriptorBefore + ); + + guard.scan(); + await waitForExpect(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + guard.dispose(); + resolveSigning?.({ + ok: true, + json: async () => ({ href: '/first-party/proxy?late=1' }), + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(image.getAttribute('src')).toBe('https://img.example/existing.gif'); + expect(Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')).toEqual( + descriptorBefore + ); + + const replacement = installDynamicImageProxy(false); + expect(replacement).not.toBe(guard); + expect(Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')).not.toEqual( + descriptorBefore + ); + replacement.dispose(); + expect(Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')).toEqual( + descriptorBefore + ); + }); + + it('does not overwrite a foreign iframe hook installed after activation', async () => { + const { installDynamicIframeProxy } = await import('../../../src/integrations/creative/iframe'); + const guard = installDynamicIframeProxy(false); + const owned = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src'); + expect(owned).toBeDefined(); + const foreignGet = function (this: HTMLIFrameElement): string { + return this.getAttribute('src') ?? ''; + }; + const foreignSet = function (this: HTMLIFrameElement, value: string): void { + this.setAttribute('src', value); + }; + Object.defineProperty(HTMLIFrameElement.prototype, 'src', { + configurable: true, + enumerable: owned?.enumerable ?? true, + get: foreignGet, + set: foreignSet, + }); + + guard.dispose(); + + const current = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src'); + expect(current?.get).toBe(foreignGet); + expect(current?.set).toBe(foreignSet); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts new file mode 100644 index 000000000..1989af611 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { CreativeBootV1 } from '../../../src/core/types'; +import { + createCreativeStartup, + type CreativeGuardHandle, +} from '../../../src/integrations/creative/startup'; + +function config(overrides: Partial = {}): Readonly { + return Object.freeze({ + version: 1 as const, + enabled: true, + clickGuard: true, + renderGuard: true, + ...overrides, + }); +} + +function guard(name: string, order: string[]): CreativeGuardHandle { + return Object.freeze({ + dispose: vi.fn(() => order.push(`dispose:${name}`)), + scan: vi.fn(() => order.push(`scan:${name}`)), + }); +} + +function readyDocument(readyState: DocumentReadyState = 'complete') { + let listener: (() => void) | undefined; + return { + document: { + readyState, + addEventListener: vi.fn( + (_type: 'DOMContentLoaded', next: () => void, _options: { once: true }) => { + listener = next; + } + ), + removeEventListener: vi.fn((_type: 'DOMContentLoaded', candidate: () => void) => { + if (listener === candidate) listener = undefined; + }), + }, + dispatchReady: (): void => { + const current = listener; + listener = undefined; + current?.(); + }, + }; +} + +describe('creative startup ownership', () => { + it('installs selected guards synchronously, scans after commit, and disposes in reverse', async () => { + const order: string[] = []; + const click = guard('click', order); + const image = guard('image', order); + const iframe = guard('iframe', order); + const target = readyDocument(); + const startup = createCreativeStartup({ + document: target.document, + installClickGuard: vi.fn(() => (order.push('install:click'), click)), + installDynamicImageProxy: vi.fn(() => (order.push('install:image'), image)), + installDynamicIframeProxy: vi.fn(() => (order.push('install:iframe'), iframe)), + }); + const boot = config(); + + const release = startup.activate(boot); + expect(order).toEqual(['install:click', 'install:image', 'install:iframe']); + + startup.start(boot); + expect(order).toEqual([ + 'install:click', + 'install:image', + 'install:iframe', + 'scan:click', + 'scan:image', + 'scan:iframe', + ]); + + release(); + release(); + expect(order.slice(-3)).toEqual(['dispose:iframe', 'dispose:image', 'dispose:click']); + }); + + it('owns one loading-document rescan and removes it on disposal', async () => { + const order: string[] = []; + const click = guard('click', order); + const target = readyDocument('loading'); + const startup = createCreativeStartup({ + document: target.document, + installClickGuard: () => click, + installDynamicImageProxy: () => guard('image', order), + installDynamicIframeProxy: () => guard('iframe', order), + }); + const boot = config({ renderGuard: false }); + + const release = startup.activate(boot); + expect(target.document.addEventListener).toHaveBeenCalledExactlyOnceWith( + 'DOMContentLoaded', + expect.any(Function), + { once: true } + ); + startup.start(boot); + expect(click.scan).not.toHaveBeenCalled(); + + target.dispatchReady(); + target.dispatchReady(); + expect(click.scan).toHaveBeenCalledTimes(1); + + release(); + expect(target.document.removeEventListener).toHaveBeenCalledTimes(1); + expect(click.dispose).toHaveBeenCalledTimes(1); + }); + + it('rolls back earlier guards when a later installer throws', async () => { + const order: string[] = []; + const click = guard('click', order); + const image = guard('image', order); + const target = readyDocument(); + const startup = createCreativeStartup({ + document: target.document, + installClickGuard: () => click, + installDynamicImageProxy: () => image, + installDynamicIframeProxy: () => { + throw new Error('fictional iframe installation failure'); + }, + }); + + expect(() => startup.activate(config())).toThrow('fictional iframe installation failure'); + expect(order).toEqual(['dispose:image', 'dispose:click']); + }); + + it('removes an exact ready listener when hostile registration throws after installing it', () => { + const order: string[] = []; + const click = guard('click', order); + let listener: (() => void) | undefined; + const document = { + readyState: 'loading' as const, + addEventListener: vi.fn( + (_type: 'DOMContentLoaded', candidate: () => void, _options: { once: true }) => { + listener = candidate; + throw new Error('fictional ready listener registration failure'); + } + ), + removeEventListener: vi.fn((_type: 'DOMContentLoaded', candidate: () => void) => { + if (listener === candidate) listener = undefined; + }), + }; + const startup = createCreativeStartup({ + document, + installClickGuard: () => click, + installDynamicImageProxy: () => guard('image', order), + installDynamicIframeProxy: () => guard('iframe', order), + }); + + expect(() => startup.activate(config({ renderGuard: false }))).toThrow( + 'fictional ready listener registration failure' + ); + expect(document.removeEventListener).toHaveBeenCalledExactlyOnceWith( + 'DOMContentLoaded', + expect.any(Function) + ); + expect(click.dispose).toHaveBeenCalledTimes(1); + + listener?.(); + expect(click.scan).not.toHaveBeenCalled(); + }); + + it('contains hostile scans and still visits every active guard', async () => { + const order: string[] = []; + const click = guard('click', order); + const image = guard('image', order); + const iframe = guard('iframe', order); + vi.mocked(click.scan).mockImplementation(() => { + order.push('scan:click'); + throw new Error('fictional click scan failure'); + }); + const target = readyDocument(); + const startup = createCreativeStartup({ + document: target.document, + installClickGuard: () => click, + installDynamicImageProxy: () => image, + installDynamicIframeProxy: () => iframe, + }); + const boot = config(); + startup.activate(boot); + + expect(() => startup.start(boot)).not.toThrow(); + expect(image.scan).toHaveBeenCalledTimes(1); + expect(iframe.scan).toHaveBeenCalledTimes(1); + }); + + it('prevents a late start after release and rejects duplicate lifecycle calls', async () => { + const order: string[] = []; + const click = guard('click', order); + const target = readyDocument(); + const startup = createCreativeStartup({ + document: target.document, + installClickGuard: () => click, + installDynamicImageProxy: () => guard('image', order), + installDynamicIframeProxy: () => guard('iframe', order), + }); + const boot = config({ renderGuard: false }); + const release = startup.activate(boot); + expect(() => startup.activate(boot)).toThrow('already activated'); + release(); + + startup.start(boot); + expect(click.scan).not.toHaveBeenCalled(); + expect(() => startup.start(boot)).toThrow('already started'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/datadome/module.test.ts b/crates/trusted-server-js/lib/test/integrations/datadome/module.test.ts new file mode 100644 index 000000000..1fa9b1e34 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/datadome/module.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const ownedGuard = vi.hoisted(() => ({ + install: vi.fn(), + reset: vi.fn(), +})); + +vi.mock('../../../src/integrations/datadome/script_guard', () => ({ + installDataDomeGuard: ownedGuard.install, + resetGuardState: ownedGuard.reset, +})); + +import { + createDataDomeIntegrationRegistration, + createDataDomeRuntime, +} from '../../../src/integrations/datadome/module'; +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); +const RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +describe('transactional DataDome integration module', () => { + beforeEach(() => { + ownedGuard.install.mockReset(); + ownedGuard.reset.mockReset(); + }); + + it('prepares inertly, activates before publication, and releases exactly once', async () => { + const order: string[] = []; + ownedGuard.install.mockImplementation(() => order.push('datadome:activate')); + ownedGuard.reset.mockImplementation(() => order.push('datadome:release')); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: RUNTIME_SRC, + integrations: [{ id: 'datadome', phase: 'takeover' }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['datadome']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({}), + }), + }); + registry.register(createDataDomeIntegrationRegistration(RELEASE_ID)); + + expect(ownedGuard.install).not.toHaveBeenCalled(); + const result = await registry.install(callbacks(order)); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['core', 'datadome:activate', 'publish', 'drain']); + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(ownedGuard.reset).toHaveBeenCalledOnce(); + expect(order[order.length - 1]).toBe('datadome:release'); + }); + + it.each([undefined, null, false, Object.freeze({ extra: true })])( + 'rejects non-exact config %j', + async (config) => { + const activate = vi.fn(() => vi.fn()); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: RUNTIME_SRC, + integrations: [{ id: 'datadome', phase: 'takeover' }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['datadome']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + datadome: Object.freeze({ activate, start: vi.fn() }), + }), + }), + }); + registry.register(createDataDomeIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + } + ); + + it('owns and reverses the concrete DataDome guard', () => { + const order: string[] = []; + const runtime = createDataDomeRuntime({ + installGuard: () => order.push('install'), + resetGuard: () => order.push('reset'), + started: () => order.push('started'), + }); + + const release = runtime.activate(undefined); + runtime.start(undefined); + release(); + release(); + + expect(order).toEqual(['install', 'started', 'reset']); + }); + + it('rolls back an attempted guard installation that throws', () => { + const resetGuard = vi.fn(); + const runtime = createDataDomeRuntime({ + installGuard: () => { + throw new Error('fictional guard failure'); + }, + resetGuard, + started: vi.fn(), + }); + + expect(() => runtime.activate(undefined)).toThrowError('fictional guard failure'); + expect(resetGuard).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts b/crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts deleted file mode 100644 index 5ca9d7598..000000000 --- a/crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { installDidomiSdkProxy } from '../../../src/integrations/didomi'; - -const ORIGINAL_WINDOW = global.window; - -// Mirrors the non-exported DidomiConfig shape in src/integrations/didomi. -type TestDidomiConfig = { - sdkPath?: string; - [key: string]: unknown; -}; - -type TestDidomiWindow = Window & { - didomiConfig?: TestDidomiConfig; - __tsjs_didomi?: { proxyPath?: string }; -}; - -function createWindow(url: string) { - return { - location: new URL(url) as unknown as Location, - } as TestDidomiWindow; -} - -describe('integrations/didomi', () => { - let testWindow: ReturnType; - - beforeEach(() => { - testWindow = createWindow('https://example.com/page'); - Object.assign(globalThis, { window: testWindow }); - }); - - afterEach(() => { - Object.assign(globalThis, { window: ORIGINAL_WINDOW }); - }); - - it('initializes didomiConfig and forces sdkPath through trusted server proxy', () => { - installDidomiSdkProxy(); - - expect(testWindow.didomiConfig).toBeDefined(); - expect(testWindow.didomiConfig!.sdkPath).toBe( - 'https://example.com/integrations/didomi/consent/' - ); - }); - - it('preserves existing config fields while overriding sdkPath', () => { - testWindow.didomiConfig = { apiKey: 'abc', sdkPath: 'https://sdk.privacy-center.org/' }; - - installDidomiSdkProxy(); - - expect(testWindow.didomiConfig.apiKey).toBe('abc'); - expect(testWindow.didomiConfig.sdkPath).toBe( - 'https://example.com/integrations/didomi/consent/' - ); - }); - - it('uses the server-injected custom proxy path', () => { - testWindow.__tsjs_didomi = { proxyPath: '/my-custom-consent/' }; - - installDidomiSdkProxy(); - - expect(testWindow.didomiConfig!.sdkPath).toBe('https://example.com/my-custom-consent/'); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/didomi/module.test.ts b/crates/trusted-server-js/lib/test/integrations/didomi/module.test.ts new file mode 100644 index 000000000..b2780ec0e --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/didomi/module.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createDidomiIntegrationRegistration, + createDidomiRuntime, +} from '../../../src/integrations/didomi/module'; +import { createIntegrationRegistry } from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); +const RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + +describe('transactional Didomi integration module', () => { + it('sets an absolute SDK path without clobbering publisher config and compare-restores it', () => { + const config = { custom: 'publisher', sdkPath: 'https://publisher.example/sdk/' }; + const target = { + didomiConfig: config, + location: { origin: 'https://news.example' }, + }; + const started = vi.fn(); + const runtime = createDidomiRuntime({ started, target }); + const boot = Object.freeze({ proxyPath: '/integrations/didomi/consent/' }); + + const release = runtime.activate(boot); + + expect(config).toEqual({ + custom: 'publisher', + sdkPath: 'https://news.example/integrations/didomi/consent/', + }); + runtime.start(boot); + expect(started).toHaveBeenCalledOnce(); + release(); + release(); + expect(config).toEqual({ custom: 'publisher', sdkPath: 'https://publisher.example/sdk/' }); + }); + + it('does not overwrite a publisher replacement during disposal', () => { + const config = { sdkPath: 'https://publisher.example/original/' }; + const runtime = createDidomiRuntime({ + started: vi.fn(), + target: { didomiConfig: config, location: { origin: 'https://news.example' } }, + }); + const release = runtime.activate(Object.freeze({ proxyPath: '/integrations/didomi/consent/' })); + config.sdkPath = 'https://publisher.example/replacement/'; + + release(); + + expect(config.sdkPath).toBe('https://publisher.example/replacement/'); + }); + + it.each([ + ['mutable', { proxyPath: '/integrations/didomi/consent/' }], + ['relative', Object.freeze({ proxyPath: 'integrations/didomi/consent/' })], + ['protocol relative', Object.freeze({ proxyPath: '//attacker.example/consent/' })], + ['backslash authority', Object.freeze({ proxyPath: '/\\attacker.example/consent/' })], + ['extra', Object.freeze({ proxyPath: '/integrations/didomi/consent/', legacy: true })], + ])('rejects %s boot config before activation', async (_name, config) => { + const activate = vi.fn(() => vi.fn()); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: RUNTIME_SRC, + integrations: [{ id: 'didomi', phase: 'takeover' }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['didomi']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + didomi: Object.freeze({ activate, start: vi.fn() }), + }), + }), + }); + registry.register(createDidomiIntegrationRegistration(RELEASE_ID)); + + await expect( + registry.install({ activateCore: vi.fn(), publish: vi.fn(), drainPreload: vi.fn() }) + ).resolves.toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(activate).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/google_tag_manager/module.test.ts b/crates/trusted-server-js/lib/test/integrations/google_tag_manager/module.test.ts new file mode 100644 index 000000000..e1cb1c055 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/google_tag_manager/module.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const ownedGuards = vi.hoisted(() => ({ + installBeacon: vi.fn(), + installScript: vi.fn(), + resetBeacon: vi.fn(), + resetScript: vi.fn(), +})); + +vi.mock('../../../src/integrations/google_tag_manager/script_guard', () => ({ + installGtmBeaconGuard: ownedGuards.installBeacon, + installGtmGuard: ownedGuards.installScript, + resetBeaconGuardState: ownedGuards.resetBeacon, + resetGuardState: ownedGuards.resetScript, +})); + +import { + createGoogleTagManagerIntegrationRegistration, + createGoogleTagManagerRuntime, +} from '../../../src/integrations/google_tag_manager/module'; +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); +const RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +describe('transactional Google Tag Manager integration module', () => { + beforeEach(() => { + ownedGuards.installBeacon.mockReset(); + ownedGuards.installScript.mockReset(); + ownedGuards.resetBeacon.mockReset(); + ownedGuards.resetScript.mockReset(); + }); + + it('activates both guards before publication and releases them in reverse order', async () => { + const order: string[] = []; + ownedGuards.installBeacon.mockImplementation(() => order.push('beacon:install')); + ownedGuards.installScript.mockImplementation(() => order.push('script:install')); + ownedGuards.resetBeacon.mockImplementation(() => order.push('beacon:reset')); + ownedGuards.resetScript.mockImplementation(() => order.push('script:reset')); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: RUNTIME_SRC, + integrations: [{ id: 'google_tag_manager', phase: 'takeover' }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['google_tag_manager']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({}), + }), + }); + registry.register(createGoogleTagManagerIntegrationRegistration(RELEASE_ID)); + + expect(order).toEqual([]); + const result = await registry.install(callbacks(order)); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['core', 'script:install', 'beacon:install', 'publish', 'drain']); + if (result.state === 'kernel') result.dispose(); + expect(order.slice(-2)).toEqual(['beacon:reset', 'script:reset']); + }); + + it('rolls back the script guard when beacon activation throws', () => { + const resetBeaconGuard = vi.fn(); + const resetScriptGuard = vi.fn(); + const runtime = createGoogleTagManagerRuntime({ + installBeaconGuard: () => { + throw new Error('fictional beacon failure'); + }, + installScriptGuard: vi.fn(), + resetBeaconGuard, + resetScriptGuard, + started: vi.fn(), + }); + + expect(() => runtime.activate(undefined)).toThrowError('fictional beacon failure'); + expect(resetBeaconGuard).toHaveBeenCalledOnce(); + expect(resetScriptGuard).toHaveBeenCalledOnce(); + }); + + it('rolls back an attempted script guard installation that throws', () => { + const resetBeaconGuard = vi.fn(); + const resetScriptGuard = vi.fn(); + const runtime = createGoogleTagManagerRuntime({ + installBeaconGuard: vi.fn(), + installScriptGuard: () => { + throw new Error('fictional script failure'); + }, + resetBeaconGuard, + resetScriptGuard, + started: vi.fn(), + }); + + expect(() => runtime.activate(undefined)).toThrowError('fictional script failure'); + expect(resetBeaconGuard).not.toHaveBeenCalled(); + expect(resetScriptGuard).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts b/crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts index 971d396f4..933ece79c 100644 --- a/crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts @@ -372,10 +372,10 @@ describe('GTM Beacon Guard', () => { originalFetch = window.fetch; sendBeaconSpy = vi.fn(() => true); - navigator.sendBeacon = sendBeaconSpy; + navigator.sendBeacon = sendBeaconSpy as typeof navigator.sendBeacon; fetchSpy = vi.fn(() => Promise.resolve(new Response('', { status: 200 }))); - window.fetch = fetchSpy; + window.fetch = fetchSpy as typeof window.fetch; resetBeaconGuardState(); }); @@ -397,7 +397,7 @@ describe('GTM Beacon Guard', () => { navigator.sendBeacon('https://www.google-analytics.com/g/collect?v=2&tid=G-JGPCNWGVHC', ''); - const calledUrl = sendBeaconSpy.mock.calls[0][0]; + const calledUrl = sendBeaconSpy.mock.calls[0]![0]; expect(calledUrl).toContain('/integrations/google_tag_manager/g/collect'); expect(calledUrl).not.toContain('google-analytics.com'); }); @@ -407,7 +407,7 @@ describe('GTM Beacon Guard', () => { navigator.sendBeacon('https://analytics.google.com/g/collect?v=2&tid=G-DQMZGMPHXN', ''); - const calledUrl = sendBeaconSpy.mock.calls[0][0]; + const calledUrl = sendBeaconSpy.mock.calls[0]![0]; expect(calledUrl).toContain('/integrations/google_tag_manager/g/collect'); expect(calledUrl).not.toContain('analytics.google.com'); }); @@ -417,7 +417,7 @@ describe('GTM Beacon Guard', () => { await window.fetch('https://www.google-analytics.com/g/collect?v=2&tid=G-TEST'); - const calledUrl = fetchSpy.mock.calls[0][0]; + const calledUrl = fetchSpy.mock.calls[0]![0]; expect(calledUrl).toContain('/integrations/google_tag_manager/g/collect'); expect(calledUrl).not.toContain('google-analytics.com'); }); @@ -435,7 +435,7 @@ describe('GTM Beacon Guard', () => { navigator.sendBeacon('https://www.google-analytics.com/g/collect?v=2&tid=G-TEST&cid=123', ''); - const calledUrl = sendBeaconSpy.mock.calls[0][0]; + const calledUrl = sendBeaconSpy.mock.calls[0]![0]; expect(calledUrl).toContain('v=2&tid=G-TEST&cid=123'); }); }); 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 deleted file mode 100644 index e034a8ba4..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ /dev/null @@ -1,5387 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import { resolve } from 'node:path'; - -import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; - -import envelope from '../../fixtures/aps-renderer-v1.json'; -import { - registerPublisherFirstImpressionAuctions, - resolveFirstImpressionElement, -} from '../../../src/core/first_impression'; -import type { AuctionBidData, TsjsApi } from '../../../src/core/types'; -import { - APS_PREBID_CREATIVE_RUNNER_URL, - APS_RENDERING_MODE_ATTRIBUTE_NAME, -} from '../../../src/integrations/aps/render'; - -let publisherNativeScript: HTMLScriptElement | undefined; - -function enablePublisherNativeMode(): { remove(): void } { - publisherNativeScript = document.createElement('script'); - publisherNativeScript.setAttribute(APS_RENDERING_MODE_ATTRIBUTE_NAME, 'publisher_native'); - return { - remove: () => { - publisherNativeScript = undefined; - }, - }; -} - -function nativeRunnerIn(divId: string): { - frame: HTMLIFrameElement; - runner: HTMLScriptElement; - event: CustomEvent<{ aaxResponse: string; seatBidId: string }>; -} { - const container = document.getElementById(divId)!; - const frame = Array.from(container.querySelectorAll('iframe')).find( - (candidate) => candidate.title === 'Ad content' - ); - expect(frame).not.toBeUndefined(); - const runner = frame!.contentDocument?.querySelector('script'); - const frameWindow = frame!.contentWindow as unknown as { - _aps: Map> }>; - }; - const event = Array.from(frameWindow._aps.values())[0]?.queue[0]; - expect(runner?.src).toBe(APS_PREBID_CREATIVE_RUNNER_URL); - expect(event).not.toBeUndefined(); - return { frame: frame!, runner: runner!, event }; -} - -function apsRenderer() { - const bid = envelope.seatbid[0].bid[0]; - return { - type: 'aps' as const, - version: 1 as const, - accountId: 'example-account-id', - bidId: bid.id, - creativeId: 'fictional-creative-id', - tagType: 'iframe' as const, - creativeUrl: bid.ext.creativeurl, - aaxResponse: btoa(JSON.stringify(envelope)), - width: bid.w, - height: bid.h, - }; -} - -// Track every 'message' EventListener added to window across the entire test -// file. This lets the installTsRenderBridge suite remove all accumulated -// handlers (registered by each vi.resetModules() + module re-import in the -// installTsAdInit suite) before dispatching its own events. The spy is -// restored and remaining handlers are detached in the afterAll below so the -// patch never leaks past this file. -const allMessageHandlers: EventListener[] = []; -const originalWindowAddEventListener = window.addEventListener.bind(window); -// Plain wrapper, deliberately not vi.spyOn: the render-bridge suite spies on -// window.addEventListener itself, and vi.spyOn on an already-spied method -// returns the same mock instance — its "original" would alias the inner -// implementation and recurse. -(window as { addEventListener: typeof window.addEventListener }).addEventListener = (( - type: string, - handler: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions -) => { - if (type === 'message' && handler) { - allMessageHandlers.push(handler as EventListener); - } - return originalWindowAddEventListener(type, handler, options); -}) as typeof window.addEventListener; - -afterAll(() => { - for (const handler of allMessageHandlers) { - window.removeEventListener('message', handler); - } - allMessageHandlers.length = 0; - (window as { addEventListener: typeof window.addEventListener }).addEventListener = - originalWindowAddEventListener; -}); - -interface SlotRenderEvent { - isEmpty: boolean; - slot: { - getSlotElementId(): string; - getTargeting(key: string): string[]; - }; -} - -// The `Prebid Response` payload the render bridge posts back to the Prebid -// Universal Creative over the message port. -interface PrebidResponseMessage { - message?: string; - adId?: string; - ad?: string; - width?: number; - height?: number; -} - -// `tsjs` is declared globally as the full `TsjsApi` (core/types.ts). Omitting -// it from `Window` before re-adding it as a `Partial` avoids the intersection -// that would force every fixture below to satisfy the whole `TsjsApi` shape. -type TestWindow = Omit & { - googletag?: unknown; - apstag?: { setDisplayBids?: () => void }; - tsjs?: Partial; -}; - -async function runGptBootstrapWithGoogleTag(googletag: object): Promise { - const bootstrapUrl = new URL( - '../../../../../trusted-server-core/src/integrations/gpt_bootstrap.js', - import.meta.url - ); - const urlPath = decodeURIComponent(bootstrapUrl.pathname); - let bootstrapPath: string; - if (urlPath.startsWith('/@fs/')) { - bootstrapPath = urlPath.slice('/@fs'.length); - } else if (bootstrapUrl.protocol === 'file:') { - bootstrapPath = urlPath; - } else { - bootstrapPath = path.resolve(process.cwd(), `.${urlPath}`); - } - const bootstrap = await readFile(bootstrapPath, 'utf8'); - const runBootstrap = new Function('window', 'googletag', bootstrap) as ( - window: Window, - googletag: object - ) => void; - runBootstrap(window, googletag); -} - -type HandoffImplementation = 'bootstrap' | 'bundle'; - -async function installHandoff(implementation: HandoffImplementation): Promise { - if (implementation === 'bootstrap') { - const bootstrap = readFileSync( - resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' - ); - window.eval(bootstrap); - return; - } - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); -} - -interface ResponsiveSlotElementOptions { - containerVisible?: boolean; - containerWidth?: number; - containerHeight?: number; - elementHidden?: boolean; - elementWidth?: number; - elementHeight?: number; - checkVisibility?: boolean; -} - -function appendResponsiveSlotElement( - id: string, - { - containerVisible = false, - containerWidth = 0, - containerHeight = 0, - elementHidden = false, - elementWidth = 0, - elementHeight = 0, - checkVisibility, - }: ResponsiveSlotElementOptions = {} -): HTMLDivElement { - const container = document.createElement('div'); - container.id = `${id}-container`; - container.dataset.responsiveSlotTest = 'true'; - container.style.display = containerVisible ? 'block' : 'none'; - container.getBoundingClientRect = () => - ({ width: containerWidth, height: containerHeight }) as DOMRect; - - const element = document.createElement('div'); - element.id = id; - element.style.display = elementHidden ? 'none' : 'block'; - element.getBoundingClientRect = () => ({ width: elementWidth, height: elementHeight }) as DOMRect; - if (checkVisibility !== undefined) { - (element as HTMLElement & { checkVisibility?: () => boolean }).checkVisibility = vi - .fn() - .mockReturnValue(checkVisibility); - } - container.appendChild(element); - document.body.appendChild(container); - return element; -} - -function runGptBootstrap(): void { - const bootstrap = readFileSync( - resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' - ); - window.eval(bootstrap); -} - -describe('installTsAdInit', () => { - beforeEach(() => { - vi.resetModules(); - const tw = window as TestWindow; - delete tw.tsjs; - // jsdom does not implement navigator.sendBeacon; polyfill it for tests - if (!('sendBeacon' in navigator)) { - Object.defineProperty(navigator, 'sendBeacon', { - value: vi.fn().mockReturnValue(true), - writable: true, - configurable: true, - }); - } - // adInit now queries the DOM for div elements by id/prefix — create the - // test div so getElementById and querySelector both resolve correctly. - if (!document.getElementById('div-atf-sidebar')) { - const div = document.createElement('div'); - div.id = 'div-atf-sidebar'; - document.body.appendChild(div); - } - }); - - afterEach(() => { - document.getElementById('div-atf-sidebar')?.remove(); - document.getElementById('div-new-slot')?.remove(); - document.getElementById('div-atf-sidebar-2')?.remove(); - document.getElementById('div-size-hydrated')?.remove(); - document.getElementById('ad-header-0-_r_1_')?.remove(); - document.getElementById("ad'prefix-real")?.remove(); - document.querySelectorAll('[data-responsive-slot-test]').forEach((element) => element.remove()); - }); - - function configureOpportunityDiagnostics( - bid: AuctionBidData | undefined, - recordTrustedServerOpportunity: ReturnType, - formats: Array<[number, number]> = [[300, 250]] - ) { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - clearTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats, - targeting: {}, - }, - ], - bids: bid ? { atf_sidebar_ad: bid } : {}, - gptDiagnosticsRecorder: { - recordTrustedServerOpportunity, - } as unknown as TsjsApi['gptDiagnosticsRecorder'], - }; - - return { mockPubads, mockSlot }; - } - - it('leaves a publisher-auctioned slot untouched when delayed adInit receives no candidate', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - undefined, - recordTrustedServerOpportunity - ); - const ts = (window as TestWindow).tsjs as TsjsApi; - registerPublisherFirstImpressionAuctions(ts, ['div-atf-sidebar']); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - ts.adInit!(); - - expect(mockSlot.setTargeting).not.toHaveBeenCalled(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); - expect(recordTrustedServerOpportunity).not.toHaveBeenCalled(); - expect(ts.divToSlotId).toEqual({}); - expect(ts.prevSlotTargetingKeys).toEqual({}); - }); - - it('falls back once when a publisher auction abandons its first-impression claim', async () => { - vi.useFakeTimers(); - try { - const recordTrustedServerOpportunity = vi.fn(); - const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - { hb_pb: '1.10', hb_adid: 'example-fallback-ad', adm: '
Fallback
' }, - recordTrustedServerOpportunity - ); - const ts = (window as TestWindow).tsjs as TsjsApi; - registerPublisherFirstImpressionAuctions(ts, ['div-atf-sidebar']); - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - ts.adInit!(); - - expect(mockPubads.refresh).not.toHaveBeenCalled(); - vi.advanceTimersByTime(5001); - - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalledOnce(); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); - expect(recordTrustedServerOpportunity).toHaveBeenCalledOnce(); - - vi.advanceTimersByTime(10_000); - expect(mockPubads.refresh).toHaveBeenCalledOnce(); - } finally { - vi.useRealTimers(); - } - }); - - it('does not clear targeting or request again after TS claims an existing slot', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - undefined, - recordTrustedServerOpportunity - ); - const ts = (window as TestWindow).tsjs as TsjsApi; - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - ts.adInit!(); - const clearCalls = mockSlot.clearTargeting.mock.calls.length; - const targetingCalls = mockSlot.setTargeting.mock.calls.length; - ts.adInit!(); - - expect(mockSlot.clearTargeting).toHaveBeenCalledTimes(clearCalls); - expect(mockSlot.setTargeting).toHaveBeenCalledTimes(targetingCalls); - expect(mockPubads.refresh).toHaveBeenCalledOnce(); - expect(recordTrustedServerOpportunity).toHaveBeenCalledOnce(); - }); - - it.each(['slotRequested', 'slotRenderEnded'] as const)( - 'leaves a publisher slot untouched after an earlier %s event', - async (eventName) => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - { hb_pb: '2.00', hb_adid: 'late-page-bid', adm: '
Late
' }, - recordTrustedServerOpportunity - ); - const ts = (window as TestWindow).tsjs as TsjsApi; - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - const lifecycleListener = mockPubads.addEventListener.mock.calls.find( - ([registeredEvent]) => registeredEvent === eventName - )?.[1] as ((event: SlotRenderEvent) => void) | undefined; - expect(lifecycleListener).toBeDefined(); - lifecycleListener!({ isEmpty: false, slot: mockSlot }); - - ts.adInit!(); - - expect(mockSlot.setTargeting).not.toHaveBeenCalled(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); - expect(recordTrustedServerOpportunity).not.toHaveBeenCalled(); - expect(ts.firstImpression?.slots['div-atf-sidebar']?.owner).toBe('publisher'); - expect(ts.firstImpression?.slots['div-atf-sidebar']?.phase).toBe( - eventName === 'slotRequested' ? 'requested' : 'rendered' - ); - } - ); - - it.each([ - [ - 'inline markup', - { hb_pb: '1.00', hb_adid: 'abc-uuid', adm: '
Creative
' }, - 'renderable_candidate', - ], - [ - 'complete cache coordinates', - { - hb_bidder: 'example-bidder', - hb_adid: 'abc-uuid', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/cache', - }, - 'renderable_candidate', - ], - [ - 'an ad ID without a render source', - { hb_pb: '1.00', hb_adid: 'abc-uuid' }, - 'unrenderable_candidate', - ], - [ - 'a render source without an ad ID', - { hb_pb: '1.00', adm: '
Creative
' }, - 'unrenderable_candidate', - ], - [ - 'no non-empty Trusted Server bid targeting', - { hb_pb: '', hb_bidder: '', hb_adid: '', adm: '
Creative
' }, - 'no_candidate', - ], - ] as const)( - 'records exactly one %s opportunity for every resolved GPT slot', - async (_description, bid, expectedOpportunity) => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockSlot } = configureOpportunityDiagnostics( - bid as AuctionBidData, - recordTrustedServerOpportunity - ); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(recordTrustedServerOpportunity).toHaveBeenCalledTimes(1); - expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( - mockSlot, - 'atf_sidebar_ad', - expectedOpportunity, - undefined, - undefined - ); - } - ); - - it('forwards winning bid auction metadata to diagnostics only when present', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockSlot } = configureOpportunityDiagnostics( - { - hb_pb: '1.00', - hb_bidder: 'example', - hb_adid: 'creative-1', - hb_auction_id: 'auction-123', - }, - recordTrustedServerOpportunity - ); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( - mockSlot, - 'atf_sidebar_ad', - 'unrenderable_candidate', - 'auction-123', - undefined - ); - }); - - it('retains handoff formats when reusing a Trusted Server-defined GPT slot', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const formats: Array<[number, number]> = [ - [300, 250], - [728, 90], - [320, 50], - ]; - const { mockSlot } = configureOpportunityDiagnostics( - undefined, - recordTrustedServerOpportunity, - formats - ); - (window as TestWindow).tsjs!.gptSlotHandoffs = { - 'div-atf-sidebar': { - gamUnitPath: '/123/atf', - formats, - divIdPrefix: 'div-atf-sidebar', - slotElementId: 'div-atf-sidebar', - publisherClaimed: true, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( - mockSlot, - 'atf_sidebar_ad', - 'no_candidate', - undefined, - formats - ); - }); - - it('forwards configured formats when defining a Trusted Server GPT slot', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const formats: Array<[number, number]> = [ - [300, 250], - [728, 90], - ]; - const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - undefined, - recordTrustedServerOpportunity, - formats - ); - mockPubads.getSlots.mockReturnValue([]); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( - mockSlot, - 'atf_sidebar_ad', - 'no_candidate', - undefined, - formats - ); - }); - - it('keeps slot delivery running when requested-size diagnostics access throws', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - undefined, - recordTrustedServerOpportunity - ); - Object.defineProperty((window as TestWindow).tsjs!, 'gptSlotHandoffs', { - configurable: true, - get: () => { - throw new Error('diagnostics handoff unavailable'); - }, - }); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - expect(recordTrustedServerOpportunity).not.toHaveBeenCalled(); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); - }); - - it('records no_candidate when the resolved slot has no bid', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockSlot } = configureOpportunityDiagnostics(undefined, recordTrustedServerOpportunity); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(recordTrustedServerOpportunity).toHaveBeenCalledTimes(1); - expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( - mockSlot, - 'atf_sidebar_ad', - 'no_candidate', - undefined, - undefined - ); - }); - - it('keeps targeting, display, and refresh running when opportunity diagnostics throws', async () => { - const existingSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const definedSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-new-slot'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const refresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([existingSlot]), - addEventListener: vi.fn(), - refresh, - }; - const display = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(definedSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - display, - }; - const newSlotDiv = document.createElement('div'); - newSlotDiv.id = 'div-new-slot'; - document.body.appendChild(newSlotDiv); - const recordTrustedServerOpportunity = vi.fn(() => { - throw new Error('diagnostics unavailable'); - }); - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - { - id: 'new_slot_ad', - gam_unit_path: '/123/new', - div_id: 'div-new-slot', - formats: [[728, 90]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_adid: 'existing-id', - adm: '
Existing
', - }, - new_slot_ad: { - hb_pb: '2.00', - hb_adid: 'new-id', - adm: '
New
', - }, - }, - gptDiagnosticsRecorder: { - recordTrustedServerOpportunity, - } as unknown as TsjsApi['gptDiagnosticsRecorder'], - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - - expect(recordTrustedServerOpportunity).toHaveBeenCalledTimes(2); - expect(existingSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(definedSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '2.00'); - expect(display).toHaveBeenCalledWith('div-new-slot'); - expect(refresh).toHaveBeenCalledWith([existingSlot]); - }); - - it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['abc']), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { pos: 'atf' }, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc-uuid', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/pbc/v1/cache', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', - }, - }, - }; - - const fetchSpy = vi.spyOn(global, 'fetch'); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(fetchSpy).not.toHaveBeenCalled(); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'abc-uuid'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_host', 'cache.example.com'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.enableSingleRequest).toHaveBeenCalledOnce(); - expect(mockPubads.refresh).toHaveBeenCalled(); - - fetchSpy.mockRestore(); - }); - - it('displays TS-defined slots and does not include them in refresh', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - // Publisher has not defined this slot, so TS defines (owns) it. - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - }; - const defineSlotMock = vi.fn().mockReturnValue(mockSlot); - const displayMock = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: defineSlotMock, - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(defineSlotMock).toHaveBeenCalled(); - // GPT requires display() to register/render a freshly-defined slot. - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - // TS-owned slots are displayed, not refreshed (refresh() no-ops for a slot - // that was never displayed). - expect(nativeRefresh).not.toHaveBeenCalled(); - }); - - it('hands a late publisher definition the TS inner-div slot without a second request', async () => { - type FakeSlot = { - addService(service: unknown): FakeSlot; - setTargeting(key: string, value: string | string[]): FakeSlot; - getSlotElementId(): string; - getTargeting(key?: string): string[]; - }; - const slots = new Map(); - const requests: string[] = []; - const makeSlot = (elementId: string): FakeSlot => ({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - getTargeting: vi.fn().mockReturnValue([]), - }); - const pubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => Array.from(slots.values())), - addEventListener: vi.fn(), - refresh: vi.fn((requestedSlots?: FakeSlot[]) => { - (requestedSlots ?? Array.from(slots.values())).forEach((slot) => - requests.push(slot.getSlotElementId()) - ); - }), - }; - const nativeDefineSlot = vi.fn( - (_adUnitPath: string, _formats: number[][], elementId: string) => { - if (slots.has(elementId)) return null; - const slot = makeSlot(elementId); - slots.set(elementId, slot); - return slot; - } - ); - const nativeDisplay = vi.fn((elementId: string) => requests.push(elementId)); - const destroySlots = vi.fn(); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - destroySlots, - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - const publisherDefineSlot = googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => FakeSlot; - const publisherDisplay = googletag.display as unknown as (elementId: string) => void; - const publisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); - publisherSlot.addService(pubads); - publisherDisplay('div-atf-sidebar'); - - expect(nativeDefineSlot).toHaveBeenCalledTimes(1); - expect(nativeDisplay).toHaveBeenCalledTimes(1); - expect(requests).toEqual(['div-atf-sidebar']); - expect((window as TestWindow).tsjs!.prevGptSlots).toEqual([]); - - const duplicatePublisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); - expect(duplicatePublisherSlot).toBeNull(); - expect(nativeDefineSlot).toHaveBeenCalledTimes(2); - - (window as TestWindow).tsjs!.adSlots = []; - (window as TestWindow).tsjs!.adInit!(); - expect(destroySlots).not.toHaveBeenCalled(); - }); - - it.each(['slot', 'element'] as const)( - 'hands a hydrated publisher ID off when it displays by %s', - async (displayMode) => { - type FakeSlot = { - addService(service: unknown): FakeSlot; - setTargeting(key: string, value: string | string[]): FakeSlot; - getSlotElementId(): string; - getTargeting(key?: string): string[]; - }; - const ssrDiv = document.getElementById('div-atf-sidebar')!; - ssrDiv.id = 'ad-header-0-_R_0_'; - const hydratedId = 'ad-header-0-_r_1_'; - const slots = new Map(); - const requests: string[] = []; - const makeSlot = (elementId: string): FakeSlot => ({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - getTargeting: vi.fn().mockReturnValue([]), - }); - const pubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => Array.from(slots.values())), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - const nativeDefineSlot = vi.fn( - (_adUnitPath: string, _formats: number[][], elementId: string) => { - const slot = makeSlot(elementId); - slots.set(elementId, slot); - return slot; - } - ); - const nativeDisplay = vi.fn((target: string | Element | FakeSlot) => { - if (typeof target === 'string') { - requests.push(target); - } else if ('getSlotElementId' in target) { - requests.push(target.getSlotElementId()); - } else { - requests.push(target.id); - } - }); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'header_ad', - gam_unit_path: '/123/header', - div_id: 'ad-header-0-', - formats: [[970, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - ssrDiv.id = hydratedId; - - const publisherSlot = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => FakeSlot - )('/123/header', [[970, 250]], hydratedId); - publisherSlot.addService(pubads); - const publisherDisplay = googletag.display as unknown as ( - target: string | Element | FakeSlot - ) => void; - publisherDisplay(displayMode === 'slot' ? publisherSlot : ssrDiv); - - expect(nativeDefineSlot).toHaveBeenCalledTimes(1); - expect(requests).toEqual(['ad-header-0-_R_0_']); - expect((window as TestWindow).tsjs!.gptSlotHandoffs[hydratedId]).toBe( - (window as TestWindow).tsjs!.gptSlotHandoffs['ad-header-0-_R_0_'] - ); - } - ); - - it('does not transfer an ambiguous hydrated publisher definition', async () => { - type FakeSlot = { - addService(service: unknown): FakeSlot; - setTargeting(key: string, value: string | string[]): FakeSlot; - getSlotElementId(): string; - getTargeting(key?: string): string[]; - }; - const makeSlot = (elementId: string): FakeSlot => ({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - getTargeting: vi.fn().mockReturnValue([]), - }); - const firstSlot = makeSlot('ad-header-0-_R_0_'); - const secondSlot = makeSlot('ad-header-0-_R_1_'); - const nativeDefineSlot = vi.fn((_adUnitPath: string, _formats: number[][], elementId: string) => - makeSlot(elementId) - ); - const pubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => [firstSlot, secondSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - enableServices: vi.fn(), - }; - const firstHandoff = { - gamUnitPath: '/123/header', - formats: [[970, 250]], - divIdPrefix: 'ad-header-0-', - slotElementId: 'ad-header-0-_R_0_', - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - const secondHandoff = { ...firstHandoff, slotElementId: 'ad-header-0-_R_1_' }; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - 'ad-header-0-_R_0_': firstHandoff, - 'ad-header-0-_R_1_': secondHandoff, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - const defined = ( - (window as TestWindow).googletag as { - defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot; - } - ).defineSlot('/123/header', [[970, 250]], 'ad-header-0-_r_1_'); - - expect(nativeDefineSlot).toHaveBeenCalledOnce(); - expect(defined).not.toBe(firstSlot); - expect(defined).not.toBe(secondSlot); - expect(firstHandoff.publisherClaimed).toBe(false); - expect(secondHandoff.publisherClaimed).toBe(false); - }); - - it('delegates a div-less publisher definition with an unclaimed bundle handoff', async () => { - const fallbackSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-ts-fallback'), - }; - const nativeDefineSlot = vi.fn().mockReturnValue(null); - const pubads = { - getSlots: vi.fn().mockReturnValue([fallbackSlot]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - const handoff = { - gamUnitPath: '/123/fallback', - formats: [[300, 250]], - divIdPrefix: 'div-ts-', - slotElementId: 'div-ts-fallback', - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { 'div-ts-fallback': handoff }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - expect(() => - ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId?: string - ) => unknown - )('/123/unrelated', [[728, 90]]) - ).not.toThrow(); - expect(nativeDefineSlot).toHaveBeenCalledWith('/123/unrelated', [[728, 90]]); - expect(handoff.publisherClaimed).toBe(false); - }); - - it('prunes destroyed TS-owned handoffs and their aliases on SPA navigation', async () => { - const slots = new Map< - string, - { - addService(service: unknown): unknown; - getSlotElementId(): string; - getTargeting(key?: string): string[]; - setTargeting(key: string, value: string | string[]): unknown; - } - >(); - const makeSlot = (elementId: string) => ({ - addService: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - getTargeting: vi.fn().mockReturnValue([]), - setTargeting: vi.fn().mockReturnThis(), - }); - const destroySlots = vi.fn(); - const pubads = { - addEventListener: vi.fn(), - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => Array.from(slots.values())), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn((_adUnitPath: string, _formats: number[][], elementId: string) => { - const slot = makeSlot(elementId); - slots.set(elementId, slot); - return slot; - }), - destroySlots, - display: vi.fn(), - enableServices: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - const handoff = (window as TestWindow).tsjs!.gptSlotHandoffs['div-atf-sidebar']; - (window as TestWindow).tsjs!.gptSlotHandoffs['div-atf-sidebar-hydrated'] = handoff; - (window as TestWindow).tsjs!.gptSlotHandoffs.unrelated = { - ...handoff, - slotElementId: 'div-unrelated', - }; - const ownedSlot = slots.get('div-atf-sidebar')!; - - (window as TestWindow).tsjs!.adSlots = []; - (window as TestWindow).tsjs!.adInit!(); - - expect(destroySlots).toHaveBeenCalledWith([ownedSlot]); - expect((window as TestWindow).tsjs!.gptSlotHandoffs).toEqual({ - unrelated: expect.objectContaining({ slotElementId: 'div-unrelated' }), - }); - }); - - it('suppresses a cross-realm element display without throwing', async () => { - const nativeDisplay = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn(), - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - }; - const iframe = document.createElement('iframe'); - document.body.appendChild(iframe); - const crossRealmElement = iframe.contentDocument!.createElement('div'); - crossRealmElement.id = 'div-cross-realm'; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - 'div-cross-realm': { - gamUnitPath: '/123/cross-realm', - formats: [[300, 250]], - divIdPrefix: 'div-cross-realm', - slotElementId: 'div-cross-realm', - publisherClaimed: true, - suppressPublisherDisplay: true, - suppressPublisherRefresh: false, - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - expect(() => - (googletag.display as unknown as (target: Element) => void)(crossRealmElement) - ).not.toThrow(); - expect(nativeDisplay).not.toHaveBeenCalled(); - iframe.remove(); - }); - - it('runs the embedded bootstrap handoff for a hydrated publisher ID', async () => { - type FakeSlot = { - addService(service: unknown): FakeSlot; - setTargeting(key: string, value: string | string[]): FakeSlot; - getSlotElementId(): string; - }; - const ssrDiv = document.getElementById('div-atf-sidebar')!; - const hydratedId = 'ad-header-0-_r_1_'; - ssrDiv.id = 'ad-header-0-_R_0_'; - const slots = new Map(); - const requests: string[] = []; - const makeSlot = (elementId: string): FakeSlot => ({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - }); - const pubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => Array.from(slots.values())), - refresh: vi.fn(), - }; - const nativeDefineSlot = vi.fn( - (_adUnitPath: string, _formats: number[][], elementId: string) => { - if (slots.has(elementId) || elementId === hydratedId) return null; - const slot = makeSlot(elementId); - slots.set(elementId, slot); - return slot; - } - ); - const nativeDisplay = vi.fn((target: string | FakeSlot) => { - requests.push(typeof target === 'string' ? target : target.getSlotElementId()); - }); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'header_ad', - gam_unit_path: '/123/header', - div_id: 'ad-header-0-', - formats: [[970, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - runGptBootstrap(); - (window as TestWindow).tsjs!.adInit!(); - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - ssrDiv.id = hydratedId; - - const googletag = (window as TestWindow).googletag as { - defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot | null; - display(target: FakeSlot): void; - }; - const publisherSlot = googletag.defineSlot('/123/header', [[970, 250]], ssrDiv.id); - expect(publisherSlot).not.toBeNull(); - googletag.display(publisherSlot!); - - expect(nativeDefineSlot).toHaveBeenCalledTimes(1); - expect(requests).toEqual(['ad-header-0-_R_0_']); - - const duplicatePublisherSlot = googletag.defineSlot('/123/header', [[970, 250]], ssrDiv.id); - expect(duplicatePublisherSlot).toBeNull(); - expect(nativeDefineSlot).toHaveBeenCalledTimes(2); - }); - - it('delegates a div-less publisher definition with an unclaimed bootstrap handoff', () => { - const fallbackSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-ts-fallback'), - }; - const nativeDefineSlot = vi.fn().mockReturnValue(null); - const pubads = { - getSlots: vi.fn().mockReturnValue([fallbackSlot]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - const handoff = { - gamUnitPath: '/123/fallback', - formats: [[300, 250]], - divIdPrefix: 'div-ts-', - slotElementId: 'div-ts-fallback', - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { 'div-ts-fallback': handoff }, - }; - - const bootstrap = readFileSync( - resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' - ); - window.eval(bootstrap); - - expect(() => - ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId?: string - ) => unknown - )('/123/unrelated', [[728, 90]]) - ).not.toThrow(); - expect(nativeDefineSlot).toHaveBeenCalledWith('/123/unrelated', [[728, 90]]); - expect(handoff.publisherClaimed).toBe(false); - }); - - it.each(['bootstrap', 'bundle'] as const)( - 'does not hand a sibling slot to a TS fallback through the %s prefix path', - async (implementation) => { - const fallbackSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - }; - const siblingSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar-2'), - }; - const nativeDefineSlot = vi.fn().mockReturnValue(siblingSlot); - const nativeDisplay = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([fallbackSlot]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - }; - const handoff = { - gamUnitPath: '/123/mpu', - formats: [[300, 250]], - divIdPrefix: 'div-atf-sidebar', - slotElementId: 'div-atf-sidebar', - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - const siblingElement = document.createElement('div'); - siblingElement.id = 'div-atf-sidebar-2'; - document.body.appendChild(siblingElement); - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { 'div-atf-sidebar': handoff }, - }; - - await installHandoff(implementation); - - const publisherSlot = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => typeof siblingSlot - )('/123/mpu', [[300, 250]], siblingElement.id); - (googletag.display as unknown as (target: string) => void)(siblingElement.id); - - expect(publisherSlot).toBe(siblingSlot); - expect(nativeDefineSlot).toHaveBeenCalledOnce(); - expect(nativeDisplay).toHaveBeenCalledWith(siblingElement.id); - expect(handoff.publisherClaimed).toBe(false); - expect(handoff.suppressPublisherDisplay).toBe(false); - } - ); - - it.each(['bootstrap', 'bundle'] as const)( - 'hands a publisher shorthand size to the TS fallback through the %s prefix path', - async (implementation) => { - const fallbackSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-size-original'), - }; - const nativeDefineSlot = vi.fn(); - const nativeDisplay = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([fallbackSlot]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - }; - const handoff = { - gamUnitPath: '/123/size', - formats: [[300, 250]], - divIdPrefix: 'div-size-', - slotElementId: 'div-size-original', - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - const hydratedElement = document.createElement('div'); - hydratedElement.id = 'div-size-hydrated'; - document.body.appendChild(hydratedElement); - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { 'div-size-original': handoff }, - }; - - await installHandoff(implementation); - - const publisherSlot = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[], - elementId: string - ) => typeof fallbackSlot - )('/123/size', [300, 250], hydratedElement.id); - (googletag.display as unknown as (target: string) => void)(hydratedElement.id); - - expect(publisherSlot).toBe(fallbackSlot); - expect(nativeDefineSlot).not.toHaveBeenCalled(); - expect(nativeDisplay).not.toHaveBeenCalled(); - expect(handoff.publisherClaimed).toBe(true); - expect(handoff.suppressPublisherDisplay).toBe(false); - } - ); - - it('filters only the claimed slot from the first bootstrap global refresh', () => { - const claimedSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-claimed'), - }; - const unrelatedSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), - }; - const nativeRefresh = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([claimedSlot, unrelatedSlot]), - refresh: nativeRefresh, - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn(), - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - 'div-claimed': { - gamUnitPath: '/123/claimed', - formats: [[300, 250]], - divIdPrefix: 'div-claimed', - slotElementId: 'div-claimed', - publisherClaimed: true, - suppressPublisherDisplay: false, - suppressPublisherRefresh: true, - }, - }, - }; - - return installHandoff('bootstrap').then(() => { - (pubads.refresh as () => void)(); - - expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot]); - expect((window as TestWindow).tsjs!.gptSlotHandoffs['div-claimed']).toEqual( - expect.objectContaining({ suppressPublisherRefresh: false }) - ); - }); - }); - - it('preserves refresh options while filtering a claimed bootstrap slot', () => { - const claimedSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-claimed'), - }; - const unrelatedSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), - }; - const nativeRefresh = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([claimedSlot, unrelatedSlot]), - refresh: nativeRefresh, - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn(), - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - 'div-claimed': { - gamUnitPath: '/123/claimed', - formats: [[300, 250]], - divIdPrefix: 'div-claimed', - slotElementId: 'div-claimed', - publisherClaimed: true, - suppressPublisherDisplay: false, - suppressPublisherRefresh: true, - }, - }, - }; - const refreshOptions = { changeCorrelator: false }; - - return installHandoff('bootstrap').then(() => { - (pubads.refresh as (slots: (typeof claimedSlot)[], options: typeof refreshOptions) => void)( - [claimedSlot, unrelatedSlot], - refreshOptions - ); - - expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot], refreshOptions); - }); - }); - - it('does not transfer an ambiguous hydrated publisher definition through bootstrap', () => { - const firstSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-prefix-original-a'), - }; - const secondSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-prefix-original-b'), - }; - const nativeDefineSlot = vi.fn().mockReturnValue(null); - const pubads = { - getSlots: vi.fn().mockReturnValue([firstSlot, secondSlot]), - refresh: vi.fn(), - }; - const firstHandoff = { - gamUnitPath: '/123/prefix', - formats: [[300, 250]], - divIdPrefix: 'div-prefix-', - slotElementId: 'div-prefix-original-a', - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - const secondHandoff = { - ...firstHandoff, - slotElementId: 'div-prefix-original-b', - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - 'div-prefix-original-a': firstHandoff, - 'div-prefix-original-b': secondHandoff, - }, - }; - - return installHandoff('bootstrap').then(() => { - const defined = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => null - )('/123/prefix', [[300, 250]], 'div-prefix-hydrated'); - - expect(defined).toBeNull(); - expect(nativeDefineSlot).toHaveBeenCalledOnce(); - expect(firstHandoff.publisherClaimed).toBe(false); - expect(secondHandoff.publisherClaimed).toBe(false); - }); - }); - - it('preserves refresh options while filtering a claimed disabled-load slot', async () => { - type FakeSlot = { - addService(service: unknown): FakeSlot; - setTargeting(key: string, value: string | string[]): FakeSlot; - getSlotElementId(): string; - getTargeting(key?: string): string[]; - }; - const slots = new Map(); - const makeSlot = (elementId: string): FakeSlot => ({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - getTargeting: vi.fn().mockReturnValue([]), - }); - const nativeRefresh = vi.fn(); - const pubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => Array.from(slots.values())), - addEventListener: vi.fn(), - refresh: nativeRefresh, - disableInitialLoad: vi.fn(), - }; - const nativeDefineSlot = vi.fn( - (_adUnitPath: string, _formats: number[][], elementId: string) => { - const slot = makeSlot(elementId); - slots.set(elementId, slot); - return slot; - } - ); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - pubads.disableInitialLoad(); - (window as TestWindow).tsjs!.adInit!(); - - const publisherSlot = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => FakeSlot - )('/123/atf', [[300, 250]], 'div-atf-sidebar'); - const unrelatedSlot = makeSlot('div-unrelated'); - const refreshOptions = { changeCorrelator: false }; - ( - pubads.refresh as unknown as ( - requestedSlots: FakeSlot[], - options: { changeCorrelator: boolean } - ) => void - )([publisherSlot, unrelatedSlot], refreshOptions); - - expect(nativeRefresh).toHaveBeenLastCalledWith([unrelatedSlot], refreshOptions); - }); - - it('suppresses only the claimed slot from the first disabled-load publisher refresh', async () => { - type FakeSlot = { - addService(service: unknown): FakeSlot; - setTargeting(key: string, value: string | string[]): FakeSlot; - getSlotElementId(): string; - getTargeting(key?: string): string[]; - }; - const slots = new Map(); - const requests: string[] = []; - let initialLoadDisabled = false; - const makeSlot = (elementId: string): FakeSlot => ({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - getTargeting: vi.fn().mockReturnValue([]), - }); - const pubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => Array.from(slots.values())), - addEventListener: vi.fn(), - refresh: vi.fn((requestedSlots?: FakeSlot[]) => { - (requestedSlots ?? Array.from(slots.values())).forEach((slot) => - requests.push(slot.getSlotElementId()) - ); - }), - disableInitialLoad: vi.fn(() => { - initialLoadDisabled = true; - }), - }; - const nativeDefineSlot = vi.fn( - (_adUnitPath: string, _formats: number[][], elementId: string) => { - const slot = makeSlot(elementId); - slots.set(elementId, slot); - return slot; - } - ); - const nativeDisplay = vi.fn((elementId: string) => { - if (!initialLoadDisabled) requests.push(elementId); - }); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - pubads.disableInitialLoad(); - (window as TestWindow).tsjs!.adInit!(); - - const publisherDefineSlot = googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => FakeSlot; - const publisherDisplay = googletag.display as unknown as (elementId: string) => void; - const publisherRefresh = pubads.refresh as unknown as () => void; - const publisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); - publisherSlot.addService(pubads); - publisherDisplay('div-atf-sidebar'); - slots.set('div-unrelated', makeSlot('div-unrelated')); - publisherRefresh(); - - expect(nativeDefineSlot).toHaveBeenCalledTimes(1); - expect(nativeDisplay).toHaveBeenCalledTimes(1); - expect(requests.filter((elementId) => elementId === 'div-atf-sidebar')).toHaveLength(1); - expect(requests).toContain('div-unrelated'); - }); - - it('refreshes TS-defined slots when the publisher disabled GPT initial load', async () => { - // With pubads().disableInitialLoad(), display() only registers a freshly - // defined slot — the ad request must come from refresh(). A TS-owned slot - // must therefore be refreshed too, or it renders blank. - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - // Publisher has not defined this slot, so TS defines (owns) it. - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - disableInitialLoad: vi.fn(), - }; - const getConfigMock = vi.fn().mockReturnValue(undefined); - const displayMock = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - // Exercise the wrapper fallback used when the getter has no value. - getConfig: getConfigMock, - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - // Publisher disables initial load — goes through the wrapper the detector - // installed, recording the state on window.tsjs. - mockPubads.disableInitialLoad(); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - // The slot is still registered via display(), and additionally refreshed so - // it actually requests an ad under disableInitialLoad(). - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - }); - - it('preserves legacy state in the edge bootstrap when getConfig does not report it', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - }; - const disableInitialLoadMock = vi.fn(); - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - refresh: nativeRefresh, - disableInitialLoad: disableInitialLoadMock, - }; - const displayMock = vi.fn(); - const getConfigMock = vi.fn().mockReturnValue(undefined); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - getConfig: getConfigMock, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - await runGptBootstrapWithGoogleTag(googletag); - - mockPubads.disableInitialLoad(); - expect(disableInitialLoadMock).toHaveBeenCalledOnce(); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - }); - - it('tracks setConfig state and re-enabling in the edge bootstrap', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - }; - type InitialLoadConfig = { - disableInitialLoad?: boolean | null; - }; - let effectiveConfig: { disableInitialLoad?: boolean } = {}; - const setConfigMock = vi.fn((config: InitialLoadConfig) => { - if ('disableInitialLoad' in config) { - effectiveConfig = { disableInitialLoad: config.disableInitialLoad === true }; - } - }); - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - refresh: nativeRefresh, - }; - const displayMock = vi.fn(); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - getConfig: undefined as undefined | (() => { disableInitialLoad?: boolean }), - setConfig: setConfigMock, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - await runGptBootstrapWithGoogleTag(googletag); - - // Older GPT runtimes may expose setConfig without getConfig. In that case, - // the wrapper tracks explicit initial-load updates directly. - googletag.setConfig({ disableInitialLoad: true }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - googletag.setConfig({ disableInitialLoad: false }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - googletag.getConfig = vi.fn(() => effectiveConfig); - setConfigMock.mockClear(); - googletag.setConfig({ disableInitialLoad: true }); - expect(setConfigMock).toHaveBeenCalledOnce(); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - - nativeRefresh.mockClear(); - googletag.setConfig({ disableInitialLoad: false }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).not.toHaveBeenCalled(); - - googletag.setConfig({ disableInitialLoad: true }); - googletag.setConfig({ disableInitialLoad: null }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).not.toHaveBeenCalled(); - }); - - it('tracks the effective initial-load state from setConfig', async () => { - // Modern GPT configuration uses googletag.setConfig() rather than the - // legacy pubads().disableInitialLoad() method. TS must detect both forms. - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - type InitialLoadConfig = { - disableInitialLoad?: boolean | null; - singleRequest?: boolean; - }; - let effectiveConfig: { disableInitialLoad?: boolean } = {}; - const setConfigMock = vi.fn((config: InitialLoadConfig) => { - if ('disableInitialLoad' in config) { - effectiveConfig = { disableInitialLoad: config.disableInitialLoad === true }; - } - }); - const disableInitialLoadMock = vi.fn(() => { - effectiveConfig = { disableInitialLoad: true }; - }); - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - // Publisher has not defined this slot, so TS defines (owns) it. - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - disableInitialLoad: disableInitialLoadMock, - }; - const displayMock = vi.fn(); - const getConfigMock = vi.fn(() => effectiveConfig); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - getConfig: undefined as undefined | typeof getConfigMock, - setConfig: setConfigMock, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - installTsAdInit(); - - const gpt = (window as TestWindow).googletag as { - setConfig(config: InitialLoadConfig): void; - }; - gpt.setConfig({ singleRequest: true }); - expect(setConfigMock).toHaveBeenCalledOnce(); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).not.toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(nativeRefresh).not.toHaveBeenCalled(); - - // Fall back to the explicit setConfig value when getConfig is unavailable. - gpt.setConfig({ disableInitialLoad: true }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - gpt.setConfig({ disableInitialLoad: false }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - googletag.getConfig = getConfigMock; - setConfigMock.mockClear(); - const config = { disableInitialLoad: true, singleRequest: true }; - gpt.setConfig(config); - expect(setConfigMock).toHaveBeenCalledOnce(); - expect(setConfigMock).toHaveBeenLastCalledWith(config); - expect(getConfigMock).toHaveBeenCalledWith('disableInitialLoad'); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - // The slot already spent its first impression above. Changing GPT's - // initial-load mode must not make a repeated adInit request it again. - expect(nativeRefresh).not.toHaveBeenCalled(); - - nativeRefresh.mockClear(); - gpt.setConfig({ disableInitialLoad: false }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - gpt.setConfig({ disableInitialLoad: null }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).not.toHaveBeenCalled(); - - // GPT exposes one effective setting across the modern and legacy APIs. - // A legacy call made after setConfig(false) disables initial load. - mockPubads.disableInitialLoad(); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).not.toHaveBeenCalled(); - - // A later modern call can re-enable initial load after the legacy API. - nativeRefresh.mockClear(); - gpt.setConfig({ disableInitialLoad: false }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).not.toHaveBeenCalled(); - - // Resetting the setting to its default has the same effective result. - mockPubads.disableInitialLoad(); - gpt.setConfig({ disableInitialLoad: null }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).not.toHaveBeenCalled(); - }); - - it('reads initial-load configuration effective before detector installation', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - }; - const displayMock = vi.fn(); - const getConfigMock = vi.fn().mockReturnValue({ disableInitialLoad: true }); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - getConfig: getConfigMock, - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - expect(getConfigMock).toHaveBeenCalledWith('disableInitialLoad'); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - }); - - it('sets adInitRefreshInProgress only for the duration of the internal refresh', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - let flagDuringRefresh: boolean | undefined; - const mockPubads = { - enableSingleRequest: vi.fn(), - // Publisher-owned slot reused by TS, so it goes through refresh() (which - // carries the bypass flag) rather than display(). - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(() => { - flagDuringRefresh = (window as TestWindow).tsjs!.adInitRefreshInProgress; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(mockPubads.refresh).toHaveBeenCalled(); - expect(flagDuringRefresh).toBe(true); - expect((window as TestWindow).tsjs!.adInitRefreshInProgress).toBe(false); - }); - - it('clears stale TS targeting from previously touched slots when the new route has no TS slots', async () => { - const clearTargeting = vi.fn().mockReturnThis(); - const staleSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - clearTargeting, - getSlotElementId: vi.fn().mockReturnValue('div-old-route'), - getTargeting: vi.fn((key: string) => (key === 'ts' ? ['publisher-value'] : [])), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([staleSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - // New route has no matching TS slots. - adSlots: [], - bids: {}, - // Previous route touched the publisher-owned slot on div-old-route. - divToSlotId: { 'div-old-route': 'old_slot' }, - prevSlotTargetingKeys: { 'div-old-route': ['pos'] }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(clearTargeting).toHaveBeenCalledWith('hb_bidder'); - expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(clearTargeting).toHaveBeenCalledWith('pos'); - expect(clearTargeting).not.toHaveBeenCalledWith('ts'); - expect(mockPubads.refresh).not.toHaveBeenCalled(); - expect((window as TestWindow).tsjs!.divToSlotId).toEqual({}); - expect((window as TestWindow).tsjs!.prevSlotTargetingKeys).toEqual({}); - }); - - it('does not enable GPT services when the page-bids response has no slots', async () => { - // A gated page-bids response returns no slots. With nothing to display or - // refresh and services not already enabled, adInit() must not call - // enableSingleRequest()/enableServices() and activate the publisher's GPT - // services on a consent-denied or kill-switched navigation. - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - const enableServices = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices, - }; - (window as TestWindow).tsjs = { - adSlots: [], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(mockPubads.enableSingleRequest).not.toHaveBeenCalled(); - expect(enableServices).not.toHaveBeenCalled(); - expect((window as TestWindow).tsjs!.servicesEnabled).toBeFalsy(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); - }); - - it('keeps the GAM path when a bid carries inline adm (adInit does not inject)', async () => { - const slotEl = document.getElementById('div-atf-sidebar')!; - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['debug-uuid']), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - const destroySlots = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - destroySlots, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { pos: 'atf' }, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '0.20', - hb_bidder: 'mocktioneer', - hb_adid: 'debug-uuid', - adm: '
Inline creative
', - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(slotEl.innerHTML).toBe(''); - expect(destroySlots).not.toHaveBeenCalledWith([mockSlot]); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '0.20'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'mocktioneer'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'debug-uuid'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); - }); - - // Helper: full adInit setup for a single slot whose bid carries an iframe adm. - // `debugBid` toggles the per-bid `debug_bid` field that gates the testing bypass. - async function fireSlotRenderWithAdm(debugBid: boolean): Promise { - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['abc']), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - adm: '', - ...(debugBid ? { debug_bid: { slot_id: 'atf_sidebar_ad' } } : {}), - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - // A pre-existing GAM iframe; the bypass, if it runs, rewrites its src. - const slotEl = document.getElementById('div-atf-sidebar')!; - const gamIframe = document.createElement('iframe'); - gamIframe.src = 'about:blank'; - slotEl.appendChild(gamIframe); - - expect(capturedListener).toBeDefined(); - capturedListener!({ isEmpty: false, slot: mockSlot }); - return gamIframe; - } - - it('does not run the GAM-replace bypass without debug_bid (production)', async () => { - const gamIframe = await fireSlotRenderWithAdm(false); - // No debug_bid ⇒ testing bypass is off; the render bridge handles the creative - // and GAM stays in the loop, so the GAM iframe src is untouched. - expect(gamIframe.src).toBe('about:blank'); - }); - - it('runs the GAM-replace bypass when debug_bid is present (testing)', async () => { - const gamIframe = await fireSlotRenderWithAdm(true); - // debug_bid present ⇒ inject_adm_for_testing on ⇒ direct GAM replace fires, - // rewriting the iframe to the creative URL from the adm. - expect(gamIframe.src).toBe('https://cdn.example/creative.html'); - }); - - it('does not fire win/billing beacons from slotRenderEnded targeting alone', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['abc']), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(capturedListener).toBeDefined(); - capturedListener!({ isEmpty: false, slot: mockSlot }); - - expect(beaconSpy).not.toHaveBeenCalled(); - - // GPT slot targeting is request state, not proof that the TS creative - // rendered. A repeated non-empty render must still not bill from this path. - capturedListener!({ isEmpty: false, slot: mockSlot }); - expect(beaconSpy).not.toHaveBeenCalled(); - - beaconSpy.mockRestore(); - }); - - it('does not fire beacons for an APS-style bid that carries no hb_adid', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.50', - hb_bidder: 'aps', - nurl: 'https://aps/win', - burl: 'https://aps/bill', - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(capturedListener).toBeDefined(); - - // Without an hb_adid to confirm the rendered creative is ours, a non-empty - // render is not proof of a TS win: the slot could have been filled by other - // GAM demand. The beacon must not fire, so we never over-report billing. - capturedListener!({ isEmpty: false, slot: mockSlot }); - expect(beaconSpy).not.toHaveBeenCalled(); - - beaconSpy.mockRestore(); - }); - - it('does not fire nurl/burl when bid did not win GAM line item', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlotNoMatch = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['OTHER_BID_ID']), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlotNoMatch]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlotNoMatch), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - capturedListener!({ isEmpty: false, slot: mockSlotNoMatch }); - - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('does not fire beacons for slotRenderEnded on slots not owned by TS', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['abc']), - }; - const arenaSlot = { - getSlotElementId: () => 'arena-owned-div', - getTargeting: () => [], - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc' }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - capturedListener!({ isEmpty: false, slot: arenaSlot }); - - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('does not call native apstag for a Trusted Server APS renderer winner', async () => { - const setDisplayBidsSpy = vi.fn(); - (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.50', - hb_bidder: 'aps', - hb_adid: envelope.seatbid[0].bid[0].id, - renderer: apsRenderer(), - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(setDisplayBidsSpy).not.toHaveBeenCalled(); - expect((window as TestWindow).apstag).toEqual({ setDisplayBids: setDisplayBidsSpy }); - - delete (window as TestWindow).apstag; - }); - - it('does not call apstag.setDisplayBids when hb_bidder is not aps', async () => { - const setDisplayBidsSpy = vi.fn(); - (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo' }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(setDisplayBidsSpy).not.toHaveBeenCalled(); - - delete (window as TestWindow).apstag; - }); - - it('calls refresh even when tsjs.bids is empty (graceful fallback)', async () => { - const emptyTestSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([emptyTestSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - }), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(mockPubads.refresh).toHaveBeenCalled(); - }); - - it.each([ - { implementation: 'runtime', activeIndexes: [2], publisherOwned: true, selectedIndex: 2 }, - { implementation: 'runtime', activeIndexes: [], selectedIndex: null }, - { - implementation: 'runtime', - candidateIndexes: [2], - activeIndexes: [], - selectedIndex: null, - }, - { - implementation: 'runtime', - activeIndexes: [], - elementLayoutIndexes: [1], - visibleContainerIndexes: [1], - selectedIndex: 1, - }, - { implementation: 'runtime', activeIndexes: [0, 2], selectedIndex: null }, - { - implementation: 'runtime', - activeIndexes: [2, 3], - hiddenElementIndexes: [2], - selectedIndex: 3, - }, - { - implementation: 'runtime', - activeIndexes: [], - hiddenElementIndexes: [0, 1, 3], - visibleContainerIndexes: [2], - selectedIndex: 2, - }, - { - implementation: 'runtime', - activeIndexes: [], - hiddenElementIndexes: [0, 1, 3], - visibleContainerIndexes: [1, 2], - containerWidthIndexes: [2], - selectedIndex: 2, - }, - { implementation: 'runtime', activeIndexes: [2], divId: '', selectedIndex: null }, - { implementation: 'bootstrap', activeIndexes: [2], publisherOwned: true, selectedIndex: 2 }, - { implementation: 'bootstrap', activeIndexes: [], selectedIndex: null }, - { - implementation: 'bootstrap', - candidateIndexes: [2], - activeIndexes: [], - selectedIndex: null, - }, - { - implementation: 'bootstrap', - activeIndexes: [], - elementLayoutIndexes: [1], - visibleContainerIndexes: [1], - selectedIndex: 1, - }, - { implementation: 'bootstrap', activeIndexes: [0, 2], selectedIndex: null }, - { - implementation: 'bootstrap', - activeIndexes: [2, 3], - hiddenElementIndexes: [2], - selectedIndex: 3, - }, - { - implementation: 'bootstrap', - activeIndexes: [], - hiddenElementIndexes: [0, 1, 3], - visibleContainerIndexes: [2], - selectedIndex: 2, - }, - { - implementation: 'bootstrap', - activeIndexes: [], - hiddenElementIndexes: [0, 1, 3], - visibleContainerIndexes: [1, 2], - containerWidthIndexes: [2], - selectedIndex: 2, - }, - { implementation: 'bootstrap', activeIndexes: [2], divId: '', selectedIndex: null }, - ] as const)( - '$implementation resolves responsive matches $activeIndexes to $selectedIndex', - async (testCase) => { - const { implementation, activeIndexes, selectedIndex } = testCase; - const hiddenElementIndexes = - 'hiddenElementIndexes' in testCase ? testCase.hiddenElementIndexes : []; - const elementLayoutIndexes = - 'elementLayoutIndexes' in testCase ? testCase.elementLayoutIndexes : []; - const visibleContainerIndexes = - 'visibleContainerIndexes' in testCase ? testCase.visibleContainerIndexes : activeIndexes; - const containerWidthIndexes = - 'containerWidthIndexes' in testCase ? testCase.containerWidthIndexes : activeIndexes; - const containerHeightIndexes = - 'containerHeightIndexes' in testCase ? testCase.containerHeightIndexes : activeIndexes; - const elementWidthIndexes = - 'elementWidthIndexes' in testCase ? testCase.elementWidthIndexes : elementLayoutIndexes; - const elementHeightIndexes = - 'elementHeightIndexes' in testCase ? testCase.elementHeightIndexes : elementLayoutIndexes; - const candidateIndexes = - 'candidateIndexes' in testCase ? testCase.candidateIndexes : [0, 1, 2, 3]; - const divId = 'divId' in testCase ? testCase.divId : 'ad-responsive-'; - const publisherOwned = 'publisherOwned' in testCase && testCase.publisherOwned; - const elements = ['a', 'b', 'c', 'd'].map((suffix, index) => - appendResponsiveSlotElement( - (candidateIndexes as readonly number[]).includes(index) - ? `ad-responsive-${suffix}` - : `unrelated-responsive-${suffix}`, - { - containerVisible: (visibleContainerIndexes as readonly number[]).includes(index), - containerWidth: (containerWidthIndexes as readonly number[]).includes(index) ? 320 : 0, - containerHeight: (containerHeightIndexes as readonly number[]).includes(index) - ? 100 - : 0, - elementHidden: (hiddenElementIndexes as readonly number[]).includes(index), - elementWidth: (elementWidthIndexes as readonly number[]).includes(index) ? 300 : 0, - elementHeight: (elementHeightIndexes as readonly number[]).includes(index) ? 250 : 0, - } - ) - ); - const selectedElement = selectedIndex === null ? undefined : elements[selectedIndex]; - expect(resolveFirstImpressionElement(divId)).toBe(selectedElement); - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(selectedElement?.id ?? elements[0]!.id), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue(publisherOwned ? [mockSlot] : []), - addEventListener: vi.fn(), - refresh: nativeRefresh, - }; - const defineSlot = vi.fn().mockReturnValue(mockSlot); - const nativeDisplay = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'responsive_slot', - gam_unit_path: '/123/responsive', - div_id: divId, - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - if (implementation === 'runtime') { - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - } else { - runGptBootstrap(); - } - (window as TestWindow).tsjs!.adInit!(); - - if (selectedElement) { - if (publisherOwned) { - expect(defineSlot).not.toHaveBeenCalled(); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - } else { - expect(defineSlot).toHaveBeenCalledWith( - '/123/responsive', - [[300, 250]], - selectedElement.id - ); - expect(nativeDisplay).toHaveBeenCalledWith(selectedElement.id); - } - expect((window as TestWindow).tsjs!.divToSlotId).toEqual({ - [selectedElement.id]: 'responsive_slot', - }); - } else { - expect(defineSlot).not.toHaveBeenCalled(); - expect((window as TestWindow).tsjs!.divToSlotId).toEqual({}); - } - } - ); - - it.each(['runtime', 'bootstrap'] as const)( - '$implementation reports an ambiguous prefix once during adInit', - async (implementation) => { - const elements = ['a', 'b', 'c', 'd'].map((suffix) => - appendResponsiveSlotElement(`ad-warning-${suffix}`, { containerVisible: true }) - ); - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elements[0]!.id), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: vi.fn(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - const bootstrapWarn = vi.fn(); - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'warning_slot', - gam_unit_path: '/123/warning', - div_id: 'ad-warning-', - formats: [[300, 250]], - targeting: {}, - }, - { - id: 'warning_slot_duplicate', - gam_unit_path: '/123/warning', - div_id: 'ad-warning-', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - ...(implementation === 'bootstrap' ? { log: { warn: bootstrapWarn } } : {}), - }; - - const runtimeWarn = vi.spyOn(console, 'warn'); - if (implementation === 'runtime') { - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - } else { - runGptBootstrap(); - } - (window as TestWindow).tsjs!.adInit!(); - - if (implementation === 'runtime') { - const warningCall = runtimeWarn.mock.calls.find((call) => - call.includes('GPT slot prefix did not resolve to one active element') - ); - expect(runtimeWarn).toHaveBeenCalledTimes(1); - expect(warningCall).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - divId: 'ad-warning-', - prefixMatchCount: 4, - activeMatchCount: 0, - }), - ]) - ); - } else { - expect(bootstrapWarn).toHaveBeenCalledTimes(1); - expect(bootstrapWarn).toHaveBeenCalledWith( - 'GPT slot prefix did not resolve to one active element', - { - divId: 'ad-warning-', - prefixMatchCount: 4, - activeMatchCount: 0, - } - ); - } - runtimeWarn.mockRestore(); - } - ); - - it.each(['runtime', 'bootstrap'] as const)( - '$implementation trusts checkVisibility when resolving a visible slot', - async (implementation) => { - const element = appendResponsiveSlotElement('ad-native-slot', { - checkVisibility: true, - }); - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(element.id), - getTargeting: vi.fn().mockReturnValue([]), - }; - const defineSlot = vi.fn().mockReturnValue(mockSlot); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'native_visibility_slot', - gam_unit_path: '/123/native-visibility', - div_id: 'ad-native-', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - if (implementation === 'runtime') { - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - } else { - runGptBootstrap(); - } - (window as TestWindow).tsjs!.adInit!(); - - expect(defineSlot).toHaveBeenCalledWith('/123/native-visibility', [[300, 250]], element.id); - } - ); - - it('resolves dynamic div prefixes without interpolating div_id into a CSS selector', async () => { - const dynamicDiv = document.createElement('div'); - dynamicDiv.id = "ad'prefix-real"; - document.body.appendChild(dynamicDiv); - - const dynamicSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue("ad'prefix-real"), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([dynamicSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(dynamicSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'dynamic_slot', - gam_unit_path: '/123/dynamic', - div_id: "ad'prefix-", - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - expect(mockPubads.refresh).toHaveBeenCalledWith([dynamicSlot]); - }); -}); - -describe('parseCachedBid', () => { - async function parseCachedBid(body: string) { - const mod = await import('../../../src/integrations/gpt/index'); - return mod.parseCachedBid(body); - } - - it('decodes adm, dimensions, and price from a PBS Cache bid object', async () => { - const bid = await parseCachedBid( - JSON.stringify({ adm: '
cached
', w: 300, h: 250, price: 1.23 }) - ); - expect(bid).toEqual({ adm: '
cached
', width: 300, height: 250, price: 1.23 }); - }); - - it('accepts width/height as an alternate dimension spelling', async () => { - const bid = await parseCachedBid( - JSON.stringify({ adm: '
cached
', width: 728, height: 90 }) - ); - expect(bid?.width).toBe(728); - expect(bid?.height).toBe(90); - }); - - it('treats zero dimensions as absent so the caller falls back', async () => { - const bid = await parseCachedBid(JSON.stringify({ adm: '
cached
', w: 0, h: 0 })); - expect(bid?.width).toBeUndefined(); - expect(bid?.height).toBeUndefined(); - }); - - it('treats a non-JSON body as raw creative markup with no metadata', async () => { - const bid = await parseCachedBid('
raw
'); - expect(bid).toEqual({ adm: '
raw
' }); - }); - - it('returns undefined when the JSON payload carries no usable adm', async () => { - expect(await parseCachedBid(JSON.stringify({ w: 300, h: 250 }))).toBeUndefined(); - expect(await parseCachedBid(' ')).toBeUndefined(); - }); -}); - -describe('installTsRenderBridge', () => { - let fetchStub: ReturnType; - - beforeEach(() => { - vi.resetModules(); - publisherNativeScript = undefined; - // Remove ALL accumulated 'message' handlers from previous test module imports - // to prevent stale bridge listeners from intercepting our test event. - for (const handler of allMessageHandlers) { - window.removeEventListener('message', handler); - } - allMessageHandlers.length = 0; - - fetchStub = vi.fn(); - vi.stubGlobal('fetch', fetchStub); - if (typeof navigator.sendBeacon !== 'function') { - Object.defineProperty(navigator, 'sendBeacon', { - value: vi.fn().mockReturnValue(true), - writable: true, - configurable: true, - }); - } - - (window as TestWindow).tsjs = { - bids: { - homepage_header: { - hb_adid: 'test-cache-uuid', - hb_bidder: 'kargo', - hb_pb: '1.50', - hb_cache_host: 'openads.example.com', - hb_cache_path: '/cache', - nurl: 'https://ssp.example/win', - burl: 'https://ssp.example/bill', - }, - }, - adSlots: [ - { - id: 'homepage_header', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-header', - targeting: {}, - }, - ], - divToSlotId: { 'div-header': 'homepage_header' }, - }; - }); - - afterEach(() => { - vi.unstubAllGlobals(); - document.getElementById('div-header')?.remove(); - delete (window as TestWindow).tsjs; - }); - - function createTrustedSlotIframe(divId = 'div-header'): Window { - const slot = document.createElement('div'); - slot.id = divId; - const iframe = document.createElement('iframe'); - slot.appendChild(iframe); - document.body.appendChild(slot); - return iframe.contentWindow!; - } - - function createCollapsedTrustedSlotIframe(divId = 'div-header') { - const slot = document.createElement('div'); - slot.id = divId; - const wrapper = document.createElement('div'); - wrapper.style.width = '1px'; - wrapper.style.height = '1px'; - const iframe = document.createElement('iframe'); - iframe.width = '1'; - iframe.height = '1'; - iframe.style.width = '1px'; - iframe.style.height = '1px'; - wrapper.appendChild(iframe); - slot.appendChild(wrapper); - document.body.appendChild(slot); - return { iframe, slot, source: iframe.contentWindow!, wrapper }; - } - - async function captureBridgeListener(): Promise<(e: MessageEvent) => unknown> { - let bridgeListener: ((e: MessageEvent) => unknown) | undefined; - const origAdd = window.addEventListener.bind(window); - const currentScriptSpy = publisherNativeScript - ? vi.spyOn(document, 'currentScript', 'get').mockReturnValue(publisherNativeScript) - : undefined; - const addSpy = vi - .spyOn(window, 'addEventListener') - .mockImplementation( - (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { - if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; - origAdd( - type, - handler as EventListener, - opts as boolean | AddEventListenerOptions | undefined - ); - } - ); - await import('../../../src/integrations/gpt/index'); - addSpy.mockRestore(); - currentScriptSpy?.mockRestore(); - - expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); - return bridgeListener!; - } - - it('records an inline creative request and response with the same opaque attempt ID', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(41); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - tsjs.bids.homepage_header.adm = '
Creative
'; - delete tsjs.bids.homepage_header.nurl; - delete tsjs.bids.homepage_header.burl; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const postMessage = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(postMessage).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(41); - expect(postMessage.mock.invocationCallOrder[0]).toBeLessThan( - recordTrustedServerCreativeResponse.mock.invocationCallOrder[0] - ); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - }); - - it('expands an authenticated collapsed inline creative shell after response delivery', async () => { - const tsjs = (window as TestWindow).tsjs!; - tsjs.bids.homepage_header.adm = '
Fictional creative
'; - tsjs.bids.homepage_header.w = 728; - tsjs.bids.homepage_header.h = 90; - delete tsjs.bids.homepage_header.nurl; - delete tsjs.bids.homepage_header.burl; - const bridgeListener = await captureBridgeListener(); - const collapsed = createCollapsedTrustedSlotIframe(); - const postMessage = vi.fn(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source: collapsed.iframe.contentWindow!, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(postMessage).toHaveBeenCalledOnce(); - expect(collapsed.iframe.width).toBe('728'); - expect(collapsed.iframe.height).toBe('90'); - expect(collapsed.wrapper.style.width).toBe('728px'); - expect(collapsed.wrapper.style.height).toBe('90px'); - }); - - it('expands every collapsed ancestor through the authenticated slot root', async () => { - const tsjs = (window as TestWindow).tsjs!; - tsjs.bids.homepage_header.adm = '
Fictional creative
'; - tsjs.bids.homepage_header.w = 728; - tsjs.bids.homepage_header.h = 90; - delete tsjs.bids.homepage_header.nurl; - delete tsjs.bids.homepage_header.burl; - const bridgeListener = await captureBridgeListener(); - const collapsed = createCollapsedTrustedSlotIframe(); - const outerWrapper = document.createElement('div'); - outerWrapper.style.width = '1px'; - outerWrapper.style.height = '1px'; - collapsed.slot.insertBefore(outerWrapper, collapsed.wrapper); - outerWrapper.appendChild(collapsed.wrapper); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source: collapsed.iframe.contentWindow!, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(collapsed.wrapper.style.width).toBe('728px'); - expect(collapsed.wrapper.style.height).toBe('90px'); - expect(outerWrapper.style.width).toBe('728px'); - expect(outerWrapper.style.height).toBe('90px'); - }); - - it.each(['fixed', 'anchor', 'expanded', 'oversized'] as const)( - 'does not resize a %s Universal Creative shell', - async (guard) => { - const tsjs = (window as TestWindow).tsjs!; - tsjs.bids.homepage_header.adm = '
Fictional creative
'; - tsjs.bids.homepage_header.w = guard === 'oversized' ? 10_001 : 300; - tsjs.bids.homepage_header.h = 250; - delete tsjs.bids.homepage_header.nurl; - delete tsjs.bids.homepage_header.burl; - const bridgeListener = await captureBridgeListener(); - const collapsed = createCollapsedTrustedSlotIframe(); - if (guard === 'fixed') collapsed.iframe.style.position = 'fixed'; - if (guard === 'expanded') collapsed.iframe.style.width = '300px'; - if (guard === 'anchor') { - const anchor = document.createElement('ins'); - anchor.dataset.anchorStatus = 'displayed'; - collapsed.slot.insertBefore(anchor, collapsed.wrapper); - anchor.appendChild(collapsed.wrapper); - } - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source: collapsed.source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(collapsed.iframe.width).toBe('1'); - expect(collapsed.iframe.height).toBe('1'); - expect(collapsed.wrapper.style.width).toBe('1px'); - expect(collapsed.wrapper.style.height).toBe('1px'); - } - ); - - it('records no creative evidence for an ad ID the requesting slot does not own', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(42); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'someone-elses-ad-id' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(recordTrustedServerCreativeRequest).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - }); - - it.each([ - ['missing cache coordinates', {}], - ['incomplete cache coordinates', { hb_cache_host: 'cache.example.com' }], - ] as const)( - 'records missing_render_source for an exact-owned request with %s', - async (_description, cacheFields) => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(45); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - delete tsjs.bids.homepage_header.hb_cache_host; - delete tsjs.bids.homepage_header.hb_cache_path; - Object.assign(tsjs.bids.homepage_header, cacheFields); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(45, 'missing_render_source'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(stopImmediatePropagation).not.toHaveBeenCalled(); - expect(fetchStub).not.toHaveBeenCalled(); - } - ); - - it('records no failure when diagnostics declined to open a creative attempt', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(undefined); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - delete tsjs.bids.homepage_header.hb_cache_host; - delete tsjs.bids.homepage_header.hb_cache_path; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - - // Without an attempt ID there is nothing to attribute the failure to, and - // the missing-source fallback must still run untouched. - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(stopImmediatePropagation).not.toHaveBeenCalled(); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('records response_post_failed without resizing when posting inline markup throws', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(46); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - tsjs.bids.homepage_header.adm = '
Creative
'; - - const bridgeListener = await captureBridgeListener(); - const collapsed = createCollapsedTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [ - { - postMessage: vi.fn(() => { - throw new Error('port closed'); - }), - }, - ], - source: collapsed.source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(stopImmediatePropagation).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(46, 'response_post_failed'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(collapsed.iframe.width).toBe('1'); - expect(collapsed.iframe.height).toBe('1'); - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('serves a server APS renderer once and rejects a repeated request', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - hb_pb: '1.23', - renderer, - // These must not be used even if unexpected legacy fields coexist. - nurl: 'https://notify.example/win', - burl: 'https://notify.example/bill', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/cache', - }; - - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const bridgeListener = await captureBridgeListener(); - const collapsed = createCollapsedTrustedSlotIframe(); - const source = collapsed.source; - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (message: string) => portMessages.push(message) }; - const event = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent; - - bridgeListener(event); - bridgeListener(event); - - expect(stopSpy).toHaveBeenCalledTimes(2); - expect(fetchStub).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - // Server-rendered APS capabilities are one-shot per slot and ad ID. A - // repeated Universal Creative request is claimed but receives no payload. - expect(portMessages).toHaveLength(1); - const response = JSON.parse(portMessages[0]) as Record; - expect(Object.keys(response).sort()).toEqual( - [ - 'adId', - 'apsRenderer', - 'height', - 'message', - 'renderer', - 'rendererUrl', - 'rendererVersion', - 'width', - ].sort() - ); - expect(response).toEqual({ - message: 'Prebid Response', - adId: renderer.bidId, - renderer: expect.stringContaining('window.render=function'), - rendererVersion: 4, - rendererUrl: new URL('/integrations/aps/renderer', window.location.origin).href, - apsRenderer: renderer, - width: 300, - height: 250, - }); - expect(String(response.renderer)).not.toContain(renderer.accountId); - expect(String(response.renderer)).not.toContain(renderer.aaxResponse); - expect(collapsed.iframe.width).toBe('300'); - expect(collapsed.iframe.height).toBe('250'); - expect(collapsed.wrapper.style.width).toBe('300px'); - expect(collapsed.wrapper.style.height).toBe('250px'); - - // Universal Creative's dynamic-renderer path evaluates the returned static - // source and calls window.render(response, helper, targetWindow). Consume - // the exact bridge response through that deployed protocol shape. - const dynamicWindow = window as unknown as { - render?: (data: Record, helper: unknown, target: Window) => Promise; - }; - window.eval(String(response.renderer)); - try { - const rendered = dynamicWindow.render!(response, undefined, window); - const outerFrame = document.querySelector( - 'iframe[src*="/integrations/aps/renderer#tsaps="]' - )!; - expect(outerFrame).not.toBeNull(); - expect(outerFrame.getAttribute('sandbox')).not.toContain('allow-same-origin'); - - const rendererPost = vi.spyOn(outerFrame.contentWindow!, 'postMessage'); - outerFrame.dispatchEvent(new Event('load')); - const sent = rendererPost.mock.calls[0][0] as { nonce: string }; - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: outerFrame.contentWindow, - }) - ); - await expect(rendered).resolves.toBeUndefined(); - outerFrame.remove(); - } finally { - delete dynamicWindow.render; - } - beaconSpy.mockRestore(); - }); - - it('contract test: renders a server APS owner with the injected runner and no Universal Creative response', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: renderer.bidId, - renderer, - }; - const marker = enablePublisherNativeMode(); - - try { - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - const request = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent; - - bridgeListener(request); - bridgeListener(request); - const native = nativeRunnerIn('div-header'); - expect(native.event.type).toBe('prebid/creative/render'); - expect(native.event.detail).toEqual({ - aaxResponse: renderer.aaxResponse, - seatBidId: renderer.bidId, - }); - native.runner.dispatchEvent(new Event('load')); - await Promise.resolve(); - await Promise.resolve(); - - expect(native.frame.style.display).toBe(''); - expect(portMessages).toEqual([]); - expect(document.querySelector('iframe[src*="/integrations/aps/renderer"]')).toBeNull(); - } finally { - marker.remove(); - } - }); - - it('contract test: fails a server APS runner without a Universal Creative response or fallback', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: renderer.bidId, - renderer, - }; - const marker = enablePublisherNativeMode(); - - try { - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - const request = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent; - - bridgeListener(request); - nativeRunnerIn('div-header').runner.dispatchEvent(new Event('error')); - await Promise.resolve(); - await Promise.resolve(); - bridgeListener(request); - - expect(portMessages).toEqual([]); - expect(document.querySelector('iframe[title="Ad content"]')).toBeNull(); - expect(document.querySelector('iframe[src*="/integrations/aps/renderer"]')).toBeNull(); - } finally { - marker.remove(); - } - }); - - it('serves a registered Prebid APS renderer when its generated ad ID differs from the APS bid ID', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'prebid-generated-ad-id'; - const markUsed = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed, - }, - }; - - const bridgeListener = await captureBridgeListener(); - const collapsed = createCollapsedTrustedSlotIframe(); - const source = collapsed.source; - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const event = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent; - - bridgeListener(event); - const foreignIframe = document.createElement('iframe'); - document.body.appendChild(foreignIframe); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source: foreignIframe.contentWindow, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).toHaveBeenCalledTimes(2); - expect(portMessages).toHaveLength(1); - expect(markUsed).toHaveBeenCalledTimes(1); - expect(JSON.parse(portMessages[0])).toEqual( - expect.objectContaining({ - message: 'Prebid Response', - adId: prebidAdId, - apsRenderer: renderer, - width: renderer.width, - height: renderer.height, - }) - ); - expect(renderer.bidId).not.toBe(prebidAdId); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); - expect(collapsed.iframe.width).toBe('300'); - expect(collapsed.iframe.height).toBe('250'); - expect(collapsed.wrapper.style.width).toBe('300px'); - expect(collapsed.wrapper.style.height).toBe('250px'); - expect(fetchStub).not.toHaveBeenCalled(); - foreignIframe.remove(); - }); - - 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'; - const markUsed = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed, - }, - }; - const marker = enablePublisherNativeMode(); - - try { - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - const request = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent; - - bridgeListener(request); - nativeRunnerIn('div-header').runner.dispatchEvent(new Event('error')); - await Promise.resolve(); - await Promise.resolve(); - bridgeListener(request); - - expect(markUsed).not.toHaveBeenCalled(); - expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); - expect(document.querySelector('iframe[title="Ad content"]')).toBeNull(); - expect(document.querySelector('iframe[src*="/integrations/aps/renderer"]')).toBeNull(); - } finally { - marker.remove(); - } - }); - - it('contract test: consumes a registered APS capability and marks it used only after runner load', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'native-prebid-ad-id'; - const markUsed = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed, - }, - }; - const marker = enablePublisherNativeMode(); - - try { - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - const request = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent; - - bridgeListener(request); - expect(markUsed).not.toHaveBeenCalled(); - const native = nativeRunnerIn('div-header'); - native.runner.dispatchEvent(new Event('load')); - await Promise.resolve(); - await Promise.resolve(); - bridgeListener(request); - - expect(native.frame.style.display).toBe(''); - expect(markUsed).toHaveBeenCalledOnce(); - expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); - } finally { - marker.remove(); - } - }); - - it('uses the requesting frame to disambiguate a registered APS slot prefix', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'native-dynamic-prebid-ad-id'; - const markUsed = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-native-', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed, - }, - }; - const marker = enablePublisherNativeMode(); - createTrustedSlotIframe('div-native-first'); - const source = createTrustedSlotIframe('div-native-second'); - - try { - const bridgeListener = await captureBridgeListener(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - const native = nativeRunnerIn('div-native-second'); - native.runner.dispatchEvent(new Event('load')); - await Promise.resolve(); - await Promise.resolve(); - - expect(native.frame.style.display).toBe(''); - expect(markUsed).toHaveBeenCalledOnce(); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); - } finally { - marker.remove(); - document.getElementById('div-native-first')?.remove(); - document.getElementById('div-native-second')?.remove(); - } - }); - - it('still serves the APS renderer when markUsed throws', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'throwing-mark-used-ad-id'; - const markUsed = vi.fn(() => { - throw new Error('fictional markUsed failure'); - }); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed, - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(portMessages).toHaveLength(1); - expect(JSON.parse(portMessages[0])).toEqual( - expect.objectContaining({ - message: 'Prebid Response', - adId: prebidAdId, - apsRenderer: renderer, - }) - ); - expect(markUsed).toHaveBeenCalledTimes(1); - }); - - it('prunes expired consumed APS renderer IDs', async () => { - vi.useFakeTimers(); - try { - const renderer = apsRenderer(); - const prebidAdId = 'expiring-consumed-ad-id'; - const start = Date.now(); - const firstMarkUsed = vi.fn(); - const secondMarkUsed = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: start, - expiresAt: start + 60_000, - markUsed: firstMarkUsed, - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - const portMessages: string[] = []; - const sendRequest = (): void => { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - }; - - sendRequest(); - vi.advanceTimersByTime(60_001); - (window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId] = { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed: secondMarkUsed, - }; - sendRequest(); - - expect(portMessages).toHaveLength(2); - expect(stopImmediatePropagation).toHaveBeenCalledTimes(2); - expect(firstMarkUsed).toHaveBeenCalledTimes(1); - expect(secondMarkUsed).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it('fails closed when consumed APS renderer tombstones reach capacity', async () => { - const renderer = apsRenderer(); - const capacity = 256; - const callbacks = Array.from({ length: capacity + 1 }, () => ({ - markUsed: vi.fn(), - })); - const entries = Object.fromEntries( - callbacks.map((lifecycle, index) => [ - `capacity-ad-${index}`, - { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - ...lifecycle, - }, - ]) - ); - (window as TestWindow).tsjs.apsPrebidRenderers = entries; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - const portMessages: string[] = []; - const sendRequest = (adId: string): void => { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - }; - - for (let index = 0; index < capacity; index += 1) { - sendRequest(`capacity-ad-${index}`); - } - sendRequest(`capacity-ad-${capacity}`); - sendRequest('capacity-ad-0'); - - expect(portMessages).toHaveLength(capacity); - expect(callbacks[capacity].markUsed).not.toHaveBeenCalled(); - expect(entries[`capacity-ad-${capacity}`]).toBeDefined(); - expect(callbacks[0].markUsed).toHaveBeenCalledTimes(1); - expect(stopImmediatePropagation).toHaveBeenCalledTimes(capacity + 2); - }); - - it('does not expose a registered Prebid APS renderer to another slot iframe', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'prebid-generated-ad-id'; - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed: vi.fn(), - }, - }; - - const footer = document.createElement('div'); - footer.id = 'div-footer'; - const foreignIframe = document.createElement('iframe'); - footer.appendChild(foreignIframe); - document.body.appendChild(footer); - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source: foreignIframe.contentWindow, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).toHaveBeenCalledTimes(1); - expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeDefined(); - footer.remove(); - }); - - it('drops an expired Prebid APS renderer without claiming the creative request', async () => { - const prebidAdId = 'expired-prebid-ad-id'; - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer: apsRenderer(), - registeredAt: Date.now() - 61_000, - expiresAt: Date.now() - 1_000, - markUsed: vi.fn(), - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); - }); - - it('claims a TS-owned request before rejecting invalid APS data', async () => { - const renderer = { ...apsRenderer(), aaxResponse: 'invalid' }; - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - renderer, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).toHaveBeenCalledOnce(); - expect(portMessages).toEqual([]); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('accepts an APS request from a dynamic slot root resolved from its configured prefix', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - renderer, - }; - (window as TestWindow).tsjs.adSlots[0].div_id = 'div-header-'; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe('div-header-dynamic'); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(portMessages).toHaveLength(1); - document.getElementById('div-header-dynamic')?.remove(); - }); - - it('does not let an overlapping slot prefix claim another slot iframe', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - renderer, - }; - (window as TestWindow).tsjs.adSlots.push({ - id: 'homepage_header_mobile', - formats: [[320, 50]], - gam_unit_path: '/a/b/mobile', - div_id: 'div-header-mobile', - targeting: {}, - }); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe('div-header-mobile'); - const portMessages: string[] = []; - const stopSpy = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toEqual([]); - document.getElementById('div-header-mobile')?.remove(); - }); - - it('ignores an APS ad ID requested by another configured slot', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - renderer, - }; - (window as TestWindow).tsjs.adSlots.push({ - id: 'homepage_footer', - formats: [[300, 250]], - gam_unit_path: '/a/b/footer', - div_id: 'div-footer', - targeting: {}, - }); - const footer = document.createElement('div'); - footer.id = 'div-footer'; - const foreignIframe = document.createElement('iframe'); - footer.appendChild(foreignIframe); - document.body.appendChild(footer); - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source: foreignIframe.contentWindow, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toEqual([]); - expect(fetchStub).not.toHaveBeenCalled(); - footer.remove(); - }); - - it('calls stopImmediatePropagation and fetches PBS Cache for a TS bid', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(43); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - const mockAd = '
Test Creative
'; - // PBS Cache (returnCreative=false) returns the cached bid as a JSON object; - // the creative lives under `adm`, not as the raw response body. The bridge - // must parse it and forward `adm`, mirroring the Prebid Universal Creative. - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: mockAd, width: 728, height: 90 })), - } as Response); - - // Capture the bridge's 'message' listener at module-init time. - let bridgeListener: ((e: MessageEvent) => unknown) | undefined; - const origAdd = window.addEventListener.bind(window); - const addSpy = vi - .spyOn(window, 'addEventListener') - .mockImplementation( - (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { - if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; - origAdd( - type, - handler as EventListener, - opts as boolean | AddEventListenerOptions | undefined - ); - } - ); - await import('../../../src/integrations/gpt/index'); - addSpy.mockRestore(); // Restore only addEventListener — fetchStub must stay stubbed - - expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); - - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const postMessage = vi.fn((message: string) => portMessages.push(message)); - const fakePort = { postMessage }; - const source = createTrustedSlotIframe(); - - // Dispatch the fake event — bridge listener fires synchronously, then runs - // fire-and-forget fetch().then() chains asynchronously. - bridgeListener!( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - // Flush microtasks so the fetch mock resolves and .then chains fire. - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchStub).toHaveBeenCalledWith( - 'https://openads.example.com/cache?uuid=test-cache-uuid', - { mode: 'cors' } - ); - expect(stopSpy).toHaveBeenCalled(); - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - expect(parsed.message).toBe('Prebid Response'); - expect(parsed.adId).toBe('test-cache-uuid'); - expect(parsed.ad).toBe(mockAd); - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(43); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - expect(postMessage.mock.invocationCallOrder[0]).toBeLessThan( - recordTrustedServerCreativeResponse.mock.invocationCallOrder[0] - ); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/bill'); - expect(beaconSpy).toHaveBeenCalledTimes(2); - - bridgeListener!( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('does not classify a downstream cache-processing throw as cache_fetch_failed', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(54); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: '
Creative
' })), - } as Response); - const { log } = await import('../../../src/core/log'); - const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => { - throw new Error('success logging unavailable'); - }); - - try { - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const postMessage = vi.fn(); - const dispatch = () => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - dispatch(); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(postMessage).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(54); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - - // A second request must run after the first downstream failure, proving - // the in-flight key was still cleared by the promise's finally handler. - dispatch(); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(fetchStub).toHaveBeenCalledTimes(2); - expect(postMessage).toHaveBeenCalledTimes(2); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - } finally { - debugSpy.mockRestore(); - beaconSpy.mockRestore(); - } - }); - - it.each([ - [ - 'an HTTP non-ok response', - (stub: ReturnType) => - stub.mockResolvedValue({ ok: false, status: 503 } as Response), - ], - [ - 'a response body read rejection', - (stub: ReturnType) => - stub.mockResolvedValue({ - ok: true, - text: () => Promise.reject(new Error('body unavailable')), - } as Response), - ], - [ - 'a network rejection', - (stub: ReturnType) => stub.mockRejectedValue(new Error('network unavailable')), - ], - ] as const)('records cache_fetch_failed once for %s', async (_description, arrangeFetch) => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(47); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - arrangeFetch(fetchStub); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(47, 'cache_fetch_failed'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('records only response_post_failed when posting cached markup throws', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(48); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: '
Creative
' })), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [ - { - postMessage: vi.fn(() => { - throw new Error('port closed'); - }), - }, - ], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(48, 'response_post_failed'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it.each(['request', 'response'] as const)( - 'keeps inline delivery and beacons unchanged when the diagnostics %s writer throws', - async (throwingWriter) => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn(() => { - if (throwingWriter === 'request') throw new Error('diagnostics request failed'); - return 49; - }); - const recordTrustedServerCreativeResponse = vi.fn(() => { - if (throwingWriter === 'response') throw new Error('diagnostics response failed'); - }); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - tsjs.bids.homepage_header.adm = '
Creative
'; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - const postMessage = vi.fn(); - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(stopImmediatePropagation).toHaveBeenCalledTimes(1); - expect(postMessage).toHaveBeenCalledTimes(1); - expect(beaconSpy).toHaveBeenCalledTimes(2); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - if (throwingWriter === 'response') { - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(49); - } else { - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - } - beaconSpy.mockRestore(); - } - ); - - it('does not turn a throwing cache response diagnostic into a cache failure', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(50); - const recordTrustedServerCreativeResponse = vi.fn(() => { - throw new Error('diagnostics response failed'); - }); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: '
Creative
' })), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const postMessage = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(postMessage).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(50); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('preserves missing-source fallback when the failure diagnostic throws', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(51); - const recordTrustedServerCreativeFailure = vi.fn(() => { - throw new Error('diagnostics failure writer failed'); - }); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse: vi.fn(), - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - delete tsjs.bids.homepage_header.hb_cache_host; - delete tsjs.bids.homepage_header.hb_cache_path; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(51, 'missing_render_source'); - expect(stopImmediatePropagation).not.toHaveBeenCalled(); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('uses the requesting frame to resolve inline adm under an ambiguous prefix', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const tsjs = (window as TestWindow).tsjs!; - tsjs.bids.homepage_header.adm = '
Prefix inline creative
'; - delete tsjs.bids.homepage_header.hb_cache_host; - delete tsjs.bids.homepage_header.hb_cache_path; - tsjs.adSlots = [ - { - id: 'homepage_header', - formats: [[728, 90]], - gam_unit_path: '/a/b/c', - div_id: 'div-inline-prefix-', - targeting: {}, - }, - ]; - tsjs.divToSlotId = {}; - createTrustedSlotIframe('div-inline-prefix-first'); - const source = createTrustedSlotIframe('div-inline-prefix-second'); - const bridgeListener = await captureBridgeListener(); - const postMessage = vi.fn(); - const stopImmediatePropagation = vi.fn(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - - expect(postMessage).toHaveBeenCalledOnce(); - expect(JSON.parse(postMessage.mock.calls[0]![0])).toEqual( - expect.objectContaining({ ad: '
Prefix inline creative
' }) - ); - expect(stopImmediatePropagation).toHaveBeenCalledOnce(); - expect(fetchStub).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('rejects a requesting frame owned by multiple prefix candidates', async () => { - const tsjs = (window as TestWindow).tsjs!; - tsjs.bids.homepage_header.adm = '
Ambiguous inline creative
'; - tsjs.adSlots = [ - { - id: 'homepage_header', - formats: [[728, 90]], - gam_unit_path: '/a/b/c', - div_id: 'div-nested-prefix-', - targeting: {}, - }, - ]; - tsjs.divToSlotId = {}; - const outer = document.createElement('div'); - outer.id = 'div-nested-prefix-outer'; - const inner = document.createElement('div'); - inner.id = 'div-nested-prefix-inner'; - const iframe = document.createElement('iframe'); - inner.appendChild(iframe); - outer.appendChild(inner); - document.body.appendChild(outer); - const bridgeListener = await captureBridgeListener(); - const postMessage = vi.fn(); - const stopImmediatePropagation = vi.fn(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source: iframe.contentWindow, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - - expect(postMessage).not.toHaveBeenCalled(); - expect(stopImmediatePropagation).not.toHaveBeenCalled(); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('uses the requesting frame when a responsive prefix is ambiguous', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve('
Responsive Creative
'), - } as Response); - - const resolvedSlot = document.createElement('div'); - resolvedSlot.id = 'div-responsive-a'; - const iframe = document.createElement('iframe'); - resolvedSlot.appendChild(iframe); - document.body.appendChild(resolvedSlot); - const laterSibling = document.createElement('div'); - laterSibling.id = 'div-responsive-b'; - document.body.appendChild(laterSibling); - - (window as TestWindow).tsjs!.adSlots = [ - { - id: 'homepage_header', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-responsive-', - targeting: {}, - }, - ]; - (window as TestWindow).tsjs!.divToSlotId = {}; - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const stopSpy = vi.fn(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source: iframe.contentWindow, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchStub).toHaveBeenCalledWith( - 'https://openads.example.com/cache?uuid=test-cache-uuid', - { mode: 'cors' } - ); - expect(portMessages).toHaveLength(1); - expect(stopSpy).toHaveBeenCalled(); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/bill'); - beaconSpy.mockRestore(); - }); - - it('declines to render when the PBS Cache response carries no adm', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(44); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - // A returnCreative=false JSON entry with no `adm` (VAST-only, or malformed). - // The bridge must NOT forward the serialized bid document to PUC. - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ width: 728, height: 90 })), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - // TS owns the adId so Prebid is still stopped, but with nothing renderable - // the bridge sends no Prebid Response and fires no win/billing beacons. - expect(fetchStub).toHaveBeenCalled(); - expect(stopSpy).toHaveBeenCalled(); - expect(portMessages).toHaveLength(0); - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(44, 'invalid_cache_payload'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('renders a non-JSON PBS Cache body as raw creative markup', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const rawAd = '
Raw Cached Creative
'; - // Backward compatibility: a cache that returns the creative markup directly - // (not a JSON bid object) is still rendered as-is. - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(rawAd), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - expect(parsed.ad).toBe(rawAd); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('sizes a PBS Cache render and its collapsed shell from cached bid dimensions', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - // Cached bid is 300x250 while the slot's first format is 728x90 (from the - // default setup). The response must use the cached dimensions. - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: '
cached
', w: 300, h: 250 })), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const collapsed = createCollapsedTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source: collapsed.source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - expect(parsed.width).toBe(300); - expect(parsed.height).toBe(250); - expect(collapsed.iframe.width).toBe('300'); - expect(collapsed.iframe.height).toBe('250'); - expect(collapsed.wrapper.style.width).toBe('300px'); - expect(collapsed.wrapper.style.height).toBe('250px'); - beaconSpy.mockRestore(); - }); - - it('does not resize a stale cache response after navigation', async () => { - const recordTrustedServerCreativeResponse = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest: vi.fn().mockReturnValue(91), - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure: vi.fn(), - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let resolveText: ((body: string) => void) | undefined; - fetchStub.mockResolvedValue({ - ok: true, - text: () => - new Promise((resolve) => { - resolveText = resolve; - }), - } as Response); - const bridgeListener = await captureBridgeListener(); - const collapsed = createCollapsedTrustedSlotIframe(); - const postMessage = vi.fn(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source: collapsed.source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await Promise.resolve(); - expect(resolveText).toBeDefined(); - (window as TestWindow).tsjs!.navGeneration = 1; - resolveText?.(JSON.stringify({ adm: '
cached
', w: 300, h: 250 })); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(postMessage).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - expect(collapsed.iframe.width).toBe('1'); - expect(collapsed.iframe.height).toBe('1'); - beaconSpy.mockRestore(); - }); - - it('expands ${AUCTION_PRICE} from the cached bid price before responding', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - fetchStub.mockResolvedValue({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - adm: 'go', - price: 2.5, - }) - ), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - expect(parsed.ad).toContain('p=2.5'); - expect(parsed.ad).not.toContain('${AUCTION_PRICE}'); - beaconSpy.mockRestore(); - }); - - it('fetches PBS Cache once when two same-adId messages race before the fetch resolves', async () => { - // Concurrent render double-fire guard: two 'Prebid Request' messages for the - // same adId can arrive before the first cache fetch settles. The in-flight - // `renderingAdIds` gate must collapse them to a single fetch — the persistent - // firedBeacons dedup only engages after a fetch resolves, so it cannot stop - // the second fetch on its own. Deferring the fetch keeps both messages in the - // window where only the in-flight gate can prevent the duplicate. - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const mockAd = '
Test Creative
'; - let resolveFetch: (value: Response) => void = () => {}; - fetchStub.mockReturnValue( - new Promise((resolve) => { - resolveFetch = resolve; - }) - ); - - const bridgeListener = await captureBridgeListener(); - - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - const dispatch = (): unknown => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - // Both messages dispatched before the deferred fetch resolves. - dispatch(); - dispatch(); - - // The second message hit the in-flight gate — only one fetch launched. - expect(fetchStub).toHaveBeenCalledTimes(1); - - // Resolve the single fetch and flush its .then chain. - resolveFetch({ ok: true, text: () => Promise.resolve(mockAd) } as Response); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchStub).toHaveBeenCalledTimes(1); - expect(portMessages).toHaveLength(1); - // A single render still fires both win and billing beacons exactly once. - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/bill'); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('does not let one slot block a PBS Cache render for another slot sharing an adId', async () => { - // The in-flight guard must be scoped to the requesting slot, not the shared - // adId: two distinct slots sharing one hb_adid must each fetch and render. - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - // Deferred fetch that stays pending, so both messages are in flight when we - // assert the launched-fetch count. - fetchStub.mockReturnValue(new Promise(() => {})); - (window as TestWindow).tsjs = { - bids: { - slot_a: { - hb_adid: 'shared-uuid', - hb_bidder: 'ix', - hb_pb: '1.00', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/cache', - }, - slot_b: { - hb_adid: 'shared-uuid', - hb_bidder: 'ix', - hb_pb: '1.00', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/cache', - }, - }, - adSlots: [ - { - id: 'slot_a', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a', - div_id: 'div-a', - targeting: {}, - }, - { - id: 'slot_b', - formats: [[300, 250]] as [number, number][], - gam_unit_path: '/a', - div_id: 'div-b', - targeting: {}, - }, - ], - divToSlotId: { 'div-a': 'slot_a', 'div-b': 'slot_b' }, - }; - - const bridgeListener = await captureBridgeListener(); - - const mkIframe = (divId: string): Window => { - const slot = document.createElement('div'); - slot.id = divId; - const iframe = document.createElement('iframe'); - slot.appendChild(iframe); - document.body.appendChild(slot); - return iframe.contentWindow!; - }; - const sourceA = mkIframe('div-a'); - const sourceB = mkIframe('div-b'); - - try { - for (const source of [sourceA, sourceB]) { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'shared-uuid' }), - ports: [{ postMessage: () => {} }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - } - - // Each slot launches its own fetch — the shared adId does not cross-block. - expect(fetchStub).toHaveBeenCalledTimes(2); - } finally { - document.getElementById('div-a')?.remove(); - document.getElementById('div-b')?.remove(); - beaconSpy.mockRestore(); - } - }); - - it('serves inline adm without fetching PBS Cache even when cache coords are present', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const inlineAdm = '
Inline Creative
'; - (window as TestWindow).tsjs = { - bids: { - homepage_header: { - hb_adid: 'debug-adid', - hb_bidder: 'mocktioneer', - hb_pb: '0.20', - // Production shape: cache coordinates ARE present, but the bridge must - // prefer the local inline adm and skip the PBS Cache fetch. - hb_cache_host: 'cache.example.com', - hb_cache_path: '/pbc/v1/cache', - nurl: 'https://debug.example/win', - burl: 'https://debug.example/bill', - adm: inlineAdm, - }, - }, - adSlots: [ - { - id: 'homepage_header', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-header', - targeting: {}, - }, - ], - divToSlotId: { 'div-header': 'homepage_header' }, - }; - - let bridgeListener: ((e: MessageEvent) => unknown) | undefined; - const origAdd = window.addEventListener.bind(window); - const addSpy = vi - .spyOn(window, 'addEventListener') - .mockImplementation( - (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { - if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; - origAdd( - type, - handler as EventListener, - opts as boolean | AddEventListenerOptions | undefined - ); - } - ); - await import('../../../src/integrations/gpt/index'); - addSpy.mockRestore(); - - expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); - - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener!( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-adid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchStub).not.toHaveBeenCalled(); - expect(stopSpy).toHaveBeenCalled(); - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - expect(parsed.message).toBe('Prebid Response'); - expect(parsed.adId).toBe('debug-adid'); - expect(parsed.ad).toBe(inlineAdm); - expect(parsed.width).toBe(728); - expect(parsed.height).toBe(90); - expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/bill'); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('sizes the inline response from the winning bid, not the first slot format', async () => { - // Multi-size slot whose winner is the SECOND configured format. Sizing from - // slot.formats[0] would render the 300x250 winner in a 728x90 box. - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const winnerAdm = '
Winner 300x250
'; - (window as TestWindow).tsjs = { - bids: { - homepage_header: { - hb_adid: 'winner-adid', - hb_bidder: 'ix', - hb_pb: '2.00', - w: 300, - h: 250, - adm: winnerAdm, - }, - }, - adSlots: [ - { - id: 'homepage_header', - formats: [ - [728, 90], - [300, 250], - ] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-header', - targeting: {}, - }, - ], - divToSlotId: { 'div-header': 'homepage_header' }, - }; - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - try { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'winner-adid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - expect(parsed.width).toBe(300); - expect(parsed.height).toBe(250); - } finally { - beaconSpy.mockRestore(); - } - }); - - it('resolves the requesting slot bid when two slots share one hb_adid', async () => { - // Duplicate hb_adid across slots: PBS Cache is absent, so hb_adid falls back - // to a creative id that a bidder reuses across slots. The bridge must resolve - // the bid by the requesting slot, not the first bid whose hb_adid matches — - // otherwise every slot but the first renders blank. - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const headerAdm = '
Header Creative
'; - const inContentAdm = '
In-Content Creative
'; - (window as TestWindow).tsjs = { - bids: { - homepage_header: { - hb_adid: 'shared-creative-id', - hb_bidder: 'ix', - hb_pb: '0.53', - adm: headerAdm, - }, - homepage_in_content: { - hb_adid: 'shared-creative-id', - hb_bidder: 'ix', - hb_pb: '0.40', - adm: inContentAdm, - }, - }, - adSlots: [ - { - id: 'homepage_header', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-header', - targeting: {}, - }, - { - id: 'homepage_in_content', - formats: [[300, 250]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-in-content', - targeting: {}, - }, - ], - divToSlotId: { 'div-header': 'homepage_header', 'div-in-content': 'homepage_in_content' }, - }; - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - - // Iframe belongs to the SECOND slot, whose bid is not the first hb_adid match. - const slot = document.createElement('div'); - slot.id = 'div-in-content'; - const iframe = document.createElement('iframe'); - slot.appendChild(iframe); - document.body.appendChild(slot); - const source = iframe.contentWindow!; - - try { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'shared-creative-id' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - // The requesting slot's own creative and dimensions, not the first match's. - expect(parsed.ad).toBe(inContentAdm); - expect(parsed.width).toBe(300); - expect(parsed.height).toBe(250); - } finally { - slot.remove(); - beaconSpy.mockRestore(); - } - }); - - it('falls back to keepalive fetch when sendBeacon is unavailable', async () => { - const originalSendBeacon = navigator.sendBeacon; - Object.defineProperty(navigator, 'sendBeacon', { - value: undefined, - writable: true, - configurable: true, - }); - - try { - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: 'debug-no-beacon', - hb_bidder: 'mocktioneer', - hb_pb: '0.20', - nurl: 'https://debug.example/win', - burl: 'https://debug.example/bill', - adm: '
Debug Creative
', - }; - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-no-beacon' }), - ports: [fakePort], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(fetchStub).toHaveBeenCalledWith('https://debug.example/win', { - method: 'POST', - keepalive: true, - mode: 'no-cors', - }); - expect(fetchStub).toHaveBeenCalledWith('https://debug.example/bill', { - method: 'POST', - keepalive: true, - mode: 'no-cors', - }); - } finally { - Object.defineProperty(navigator, 'sendBeacon', { - value: originalSendBeacon, - writable: true, - configurable: true, - }); - } - }); - - it('falls back to keepalive fetch when sendBeacon rejects the payload', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(false); - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: 'debug-rejected-beacon', - hb_bidder: 'mocktioneer', - hb_pb: '0.20', - nurl: 'https://debug.example/win', - burl: 'https://debug.example/bill', - adm: '
Debug Creative
', - }; - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - const event = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-rejected-beacon' }), - ports: [fakePort], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent; - - bridgeListener(event); - - expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/bill'); - expect(fetchStub).toHaveBeenCalledWith('https://debug.example/win', { - method: 'POST', - keepalive: true, - mode: 'no-cors', - }); - expect(fetchStub).toHaveBeenCalledWith('https://debug.example/bill', { - method: 'POST', - keepalive: true, - mode: 'no-cors', - }); - - bridgeListener(event); - expect(fetchStub).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('ignores message when adId does not match any TS bid', async () => { - await import('../../../src/integrations/gpt/index'); - fetchStub.mockResolvedValue({ ok: true, text: () => Promise.resolve('') } as Response); - - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ message: 'Prebid Request', adId: 'unknown-id' }), - ports: [], - }) - ); - - await new Promise((r) => setTimeout(r, 100)); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('ignores matching adId messages from outside configured slot iframes', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(52); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - await import('../../../src/integrations/gpt/index'); - fetchStub.mockResolvedValue({ ok: true, text: () => Promise.resolve('') } as Response); - - const foreignIframe = document.createElement('iframe'); - document.body.appendChild(foreignIframe); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const stopSpy = vi.fn(); - - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort as unknown as MessagePort], - source: foreignIframe.contentWindow, - }) - ); - - await new Promise((r) => setTimeout(r, 50)); - expect(fetchStub).not.toHaveBeenCalled(); - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toHaveLength(0); - expect(recordTrustedServerCreativeRequest).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - foreignIframe.remove(); - }); - - it('ignores a request whose source slot does not own the resolved adId', async () => { - // Two configured slots; slot A's iframe requests slot B's hb_adid. The - // bridge must not return slot B's creative or fire slot B's beacons. - (window as TestWindow).tsjs!.bids!.homepage_footer = { - hb_adid: 'footer-uuid', - hb_bidder: 'kargo', - hb_pb: '2.00', - hb_cache_host: 'openads.example.com', - hb_cache_path: '/cache', - nurl: 'https://ssp.example/footer-win', - burl: 'https://ssp.example/footer-bill', - }; - (window as TestWindow).tsjs!.adSlots!.push({ - id: 'homepage_footer', - formats: [[300, 250]] as [number, number][], - gam_unit_path: '/a/b/footer', - div_id: 'div-footer', - targeting: {}, - }); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(53); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - await import('../../../src/integrations/gpt/index'); - fetchStub.mockResolvedValue({ ok: true, text: () => Promise.resolve('') } as Response); - - // Source iframe lives under slot A (div-header). - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - - window.dispatchEvent( - new MessageEvent('message', { - // adId belongs to slot B (homepage_footer), not slot A's iframe. - data: JSON.stringify({ message: 'Prebid Request', adId: 'footer-uuid' }), - ports: [fakePort as unknown as MessagePort], - source, - }) - ); - - await new Promise((r) => setTimeout(r, 50)); - expect(fetchStub).not.toHaveBeenCalled(); - expect(portMessages).toHaveLength(0); - expect(beaconSpy).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeRequest).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - document.getElementById('div-footer')?.remove(); - }); - - it('ignores non-Prebid messages', async () => { - await import('../../../src/integrations/gpt/index'); - window.dispatchEvent( - new MessageEvent('message', { data: JSON.stringify({ message: 'Other' }) }) - ); - await new Promise((r) => setTimeout(r, 50)); - expect(fetchStub).not.toHaveBeenCalled(); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/diagnostics_facts.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/diagnostics_facts.test.ts new file mode 100644 index 000000000..e6a5144e5 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/diagnostics_facts.test.ts @@ -0,0 +1,584 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import type { + GoogletagAdapter, + GoogletagDiagnosticsFact, + GoogletagDiagnosticsObserver, + GoogletagFacade, +} from '../../../src/adapters/googletag'; +import { + activateGptDiagnosticsEventListeners, + activateGptDiagnosticsFactCapture, + createGptDiagnosticsFactBuffer, + createTrustedServerOpportunityFact, + publishTrustedServerRequestFact, + projectGptTraceFact, + type GptDiagnosticsFact, +} from '../../../src/integrations/gpt/diagnostics_facts'; + +function fact(index: number): Readonly { + return Object.freeze({ + kind: 'slotRequested', + observedAtMs: index, + slot: Object.freeze({ + token: Object.freeze(Object.create(null) as object), + elementId: `slot-${index}`, + }), + }); +} + +describe('GPT diagnostics fact transport', () => { + it('publishes one production request-intent fact from the exact projection and slot identity', () => { + const physicalSlot = Object.freeze({}); + const identity = Object.freeze({ + token: Object.freeze({}), + traceToken: 'gt1_1' as never, + runtimeSlotNumber: 1, + elementId: 'slot-1', + adUnitPath: '/123/slot-1', + }); + const publish = vi.fn(() => true); + const projection = Object.freeze({ + version: 1 as const, + auction: Object.freeze({ + version: 1 as const, + auctionId: 'auction-1', + results: Object.freeze([ + Object.freeze({ + slot: 'slot-1', + outcome: 'winner' as const, + candidateId: 'candidate001', + }), + ]), + }), + slots: Object.freeze([ + Object.freeze({ + slot: 'slot-1', + gamUnitPath: '/123/slot-1', + divId: 'slot-1', + formats: Object.freeze([ + Object.freeze([300, 250] as const), + Object.freeze([728, 90] as const), + ]), + targeting: Object.freeze({}), + }), + ]), + bids: Object.freeze([ + Object.freeze({ + candidateId: 'candidate001', + slot: 'slot-1', + provider: 'gpt', + upstreamBidId: 'upstream-1', + cpm: 1, + currency: 'USD' as const, + targeting: Object.freeze({}), + rendererReservationId: `r1_${'a'.repeat(22)}`, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
creative
', + width: 300, + height: 250, + }), + }), + ]), + }); + + expect( + publishTrustedServerRequestFact({ + adapter: { diagnosticsIdentity: (slot) => (slot === physicalSlot ? identity : undefined) }, + buffer: { publish }, + physicalSlot, + projection, + registeredSlotId: 'slot-1', + }) + ).toBe(true); + expect(publish).toHaveBeenCalledExactlyOnceWith({ + kind: 'trustedServerOpportunity', + auctionSlotId: 'slot-1', + opportunity: 'renderable_candidate', + requestedSlotSizes: [ + [300, 250], + [728, 90], + ], + slot: identity, + trustedServerAuctionId: 'auction-1', + }); + }); + + it('copies and delivers exact Trusted Server requested-size evidence before GPT callbacks', () => { + const slot = Object.freeze({ + token: Object.freeze(Object.create(null) as object), + traceToken: 'gt1_1' as never, + runtimeSlotNumber: 1, + cycleOrdinal: 1 as never, + elementId: 'fictional-slot', + }); + const formats: Array<[number, number]> = [ + [300, 250], + [728, 90], + ]; + const opportunity = createTrustedServerOpportunityFact({ + auctionSlotId: 'fictional-slot', + opportunity: 'renderable_candidate', + requestedSlotSizes: formats, + slot, + trustedServerAuctionId: 'fictional-auction', + }); + formats[0]![0] = 1; + const received: Readonly[] = []; + const buffer = createGptDiagnosticsFactBuffer(); + + expect(opportunity).toEqual({ + kind: 'trustedServerOpportunity', + auctionSlotId: 'fictional-slot', + opportunity: 'renderable_candidate', + requestedSlotSizes: [ + [300, 250], + [728, 90], + ], + slot, + trustedServerAuctionId: 'fictional-auction', + }); + expect(Object.isFrozen(opportunity)).toBe(true); + expect(Object.isFrozen(opportunity?.requestedSlotSizes)).toBe(true); + expect(Object.isFrozen(opportunity?.requestedSlotSizes?.[0])).toBe(true); + expect(buffer.publish(opportunity!)).toBe(true); + buffer.activate((candidate) => received.push(candidate)); + expect(received).toEqual([opportunity]); + }); + + it('projects only the data-safe exact trace identity and preserves event fields', () => { + const opaqueToken = Object.freeze(Object.create(null) as object); + const projected = projectGptTraceFact( + Object.freeze({ + kind: 'slotRenderEnded', + observedAtMs: 12.5, + slot: Object.freeze({ + token: opaqueToken, + traceToken: 'gt1_z', + cycleOrdinal: 7, + elementId: 'fictional-slot', + adUnitPath: '/example/fictional-slot', + }), + isEmpty: false, + responseIdentifier: 'fictional-response', + }) as Readonly + ); + + expect(projected).toEqual({ + kind: 'slotRenderEnded', + observedAtMs: 12.5, + slot: { token: 'gt1_z', cycleOrdinal: 7, elementId: 'fictional-slot' }, + isEmpty: false, + responseIdentifier: 'fictional-response', + }); + expect(Object.isFrozen(projected)).toBe(true); + expect(Object.isFrozen(projected?.slot)).toBe(true); + expect(Reflect.ownKeys(projected?.slot ?? {}).sort()).toEqual([ + 'cycleOrdinal', + 'elementId', + 'token', + ]); + expect(Object.values(projected?.slot ?? {})).not.toContain(opaqueToken); + expect(JSON.stringify(projected)).not.toContain('/example/fictional-slot'); + }); + + it.each([ + ['missing cycle', Object.freeze({ token: Object.freeze({}), traceToken: 'gt1_1' })], + [ + 'zero cycle', + Object.freeze({ token: Object.freeze({}), traceToken: 'gt1_1', cycleOrdinal: 0 }), + ], + [ + 'overflow cycle', + Object.freeze({ + token: Object.freeze({}), + traceToken: 'gt1_1', + cycleOrdinal: 4_294_967_296, + }), + ], + [ + 'noncanonical token', + Object.freeze({ token: Object.freeze({}), traceToken: 'gt1_01', cycleOrdinal: 1 }), + ], + [ + 'overflow token', + Object.freeze({ token: Object.freeze({}), traceToken: 'gt1_10000000', cycleOrdinal: 1 }), + ], + ])('omits %s trace projections without changing the raw fact', (_label, slot) => { + const raw = Object.freeze({ kind: 'slotRequested', observedAtMs: 1, slot }); + + expect(projectGptTraceFact(raw as Readonly)).toBeUndefined(); + expect(raw.slot).toBe(slot); + }); + + it('requires diagnostics observation on every GPT adapter', () => { + expectTypeOf().toMatchTypeOf<{ + observeDiagnostics(observer: GoogletagDiagnosticsObserver): (() => void) | undefined; + }>(); + }); + + it('buffers 512 facts, evicts the oldest, replays in order, then releases the buffer', () => { + const buffer = createGptDiagnosticsFactBuffer(); + for (let index = 0; index < 513; index += 1) expect(buffer.publish(fact(index))).toBe(true); + const received: number[] = []; + + const release = buffer.activate((item) => { + received.push(Number(item.slot.elementId?.slice('slot-'.length))); + }); + + expect(received).toHaveLength(512); + expect(received[0]).toBe(1); + expect(received[511]).toBe(512); + expect(buffer.publish(fact(513))).toBe(true); + expect(received[512]).toBe(513); + release?.(); + expect(buffer.publish(fact(514))).toBe(true); + expect(received).toHaveLength(513); + const replacement = vi.fn(); + expect(buffer.activate(replacement)).toEqual(expect.any(Function)); + expect(replacement).toHaveBeenCalledWith(fact(514)); + buffer.dispose(); + expect(buffer.publish(fact(515))).toBe(false); + }); + + it('validates and rehydrates the bounded first-display fact buffer once', () => { + const overflows = vi.fn(); + const buffer = createGptDiagnosticsFactBuffer({ onOverflow: overflows }); + const transferredToken = Object.freeze(Object.create(null) as object); + const base = { + version: 1 as const, + token: 'gt1_5', + runtimeSlotNumber: 5, + cycleOrdinal: 1, + disposition: 'matched' as const, + issueReason: null, + capturedAtMs: 1, + elementId: 'slot-1', + adUnitPath: '/example/slot-1', + requestedSlotSizes: Object.freeze([Object.freeze([300, 250] as const)]), + isEmpty: null, + renderedSize: null, + isBackfill: null, + slotContentChanged: null, + visibilityPercent: null, + }; + const adopted = Object.freeze({ + facts: Object.freeze([ + Object.freeze({ ...base, event: 'slotRequested' as const }), + Object.freeze({ + ...base, + event: 'slotRenderEnded' as const, + capturedAtMs: 2, + requestedSlotSizes: null, + isEmpty: false, + renderedSize: Object.freeze([300, 250] as const), + isBackfill: false, + slotContentChanged: true, + }), + ]), + overflowCount: 7, + dropCount: 3, + }); + const received: Readonly[] = []; + + expect( + buffer.adoptFirstDisplay( + adopted, + (traceToken) => + traceToken === 'gt1_5' + ? Object.freeze({ + token: transferredToken, + traceToken: 'gt1_5' as never, + runtimeSlotNumber: 5, + cycleOrdinal: 1 as never, + elementId: 'slot-1', + adUnitPath: '/example/slot-1', + }) + : undefined, + (traceToken, slot) => + traceToken === 'gt1_5' + ? createTrustedServerOpportunityFact({ + auctionSlotId: 'slot-1', + opportunity: 'renderable_candidate', + requestedSlotSizes: [[300, 250]], + slot, + }) + : undefined + ) + ).toBe(true); + const release = buffer.activate((value) => received.push(value)); + expect(received).toHaveLength(3); + expect(received[0]).toMatchObject({ + kind: 'trustedServerOpportunity', + auctionSlotId: 'slot-1', + requestedSlotSizes: [[300, 250]], + }); + expect(received.slice(1).map((value) => projectGptTraceFact(value as never))).toEqual([ + { + kind: 'slotRequested', + observedAtMs: 1, + slot: { token: 'gt1_5', cycleOrdinal: 1, elementId: 'slot-1' }, + }, + { + kind: 'slotRenderEnded', + observedAtMs: 2, + slot: { token: 'gt1_5', cycleOrdinal: 1, elementId: 'slot-1' }, + isEmpty: false, + }, + ]); + expect(typeof received[0]?.slot.token).toBe('object'); + expect(received[0]?.slot.token).toBe(received[1]?.slot.token); + expect(received[0]?.slot.token).toBe(received[2]?.slot.token); + expect(received[0]?.slot.token).toBe(transferredToken); + expect(received[0]?.slot.runtimeSlotNumber).toBe(5); + expect(Object.isFrozen(received[0])).toBe(true); + expect(Object.isFrozen(received[0]?.slot)).toBe(true); + release?.(); + for (let index = 0; index < 513; index += 1) buffer.publish(fact(index + 3)); + expect(overflows).toHaveBeenLastCalledWith(8); + expect( + buffer.adoptFirstDisplay( + Object.freeze({ facts: Object.freeze([]), overflowCount: 0, dropCount: 0 }) + ) + ).toBe(false); + }); + + it('rehydrates separate cycles for one transferred physical GPT slot', () => { + const buffer = createGptDiagnosticsFactBuffer(); + const transferredToken = Object.freeze(Object.create(null) as object); + const transferredFact = ( + cycleOrdinal: number, + event: 'slotRequested' | 'slotRenderEnded', + capturedAtMs: number + ) => + Object.freeze({ + version: 1 as const, + event, + token: 'gt1_5', + runtimeSlotNumber: 5, + cycleOrdinal, + disposition: 'matched' as const, + issueReason: null, + capturedAtMs, + elementId: 'slot-1', + adUnitPath: '/example/slot-1', + requestedSlotSizes: + event === 'slotRequested' ? Object.freeze([Object.freeze([300, 250] as const)]) : null, + isEmpty: event === 'slotRenderEnded' ? false : null, + renderedSize: event === 'slotRenderEnded' ? Object.freeze([300, 250] as const) : null, + isBackfill: null, + slotContentChanged: null, + visibilityPercent: null, + }); + const received: Readonly[] = []; + + expect( + buffer.adoptFirstDisplay( + Object.freeze({ + facts: Object.freeze([ + transferredFact(1, 'slotRequested', 1), + transferredFact(1, 'slotRenderEnded', 2), + transferredFact(2, 'slotRequested', 3), + transferredFact(2, 'slotRenderEnded', 4), + ]), + overflowCount: 0, + dropCount: 0, + }), + () => + Object.freeze({ + token: transferredToken, + traceToken: 'gt1_5' as never, + runtimeSlotNumber: 5, + cycleOrdinal: 2 as never, + elementId: 'slot-1', + adUnitPath: '/example/slot-1', + }), + (_traceToken, slot) => + createTrustedServerOpportunityFact({ + auctionSlotId: 'slot-1', + opportunity: 'renderable_candidate', + requestedSlotSizes: [[300, 250]], + slot, + }) + ) + ).toBe(true); + buffer.activate((value) => received.push(value)); + + expect( + received.map((value) => ({ + kind: value.kind, + cycleOrdinal: value.slot.cycleOrdinal, + })) + ).toEqual([ + { kind: 'trustedServerOpportunity', cycleOrdinal: 1 }, + { kind: 'slotRequested', cycleOrdinal: 1 }, + { kind: 'slotRenderEnded', cycleOrdinal: 1 }, + { kind: 'trustedServerOpportunity', cycleOrdinal: 2 }, + { kind: 'slotRequested', cycleOrdinal: 2 }, + { kind: 'slotRenderEnded', cycleOrdinal: 2 }, + ]); + expect(received.every((value) => value.slot.token === transferredToken)).toBe(true); + }); + + it('rejects malformed first-display facts without consuming adoption', () => { + const buffer = createGptDiagnosticsFactBuffer(); + const malformed = Object.freeze({ + facts: Object.freeze([ + Object.freeze({ + version: 1, + event: 'slotRequested', + token: 'gt1_01', + runtimeSlotNumber: 1, + cycleOrdinal: 1, + disposition: 'matched', + issueReason: null, + capturedAtMs: 1, + elementId: null, + adUnitPath: null, + requestedSlotSizes: null, + isEmpty: null, + renderedSize: null, + isBackfill: null, + slotContentChanged: null, + visibilityPercent: null, + }), + ]), + overflowCount: 0, + dropCount: 0, + }); + + expect(buffer.adoptFirstDisplay(malformed)).toBe(false); + expect( + buffer.adoptFirstDisplay( + Object.freeze({ facts: Object.freeze([]), overflowCount: 0, dropCount: 0 }) + ) + ).toBe(true); + }); + + it('isolates consumer throws and admits only one live module consumer', () => { + const errors: unknown[] = []; + const buffer = createGptDiagnosticsFactBuffer({ + onConsumerError: (error) => errors.push(error), + }); + buffer.publish(fact(1)); + const release = buffer.activate(() => { + throw new Error('fictional consumer failure'); + }); + + expect(errors).toHaveLength(1); + expect(buffer.activate(vi.fn())).toBeUndefined(); + expect(buffer.publish(fact(2))).toBe(true); + expect(errors).toHaveLength(2); + release?.(); + expect(buffer.activate(vi.fn())).toEqual(expect.any(Function)); + buffer.dispose(); + }); + + it('adds four diagnostics-only listeners while active and disposes all ownership', async () => { + const subscriptions: Array = []; + const releases: Array> = []; + let observer: GoogletagDiagnosticsObserver | undefined; + const operationDispose = vi.fn(); + const facade = Object.freeze({ + subscribe: ( + eventType: string, + _listener: (event: unknown) => void, + diagnosticsOwner?: boolean + ) => { + subscriptions.push([eventType, diagnosticsOwner]); + const release = vi.fn(); + releases.push(release); + return release; + }, + }) as unknown as Readonly; + const adapter = Object.freeze({ + observeDiagnostics: (candidate: GoogletagDiagnosticsObserver) => { + observer = candidate; + return () => { + observer = undefined; + }; + }, + run: (command: (gpt: Readonly) => Value) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: operationDispose, + }), + }) as unknown as GoogletagAdapter; + const buffer = createGptDiagnosticsFactBuffer(); + + const dispose = activateGptDiagnosticsFactCapture(adapter, buffer); + await Promise.resolve(); + + expect(observer).toEqual(expect.any(Function)); + expect(subscriptions).toEqual([ + ['slotResponseReceived', true], + ['slotOnload', true], + ['impressionViewable', true], + ['slotVisibilityChanged', true], + ]); + dispose?.(); + dispose?.(); + expect(operationDispose).toHaveBeenCalledOnce(); + expect(releases.every((release) => release.mock.calls.length === 1)).toBe(true); + expect(observer).toBeUndefined(); + }); + + it('rejects capture when another diagnostics observer owns the adapter', () => { + const run = vi.fn(); + const adapter = Object.freeze({ + observeDiagnostics: () => undefined, + run, + }) as unknown as Pick; + + expect( + activateGptDiagnosticsFactCapture(adapter, createGptDiagnosticsFactBuffer()) + ).toBeUndefined(); + expect(run).not.toHaveBeenCalled(); + }); + + it('lets the GPT owner install four diagnostics-only publishers without claiming observation', async () => { + const subscriptions: Array = []; + const releases: Array> = []; + const operationDispose = vi.fn(); + const facade = Object.freeze({ + subscribe: ( + eventType: string, + _listener: (event: unknown) => void, + diagnosticsOwner?: boolean + ) => { + subscriptions.push([eventType, diagnosticsOwner]); + const release = vi.fn(); + releases.push(release); + return release; + }, + }) as unknown as Readonly; + const observeDiagnostics = vi.fn(); + const adapter = Object.freeze({ + observeDiagnostics, + run: (command: (gpt: Readonly) => Value) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: operationDispose, + }), + }) as unknown as GoogletagAdapter; + + const dispose = activateGptDiagnosticsEventListeners(adapter); + await Promise.resolve(); + + expect(observeDiagnostics).not.toHaveBeenCalled(); + expect(subscriptions).toEqual([ + ['slotResponseReceived', true], + ['slotOnload', true], + ['impressionViewable', true], + ['slotVisibilityChanged', true], + ]); + dispose?.(); + dispose?.(); + expect(operationDispose).toHaveBeenCalledOnce(); + expect(releases.every((release) => release.mock.calls.length === 1)).toBe(true); + }); +}); 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 deleted file mode 100644 index 55fd61634..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ /dev/null @@ -1,811 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import { FIRST_IMPRESSION_LEASE_MS } from '../../../src/core/first_impression'; -import type { FirstImpressionSlotClaim, TsjsApi } from '../../../src/core/types'; - -/** - * Executable coverage for the edge-injected `gpt_bootstrap.js` — the - * head-inline fallback that keeps initial server-side ads working when the - * main TSJS bundle fails to load. The file ships from - * `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` and is - * evaluated here verbatim, so the degradation path (fallback `adInit` and - * fallback `scheduleInitialAdInit`) is executed, not string-matched. - * - * Vitest runs with the lib directory as cwd (the vitest.config.ts root), so - * the bootstrap is resolved relative to it rather than via import.meta.url, - * which the jsdom environment rewrites to a non-file scheme. - */ -const BOOTSTRAP_SOURCE = readFileSync( - path.resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' -); - -// The command queue the bootstrap pushes into: a real array once GPT has -// loaded, or the bare `push`-only stub GPT installs before then. -type MockCommandQueue = Array<() => void> | { push: (fn: () => void) => unknown }; - -// Minimal googletag surface the bootstrap touches. -interface MockGoogleTag { - cmd: MockCommandQueue; - defineSlot: (adUnitPath: string, sizes: Array<[number, number]>, divId: string) => unknown; - pubads: () => unknown; - enableServices: () => void; - display: (divId: string) => void; - getConfig?: (key: string) => Record; - setConfig?: (config: Record) => void; -} - -// `tsjs` is declared globally as the full `TsjsApi`; `Omit` drops it from -// `Window` so the fixtures below only have to satisfy the fields they set. -type TestWindow = Omit & { - googletag?: MockGoogleTag; - tsjs?: Partial; - __tsjs_gam_attribution_enabled?: boolean; -}; - -function makeGoogleTag(overrides: Partial = {}): MockGoogleTag { - const pubads = { - getSlots: vi.fn(() => []), - refresh: vi.fn(), - }; - - return { - cmd: [], - defineSlot: vi.fn(), - pubads: vi.fn(() => pubads), - enableServices: vi.fn(), - display: vi.fn(), - ...overrides, - }; -} - -function runBootstrap(): void { - // Evaluate in the jsdom global scope, exactly as an inline '); - clonedDocument.close(); - executingScript = clonedDocument.querySelector('script'); - const queue: Array<() => void> = []; - const setConfig = vi.fn(); - win.googletag = makeGoogleTag({ cmd: queue, setConfig }); - - await importFreshGptBundle(); - queue[0](); - - expect(setConfig).toHaveBeenCalledWith({ targeting: { ts: 'true' } }); - }); - - it.each(['missing', 'throwing'])( - 'keeps module installation working with %s setConfig', - async (setConfigMode) => { - const queue: Array<() => void> = []; - const setConfig = - setConfigMode === 'throwing' - ? vi.fn(() => { - throw new Error('publisher setConfig failed'); - }) - : undefined; - win.googletag = makeGoogleTag({ cmd: queue, setConfig }); - win.__tsjs_slim_prebid_url = 'https://cdn.example.com/slim-prebid.js'; - executingScript = attributedScript(); - - await importFreshGptBundle(); - - expect(() => [...queue].forEach((command) => command())).not.toThrow(); - expect(typeof win.tsjs?.adInit).toBe('function'); - expect(typeof win.tsjs?.scheduleInitialAdInit).toBe('function'); - expect(win.tsjs?.spaHookInstalled).toBe(true); - expect(addEventListenerSpy).toHaveBeenCalledWith('popstate', expect.any(Function), true); - expect(addEventListenerSpy).toHaveBeenCalledWith('load', expect.any(Function)); - expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); - if (setConfig) { - expect(setConfig).toHaveBeenCalledWith({ targeting: { ts: 'true' } }); - } - } - ); - - it('preserves GPT-enabled shim behavior without queuing attribution when unmarked', async () => { - const queue: Array<() => void> = []; - const setConfig = vi.fn(); - const tag = makeGoogleTag({ cmd: queue, setConfig }); - win.googletag = tag; - win.__tsjs_gpt_enabled = true; - executingScript = document.createElement('script'); - - await importFreshGptBundle(); - [...queue].forEach((command) => command()); - const guard = await importGuardModule(); - - expect(guard.isGuardInstalled()).toBe(true); - expect(win.googletag).toBe(tag); - expect(win.googletag!.cmd).toBe(queue); - expect(setConfig).not.toHaveBeenCalled(); - expect(typeof win.tsjs?.adInit).toBe('function'); - }); -}); - -describe('GPT debug ADM iframe hardening', () => { - it('sandbox token list omits allow-same-origin', async () => { - const mod = await import('../../../src/integrations/gpt/index'); - - expect(mod.ADM_IFRAME_SANDBOX).toContain('allow-scripts'); - // allow-scripts + allow-same-origin on srcdoc content removes the - // sandbox's origin isolation — the pair must never be reintroduced. - expect(mod.ADM_IFRAME_SANDBOX).not.toContain('allow-same-origin'); - }); - - it('safeAdmIframeSrc accepts http(s), relative, and protocol-relative URLs', async () => { - const { safeAdmIframeSrc } = await import('../../../src/integrations/gpt/index'); - - expect(safeAdmIframeSrc('https://ads.example.com/creative')).toBe( - 'https://ads.example.com/creative' - ); - expect(safeAdmIframeSrc('http://ads.example.com/creative')).toBe( - 'http://ads.example.com/creative' - ); - expect(safeAdmIframeSrc('//ads.example.com/creative')).toBe('https://ads.example.com/creative'); - expect(safeAdmIframeSrc('/first-party/creative?sig=abc')).toBe('/first-party/creative?sig=abc'); - }); - - it('safeAdmIframeSrc rejects script-executing and opaque schemes', async () => { - const { safeAdmIframeSrc } = await import('../../../src/integrations/gpt/index'); - - expect(safeAdmIframeSrc('javascript:alert(1)')).toBeUndefined(); - expect(safeAdmIframeSrc('data:text/html,')).toBeUndefined(); - expect(safeAdmIframeSrc('blob:https://example.com/uuid')).toBeUndefined(); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts new file mode 100644 index 000000000..9f214a6d7 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts @@ -0,0 +1,266 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createGptLaterIntegrationRegistration } from '../../../src/integrations/gpt/later'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + PreparedIntegration, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +type NavigationResult = + | Readonly<{ + status: 'committed'; + navigationGeneration: object; + current: true; + }> + | Readonly<{ + status: 'rejected'; + navigationGeneration: object; + current: boolean; + }>; + +function harness(pageBidsEnabled = true) { + const navigationGeneration = Object.freeze({}); + const navigate = vi.fn<(_path: string) => Promise>(async (_path: string) => + Object.freeze({ status: 'committed', navigationGeneration, current: true }) + ); + const release = vi.fn(); + const activateLaterLifecycle = vi.fn(() => Object.freeze({ navigate, release })); + const preparationDisposers: Array<() => void> = []; + const activationDisposers: Array<() => void> = []; + const interfaces = Object.freeze({ + 'runtime.v1': Object.freeze({ document }), + 'slots.v1': Object.freeze({}), + 'auction.v1': Object.freeze({}), + 'render.v1': Object.freeze({}), + 'trace.v1': Object.freeze({}), + 'gpt.v1': Object.freeze({ activateLaterLifecycle }), + }); + const prepared = createGptLaterIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled }), + interfaces, + onDispose: (callback: () => void) => preparationDisposers.push(callback), + signal: new AbortController().signal, + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + return Object.freeze({ + activateLaterLifecycle, + activationContext: Object.freeze({ + afterCommit: vi.fn(), + onDispose: (callback: () => void) => activationDisposers.push(callback), + signal: new AbortController().signal, + } satisfies IntegrationActivationContext), + activationDisposers, + navigate, + navigationGeneration, + prepared, + preparationDisposers, + release, + }); +} + +describe('GPT deferred navigation and reconciliation owner', () => { + afterEach(() => { + vi.useRealTimers(); + window.history.replaceState({}, '', '/'); + }); + + it('leaves takeover history, listeners, timers, and reconciliation unchanged before activation', () => { + vi.useFakeTimers(); + const beforePush = window.history.pushState; + const beforeReplace = window.history.replaceState; + const owner = harness(); + + expect(owner.activateLaterLifecycle).not.toHaveBeenCalled(); + expect(window.history.pushState).toBe(beforePush); + expect(window.history.replaceState).toBe(beforeReplace); + expect(vi.getTimerCount()).toBe(0); + + owner.preparationDisposers.reverse().forEach((release) => release()); + expect(owner.activateLaterLifecycle).not.toHaveBeenCalled(); + }); + + it('owns reconciliation without installing SPA hooks when page-bids delivery is disabled', async () => { + vi.useFakeTimers(); + const beforePush = Object.getOwnPropertyDescriptor(window.history, 'pushState'); + const beforeReplace = Object.getOwnPropertyDescriptor(window.history, 'replaceState'); + const owner = harness(false); + + owner.prepared.activate(owner.activationContext); + + expect(owner.activateLaterLifecycle).toHaveBeenCalledOnce(); + expect(Object.getOwnPropertyDescriptor(window.history, 'pushState')).toEqual(beforePush); + expect(Object.getOwnPropertyDescriptor(window.history, 'replaceState')).toEqual(beforeReplace); + window.history.pushState({}, '', '/disabled-page-bids'); + window.dispatchEvent(new PopStateEvent('popstate')); + await vi.runAllTimersAsync(); + expect(owner.navigate).not.toHaveBeenCalled(); + + owner.activationDisposers.reverse().forEach((release) => release()); + expect(owner.release).toHaveBeenCalledOnce(); + owner.preparationDisposers.reverse().forEach((release) => release()); + }); + + it('owns one deferred history listener and coalesced navigation timer across repeated routes', async () => { + vi.useFakeTimers(); + const owner = harness(); + owner.prepared.activate(owner.activationContext); + + expect(owner.activateLaterLifecycle).toHaveBeenCalledOnce(); + expect(owner.navigate).not.toHaveBeenCalled(); + window.history.pushState({}, '', '/first?section=one'); + expect(owner.navigate).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenLastCalledWith('/first?section=one'); + + window.history.pushState({}, '', '/second'); + window.history.replaceState({}, '', '/third?latest=yes'); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenLastCalledWith('/third?latest=yes'); + expect(owner.navigate).toHaveBeenCalledTimes(2); + + window.dispatchEvent(new PopStateEvent('popstate')); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenCalledTimes(2); + + owner.activationDisposers.reverse().forEach((release) => release()); + owner.preparationDisposers.reverse().forEach((release) => release()); + }); + + it('restores exact history ownership and cancels a pending navigation on disposal', async () => { + vi.useFakeTimers(); + const beforePush = Object.getOwnPropertyDescriptor(window.history, 'pushState'); + const beforeReplace = Object.getOwnPropertyDescriptor(window.history, 'replaceState'); + const owner = harness(); + owner.prepared.activate(owner.activationContext); + window.history.pushState({}, '', '/pending'); + expect(vi.getTimerCount()).toBe(1); + + owner.activationDisposers.reverse().forEach((release) => release()); + expect(owner.release).toHaveBeenCalledOnce(); + expect(Object.getOwnPropertyDescriptor(window.history, 'pushState')).toEqual(beforePush); + expect(Object.getOwnPropertyDescriptor(window.history, 'replaceState')).toEqual(beforeReplace); + expect(vi.getTimerCount()).toBe(0); + window.dispatchEvent(new PopStateEvent('popstate')); + await vi.runAllTimersAsync(); + expect(owner.navigate).not.toHaveBeenCalled(); + + owner.preparationDisposers.reverse().forEach((release) => release()); + }); + + it('retries the same current route after its page-bids navigation is rejected', async () => { + vi.useFakeTimers(); + const owner = harness(); + owner.navigate.mockResolvedValueOnce( + Object.freeze({ + status: 'rejected' as const, + navigationGeneration: owner.navigationGeneration, + current: true as const, + }) + ); + owner.prepared.activate(owner.activationContext); + + window.history.pushState({}, '', '/retry-current'); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenCalledExactlyOnceWith('/retry-current'); + + window.history.replaceState({}, '', '/retry-current'); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenCalledTimes(2); + expect(owner.navigate).toHaveBeenLastCalledWith('/retry-current'); + + owner.activationDisposers.reverse().forEach((release) => release()); + owner.preparationDisposers.reverse().forEach((release) => release()); + }); + + it('retries the same current route after the navigation promise rejects', async () => { + vi.useFakeTimers(); + const owner = harness(); + owner.navigate.mockRejectedValueOnce(new Error('fictional current navigation failure')); + owner.prepared.activate(owner.activationContext); + + window.history.pushState({}, '', '/retry-rejection'); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenCalledExactlyOnceWith('/retry-rejection'); + + window.history.replaceState({}, '', '/retry-rejection'); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenCalledTimes(2); + expect(owner.navigate).toHaveBeenLastCalledWith('/retry-rejection'); + + owner.activationDisposers.reverse().forEach((release) => release()); + owner.preparationDisposers.reverse().forEach((release) => release()); + }); + + it('does not let a stale generation failure roll back a newer committed route', async () => { + vi.useFakeTimers(); + const owner = harness(); + const firstGeneration = Object.freeze({}); + const secondGeneration = Object.freeze({}); + let rejectFirst!: ( + result: Readonly<{ + status: 'rejected'; + navigationGeneration: object; + current: false; + }> + ) => void; + let commitSecond!: ( + result: Readonly<{ + status: 'committed'; + navigationGeneration: object; + current: true; + }> + ) => void; + owner.navigate + .mockImplementationOnce( + () => + new Promise((resolve) => { + rejectFirst = resolve; + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + commitSecond = resolve; + }) + ); + owner.prepared.activate(owner.activationContext); + + window.history.pushState({}, '', '/stale-first'); + await vi.advanceTimersByTimeAsync(0); + window.history.pushState({}, '', '/committed-second'); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenCalledTimes(2); + + commitSecond( + Object.freeze({ + status: 'committed', + navigationGeneration: secondGeneration, + current: true, + }) + ); + await Promise.resolve(); + rejectFirst( + Object.freeze({ + status: 'rejected', + navigationGeneration: firstGeneration, + current: false, + }) + ); + await Promise.resolve(); + + window.history.replaceState({}, '', '/committed-second'); + await vi.runAllTimersAsync(); + expect(owner.navigate).toHaveBeenCalledTimes(2); + + owner.activationDisposers.reverse().forEach((release) => release()); + owner.preparationDisposers.reverse().forEach((release) => release()); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts new file mode 100644 index 000000000..d9f307a74 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -0,0 +1,2467 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + adoptInitialGptDiagnosticsFromHandoff, + adoptInitialGptFactsFromHandoff, + adoptInitialGptSlotsFromHandoff, + adoptInitialPucTicketsFromHandoff, + installPbsCacheBridge, + publishGptWinner, + publishInitialGptProjection, + startGptSlotOperation, + type GptWinnerPublicationInput, + type GptSlotOperationInput, +} from '../../../src/integrations/gpt/module'; +import type { BrowserAuctionProjectionV1 } from '../../../src/core/types'; +import { createLegacyGptRegistrationForTest as createGptIntegrationRegistration } from '../../helpers/legacy_gpt_registration'; +import { createGptIntegrationRegistration as createProductionGptRegistration } from '../../../src/integrations/gpt/module'; +import { createRenderRuntimeIntegrationRegistration } from '../../../src/integrations/render_runtime/module'; +import type { RuntimeCapabilityV1 } from '../../../src/kernel/runtime'; +import { createNoopGoogletagAdapter, type GoogletagFacade } from '../../../src/adapters/googletag'; +import { isGuardInstalled, resetGuardState } from '../../../src/integrations/gpt/script_guard'; +import { createTestNavigationIdentityIssuer } from '../../../src/kernel/identity'; +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, + type IntegrationRegistration, +} from '../../../src/kernel/integration_registry'; +import { createRuntimeSession } from '../../../src/kernel/sessions'; +import { + createCommittedArtifactStore, + createRenderAttempt, + createSlotOperation, + type CommittedRenderArtifact, + type RenderAttempt, +} from '../../../src/services/render'; +import { createTargetingService } from '../../../src/services/targeting'; +import { createReservationService } from '../../../src/services/reservations'; +import type { SlotRequestOutcome } from '../../../src/services/slots'; +import { acceptedGptFirstDisplayTakeover } from '../../first_display/helpers/compact_takeover'; + +const RELEASE_ID = 'a'.repeat(64); +const RESERVATION_ID = `r1_${'a'.repeat(22)}`; +const GPT_CONFIG = Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }); + +describe('GPT first-display diagnostics adoption', () => { + it('hydrates exact physical-slot cycle state before slot adoption', () => { + const physicalSlot = {}; + const adoptDiagnosticsState = vi.fn(() => true); + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + artifacts: Object.freeze([]), + cycles: Object.freeze([ + Object.freeze({ + nextCycleOrdinal: 3, + records: Object.freeze([ + Object.freeze({ + ordinal: 1, + responseIdentifier: 'response-one', + seen: Object.freeze(['slotRequested', 'slotRenderEnded'] as const), + state: 'completed' as const, + }), + ]), + token: 'gt1_4', + unknownPriorCycle: false, + }), + ]), + trace: Object.freeze({ nextGlobalSlotOrdinal: 7 }), + }), + identities: Object.freeze([physicalSlot]), + }); + + expect(adoptInitialGptDiagnosticsFromHandoff(adoption, { adoptDiagnosticsState })).toBe( + adoption + ); + expect(adoptDiagnosticsState).toHaveBeenCalledWith({ + nextTraceTokenOrdinal: 7, + slots: [ + { + nextCycleOrdinal: 3, + physicalSlot, + records: [ + { + ordinal: 1, + responseIdentifier: 'response-one', + seen: ['slotRequested', 'slotRenderEnded'], + state: 'completed', + }, + ], + traceToken: 'gt1_4', + unknownPriorCycle: false, + }, + ], + }); + }); + + it('restores diagnostics facts only when the diagnostics owner is selected', () => { + const adoptFirstDisplay = vi.fn((..._arguments: unknown[]) => true); + const physicalSlot = {}; + const diagnosticsToken = Object.freeze(Object.create(null) as object); + const fact = Object.freeze({ + version: 1 as const, + event: 'slotRequested' as const, + token: 'gt1_1', + runtimeSlotNumber: 1, + cycleOrdinal: 1, + disposition: 'matched' as const, + issueReason: null, + capturedAtMs: 5, + elementId: 'slot-1', + adUnitPath: '/123/slot-1', + requestedSlotSizes: Object.freeze([Object.freeze([300, 250] as const)]), + isEmpty: null, + renderedSize: null, + isBackfill: null, + slotContentChanged: null, + visibilityPercent: null, + }); + const diagnostics = Object.freeze({ + facts: Object.freeze([fact]), + overflowCount: 3, + dropCount: 2, + }); + const adapter = { + diagnosticsIdentity: (slot: object) => + slot === physicalSlot + ? Object.freeze({ + token: diagnosticsToken, + traceToken: 'gt1_1' as never, + runtimeSlotNumber: 1, + cycleOrdinal: 1 as never, + }) + : undefined, + }; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + artifacts: Object.freeze([]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1', token: 'gt1_1' })]), + gptDiagnostics: diagnostics, + slots: Object.freeze([ + Object.freeze({ id: 'slot-1', formats: Object.freeze([Object.freeze([300, 250])]) }), + ]), + }), + identities: Object.freeze([physicalSlot]), + }); + + const projection = Object.freeze({ + auction: Object.freeze({ auctionId: 'initial-auction' }), + }) as unknown as Readonly; + expect( + adoptInitialGptFactsFromHandoff(adoption, { adoptFirstDisplay }, adapter, projection) + ).toBe(adoption); + expect(adoptFirstDisplay).toHaveBeenCalledWith( + diagnostics, + expect.any(Function), + expect.any(Function) + ); + const resolve = adoptFirstDisplay.mock.calls[0]?.[1] as (token: string) => unknown; + expect(resolve('gt1_1')).toMatchObject({ token: diagnosticsToken, runtimeSlotNumber: 1 }); + const resolveOpportunity = adoptFirstDisplay.mock.calls[0]?.[2] as ( + token: string, + slot: Readonly + ) => unknown; + expect(resolveOpportunity('gt1_1', resolve('gt1_1') as Readonly)).toMatchObject({ + kind: 'trustedServerOpportunity', + auctionSlotId: 'slot-1', + opportunity: 'renderable_candidate', + requestedSlotSizes: [[300, 250]], + trustedServerAuctionId: 'initial-auction', + }); + expect( + adoptInitialGptFactsFromHandoff(adoption, undefined, adapter, projection) + ).toBeUndefined(); + }); + + it('transfers only lifecycle-ticket tombstones to the persistent PUC owner', () => { + const adoptFirstDisplayTickets = vi.fn(() => true); + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + highWater: Object.freeze({ reservationClockEpochMs: 40, nextTicketOrdinal: 8 }), + tombstones: Object.freeze([ + Object.freeze({ expiresAtMs: 80, kind: 'ticket', value: `t1_${'a'.repeat(22)}` }), + Object.freeze({ expiresAtMs: 90, kind: 'reservation', value: RESERVATION_ID }), + ]), + }), + identities: Object.freeze([]), + }); + + expect(adoptInitialPucTicketsFromHandoff(adoption, { adoptFirstDisplayTickets })).toBe( + adoption + ); + expect(adoptFirstDisplayTickets).toHaveBeenCalledWith({ + clockEpochMs: 40, + nextTicketOrdinal: 8, + tombstones: [{ expiresAtMs: 80, ticket: `t1_${'a'.repeat(22)}` }], + }); + }); +}); + +function createAttemptHarness() { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(1); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation creation'); + const batch = navigationResult.value.createAuctionBatch('gpt-cycle'); + if (!batch) throw new Error('Expected batch creation'); + const artifacts = createCommittedArtifactStore(); + const reservations = createReservationService({ + prepareRenderSource: (candidate) => + typeof candidate === 'object' && + candidate !== null && + Object.isFrozen(candidate) && + 'type' in candidate && + 'version' in candidate + ? (candidate as Readonly<{ type: 'aps' | 'adm'; version: 1 }>) + : undefined, + }); + const createAttemptWithOwner = (parentAttemptId?: string) => { + const owner = batch.createRenderAttempt('slot-one'); + if (!owner.ok) throw new Error(`Expected attempt owner: ${owner.reason}`); + const created = createRenderAttempt({ + artifacts, + owner: owner.value, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && + candidate !== null && + Object.isFrozen(candidate) && + 'type' in candidate && + 'version' in candidate + ? (candidate as Readonly<{ type: 'aps' | 'adm'; version: 1 }>) + : undefined, + reservations, + ...(parentAttemptId === undefined ? {} : { parentAttemptId }), + }); + if (!created.ok) throw new Error(`Expected render attempt: ${created.reason}`); + return { attempt: created.value, owner: owner.value }; + }; + const primaryCreated = createAttemptWithOwner(); + const primary = primaryCreated.attempt; + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: primary.id, + slot: primary.slot, + navigationGeneration: primary.navigationGeneration, + dispose: vi.fn(), + }) satisfies CommittedRenderArtifact; + return { + artifact, + createAttempt: (parentAttemptId: string): RenderAttempt => + createAttemptWithOwner(parentAttemptId).attempt, + navigation: navigationResult.value, + primary, + primaryOwner: primaryCreated.owner, + reservations, + runtime, + }; +} + +function deferredSlotOutcome() { + let resolve!: (outcome: SlotRequestOutcome) => void; + const result = new Promise((resolveResult) => { + resolve = resolveResult; + }); + const dispose = vi.fn(); + return { + dispose, + request: vi.fn(() => Object.freeze({ status: 'active' as const, result, dispose })), + resolve, + }; +} + +function manifest(ids: readonly string[]) { + return { + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => ({ id, phase: 'takeover' as const })), + }; +} + +function registration( + id: string, + prepare: IntegrationRegistration['prepare'] +): IntegrationRegistration { + return Object.freeze({ + abi: 1, + id, + phase: 'takeover', + releaseId: RELEASE_ID, + prepareSync: () => Object.freeze({ activate: () => undefined }), + prepare, + }); +} + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +function pbsCacheProjection( + sources: readonly Readonly<{ + cacheHost: string; + cacheId: string; + cachePath: string; + divId: string; + slot: string; + }>[] +): Readonly { + return Object.freeze({ + version: 1 as const, + auction: Object.freeze({ + version: 1 as const, + auctionId: 'pbs-cache-auction', + results: Object.freeze( + sources.map((source, index) => + Object.freeze({ + slot: source.slot, + outcome: 'winner' as const, + candidateId: `CACHEBID${String(index).padStart(4, '0')}`, + }) + ) + ), + }), + slots: Object.freeze( + sources.map((source) => + Object.freeze({ + slot: source.slot, + gamUnitPath: `/123/${source.slot}`, + divId: source.divId, + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({}), + }) + ) + ), + bids: Object.freeze( + sources.map((source, index) => + Object.freeze({ + candidateId: `CACHEBID${String(index).padStart(4, '0')}`, + slot: source.slot, + provider: 'prebid', + upstreamBidId: `upstream-${index}`, + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({}), + renderSource: Object.freeze({ + type: 'pbs_cache' as const, + version: 1 as const, + cacheId: source.cacheId, + cacheHost: source.cacheHost, + cachePath: source.cachePath, + width: 300, + height: 250, + }), + }) + ) + ), + }) as unknown as Readonly; +} + +function pbsCacheSourceFrame(divId: string): HTMLIFrameElement { + const root = document.createElement('div'); + root.id = divId; + root.style.width = '1px'; + root.style.height = '1px'; + const frame = document.createElement('iframe'); + frame.setAttribute('width', '1'); + frame.setAttribute('height', '1'); + frame.style.width = '1px'; + frame.style.height = '1px'; + root.appendChild(frame); + document.body.appendChild(root); + return frame; +} + +function startPbsCacheBridge(projection: Readonly) { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(3); + return target; + }, + }), + }); + const navigation = runtime.startInitialNavigation(projection); + if (!navigation.ok) throw new Error('Expected PBS Cache test navigation'); + const observe = vi.fn(() => true); + const release = installPbsCacheBridge( + document, + Object.freeze({ + navigation: navigation.value, + projection, + session: runtime, + }), + () => true, + observe + ); + return { navigation: navigation.value, observe, release, runtime }; +} + +function dispatchPbsCacheRequest( + source: Window, + adId: string, + postMessage: ReturnType +): ReturnType { + const event = new MessageEvent('message', { + data: JSON.stringify({ message: 'Prebid Request', adId }), + ports: [Object.freeze({ postMessage }) as unknown as MessagePort], + source, + }); + const stopped = vi.spyOn(event, 'stopImmediatePropagation'); + window.dispatchEvent(event); + return stopped; +} + +describe('GPT-owned PBS Cache bridge', () => { + const sharedCacheId = 'shared cache/id'; + + afterEach(() => { + vi.unstubAllGlobals(); + document.getElementById('cache-slot-one')?.remove(); + document.getElementById('cache-slot-two')?.remove(); + document.getElementById('foreign-cache-slot')?.remove(); + }); + + it('binds duplicate cache ids to the requesting slot and preserves current-main parse, macro, and resize behavior', async () => { + const projection = pbsCacheProjection([ + { + cacheId: sharedCacheId, + cacheHost: 'first-cache.example', + cachePath: '/first', + divId: 'cache-slot-one', + slot: 'slot-one', + }, + { + cacheId: sharedCacheId, + cacheHost: 'second-cache.example:8443', + cachePath: '/opaque%2Fpath', + divId: 'cache-slot-two', + slot: 'slot-two', + }, + ]); + const first = pbsCacheSourceFrame('cache-slot-one'); + const second = pbsCacheSourceFrame('cache-slot-two'); + const foreign = pbsCacheSourceFrame('foreign-cache-slot'); + const fetchCache = vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + adm: '
cached
', + width: 320, + height: 100, + price: 2.75, + }), + })); + vi.stubGlobal('fetch', fetchCache); + const bridge = startPbsCacheBridge(projection); + const foreignPost = vi.fn(); + const foreignStopped = dispatchPbsCacheRequest( + foreign.contentWindow!, + sharedCacheId, + foreignPost + ); + expect(foreignStopped).not.toHaveBeenCalled(); + expect(fetchCache).not.toHaveBeenCalled(); + + const postMessage = vi.fn(); + const stopped = dispatchPbsCacheRequest(second.contentWindow!, sharedCacheId, postMessage); + await vi.waitFor(() => expect(postMessage).toHaveBeenCalledOnce()); + expect(stopped).toHaveBeenCalledOnce(); + expect(fetchCache).toHaveBeenCalledExactlyOnceWith( + 'https://second-cache.example:8443/opaque%2Fpath?uuid=shared%20cache%2Fid', + { mode: 'cors' } + ); + expect(JSON.parse(String(postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'Prebid Response', + adId: sharedCacheId, + ad: '
cached
', + renderer: expect.any(String), + width: 320, + height: 100, + }); + expect(second.style.width).toBe('320px'); + expect(second.style.height).toBe('100px'); + expect(first.style.width).toBe('1px'); + expect(bridge.observe).not.toHaveBeenCalled(); + bridge.release(); + bridge.runtime.dispose(); + }); + + it('keeps fetch, payload, and response-post failures typed and suppresses duplicate in-flight work', async () => { + const projection = pbsCacheProjection([ + { + cacheId: sharedCacheId, + cacheHost: 'cache.example', + cachePath: '/pbc/v1/cache', + divId: 'cache-slot-one', + slot: 'slot-one', + }, + ]); + const frame = pbsCacheSourceFrame('cache-slot-one'); + let resolveResponse!: ( + response: Readonly<{ ok: boolean; status: number; text: () => Promise }> + ) => void; + const fetchCache = vi.fn( + () => + new Promise Promise }>>( + (resolve) => { + resolveResponse = resolve; + } + ) + ); + vi.stubGlobal('fetch', fetchCache); + const bridge = startPbsCacheBridge(projection); + const firstPost = vi.fn(); + dispatchPbsCacheRequest(frame.contentWindow!, sharedCacheId, firstPost); + dispatchPbsCacheRequest(frame.contentWindow!, sharedCacheId, vi.fn()); + expect(fetchCache).toHaveBeenCalledOnce(); + resolveResponse({ ok: true, status: 200, text: async () => '{"not_adm":true}' }); + await vi.waitFor(() => + expect(bridge.observe).toHaveBeenCalledWith( + Object.freeze({ + kind: 'pbs_cache_bridge', + slotId: 'slot-one', + reason: 'invalid_cache_payload', + }) + ) + ); + expect(firstPost).not.toHaveBeenCalled(); + + fetchCache.mockResolvedValueOnce({ ok: false, status: 503, text: async () => '' }); + dispatchPbsCacheRequest(frame.contentWindow!, sharedCacheId, vi.fn()); + await vi.waitFor(() => + expect(bridge.observe).toHaveBeenCalledWith( + Object.freeze({ + kind: 'pbs_cache_bridge', + slotId: 'slot-one', + reason: 'cache_fetch_failed', + }) + ) + ); + + fetchCache.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => '
raw cached creative
', + }); + const throwingPost = vi.fn(() => { + throw new Error('closed port'); + }); + dispatchPbsCacheRequest(frame.contentWindow!, sharedCacheId, throwingPost); + await vi.waitFor(() => + expect(bridge.observe).toHaveBeenCalledWith( + Object.freeze({ + kind: 'pbs_cache_bridge', + slotId: 'slot-one', + reason: 'response_post_failed', + }) + ) + ); + expect(frame.style.width).toBe('1px'); + bridge.release(); + bridge.runtime.dispose(); + }); + + it('makes late cache completion and post-disposal messages inert', async () => { + const projection = pbsCacheProjection([ + { + cacheId: sharedCacheId, + cacheHost: 'cache.example', + cachePath: '/pbc/v1/cache', + divId: 'cache-slot-one', + slot: 'slot-one', + }, + ]); + const frame = pbsCacheSourceFrame('cache-slot-one'); + let resolveResponse!: ( + response: Readonly<{ ok: boolean; status: number; text: () => Promise }> + ) => void; + const fetchCache = vi.fn( + () => + new Promise Promise }>>( + (resolve) => { + resolveResponse = resolve; + } + ) + ); + vi.stubGlobal('fetch', fetchCache); + const bridge = startPbsCacheBridge(projection); + const postMessage = vi.fn(); + dispatchPbsCacheRequest(frame.contentWindow!, sharedCacheId, postMessage); + expect(fetchCache).toHaveBeenCalledOnce(); + expect(bridge.runtime.replaceNavigation()).toMatchObject({ ok: true }); + resolveResponse({ + ok: true, + status: 200, + text: async () => JSON.stringify({ adm: '
late
' }), + }); + await Promise.resolve(); + await Promise.resolve(); + expect(postMessage).not.toHaveBeenCalled(); + expect(bridge.observe).not.toHaveBeenCalled(); + + bridge.release(); + dispatchPbsCacheRequest(frame.contentWindow!, sharedCacheId, vi.fn()); + expect(fetchCache).toHaveBeenCalledOnce(); + bridge.runtime.dispose(); + }); +}); + +describe('transactional GPT integration module', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + resetGuardState(); + delete (window as Window & { googletag?: unknown }).googletag; + document.getElementById('takeover-slot')?.remove(); + document.getElementById('spa-winner')?.remove(); + }); + + it('adopts exact first-display GPT identities without issuing a second GPT action', () => { + const physicalSlot = {}; + const frame = {}; + const navigationGeneration = {}; + const committedArtifact: CommittedRenderArtifact = Object.freeze({ + attemptId: `a1_${'d'.repeat(22)}`, + dispose: vi.fn(), + kind: 'puc', + navigationGeneration, + slot: 'slot-1', + }); + const artifactStore = createCommittedArtifactStore(); + expect(artifactStore.promote(committedArtifact)).toBe(true); + const adoptCommittedArtifact = vi.fn(() => true); + const adoptGptSlot = vi.fn(() => Object.freeze({ ok: true as const })); + const adoptRegistrationHighWater = vi.fn(() => true); + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + highWater: Object.freeze({ nextSlotRegistrationOrdinal: 2 }), + slots: Object.freeze([ + Object.freeze({ + id: 'slot-1', + owner: 'trusted_server', + domId: 'div-1', + gamPath: '/123/slot-1', + formats: Object.freeze([Object.freeze([300, 250])]), + targetingOwnership: Object.freeze([]), + }), + ]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + }), + identities: Object.freeze([physicalSlot, frame]), + }); + + expect( + adoptInitialGptSlotsFromHandoff( + adoption, + navigationGeneration, + { + adoptCommittedArtifact, + adoptGptSlot, + adoptRegistrationHighWater, + }, + artifactStore, + { + adopt: vi.fn(), + observePublisherMutations: vi.fn(), + }, + {} as never + ) + ).toBe(adoption); + expect(adoptRegistrationHighWater).toHaveBeenCalledExactlyOnceWith(navigationGeneration, 2); + expect(adoptGptSlot).toHaveBeenCalledExactlyOnceWith(navigationGeneration, 'slot-1', { + definition: { + adUnitPath: '/123/slot-1', + elementId: 'div-1', + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot: physicalSlot, + }); + expect(adoptCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + navigationGeneration, + 'slot-1', + committedArtifact, + expect.any(Function) + ); + }); + + it.each(['unchanged', 'same_publisher_write', 'different_publisher_write'] as const)( + 'transfers first-display targeting ownership and preserves %s semantics', + (mutation) => { + const physicalSlot = {}; + const frame = {}; + const navigationGeneration = {}; + const values = new Map([['hb_adid', ['trusted']]]); + const setTargeting = vi.fn((slot: object, key: string, value: string | readonly string[]) => { + expect(slot).toBe(physicalSlot); + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + const clearTargeting = vi.fn((_slot: object, key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }); + let publisherObserver: + Readonly<{ beforePublisherMutation: (slot: object, key?: string) => void }> | undefined; + const releaseObservation = Object.assign(vi.fn(), { isCurrent: () => true }); + const facade = { + clearTargeting, + getTargeting: (_slot: object, key: string) => Object.freeze([...(values.get(key) ?? [])]), + observeTargeting: ( + slot: object, + observer: Readonly<{ beforePublisherMutation: (slot: object, key?: string) => void }> + ) => { + expect(slot).toBe(physicalSlot); + publisherObserver = observer; + return releaseObservation; + }, + setTargeting, + }; + const adapter = { + run: (command: (gpt: typeof facade) => unknown) => { + const result = command(facade); + return Object.freeze({ + status: 'present' as const, + result: Promise.resolve(result), + dispose: vi.fn(), + }); + }, + } as never; + const artifact: CommittedRenderArtifact = Object.freeze({ + attemptId: `a1_${'e'.repeat(22)}`, + dispose: vi.fn(), + kind: 'aps_mount', + navigationGeneration, + slot: 'slot-1', + }); + const artifactStore = createCommittedArtifactStore(); + expect(artifactStore.promote(artifact)).toBe(true); + let retireTargeting: (() => void) | undefined; + const service = { + adoptCommittedArtifact: vi.fn( + ( + _generation: object, + _slotId: string, + _artifact: CommittedRenderArtifact, + onRetire?: () => void + ) => { + retireTargeting = onRetire; + return true; + } + ), + adoptGptSlot: vi.fn(() => Object.freeze({ ok: true as const })), + adoptRegistrationHighWater: vi.fn(() => true), + }; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + highWater: Object.freeze({ nextSlotRegistrationOrdinal: 2 }), + slots: Object.freeze([ + Object.freeze({ + id: 'slot-1', + owner: 'publisher' as const, + targetingOwnership: Object.freeze([ + Object.freeze({ + installed: 'trusted', + key: 'hb_adid', + prior: Object.freeze(['publisher-original']), + }), + ]), + }), + ]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + }), + identities: Object.freeze([physicalSlot, frame]), + }); + const targeting = createTargetingService(); + + expect( + adoptInitialGptSlotsFromHandoff( + adoption, + navigationGeneration, + service, + artifactStore, + targeting, + adapter + ) + ).toBe(adoption); + expect(setTargeting).not.toHaveBeenCalled(); + + if (mutation !== 'unchanged') { + publisherObserver?.beforePublisherMutation(physicalSlot, 'hb_adid'); + values.set( + 'hb_adid', + Object.freeze([mutation === 'same_publisher_write' ? 'trusted' : 'publisher-new']) + ); + } + retireTargeting?.(); + + expect(values.get('hb_adid')).toEqual([ + mutation === 'unchanged' + ? 'publisher-original' + : mutation === 'same_publisher_write' + ? 'trusted' + : 'publisher-new', + ]); + expect(setTargeting).toHaveBeenCalledTimes(mutation === 'unchanged' ? 1 : 0); + expect(releaseObservation).toHaveBeenCalledOnce(); + } + ); + + it.each([ + { + diagnosticsActive: false, + adoptInitialDisplay: false, + parserStateValid: true, + gamAttributionEnabled: false, + }, + { + diagnosticsActive: true, + adoptInitialDisplay: false, + parserStateValid: true, + gamAttributionEnabled: false, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: false, + parserStateValid: true, + gamAttributionEnabled: true, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: true, + parserStateValid: true, + gamAttributionEnabled: false, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: true, + parserStateValid: true, + gamAttributionEnabled: true, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: true, + parserStateValid: false, + gamAttributionEnabled: false, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: true, + parserStateValid: 'absent', + gamAttributionEnabled: false, + }, + ])( + 'uses only catalog capabilities without replaying adopted display (diagnostics=$diagnosticsActive, adoption=$adoptInitialDisplay, parser=$parserStateValid, attribution=$gamAttributionEnabled)', + async ({ diagnosticsActive, adoptInitialDisplay, parserStateValid, gamAttributionEnabled }) => { + vi.useFakeTimers(); + const NativeMutationObserver = window.MutationObserver; + const activeMutationObservers = new Set(); + class TrackingMutationObserver implements MutationObserver { + readonly inner: MutationObserver; + + constructor(callback: MutationCallback) { + this.inner = new NativeMutationObserver(callback); + } + + disconnect(): void { + activeMutationObservers.delete(this); + this.inner.disconnect(); + } + + observe(target: Node, options?: MutationObserverInit): void { + activeMutationObservers.add(this); + this.inner.observe(target, options); + } + + takeRecords(): MutationRecord[] { + return this.inner.takeRecords(); + } + } + vi.stubGlobal('MutationObserver', TrackingMutationObserver); + const listenerTypes: string[] = []; + const removedTypes: string[] = []; + const targeting = new Map(); + const publisherSlot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) targeting.clear(); + else targeting.delete(key); + return publisherSlot; + }), + getAdUnitPath: () => '/123/spa-winner', + getSlotElementId: () => 'spa-winner', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + targeting.set(key, typeof value === 'string' ? [value] : value); + return publisherSlot; + }), + }; + const definedSlots: object[] = []; + const createDefinedSlot = (adUnitPath: string, elementId: string) => ({ + addService: vi.fn(), + clearTargeting: vi.fn(), + getAdUnitPath: () => adUnitPath, + getSlotElementId: () => elementId, + getTargeting: () => [], + setTargeting: vi.fn(), + }); + const defineSlot = vi.fn((adUnitPath: string, _sizes: unknown, elementId: string) => { + const slot = createDefinedSlot(adUnitPath, elementId); + definedSlots.push(slot); + return slot; + }); + const destroySlots = vi.fn((slots: readonly object[]) => { + for (const slot of slots) { + const index = definedSlots.indexOf(slot); + if (index >= 0) definedSlots.splice(index, 1); + } + return true; + }); + const display = vi.fn(); + const refresh = vi.fn(); + const pubads = { + addEventListener: vi.fn((type: string, _listener: (event: unknown) => void) => { + listenerTypes.push(type); + }), + disableInitialLoad: vi.fn(), + getSlots: vi.fn(() => [publisherSlot, ...definedSlots]), + refresh, + removeEventListener: vi.fn((type: string, _listener: (event: unknown) => void) => { + removedTypes.push(type); + }), + }; + const setConfig = vi.fn(); + (window as Window & { googletag?: unknown }).googletag = { + apiReady: true, + pubadsReady: true, + cmd: { push: (command: () => void) => (command(), 0) }, + defineSlot, + destroySlots, + display, + getConfig: vi.fn(() => ({ disableInitialLoad: false })), + pubads: () => pubads, + setConfig, + }; + const providerFacades = new Map>>(); + const protect = vi.fn(() => true); + const bootManifest = Object.freeze({ + version: 1 as const, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'takeover' as const }), + Object.freeze({ id: 'gpt', phase: 'takeover' as const }), + ]), + }); + const runtime = Object.freeze({ + attachAuctionContextService: () => () => undefined, + boot: () => + Object.freeze({ + auctionProjection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([ + Object.freeze({ + slot: 'takeover-slot', + outcome: 'no_bid' as const, + }), + ]), + }), + slots: Object.freeze([ + Object.freeze({ + slot: 'takeover-slot', + gamUnitPath: '/123/takeover-slot', + divId: 'takeover-slot', + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({}), + }), + ]), + bids: Object.freeze([]), + }), + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: false, + gpt: Object.freeze({ active: diagnosticsActive }), + }), + manifest: bootManifest, + }), + document, + enqueue: () => true, + generation: Object.freeze({}), + protectFirstDisplayAttemptBatch: protect, + registerAuctionContext: () => () => undefined, + } satisfies RuntimeCapabilityV1); + const registry = createIntegrationRegistry({ + manifest: bootManifest, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['render_runtime', 'gpt']), + catalog: Object.freeze([ + Object.freeze({ + id: 'render_runtime', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze([ + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + 'trace.presentation.v1', + 'direct.v1', + ]), + }), + Object.freeze({ + id: 'gpt', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze([ + 'runtime.v1', + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + ]), + provides: Object.freeze(['gpt.v1', 'gpt.events.v1', 'pbs_cache.baseline.v1']), + }), + ]), + runtimeCapability: runtime, + getBindings: (id) => + Object.freeze({ + config: + id === 'gpt' ? Object.freeze({ ...GPT_CONFIG, gamAttributionEnabled }) : undefined, + interfaces: Object.freeze({}), + }), + onCapabilityStaged: (key, facade) => { + providerFacades.set(key, facade); + return () => { + if (providerFacades.get(key) === facade) providerFacades.delete(key); + }; + }, + startedAtMs: 0, + now: () => 0, + }); + const takeoverElement = document.createElement('div'); + takeoverElement.id = 'takeover-slot'; + document.body.appendChild(takeoverElement); + const adoptedFrame = document.createElement('iframe'); + takeoverElement.appendChild(adoptedFrame); + expect(registry.register(createRenderRuntimeIntegrationRegistration(RELEASE_ID))).toBe(true); + expect(registry.register(createProductionGptRegistration(RELEASE_ID))).toBe(true); + + const installCallbacks: IntegrationInstallCallbacks = { + ...callbacks([]), + ...(adoptInitialDisplay + ? { + coordinateTakeover: ( + prepared: Parameters< + NonNullable + >[0] + ) => { + const slices = Object.freeze( + parserStateValid === 'absent' + ? (['first_display'] as const) + : (['first_display', 'gpt_initial'] as const) + ); + const candidate = acceptedGptFirstDisplayTakeover( + RELEASE_ID, + RESERVATION_ID, + parserStateValid === true + ? 'valid' + : parserStateValid === false + ? 'invalid' + : 'absent', + gamAttributionEnabled + ); + const handoff = prepared.validateHandoff( + candidate.capture || + Object.freeze({ + version: 1 as const, + releaseId: RELEASE_ID, + generation: 1, + projectionDigest: 'b'.repeat(64), + integrationConfigDigest: 'c'.repeat(64), + slices, + slots: Object.freeze([ + Object.freeze({ + id: 'takeover-slot', + aliases: Object.freeze([]), + owner: 'publisher', + domId: 'takeover-slot', + gamPath: '/123/takeover-slot', + formats: Object.freeze([Object.freeze([300, 250])]), + outcome: 'accepted' as const, + targeting: Object.freeze([ + Object.freeze(['hb_adid', RESERVATION_ID] as const), + ]), + committedArtifact: 'gpt_adm' as const, + targetingOwnership: Object.freeze([]), + gptToken: 'gt1_1', + }), + ]), + attempts: Object.freeze([ + Object.freeze({ + id: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + slotId: 'takeover-slot', + ordinal: 1, + state: 'accepted' as const, + reason: null, + }), + ]), + tombstones: Object.freeze([]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + slotId: 'takeover-slot', + kind: 'gpt_adm' as const, + owner: 'trusted_server' as const, + token: RESERVATION_ID, + }), + ]), + parserState: + parserStateValid === true + ? Object.freeze([ + Object.freeze({ + sliceId: 'gpt_initial', + observations: Object.freeze(['gam', 'v']), + values: Object.freeze([ + Object.freeze(['gam', gamAttributionEnabled] as const), + Object.freeze(['v', 1] as const), + ]), + }), + ]) + : Object.freeze([]), + gptDiagnostics: Object.freeze({ + facts: Object.freeze([]), + overflowCount: 0, + dropCount: 0, + }), + timing: Object.freeze({ + bidsScriptMs: 1, + firstDisplayMs: 2, + terminalMs: 3, + paintMs: 4, + }), + highWater: Object.freeze({ + navigationAttemptPrefix: 'AAECAwQFBgc', + nextAttemptOrdinal: 2, + nextNavigationAttemptOrdinal: 2, + nextSlotRegistrationOrdinal: 2, + nextReservationOrdinal: 2, + nextTicketOrdinal: 1, + reservationClockEpochMs: 0, + }), + cycles: Object.freeze([ + Object.freeze({ + nextCycleOrdinal: 2, + quarantines: Object.freeze([]), + records: Object.freeze([ + Object.freeze({ + ordinal: 1, + responseIdentifier: 'response-one', + seen: Object.freeze(['slotRequested', 'slotRenderEnded'] as const), + state: 'completed' as const, + }), + ]), + slotId: 'takeover-slot', + token: 'gt1_1', + unknownPriorCycle: false, + }), + ]), + trace: Object.freeze({ + nextGlobalSlotOrdinal: 2, + nextSequence: 2, + slots: Object.freeze([ + Object.freeze({ + bindings: Object.freeze([ + Object.freeze({ + atMs: 2, + cycleOrdinal: 1, + historySequence: 1, + state: 'completed' as const, + token: 'gt1_1', + }), + ]), + impressions: 1, + slotId: 'takeover-slot', + }), + ]), + }), + mutationRevision: 0, + }), + Object.freeze({ + version: 1 as const, + releaseId: RELEASE_ID, + generation: 1, + projectionDigest: 'b'.repeat(64), + integrationConfigDigest: 'c'.repeat(64), + slices, + slotCount: 1, + outcomeCount: 1, + capabilities: Object.freeze([]), + objectKinds: Object.freeze(['gpt_slot', 'dom_artifact']), + }), + candidate.boot + ); + if (!handoff) throw new Error('should validate test handoff'); + prepared.activate( + Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff, + identities: Object.freeze([publisherSlot, adoptedFrame]), + }) + ); + prepared.commit(); + }, + } + : {}), + }; + const result = await registry.install(installCallbacks); + if (adoptInitialDisplay && parserStateValid !== true) { + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(setConfig).not.toHaveBeenCalled(); + expect(defineSlot).not.toHaveBeenCalled(); + expect(display).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + vi.useRealTimers(); + return; + } + expect(result.state).toBe('kernel'); + expect(setConfig).toHaveBeenCalledTimes( + gamAttributionEnabled && !adoptInitialDisplay ? 1 : 0 + ); + if (gamAttributionEnabled && !adoptInitialDisplay) { + expect(setConfig).toHaveBeenCalledExactlyOnceWith({ targeting: { ts: 'true' } }); + } + const gpt = providerFacades.get('gpt.v1') as { + activateLaterLifecycle: () => Readonly<{ + navigate: (path: string) => Promise; + release: () => void; + }>; + navigation: () => Readonly<{ + generation: object; + currentAuctionProjection?: Readonly<{ auction?: Readonly<{ auctionId?: string }> }>; + }>; + slots: { + request: (input: Readonly>) => unknown; + }; + }; + if (adoptInitialDisplay) { + await Promise.resolve(); + expect(defineSlot).not.toHaveBeenCalled(); + expect(display).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + expect(protect).not.toHaveBeenCalled(); + if (result.state === 'kernel') result.dispose(); + expect(providerFacades.size).toBe(0); + return; + } + await vi.waitFor(() => expect(definedSlots).toHaveLength(1)); + const takeoverNavigation = gpt.navigation(); + gpt.slots.request({ + intentId: 'takeover-request', + navigationGeneration: takeoverNavigation.generation, + operation: 'display', + registeredSlotId: 'takeover-slot', + requestClass: 'initial', + }); + await vi.waitFor(() => expect(display).toHaveBeenCalledOnce()); + expect(protect).not.toHaveBeenCalled(); + expect(activeMutationObservers.size).toBe(3); + const firstPhysicalSlot = definedSlots[0]; + expect(firstPhysicalSlot).toBeDefined(); + takeoverElement.remove(); + const replacementElement = document.createElement('div'); + replacementElement.id = 'takeover-slot'; + document.body.appendChild(replacementElement); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(250); + expect(destroySlots).toHaveBeenCalledOnce(); + const destroyedPhysicalSlot = destroySlots.mock.calls[0]?.[0]?.[0]; + expect(destroyedPhysicalSlot).toBeDefined(); + expect(destroyedPhysicalSlot).not.toBe(publisherSlot); + expect(definedSlots).toHaveLength(1); + expect(definedSlots).not.toContain(destroyedPhysicalSlot); + expect(activeMutationObservers.size).toBe(3); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + expect([...providerFacades.keys()]).toEqual([ + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + 'trace.presentation.v1', + 'direct.v1', + 'gpt.v1', + 'gpt.events.v1', + 'pbs_cache.baseline.v1', + ]); + expect(Reflect.ownKeys(providerFacades.get('pbs_cache.baseline.v1') ?? {})).toEqual([]); + const render = providerFacades.get('render.v1') as { + attachPucGamAttemptRegistrar: (registrar: (input: unknown) => boolean) => () => void; + }; + expect(() => render.attachPucGamAttemptRegistrar(() => true)).toThrow('duplicated'); + expect(protect).not.toHaveBeenCalled(); + expect([...listenerTypes].sort()).toEqual( + (diagnosticsActive + ? [ + 'slotRequested', + 'slotRenderEnded', + 'slotResponseReceived', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', + ] + : ['slotRequested', 'slotRenderEnded'] + ).sort() + ); + expect(listenerTypes.slice(0, 2)).toEqual(['slotRequested', 'slotRenderEnded']); + + const placement = { + slot: 'spa-winner', + gamUnitPath: '/123/spa-winner', + divId: 'spa-winner', + formats: [[300, 250]], + targeting: {}, + }; + const bid = { + candidateId: 'BBBBBBBBBBBB', + slot: placement.slot, + provider: 'trusted', + upstreamBidId: 'spa-upstream', + cpm: 2, + currency: 'USD', + targeting: { hb_bidder: 'trusted' }, + renderSource: { + type: 'pbs_cache', + version: 1, + cacheId: 'cache id/with reserved bytes', + cacheHost: 'cache.example:8443', + cachePath: '/pbc/v1/cache', + width: 300, + height: 250, + }, + }; + const pageBids = { + version: 1, + auction: { + version: 1, + auctionId: 'spa-production', + results: [{ slot: placement.slot, outcome: 'winner', candidateId: bid.candidateId }], + }, + slots: [placement], + bids: [bid], + }; + const slotElement = document.createElement('div'); + slotElement.id = placement.divId; + document.body.appendChild(slotElement); + const fetchPageBids = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue({ ok: true, json: async () => pageBids } as Response); + const initialNavigation = gpt.navigation(); + expect(refresh).not.toHaveBeenCalled(); + const later = gpt.activateLaterLifecycle(); + expect(Object.isFrozen(later)).toBe(true); + expect(activeMutationObservers.size).toBe(3); + expect(() => gpt.activateLaterLifecycle()).toThrow('unavailable'); + const navigationResult = await later.navigate('/spa-production?route=one'); + expect(navigationResult).toEqual({ + status: 'committed', + navigationGeneration: expect.any(Object), + current: true, + }); + expect(fetchPageBids).toHaveBeenCalledExactlyOnceWith( + '/_ts/page-bids?path=%2Fspa-production%3Froute%3Done', + expect.objectContaining({ + credentials: 'include', + headers: { 'X-TSJS-Page-Bids': '1' }, + signal: expect.any(AbortSignal), + }) + ); + expect(gpt.navigation()).not.toBe(initialNavigation); + expect(gpt.navigation()?.currentAuctionProjection?.auction?.auctionId).toBe('spa-production'); + expect(refresh).toHaveBeenCalledExactlyOnceWith( + [publisherSlot], + Object.freeze({ changeCorrelator: false }) + ); + expect(publisherSlot.setTargeting).toHaveBeenCalledWith('hb_adid', bid.renderSource.cacheId); + expect(publisherSlot.setTargeting).toHaveBeenCalledWith( + 'hb_cache_host', + bid.renderSource.cacheHost + ); + expect(publisherSlot.setTargeting).toHaveBeenCalledWith( + 'hb_cache_path', + bid.renderSource.cachePath + ); + + const sourceFrame = document.createElement('iframe'); + slotElement.appendChild(sourceFrame); + const responsePort = Object.freeze({ postMessage: vi.fn() }); + fetchPageBids.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + adm: '
cached
', + w: 320, + h: 100, + price: 2.75, + }), + } as Response); + const requestEvent = new MessageEvent('message', { + data: JSON.stringify({ message: 'Prebid Request', adId: bid.renderSource.cacheId }), + ports: [responsePort as unknown as MessagePort], + source: sourceFrame.contentWindow, + }); + const stopImmediatePropagation = vi.spyOn(requestEvent, 'stopImmediatePropagation'); + window.dispatchEvent(requestEvent); + await vi.waitFor(() => expect(responsePort.postMessage).toHaveBeenCalledOnce()); + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(fetchPageBids).toHaveBeenNthCalledWith( + 2, + 'https://cache.example:8443/pbc/v1/cache?uuid=cache%20id%2Fwith%20reserved%20bytes', + { mode: 'cors' } + ); + expect(JSON.parse(String(responsePort.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'Prebid Response', + adId: bid.renderSource.cacheId, + ad: '
cached
', + renderer: expect.any(String), + width: 320, + height: 100, + }); + + let resolveStaleResponse!: (response: Response) => void; + const concurrentPageBids = { + version: 1, + auction: { + version: 1, + auctionId: 'concurrent-current', + results: [], + }, + slots: [], + bids: [], + }; + fetchPageBids + .mockReturnValueOnce( + new Promise((resolve) => { + resolveStaleResponse = resolve; + }) + ) + .mockResolvedValueOnce({ + ok: true, + json: async () => concurrentPageBids, + } as Response); + const staleNavigation = later.navigate('/stale-generation'); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledTimes(3)); + const currentNavigation = later.navigate('/current-generation'); + await expect(currentNavigation).resolves.toEqual({ + status: 'committed', + navigationGeneration: expect.any(Object), + current: true, + }); + resolveStaleResponse({ ok: true, json: async () => pageBids } as Response); + const staleResult = await staleNavigation; + expect(staleResult).toEqual({ + status: 'rejected', + navigationGeneration: expect.any(Object), + current: false, + }); + expect((staleResult as { navigationGeneration: object }).navigationGeneration).not.toBe( + ((await currentNavigation) as { navigationGeneration: object }).navigationGeneration + ); + later.release(); + expect(activeMutationObservers.size).toBe(2); + await later.navigate('/disposed-owner'); + expect(fetchPageBids).toHaveBeenCalledTimes(4); + fetchPageBids.mockRestore(); + sourceFrame.remove(); + slotElement.remove(); + + expect(setConfig.mock.calls).toEqual( + gamAttributionEnabled ? [[{ targeting: { ts: 'true' } }]] : [] + ); + + if (result.state === 'kernel') result.dispose(); + expect(activeMutationObservers.size).toBe(0); + expect(removedTypes.sort()).toEqual([...listenerTypes].sort()); + expect(providerFacades.size).toBe(0); + expect(() => render.attachPucGamAttemptRegistrar(() => true)).toThrow('unavailable'); + } + ); + + it('protects the complete immutable initial winner batch before starting either GPT request', async () => { + const candidateIds = ['candidate001', 'candidate002'] as const; + const projection = Object.freeze({ + version: 1 as const, + auction: Object.freeze({ + version: 1 as const, + auctionId: 'initial-winners', + results: Object.freeze( + candidateIds.map((candidateId, index) => + Object.freeze({ + slot: `slot-${index + 1}`, + outcome: 'winner' as const, + candidateId, + }) + ) + ), + }), + slots: Object.freeze( + candidateIds.map((_candidateId, index) => + Object.freeze({ + slot: `slot-${index + 1}`, + gamUnitPath: `/123/slot-${index + 1}`, + divId: `slot-${index + 1}`, + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({}), + }) + ) + ), + bids: Object.freeze( + candidateIds.map((candidateId, index) => + Object.freeze({ + candidateId, + slot: `slot-${index + 1}`, + provider: 'fictional', + upstreamBidId: `upstream-${index + 1}`, + cpm: index + 1, + currency: 'USD' as const, + targeting: Object.freeze({}), + rendererReservationId: `r1_${String(index + 1).repeat(22)}`, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: `

${index + 1}

`, + width: 300, + height: 250, + }), + }) + ) + ), + }); + for (const placement of projection.slots) { + const element = document.createElement('div'); + element.id = placement.divId; + document.body.appendChild(element); + } + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(7); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(projection); + if (!navigationResult.ok) throw new Error(navigationResult.reason); + const navigation = navigationResult.value; + const artifacts = createCommittedArtifactStore(); + const reservations = createReservationService({ + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as never) + : undefined, + }); + const physical = new Map(); + const requestHandle = () => + Object.freeze({ + status: 'active' as const, + result: Promise.resolve( + Object.freeze({ status: 'failed' as const, reason: 'gpt_request_failed' as const }) + ), + dispose: vi.fn(), + }); + const request = vi.fn(requestHandle); + const requestBatch = vi.fn((inputs: readonly unknown[]) => + Object.freeze(inputs.map(() => requestHandle())) + ); + const slots = Object.freeze({ + adoptGptSlot: ( + _generation: object, + registeredSlotId: string, + binding: Readonly<{ slot: object }> + ) => { + physical.set(registeredSlotId, binding.slot); + return Object.freeze({ ok: true as const }); + }, + isBoundGptSlot: (_generation: object, registeredSlotId: string, slot: object) => + physical.get(registeredSlotId) === slot, + recordPublisherDestruction: vi.fn(() => true), + request, + requestBatch, + }); + const targeting = Object.freeze({ + observePublisherMutations: () => + Object.freeze({ status: 'completed', result: Promise.resolve(), dispose: vi.fn() }), + own: (_slot: object, _key: string, _value: string, ownerId: string) => + Object.freeze({ ownerId, release: vi.fn() }), + }); + const facade = Object.freeze({ + slots: () => Object.freeze([]), + slotElementId: () => undefined, + transactionalDefine: ( + definition: Readonly<{ elementId: string }>, + _current: () => boolean, + prepare: (slot: object) => Readonly<{ commit: () => boolean }> + ) => { + const slot = Object.freeze({ elementId: definition.elementId }); + if (!prepare(slot).commit()) return Object.freeze({ status: 'failed' as const }); + return Object.freeze({ status: 'defined' as const, slot }); + }, + clearTargeting: vi.fn(), + getTargeting: vi.fn(() => Object.freeze([])), + setTargeting: vi.fn(), + }); + const googletag = Object.freeze({ + run: (command: (gpt: typeof facade) => unknown) => + Object.freeze({ + status: 'completed', + result: Promise.resolve(command(facade)), + dispose: vi.fn(), + }), + }); + let protectedLatches: readonly PromiseLike[] | undefined; + const protect = vi.fn((latches: readonly PromiseLike[]) => { + expect(Object.isFrozen(latches)).toBe(true); + expect(latches).toHaveLength(2); + expect(request).not.toHaveBeenCalled(); + expect(requestBatch).not.toHaveBeenCalled(); + protectedLatches = latches; + return true; + }); + + await publishInitialGptProjection(document, { + googletag: googletag as never, + navigation, + projection: projection as never, + protect, + pucBridge: Object.freeze({ + registerGamAttempt: vi.fn(() => true), + recordNonemptyGam: vi.fn(() => true), + }), + render: Object.freeze({ + artifacts, + createAttempt: (owner: Parameters[0]['owner']) => + createRenderAttempt({ + artifacts, + owner, + prepareRenderSource: (candidate) => candidate as never, + reservations, + }), + createSlotOperation, + publisherOrigin: window.location.origin, + registerRenderer: vi.fn(), + rendererNonces: Object.freeze({}), + renderWinner: vi.fn(() => false), + reservations, + }) as never, + slots: slots as never, + targeting: targeting as never, + }); + + expect(protect).toHaveBeenCalledOnce(); + expect(request).not.toHaveBeenCalled(); + expect(requestBatch).toHaveBeenCalledOnce(); + const requestInputs = requestBatch.mock.calls[0]?.[0] as readonly Readonly<{ + operation: string; + registeredSlotId: string; + }>[]; + expect(Object.isFrozen(requestInputs)).toBe(true); + expect( + requestInputs.map(({ operation, registeredSlotId }) => [registeredSlotId, operation]) + ).toEqual([ + ['slot-1', 'display'], + ['slot-2', 'display'], + ]); + await Promise.allSettled([...(protectedLatches ?? [])]); + artifacts.dispose(); + reservations.dispose(); + runtime.dispose(); + }); + + it('prepares inertly, activates the reversible guard, and starts only after commit', async () => { + const config = Object.freeze({ scriptUrl: '/integrations/gpt/script' }); + const order: string[] = []; + const start = vi.fn((received: unknown) => { + order.push('start'); + expect(received).toBe(config); + }); + const release = vi.fn(() => order.push('release')); + const activate = vi.fn(() => { + order.push('gpt:activate'); + return release; + }); + let finishPreparation: (() => void) | undefined; + const preparationGate = new Promise((resolve) => { + finishPreparation = resolve; + }); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'gate']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt', 'gate']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ gpt: Object.freeze({ activate, start }) }), + }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + registry.register( + registration('gate', async () => { + order.push('gate:prepare'); + await preparationGate; + return Object.freeze({ activate: () => order.push('gate:activate') }); + }) + ); + const originalDocumentWrite = document.write; + + const installing = registry.install(callbacks(order)); + await vi.waitFor(() => expect(order).toEqual(['gate:prepare'])); + + expect(isGuardInstalled()).toBe(false); + expect(document.write).toBe(originalDocumentWrite); + expect(start).not.toHaveBeenCalled(); + + finishPreparation?.(); + const result = await installing; + + expect(result).toMatchObject({ state: 'kernel' }); + expect(isGuardInstalled()).toBe(true); + expect(document.write).not.toBe(originalDocumentWrite); + expect(order).toEqual([ + 'gate:prepare', + 'core', + 'gpt:activate', + 'gate:activate', + 'publish', + 'start', + 'drain', + ]); + expect(activate).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(isGuardInstalled()).toBe(false); + expect(document.write).toBe(originalDocumentWrite); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('unwinds the GPT guard before fallback when a later activation fails', async () => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'broken']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt', 'broken']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: GPT_CONFIG, + interfaces: Object.freeze({ + gpt: Object.freeze({ activate: () => vi.fn(), start }), + }), + }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + registry.register( + registration('broken', () => ({ + activate: () => { + expect(isGuardInstalled()).toBe(true); + throw new Error('fictional activation failure'); + }, + })) + ); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + + expect(isGuardInstalled()).toBe(false); + expect(start).not.toHaveBeenCalled(); + }); + + it('never installs the guard or starts when reversible GPT activation fails', async () => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: GPT_CONFIG, + interfaces: Object.freeze({ + gpt: Object.freeze({ + activate: () => { + expect(isGuardInstalled()).toBe(false); + throw new Error('fictional observer activation failure'); + }, + start, + }), + }), + }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(isGuardInstalled()).toBe(false); + expect(start).not.toHaveBeenCalled(); + }); + + it('fails preparation without effects when the composition omits the GPT boundary', async () => { + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ config: GPT_CONFIG, interfaces: Object.freeze({}) }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + + expect(isGuardInstalled()).toBe(false); + }); + + it.each([ + [ + 'accessor', + Object.freeze( + Object.defineProperty({}, 'scriptUrl', { + enumerable: true, + get: () => '/publisher-controlled', + }) + ), + ], + ['mutable nested data', Object.freeze({ nested: {} })], + ['non-plain data', Object.freeze({ value: Object.freeze(new Date(0)) })], + ])('rejects %s configuration during inert preparation', async (_caseName, config) => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + gpt: Object.freeze({ activate: () => vi.fn(), start }), + }), + }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(start).not.toHaveBeenCalled(); + expect(isGuardInstalled()).toBe(false); + }); + + it('isolates post-commit startup failure and disposes only the GPT module', async () => { + const start = vi.fn(() => { + throw new Error('fictional GPT startup failure'); + }); + const runtimeFailures: unknown[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt']), + startedAtMs: 0, + now: () => 0, + onRuntimeFailure: (failure) => runtimeFailures.push(failure), + getBindings: () => ({ + config: GPT_CONFIG, + interfaces: Object.freeze({ + gpt: Object.freeze({ activate: () => vi.fn(), start }), + }), + }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'kernel', + runtimeFailures: [{ id: 'gpt', phase: 'after_commit' }], + }); + + expect(start).toHaveBeenCalledTimes(1); + expect(runtimeFailures).toEqual([{ id: 'gpt', phase: 'after_commit' }]); + expect(isGuardInstalled()).toBe(false); + }); + + it('starts fallback only after an attributable TS-owned empty cycle settles the primary', async () => { + const harness = createAttemptHarness(); + const slot = deferredSlotOutcome(); + const order: string[] = []; + let fallback: RenderAttempt | undefined; + const bridgeInput: unknown[] = []; + const bridge = { + registerGamAttempt: vi.fn((input: GptSlotOperationInput) => { + bridgeInput.push(input); + return input.attempt.beginGamClaim(); + }), + recordNonemptyGam: vi.fn(() => true), + }; + const invokeCreateSlotOperation = vi.fn(createSlotOperation); + const started = startGptSlotOperation({ + artifact: harness.artifact, + attempt: harness.primary, + createSlotOperation: invokeCreateSlotOperation, + createFallback: (parentAttemptId) => { + expect(harness.primary.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'gam_empty', + }); + order.push('fallback:create'); + fallback = harness.createAttempt(parentAttemptId); + return Object.freeze({ ok: true as const, value: fallback }); + }, + operation: 'refresh', + owner: harness.primaryOwner, + pucBridge: bridge, + requestClass: 'primary', + reservationId: RESERVATION_ID, + slots: { request: slot.request }, + }); + + expect(started.ok).toBe(true); + expect(invokeCreateSlotOperation).toHaveBeenCalledExactlyOnceWith({ + primary: harness.primary, + createFallback: expect.any(Function), + }); + expect(bridge.registerGamAttempt).toHaveBeenCalledTimes(1); + expect(slot.request).toHaveBeenCalledWith({ + intentId: harness.primary.id, + navigationGeneration: harness.primary.navigationGeneration, + operation: 'refresh', + registeredSlotId: harness.primary.slot, + requestClass: 'primary', + }); + + slot.resolve(Object.freeze({ status: 'empty', responseIdentifier: 'response-one' })); + await Promise.resolve(); + + expect(order).toEqual(['fallback:create']); + expect(harness.primary.snapshot()).toMatchObject({ + state: 'failed', + outcome: { outcome: 'failed', reason: 'gam_empty' }, + }); + expect(started.ok && started.value.snapshot()).toEqual({ settled: false }); + expect(slot.dispose).toHaveBeenCalledTimes(1); + expect(bridge.recordNonemptyGam).not.toHaveBeenCalled(); + + expect(fallback?.fail('gpt_request_failed')).toBe(true); + expect(started.ok && started.value.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'fallback', + primaryAttemptId: harness.primary.id, + primary: { outcome: 'failed', reason: 'gam_empty' }, + fallbackAttemptId: fallback?.id, + fallback: { outcome: 'failed', reason: 'gpt_request_failed' }, + }, + }); + expect(harness.primary.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'gam_empty', + }); + harness.runtime.dispose(); + }); + + it('joins an attributable nonempty cycle to the PUC bridge without settling the operation', async () => { + const harness = createAttemptHarness(); + const slot = deferredSlotOutcome(); + const registered: unknown[] = []; + const nonempty: unknown[] = []; + const bridge = { + registerGamAttempt: vi.fn((input: GptSlotOperationInput) => { + registered.push(input); + return input.attempt.beginGamClaim(); + }), + recordNonemptyGam: vi.fn((input: unknown) => { + nonempty.push(input); + return true; + }), + }; + const input = { + artifact: harness.artifact, + attempt: harness.primary, + createSlotOperation, + operation: 'display' as const, + owner: harness.primaryOwner, + pucBridge: bridge, + requestClass: 'primary', + reservationId: RESERVATION_ID, + slots: { request: slot.request }, + }; + const started = startGptSlotOperation(input); + slot.resolve(Object.freeze({ status: 'rendered', responseIdentifier: 'response-one' })); + await Promise.resolve(); + + expect(nonempty).toEqual(registered); + expect(started.ok && started.value.snapshot()).toEqual({ settled: false }); + expect(harness.primary.snapshot().state).toBe('waiting_for_gam_and_claim'); + expect(slot.dispose).not.toHaveBeenCalled(); + + harness.primary.cancel('superseded'); + expect(slot.dispose).toHaveBeenCalledTimes(1); + harness.runtime.dispose(); + }); + + it.each([ + [{ status: 'failed', reason: 'cycle_unattributable' }, 'cycle_unattributable'], + [{ status: 'failed', reason: 'slot_quarantined' }, 'slot_quarantined'], + [{ status: 'failed', reason: 'gpt_request_timeout' }, 'gpt_request_timeout'], + [{ status: 'failed', reason: 'gpt_completion_timeout' }, 'gpt_completion_timeout'], + [{ status: 'failed', reason: 'external_queue_full' }, 'external_queue_full'], + [{ status: 'failed', reason: 'external_ready_timeout' }, 'external_ready_timeout'], + [{ status: 'cancelled', reason: 'navigation_disposed' }, 'navigation_disposed'], + ] as const)( + 'does not start fallback for non-empty terminal cycle outcome %s', + async (slotOutcome, reason) => { + const harness = createAttemptHarness(); + const slot = deferredSlotOutcome(); + const createFallback = vi.fn(); + const started = startGptSlotOperation({ + artifact: harness.artifact, + attempt: harness.primary, + createSlotOperation, + createFallback, + operation: 'refresh', + owner: harness.primaryOwner, + pucBridge: { + registerGamAttempt: (input) => input.attempt.beginGamClaim(), + recordNonemptyGam: () => true, + }, + requestClass: 'primary', + reservationId: RESERVATION_ID, + slots: { request: slot.request }, + }); + slot.resolve(Object.freeze(slotOutcome) as SlotRequestOutcome); + await Promise.resolve(); + + expect(createFallback).not.toHaveBeenCalled(); + expect(started.ok && started.value.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'primary', + outcome: { reason }, + }, + }); + harness.runtime.dispose(); + } + ); +}); + +describe('ordered GPT winner publication', () => { + function preparePublication() { + const harness = createAttemptHarness(); + const source = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
trusted
', + width: 300, + height: 250, + }); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: harness.primary.slot, + provider: 'trusted', + upstreamBidId: 'upstream-one', + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trusted' }), + rendererReservationId: RESERVATION_ID, + renderSource: source, + }); + const placement = Object.freeze({ + slot: bid.slot, + gamUnitPath: '/123/gpt-slot', + divId: 'gpt-slot', + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({ hb_bidder: 'publisher', pos: 'top' }), + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'gpt-publication', + results: Object.freeze([ + Object.freeze({ + slot: bid.slot, + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + slots: Object.freeze([placement]), + bids: Object.freeze([bid]), + }); + expect(harness.navigation.installAuctionProjection(projection)).toBe(true); + + const order: string[] = []; + const values = new Map(); + const slot = Object.freeze({ + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + order.push(`target:${key}`); + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + }); + const facade: GoogletagFacade = Object.freeze({ + bindingToken: () => Object.freeze({}), + clearTargeting: (target: object, key?: string) => (target as typeof slot).clearTargeting(key), + enableServices: () => undefined, + transactionalDefine: () => Object.freeze({ status: 'discarded' as const }), + display: vi.fn(), + getTargeting: (target: object, key: string) => (target as typeof slot).getTargeting(key), + observeTargeting: () => { + order.push('observe'); + return Object.assign(vi.fn(), { isCurrent: () => true }); + }, + refresh: vi.fn(), + serviceState: () => + Object.freeze({ apiReady: true, initialLoadDisabled: false, pubadsReady: true }), + setTargeting: (target: object, key: string, value: string | readonly string[]) => + (target as typeof slot).setTargeting(key, value), + slotElementId: () => undefined, + slots: () => Object.freeze([slot]), + subscribe: () => vi.fn(), + transactionalReplace: () => Object.freeze({ status: 'destroyed' as const }), + }); + const googletag = Object.freeze({ + ...createNoopGoogletagAdapter(), + bindingStatus: () => 'present' as const, + run: (command: (gpt: Readonly) => Value) => { + let result: Promise; + try { + result = Promise.resolve(command(facade)); + } catch (error) { + result = Promise.reject(error); + } + return Object.freeze({ status: 'present' as const, result, dispose: vi.fn() }); + }, + }); + const targeting = createTargetingService(); + const slotOutcome = deferredSlotOutcome(); + const slots = { + isBoundGptSlot: vi.fn(() => { + order.push('slot:validate'); + return true; + }), + request: vi.fn((input: unknown) => { + order.push('request'); + expect(input).toMatchObject({ + registeredSlotId: bid.slot, + trustedServerOpportunity: { + requestedSlotSizes: [[300, 250]], + trustedServerAuctionId: 'gpt-publication', + }, + }); + const opportunity = (input as { trustedServerOpportunity: object }) + .trustedServerOpportunity; + expect(Object.isFrozen(opportunity)).toBe(true); + expect(Object.isFrozen(Reflect.get(opportunity, 'requestedSlotSizes'))).toBe(true); + expect(harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'renderable', + }); + return slotOutcome.request(); + }), + }; + let bridgeArtifact: CommittedRenderArtifact | undefined; + const pucBridge = { + registerGamAttempt: vi.fn((input: GptSlotOperationInput) => { + order.push('bridge'); + bridgeArtifact = input.artifact; + return input.attempt.beginGamClaim(); + }), + recordNonemptyGam: vi.fn(() => true), + }; + const reservations = { + registerRender: vi.fn((input: Parameters[0]) => { + order.push('reservation'); + return harness.reservations.registerRender(input); + }), + tombstone: harness.reservations.tombstone, + }; + const input: GptWinnerPublicationInput = { + artifact: harness.artifact, + attempt: harness.primary, + bid, + createSlotOperation, + googletag, + navigation: harness.navigation, + operation: 'refresh', + owner: harness.primaryOwner, + placement, + pucBridge, + requestClass: 'primary', + reservations, + slot, + slots, + targeting, + }; + return { + bid, + bridgeArtifact: () => bridgeArtifact, + harness, + input, + order, + pucBridge, + reservations, + slot, + slots, + targeting, + values, + }; + } + + it('publishes reservation, targeting, intent, and request in that exact order', async () => { + const publication = preparePublication(); + + const result = await publishGptWinner(publication.input); + + expect(result.ok).toBe(true); + expect(publication.order).toEqual([ + 'slot:validate', + 'reservation', + 'observe', + 'slot:validate', + 'target:hb_adid', + 'target:hb_bidder', + 'target:pos', + 'slot:validate', + 'bridge', + 'request', + ]); + expect(publication.values).toEqual( + new Map([ + ['hb_adid', [RESERVATION_ID]], + ['hb_bidder', ['trusted']], + ['pos', ['top']], + ]) + ); + publication.bridgeArtifact()?.dispose(); + expect(publication.values.size).toBe(0); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('fails before targeting when exact slot ownership is lost across observation', async () => { + const publication = preparePublication(); + publication.slots.isBoundGptSlot + .mockImplementationOnce(() => { + publication.order.push('slot:validate'); + return true; + }) + .mockImplementation(() => { + publication.order.push('slot:validate'); + return false; + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'slot_unresolved', + }); + expect(publication.order).toEqual(['slot:validate', 'reservation', 'observe', 'slot:validate']); + expect(publication.values.size).toBe(0); + expect(publication.slots.request).not.toHaveBeenCalled(); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('compare-restores targeting when exact slot ownership is lost during writes', async () => { + const publication = preparePublication(); + publication.slots.isBoundGptSlot + .mockImplementationOnce(() => { + publication.order.push('slot:validate'); + return true; + }) + .mockImplementationOnce(() => { + publication.order.push('slot:validate'); + return true; + }) + .mockImplementation(() => { + publication.order.push('slot:validate'); + return false; + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'slot_unresolved', + }); + expect(publication.order).toEqual([ + 'slot:validate', + 'reservation', + 'observe', + 'slot:validate', + 'target:hb_adid', + 'target:hb_bidder', + 'target:pos', + 'slot:validate', + ]); + expect(publication.values.size).toBe(0); + expect(publication.slots.request).not.toHaveBeenCalled(); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + publication.harness.runtime.dispose(); + }); + + it('rolls back targeting and tombstones when the bridge refuses before request', async () => { + const publication = preparePublication(); + publication.pucBridge.registerGamAttempt.mockImplementation(() => { + publication.order.push('bridge'); + return false; + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'gpt_request_failed', + }); + expect(publication.slots.request).not.toHaveBeenCalled(); + expect(publication.values.size).toBe(0); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('rolls back targeting and tombstones when the slot request throws', async () => { + const publication = preparePublication(); + publication.slots.request.mockImplementation(() => { + publication.order.push('request'); + throw new Error('fictional request failure'); + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'gpt_request_failed', + }); + expect(publication.order).toEqual([ + 'slot:validate', + 'reservation', + 'observe', + 'slot:validate', + 'target:hb_adid', + 'target:hb_bidder', + 'target:pos', + 'slot:validate', + 'bridge', + 'request', + ]); + expect(publication.values.size).toBe(0); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('fails before exposure when reservation insertion collides', async () => { + const publication = preparePublication(); + expect( + publication.harness.reservations.registerRender({ + reservationId: RESERVATION_ID, + slot: publication.bid.slot, + navigation: publication.harness.navigation, + attemptId: publication.harness.primary.id, + renderSource: publication.bid.renderSource, + winnerContext: Object.freeze({ selectedCpm: publication.bid.cpm }), + }) + ).toMatchObject({ ok: true }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'reservation_collision', + }); + expect(publication.order).toEqual(['slot:validate', 'reservation']); + expect(publication.values.size).toBe(0); + expect(publication.pucBridge.registerGamAttempt).not.toHaveBeenCalled(); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('compare-restores earlier targeting when a later targeting write throws', async () => { + const publication = preparePublication(); + publication.slot.setTargeting.mockImplementation((key, value) => { + publication.order.push(`target:${key}`); + if (key === 'hb_bidder') throw new Error('fictional targeting failure'); + publication.values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'gpt_request_failed', + }); + expect(publication.values.size).toBe(0); + expect(publication.slots.request).not.toHaveBeenCalled(); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + publication.harness.runtime.dispose(); + }); +}); 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 deleted file mode 100644 index 727300aa1..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts +++ /dev/null @@ -1,550 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import type { TsjsApi } from '../../../src/core/types'; - -type TestWindow = Window & { - googletag?: unknown; - tsjs?: TsjsApi; -}; - -const originalPushState = history.pushState.bind(history); -const originalReplaceState = history.replaceState.bind(history); -const BOOTSTRAP_SOURCE = readFileSync( - path.resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' -); - -function runBootstrap(): void { - new Function(BOOTSTRAP_SOURCE)(); -} - -/** - * Executable lifecycle coverage for `tsjs.scheduleInitialAdInit` — the - * deferred initial-adInit bootstrap the server's `` bids script hands - * off to. These tests run the real scheduler (and, where noted, the real - * `adInit()` and SPA auction hook) instead of string-matching the emitted - * script, so post-load ordering, two-frame deferral, exactly-once invocation, - * and stale-navigation cancellation are all exercised, not just spelled. - */ -describe('scheduleInitialAdInit', () => { - let rafQueue: FrameRequestCallback[]; - let readyState: DocumentReadyState; - let fetchStub: ReturnType; - let popstateHandlers: EventListenerOrEventListenerObject[] = []; - const realAddEventListener = window.addEventListener.bind(window); - - /** Run every queued animation-frame callback (one frame's worth). */ - function flushFrame(): void { - const queued = [...rafQueue]; - rafQueue.length = 0; - queued.forEach((cb) => cb(0)); - } - - /** Flush the microtask/timer queue so the SPA hook's awaits settle. */ - async function flushAsync(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - async function importGptModule() { - return import('../../../src/integrations/gpt/index'); - } - - beforeEach(() => { - vi.resetModules(); - delete (window as TestWindow).tsjs; - delete (window as TestWindow).googletag; - // Restore unwrapped history methods so each module import wraps exactly - // once — without this, wrappers from prior imports accumulate. - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - fetchStub = vi.fn(); - vi.stubGlobal('fetch', fetchStub); - popstateHandlers = []; - vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => { - if (type === 'popstate' && listener) popstateHandlers.push(listener); - return realAddEventListener(type, listener, options); - }); - // Manual animation-frame queue: the scheduler must be observed frame by - // frame, so frames only run when a test flushes them explicitly. - rafQueue = []; - ( - window as { requestAnimationFrame: typeof window.requestAnimationFrame } - ).requestAnimationFrame = ((cb: FrameRequestCallback) => { - rafQueue.push(cb); - return rafQueue.length; - }) as typeof window.requestAnimationFrame; - // Controllable document.readyState (jsdom reports 'complete' by default; - // the scheduler branches on it). - readyState = 'loading'; - Object.defineProperty(document, 'readyState', { - configurable: true, - get: () => readyState, - }); - }); - - afterEach(() => { - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - // Reset jsdom location back to root for the next test. - originalReplaceState({}, '', '/'); - document.body.innerHTML = ''; - popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler)); - popstateHandlers = []; - // Remove the instance properties so the prototype getters are visible again. - delete (document as unknown as Record).readyState; - delete (document as unknown as Record).hidden; - delete (window as unknown as Record).requestAnimationFrame; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('applies the SSR payload and defers adInit until window load plus two animation frames', async () => { - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!({ atf: { hb_pb: '1.00' } }); - // On the initial document (generation 0) the SSR bids are adopted - // immediately — the deferral applies to the GPT work, not the payload. - expect(ts.bids).toEqual({ atf: { hb_pb: '1.00' } }); - expect(adInit).not.toHaveBeenCalled(); - - // load alone must not run it — React commits after the load-time frame. - window.dispatchEvent(new Event('load')); - expect(adInit).not.toHaveBeenCalled(); - - // One frame is not enough: the double rAF exists so the call lands after - // React's post-hydration commit, not inside the load-event frame. - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('runs after two frames without a load event when the document is already complete', async () => { - readyState = 'complete'; - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!(); - // Still never synchronous — even past load, adInit waits two frames. - expect(adInit).not.toHaveBeenCalled(); - - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('invokes adInit exactly once even across duplicate load events and extra frames', async () => { - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!(); - window.dispatchEvent(new Event('load')); - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - flushFrame(); - - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('accepts only the first schedule call', async () => { - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!({ first: { hb_pb: '1.00' } }); - ts.scheduleInitialAdInit!({ second: { hb_pb: '2.00' } }); - expect(ts.bids).toEqual({ first: { hb_pb: '1.00' } }); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('keeps the first schedule claim across bootstrap-to-bundle handoff', async () => { - runBootstrap(); - const ts = (window as TestWindow).tsjs!; - const firstSlot = { - id: 'first_slot', - gam_unit_path: '/123/first', - div_id: 'div-first', - formats: [[300, 250]] as Array<[number, number]>, - }; - const secondSlot = { - id: 'second_slot', - gam_unit_path: '/123/second', - div_id: 'div-second', - formats: [[728, 90]] as Array<[number, number]>, - }; - - ts.scheduleInitialAdInit!({ first_slot: { hb_pb: '1.00' } }, [firstSlot]); - await importGptModule(); - const adInit = vi.fn(); - ts.adInit = adInit; - ts.scheduleInitialAdInit!({ second_slot: { hb_pb: '2.00' } }, [secondSlot]); - - expect(ts.bids).toEqual({ first_slot: { hb_pb: '1.00' } }); - expect(ts.adSlots).toEqual([firstSlot]); - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('still runs after a query-only history change before load', async () => { - // The SPA auction hook identifies routes by pathname only, so a query-only - // replaceState is not a navigation: it must neither trigger an auction nor - // cancel the pending initial adInit. (A URL-equality guard would abort - // here and leave the initial ads uninitialized.) - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!(); - history.replaceState({}, '', '/?utm_source=newsletter'); - await flushAsync(); - expect(fetchStub).not.toHaveBeenCalled(); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('cancels the initial run after an /a → /b → /a round trip before load', async () => { - // Both navigations commit and return to the original URL, so a URL - // comparison would see "unchanged" and run adInit a second time against - // the round-tripped route's live state. The navigation generation counts - // both commits and stands the initial callback down. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!(); - history.pushState({}, '', '/b'); - await flushAsync(); - history.pushState({}, '', '/'); - await flushAsync(); - expect(ts.navGeneration).toBe(2); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('applies server targeting to a publisher-displayed slot before its refresh', async () => { - // A publisher that defined and displayed its GPT slot before window load - // still gets the server-side targeting applied before the refresh that - // delivers it — the deferred run must order setTargeting ahead of the ad - // request it triggers. - const div = document.createElement('div'); - div.id = 'div-atf-sidebar'; - document.body.appendChild(div); - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - clearTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adSlots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { pos: 'atf' }, - }, - ]; - ts.bids = { atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo' } }; - - ts.scheduleInitialAdInit!(); - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); - const targetingOrder = mockSlot.setTargeting.mock.invocationCallOrder[0]!; - const refreshOrder = mockPubads.refresh.mock.invocationCallOrder[0]!; - expect(targetingOrder).toBeLessThan(refreshOrder); - }); - - it('drops the SSR payload when a navigation committed before scheduling', async () => { - // The SPA hook is installed by the synchronous head bundle, so a - // navigation can commit while the document is still streaming — before - // the script calls the scheduler. The SSR payload then belongs - // to a document the page has already left: it must not overwrite the - // live route's bids, and the initial adInit must never fire. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.navGeneration).toBe(1); - ts.bids = { live_slot: { hb_pb: '2.50' } }; - - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }); - expect(ts.bids).toEqual({ live_slot: { hb_pb: '2.50' } }); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('preserves a page-bids response applied before scheduling', async () => { - // Same race, with the SPA navigation's page-bids response fully applied - // (slots + bids + its own adInit) before the scheduler is called: the - // stale SSR payload must not corrupt the applied state, and the route's - // adInit count must stay at the SPA hook's single call. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '3.00' } }, - }), - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.bids).toEqual({ s1: { hb_pb: '3.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }); - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - - expect(ts.bids).toEqual({ s1: { hb_pb: '3.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('applies the SSR slot definitions on the initial document', async () => { - // Under a shared-template mode the head script emits no `tsjs.adSlots`, so the - // `` seam is the only source of slot definitions. They must arrive, or - // `adInit()` iterates an empty list and the page defines no TS slots at all. - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adInit = vi.fn(); - const ssrSlot = { - id: 'ssr_slot', - gam_unit_path: '/123/ssr', - div_id: 'div-ssr', - formats: [[728, 90]] as Array<[number, number]>, - }; - - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, [ssrSlot]); - - expect(ts.adSlots).toEqual([ssrSlot]); - expect(ts.bids).toEqual({ ssr_slot: { hb_pb: '1.00' } }); - }); - - it('preserves head-injected slots when initialSlots is omitted', async () => { - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adInit = vi.fn(); - const headSlot = { - id: 'head_slot', - gam_unit_path: '/123/head', - div_id: 'div-head', - formats: [[300, 250]] as Array<[number, number]>, - }; - ts.adSlots = [headSlot]; - - ts.scheduleInitialAdInit!({ head_slot: { hb_pb: '1.00' } }); - - expect(ts.adSlots).toEqual([headSlot]); - }); - - it('replaces existing slots when initialSlots is explicitly empty', async () => { - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adInit = vi.fn(); - ts.adSlots = [ - { - id: 'stale_slot', - gam_unit_path: '/123/stale', - div_id: 'div-stale', - formats: [[300, 250]], - }, - ]; - - ts.scheduleInitialAdInit!({}, []); - - expect(ts.adSlots).toEqual([]); - }); - - it('drops the SSR slot definitions when a navigation has already committed', async () => { - // The guard covered the bids and the adInit call, but the shared-template seam - // assigned `tsjs.adSlots` on the line *before* calling the scheduler — outside the - // guard entirely. A navigation that committed while the SSR document was still - // streaming therefore kept its own bids and silently lost its slots to the stale - // SSR payload, and the next `adInit()` for that route defined the wrong slots. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.navGeneration).toBe(1); - const liveSlot = { - id: 'live_slot', - gam_unit_path: '/123/live', - div_id: 'div-live', - formats: [[300, 250]] as Array<[number, number]>, - }; - ts.adSlots = [liveSlot]; - - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, [ - { - id: 'ssr_slot', - gam_unit_path: '/123/ssr', - div_id: 'div-ssr', - formats: [[728, 90]], - }, - ]); - - expect(ts.adSlots).toEqual([liveSlot]); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('cancels queued GPT work when a navigation commits before the command queue drains', async () => { - // adInit() only queues its slot work on googletag.cmd, which drains when - // GPT itself loads — possibly long after the generation check that - // guarded the adInit() call. A navigation in that gap must cancel the - // queued mutation, not let it run against the new route's DOM. - const commandQueue: Array<() => void> = []; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - const defineSlot = vi.fn(); - const destroySlots = vi.fn(); - (window as TestWindow).googletag = { - cmd: commandQueue, - defineSlot, - destroySlots, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - document.body.innerHTML = '
'; - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adSlots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - }, - ]; - ts.bids = { atf_sidebar_ad: { hb_pb: '1.00' } }; - - // GPT not loaded yet: the queued work sits in the command array. - ts.adInit!(); - expect(commandQueue.length).toBeGreaterThan(0); - - // A navigation commits before GPT drains the queue. - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.navGeneration).toBe(1); - - // GPT loads and drains the queue: the stale callback must stand down. - commandQueue.splice(0).forEach((fn) => fn()); - expect(defineSlot).not.toHaveBeenCalled(); - expect(destroySlots).not.toHaveBeenCalled(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); - expect(mockPubads.enableSingleRequest).not.toHaveBeenCalled(); - }); - - it('rides animation frames in a hidden document, holding adInit until first view', async () => { - // Browsers do not service rAF while the document is hidden, so a - // background-tab load queues the frames but does not run them until the - // tab is first viewed. This is intended (see installScheduleInitialAdInit): - // the initial request spends its impression on a viewed tab. The scheduler - // must keep riding rAF — not switch to a timer — while hidden. - Object.defineProperty(document, 'hidden', { - configurable: true, - get: () => true, - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!({ atf: { hb_pb: '1.00' } }); - window.dispatchEvent(new Event('load')); - - // Hidden tab: the frame chain is queued but unserviced — adInit waits. - expect(rafQueue.length).toBeGreaterThan(0); - expect(adInit).not.toHaveBeenCalled(); - - // First view: the browser services the pending frames. - flushFrame(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); -}); 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 deleted file mode 100644 index 979b5b0c7..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ /dev/null @@ -1,866 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import type { TsjsApi } from '../../../src/core/types'; - -type TestWindow = Window & { - googletag?: unknown; - tsjs?: TsjsApi; -}; - -const originalPushState = history.pushState.bind(history); -const originalReplaceState = history.replaceState.bind(history); - -async function importGptModule() { - return import('../../../src/integrations/gpt/index'); -} - -/** Flush the microtask/timer queue so onNavigate's awaits settle. */ -async function flushAsync(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)); -} - -/** Allow a MutationObserver-scheduled slot check to run. */ -async function flushAnimationFrame(): Promise { - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - await Promise.resolve(); -} - -describe('installSpaAuctionHook', () => { - let fetchStub: ReturnType; - // popstate listeners registered by each module import. In production the hook - // installs once (guarded by `ts.spaHookInstalled`), but tests wipe - // `window.tsjs` and re-import per test, so without explicit removal the - // listeners accumulate on the shared window and all fire on every dispatch. - let popstateHandlers: EventListenerOrEventListenerObject[] = []; - const realAddEventListener = window.addEventListener.bind(window); - - beforeEach(() => { - vi.resetModules(); - delete (window as TestWindow).tsjs; - // Restore unwrapped history methods so each module import wraps exactly - // once — without this, wrappers from prior imports accumulate. - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - fetchStub = vi.fn(); - vi.stubGlobal('fetch', fetchStub); - popstateHandlers = []; - vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => { - if (type === 'popstate' && listener) popstateHandlers.push(listener); - return realAddEventListener(type, listener, options); - }); - }); - - afterEach(() => { - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - // Reset jsdom location back to root for the next test. - originalReplaceState({}, '', '/'); - // Drop any ad containers inserted by a test so DOM state does not leak. - document.body.innerHTML = ''; - // Remove this test's popstate listener(s) so they do not fire in later tests. - popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler, true)); - popstateHandlers = []; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - 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 - // synchronously for each accepted pathname change, untouched by the - // query-only and same-path history calls the hook ignores. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - expect(ts.navGeneration).toBe(0); - - history.pushState({}, '', '/next-page'); - expect(ts.navGeneration).toBe(1); - - history.replaceState({}, '', '/next-page?utm_source=x'); - expect(ts.navGeneration).toBe(1); - - history.pushState({}, '', '/next-page'); - expect(ts.navGeneration).toBe(1); - await flushAsync(); - }); - - it.each(['bootstrap', 'bundle'] as const)( - 'invalidates unclaimed GPT handoffs before an SPA fetch (%s)', - async (implementation) => { - let resolveFetch: ((response: unknown) => void) | undefined; - fetchStub.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFetch = resolve; - }) - ); - - const routeADiv = document.createElement('div'); - routeADiv.id = 'div-atf-sidebar'; - document.body.appendChild(routeADiv); - const routeASlot = { - getSlotElementId: vi.fn().mockReturnValue(routeADiv.id), - }; - const routeBSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar-2'), - }; - const nativeDefineSlot = vi.fn().mockReturnValue(routeBSlot); - const nativeDisplay = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([routeASlot]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - }; - const staleHandoff = { - gamUnitPath: '/123/atf', - formats: [[300, 250]], - divIdPrefix: 'div-atf-sidebar', - slotElementId: routeADiv.id, - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - const claimedHandoff = { - ...staleHandoff, - slotElementId: 'div-claimed', - publisherClaimed: true, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - [staleHandoff.slotElementId]: staleHandoff, - 'div-atf-sidebar-hydrated': staleHandoff, - [claimedHandoff.slotElementId]: claimedHandoff, - }, - }; - - if (implementation === 'bootstrap') { - const bootstrap = readFileSync( - resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' - ); - window.eval(bootstrap); - } - await importGptModule(); - - routeADiv.remove(); - const routeBDiv = document.createElement('div'); - routeBDiv.id = 'div-atf-sidebar-2'; - document.body.appendChild(routeBDiv); - history.pushState({}, '', '/route-b'); - - const publisherSlot = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => typeof routeBSlot - )('/123/atf', [[300, 250]], routeBDiv.id); - googletag.display(routeBDiv.id); - - expect(publisherSlot).toBe(routeBSlot); - expect(nativeDefineSlot).toHaveBeenCalledWith('/123/atf', [[300, 250]], routeBDiv.id); - expect(nativeDisplay).toHaveBeenCalledWith(routeBDiv.id); - expect((window as TestWindow).tsjs!.gptSlotHandoffs).toEqual({ - [claimedHandoff.slotElementId]: claimedHandoff, - }); - expect(resolveFetch).toBeDefined(); - - resolveFetch!({ ok: true, json: async () => ({ slots: [], bids: {} }) }); - await flushAsync(); - } - ); - - it('fetches page-bids on pushState and applies slots/bids via adInit', async () => { - // The route's ad container already exists, so bids apply immediately. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/next-page?edition=fictional#section'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledWith( - '/_ts/page-bids?path=%2Fnext-page', - expect.objectContaining({ - credentials: 'include', - headers: { 'X-TSJS-Page-Bids': '1' }, - }) - ); - expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); - expect(ts.bids).toEqual({ s1: { hb_pb: '1.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('skips adInit on an empty page-bids response with no prior TS state', async () => { - // A gated page-bids response (template switch, auction gate, or consent - // denial) returns no slots. With no prior TS state to sweep, the hook must - // not call adInit() so a gated navigation cannot activate publisher GPT. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/gated-route'); - await flushAsync(); - - expect(ts.adSlots).toEqual([]); - expect(ts.bids).toEqual({}); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('does not defer cleanup to adInit when an empty response has only prior targeting', async () => { - // Navigation clears prior targeting synchronously, so an empty response - // does not need adInit when TS owns no slots that still require destruction. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - ts.prevSlotTargetingKeys = { 'div-prev': ['hb_pb'] }; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/cleanup-route'); - await flushAsync(); - - expect(ts.adSlots).toEqual([]); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('clears prior targeting before page-bids resolves without touching new publisher targeting', async () => { - let resolveFetch: ((response: Response) => void) | undefined; - fetchStub.mockImplementation( - () => - new Promise((resolve) => { - resolveFetch = resolve; - }) - ); - const element = document.createElement('div'); - element.id = 'div-route-slot'; - document.body.appendChild(element); - const clearTargeting = vi.fn(); - const gptSlot = { - addService: vi.fn().mockReturnThis(), - clearTargeting, - getSlotElementId: vi.fn().mockReturnValue(element.id), - getTargeting: vi.fn().mockReturnValue([]), - setTargeting: vi.fn().mockReturnThis(), - }; - const pubads = { - addEventListener: vi.fn(), - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([gptSlot]), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(gptSlot), - destroySlots: vi.fn(), - display: vi.fn(), - enableServices: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - - const { installSpaAuctionHook, installTsAdInit } = await importGptModule(); - installTsAdInit(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - ts.prevSlotTargetingKeys = { [element.id]: ['ts_route'] }; - ts.divToSlotId = { [element.id]: 'route_slot' }; - - history.pushState({}, '', '/publisher-route'); - - expect(clearTargeting.mock.calls.map(([key]) => key)).toEqual([ - 'hb_pb', - 'hb_bidder', - 'hb_adid', - 'hb_cache_host', - 'hb_cache_path', - 'ts_initial', - 'ts_route', - ]); - expect(ts.prevSlotTargetingKeys).toEqual({}); - expect(ts.divToSlotId).toEqual({}); - const cleanupCallCount = clearTargeting.mock.calls.length; - - ts.firstImpression = { - generation: 1, - nextToken: 0, - fallbackSlots: {}, - slots: { - [element.id]: { - generation: 1, - slotElementId: element.id, - element, - owner: 'publisher', - phase: 'auctioning', - expiresAt: Date.now() + 5000, - publisherAuctions: {}, - }, - }, - }; - gptSlot.setTargeting('hb_adid', 'publisher-current'); - resolveFetch!( - new Response( - JSON.stringify({ - slots: [ - { - id: 'route_slot', - gam_unit_path: '/123/route', - div_id: element.id, - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) - ); - await flushAsync(); - - expect(clearTargeting).toHaveBeenCalledTimes(cleanupCallCount); - expect(gptSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'publisher-current'); - }); - - it('defers applying bids until the route ad container is inserted', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 'late', div_id: 'div-late' }], - bids: { late: { hb_pb: '2.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - // Navigate before the new route's container has rendered. - history.pushState({}, '', '/late-route'); - await flushAsync(); - expect(adInit).not.toHaveBeenCalled(); - expect(ts.adSlots).toBeUndefined(); - - // Container commits — the hook should now apply bids exactly once. - document.body.innerHTML = '
'; - await flushAnimationFrame(); - - expect(ts.adSlots).toEqual([{ id: 'late', div_id: 'div-late' }]); - expect(ts.bids).toEqual({ late: { hb_pb: '2.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('applies bids immediately when a prefix-configured placement exists but is hidden', async () => { - // A breakpoint-hidden placement (mobile-only config while on desktop) has - // rendered its div but the tiered resolver returns no element for it. The - // slot wait must count it as present — otherwise every navigation to the - // route stalls for the full SPA_SLOT_WAIT_MS before applying bids to the - // visible slots, and adInit skips the hidden slot anyway. - document.body.innerHTML = - '
' + ''; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [ - { id: 'visible', div_id: 'div-visible' }, - { id: 'hidden', div_id: 'ad-hidden-' }, - ], - bids: { visible: { hb_pb: '2.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/mixed-route'); - await flushAsync(); - - // Bids apply without waiting out the slot timeout. - expect(ts.adSlots).toEqual([ - { id: 'visible', div_id: 'div-visible' }, - { id: 'hidden', div_id: 'ad-hidden-' }, - ]); - expect(ts.bids).toEqual({ visible: { hb_pb: '2.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('checks for route containers directly in a hidden document', async () => { - vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); - vi.stubGlobal('requestAnimationFrame', undefined); - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 'hidden', div_id: 'div-hidden' }], - bids: { hidden: { hb_pb: '3.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/hidden-route'); - await flushAsync(); - expect(adInit).not.toHaveBeenCalled(); - - document.body.innerHTML = '
'; - await flushAsync(); - - expect(ts.adSlots).toEqual([{ id: 'hidden', div_id: 'div-hidden' }]); - expect(ts.bids).toEqual({ hidden: { hb_pb: '3.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('cancels a pending visible-tab frame when the document becomes hidden', async () => { - let visibility: DocumentVisibilityState = 'visible'; - vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility); - const requestAnimationFrameMock = vi.fn().mockReturnValue(17); - const cancelAnimationFrameMock = vi.fn(); - vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock); - vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameMock); - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 'hidden-late', div_id: 'div-hidden-late' }], - bids: { 'hidden-late': { hb_pb: '3.50' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/hidden-late-route'); - await flushAsync(); - - // A mutation while visible schedules a frame that never runs. - document.body.appendChild(document.createElement('span')); - await flushAsync(); - expect(requestAnimationFrameMock).toHaveBeenCalledTimes(1); - - // The next mutation happens after the document is hidden. It must cancel - // the stale frame and perform the presence check immediately. - visibility = 'hidden'; - document.body.innerHTML = '
'; - await flushAsync(); - - expect(cancelAnimationFrameMock).toHaveBeenCalledWith(17); - expect(adInit).toHaveBeenCalledTimes(1); - expect(ts.adSlots).toEqual([{ id: 'hidden-late', div_id: 'div-hidden-late' }]); - }); - - it('waits for every configured route ad container before applying bids', async () => { - document.body.innerHTML = '
'; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [ - { id: 'first', div_id: 'div-first' }, - { id: 'second', div_id: 'div-second' }, - ], - bids: { - first: { hb_pb: '1.00' }, - second: { hb_pb: '2.00' }, - }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/multi-slot-route'); - await flushAsync(); - - expect(adInit).not.toHaveBeenCalled(); - expect(ts.adSlots).toBeUndefined(); - - const second = document.createElement('div'); - second.id = 'div-second'; - document.body.appendChild(second); - await flushAnimationFrame(); - - expect(ts.adSlots).toEqual([ - { id: 'first', div_id: 'div-first' }, - { id: 'second', div_id: 'div-second' }, - ]); - expect(ts.bids).toEqual({ - first: { hb_pb: '1.00' }, - second: { hb_pb: '2.00' }, - }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('does not fetch when pushState targets the current path', async () => { - await importGptModule(); - - history.pushState({}, '', '/'); - await flushAsync(); - - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('fetches on replaceState navigation', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - - history.replaceState({}, '', '/replaced'); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledWith( - '/_ts/page-bids?path=%2Freplaced', - expect.objectContaining({ credentials: 'include' }) - ); - }); - - it('fetches on popstate navigation to a new path', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - - // Browsers change the URL out-of-band on back/forward, then fire popstate. - // Use the unwrapped history method so the patched handler is not invoked. - originalReplaceState({}, '', '/popped'); - window.dispatchEvent(new PopStateEvent('popstate')); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledWith( - '/_ts/page-bids?path=%2Fpopped', - expect.objectContaining({ credentials: 'include' }) - ); - }); - - it('does not re-fetch on popstate to the same path', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - - history.replaceState({}, '', '/replaced'); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledTimes(1); - - // popstate on the same path (hash-only change or scroll-restoration - // back/forward) must not re-request impressions. - window.dispatchEvent(new PopStateEvent('popstate')); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledTimes(1); - }); - - it('drops a stale response that resolves after a newer navigation started', async () => { - let resolveFirst: ((value: unknown) => void) | undefined; - fetchStub - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirst = resolve; - }) - ) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ slots: [{ id: 'newer', div_id: 'div-newer' }], bids: {} }), - }); - // Container for the newer route exists so its bids apply without waiting. - document.body.innerHTML = '
'; - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/first'); - history.pushState({}, '', '/second'); - await flushAsync(); - - expect(ts.adSlots).toEqual([{ id: 'newer', div_id: 'div-newer' }]); - expect(adInit).toHaveBeenCalledTimes(1); - - // First navigation's response arrives late — it must not overwrite the - // newer route's slots or trigger another adInit. - resolveFirst!({ - ok: true, - json: async () => ({ slots: [{ id: 'stale' }], bids: {} }), - }); - await flushAsync(); - - expect(ts.adSlots).toEqual([{ id: 'newer', div_id: 'div-newer' }]); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('leaves slots and bids untouched on a non-OK response', async () => { - fetchStub.mockResolvedValue({ ok: false, status: 500 }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - ts.adSlots = [{ id: 'existing' } as never]; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/error-page'); - await flushAsync(); - - expect(ts.adSlots).toEqual([{ id: 'existing' }]); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('retries the same path after a failed page-bids fetch (currentPath rollback)', async () => { - // A failed load must roll `currentPath` back so re-navigating to the SAME - // path retries instead of being swallowed by the no-op guard at the top of - // onNavigate. Without the rollback, currentPath would already equal the - // failed path and the second navigation would return early. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValueOnce({ ok: false, status: 500 }).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - // First navigation to the path fails; nothing is applied. - history.pushState({}, '', '/retry-page'); - await flushAsync(); - expect(ts.adSlots).toBeUndefined(); - - // Re-navigate to the same path — the retry must re-fetch and apply. - history.pushState({}, '', '/retry-page'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(2); - expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('does not strand a path that was aborted mid-flight then failed on the next nav', async () => { - // Rapid A→B where A is aborted mid-flight and B then fails must roll - // `currentPath` back to the last *applied* path (here the initial route), - // not to A. Rolling back to A — which never loaded — would leave it behind - // the no-op guard so a later real navigation to A never re-fetches. - document.body.innerHTML = '
'; - let resolveA: ((value: unknown) => void) | undefined; - fetchStub - // A: still in flight when B starts (aborted, never settles on its own). - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveA = resolve; - }) - ) - // B: fails. - .mockResolvedValueOnce({ ok: false, status: 500 }) - // A retried: succeeds. - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - slots: [{ id: 'a', div_id: 'div-a' }], - bids: { a: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - // A starts (left in flight), then B aborts A and fails. - history.pushState({}, '', '/a'); - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.adSlots).toBeUndefined(); - - // Navigate back to /a. With the rollback keyed to the last applied path - // (the initial route) instead of B's previous path (/a), this is NOT - // swallowed by the no-op guard and re-fetches. - history.pushState({}, '', '/a'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(3); - expect(ts.adSlots).toEqual([{ id: 'a', div_id: 'div-a' }]); - expect(adInit).toHaveBeenCalledTimes(1); - - // The original aborted A fetch resolving late must not clobber the retry. - resolveA?.({ ok: true, json: async () => ({ slots: [{ id: 'stale' }], bids: {} }) }); - await flushAsync(); - expect(ts.adSlots).toEqual([{ id: 'a', div_id: 'div-a' }]); - }); - - it('falls back to the deprecated alias when the canonical path is behind Basic Auth', async () => { - // An operator `[[handlers]]` regex broad enough to cover `/_ts` answers the - // canonical path with 401 that no anonymous browser fetch can satisfy. - // Without the fallback, every SPA navigation on that deployment loses ads. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValueOnce({ ok: false, status: 401 }).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/auth-gated'); - await flushAsync(); - - expect(fetchStub).toHaveBeenNthCalledWith( - 1, - '/_ts/page-bids?path=%2Fauth-gated', - expect.anything() - ); - // The fallback marks itself so the server can separate a current bundle - // that could not use the canonical path (a deployment to fix) from a - // pre-rename bundle (which ages out on its own). - expect(fetchStub).toHaveBeenNthCalledWith( - 2, - '/__ts/page-bids?path=%2Fauth-gated', - expect.objectContaining({ headers: { 'X-TSJS-Page-Bids': 'fallback' } }) - ); - expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('falls back to the deprecated alias when the canonical path returns a non-JSON body', async () => { - // A server rolled back to before the rename does not register the canonical - // path, so it falls through to the publisher-origin proxy and answers 200 - // HTML. That is the wrong endpoint, not a transient failure. - document.body.innerHTML = '
'; - fetchStub - .mockResolvedValueOnce({ - ok: true, - json: async () => { - throw new SyntaxError('Unexpected token <'); - }, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - - history.pushState({}, '', '/rolled-back'); - await flushAsync(); - - expect(fetchStub).toHaveBeenNthCalledWith( - 2, - '/__ts/page-bids?path=%2Frolled-back', - expect.anything() - ); - expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); - }); - - it('stays on the alias for the rest of the session once the fallback works', async () => { - // Re-probing the canonical path on every navigation would double the - // request count for the whole session on an affected deployment. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValueOnce({ ok: false, status: 401 }).mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - - history.pushState({}, '', '/first'); - await flushAsync(); - history.pushState({}, '', '/second'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(3); - expect(fetchStub).toHaveBeenNthCalledWith( - 3, - '/__ts/page-bids?path=%2Fsecond', - expect.anything() - ); - }); - - it('does not retry the alias when the endpoint denies the request', async () => { - // 403 is the cross-site gate, which applies to both registered paths — the - // alias would deny it identically, so retrying only burns a request. - fetchStub.mockResolvedValue({ ok: false, status: 403 }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/denied'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(1); - expect(ts.adSlots).toBeUndefined(); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('is idempotent — repeated install calls do not double-fetch a navigation', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - const { installSpaAuctionHook } = await importGptModule(); - // Module init already installed the hook; both calls must be no-ops. - installSpaAuctionHook(); - installSpaAuctionHook(); - - history.pushState({}, '', '/once'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(1); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts new file mode 100644 index 000000000..cead76a52 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createBrowserGoogletagAdapter, + type GoogletagAdapter, + type GoogletagPublisherCallObserver, +} from '../../../src/adapters/googletag'; +import { createGptStartup } from '../../../src/integrations/gpt/startup'; +import { createSlotService, type SlotService } from '../../../src/services/slots'; + +describe('GPT startup bridge', () => { + it('installs one reversible typed observer and delegates all handoff state to slots', () => { + const order: string[] = []; + let observer: GoogletagPublisherCallObserver | undefined; + const release = vi.fn(); + const observePublisherCalls = vi.fn((candidate: GoogletagPublisherCallObserver) => { + observer = candidate; + return release; + }); + const adapter = Object.freeze({ observePublisherCalls }) as unknown as GoogletagAdapter; + const slot = {}; + const slots = Object.freeze({ + claimPublisherGptSlot: vi.fn(() => Object.freeze({ action: 'handoff' as const, slot })), + preparePublisherDisplay: vi.fn(() => Object.freeze({ action: 'suppress' as const })), + preparePublisherRefresh: vi.fn(() => Object.freeze({ action: 'suppress' as const })), + recordPublisherDestruction: vi.fn(() => true), + start: vi.fn(() => { + order.push('slots:start'); + return Object.freeze({ + status: 'present' as const, + result: Promise.resolve(), + dispose: vi.fn(), + }); + }), + }) satisfies Pick< + SlotService, + | 'claimPublisherGptSlot' + | 'preparePublisherDisplay' + | 'preparePublisherRefresh' + | 'recordPublisherDestruction' + | 'start' + >; + const start = vi.fn(() => order.push('external:start')); + const startup = createGptStartup({ googletag: adapter, slots: () => slots, start }); + + expect(startup.activate()).toBe(release); + expect(slots.start).not.toHaveBeenCalled(); + expect(observePublisherCalls).toHaveBeenCalledTimes(1); + expect( + observer?.defineSlot?.({ + adUnitPath: '/publisher', + elementId: 'slot', + initialLoadDisabled: true, + sizes: [300, 250], + }) + ).toEqual({ action: 'handoff', slot }); + expect(observer?.display?.({ initialLoadDisabled: true, target: 'slot' })).toEqual({ + action: 'suppress', + }); + expect( + observer?.refresh?.({ + requestedSlots: undefined, + slots: Object.freeze([slot]), + options: undefined, + }) + ).toEqual({ action: 'suppress' }); + observer?.destroySlots?.({ slots: Object.freeze([slot, {}]) }); + expect(slots.recordPublisherDestruction).toHaveBeenCalledTimes(2); + + const config = Object.freeze({ disableInitialLoad: true }); + startup.start(config); + expect(slots.start).toHaveBeenCalledOnce(); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + expect(order).toEqual(['slots:start', 'external:start']); + }); + + it('keeps reversible activation timer-free and begins readiness only from start', () => { + vi.useFakeTimers(); + const adapter = createBrowserGoogletagAdapter({}); + const slots = createSlotService({ googletag: adapter }); + const startup = createGptStartup({ googletag: adapter, slots: () => slots }); + + const release = startup.activate(); + slots.activate(); + expect(vi.getTimerCount()).toBe(0); + + startup.start(Object.freeze({})); + expect(vi.getTimerCount()).toBe(1); + + release(); + slots.dispose(); + adapter.dispose(); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + }); + + it('installs one optional reversible Prebid refresh policy into the sole GPT observer', () => { + let observer: GoogletagPublisherCallObserver | undefined; + const observePublisherCalls = vi.fn((candidate: GoogletagPublisherCallObserver) => { + observer = candidate; + return vi.fn(); + }); + const adapter = Object.freeze({ observePublisherCalls }) as unknown as GoogletagAdapter; + const slot = Object.freeze({ id: 'slot' }); + const admission = Object.freeze({ commit: vi.fn(), rollback: vi.fn() }); + const completion = Promise.resolve(); + const slots = Object.freeze({ + claimPublisherGptSlot: vi.fn(() => Object.freeze({ action: 'forward' as const })), + preparePublisherDisplay: vi.fn(() => Object.freeze({ action: 'forward' as const })), + preparePublisherRefresh: vi.fn(() => + Object.freeze({ action: 'forward' as const, admission }) + ), + recordPublisherDestruction: vi.fn(), + start: vi.fn(), + }) as unknown as Pick< + SlotService, + | 'claimPublisherGptSlot' + | 'preparePublisherDisplay' + | 'preparePublisherRefresh' + | 'recordPublisherDestruction' + | 'start' + >; + const startup = createGptStartup({ googletag: adapter, slots: () => slots }); + const boundary = startup as typeof startup & { + installRefreshPolicy: ( + policy: Readonly<{ prepare: (call: unknown) => PromiseLike | undefined }> + ) => (() => void) | undefined; + }; + const prepare = vi.fn(() => completion); + const release = boundary.installRefreshPolicy(Object.freeze({ prepare })); + + expect(release).toBeTypeOf('function'); + expect( + boundary.installRefreshPolicy(Object.freeze({ prepare: vi.fn(() => completion) })) + ).toBeUndefined(); + startup.activate(); + const call = Object.freeze({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + options: Object.freeze({ changeCorrelator: false }), + }); + expect(observer?.refresh?.(call)).toEqual({ + action: 'defer', + admission, + completion, + slots: [slot], + }); + expect(prepare).toHaveBeenCalledExactlyOnceWith(call); + + release?.(); + release?.(); + expect(observer?.refresh?.(call)).toEqual({ action: 'forward', admission }); + expect(prepare).toHaveBeenCalledOnce(); + expect(boundary.installRefreshPolicy(Object.freeze({ prepare: vi.fn() }))).toBeTypeOf( + 'function' + ); + }); +}); 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..c7e31c86e 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 @@ -1,8 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { GptDiagnosticsBinding } from '../../../src/core/types'; +import { DiagnosticsSubscriberLimitError } from '../../../src/core/trace'; import { GptDiagnosticsApiController } from '../../../src/integrations/gpt_diagnostics/api'; -import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; +import { + GptDiagnosticsStore, + type GptDiagnosticsStoreSnapshot, +} from '../../../src/integrations/gpt_diagnostics/store'; class FakeBindings { private readonly listeners = new Set<() => void>(); @@ -63,7 +67,7 @@ function fakeApiStore() { evictedRequestCycles: 0, }, })), - subscribe: vi.fn(() => () => undefined), + subscribeCommits: vi.fn(() => () => undefined), recordTrustedServerOpportunity: vi.fn(), recordPrebidRefresh: vi.fn(), recordTrustedServerCreativeRequest: vi.fn((_auctionSlotId: string) => 41), @@ -72,6 +76,16 @@ function fakeApiStore() { }; } +function scheduleInto(tasks: Array<() => void>): (callback: () => void) => () => void { + return (callback) => { + tasks.push(callback); + return () => { + const index = tasks.indexOf(callback); + if (index >= 0) tasks.splice(index, 1); + }; + }; +} + beforeEach(() => { vi.restoreAllMocks(); window.history.replaceState({}, '', '/article?private=value#fragment'); @@ -207,7 +221,7 @@ describe('GptDiagnosticsApiController', () => { }); it('detaches nested attribution evidence from the store snapshot', () => { - const source = { + const source: GptDiagnosticsStoreSnapshot = { gptObserved: true, slots: [ { @@ -274,11 +288,11 @@ describe('GptDiagnosticsApiController', () => { ); expect(cycle?.adManager?.yieldGroupIds).toEqual([10]); expect(cycle?.adManager?.yieldGroupIds).not.toBe( - source.slots[0]?.requests[0]?.adManager.yieldGroupIds + source.slots[0]?.requests[0]?.adManager?.yieldGroupIds ); expect(cycle?.adManager?.companyIds).toEqual([20]); expect(cycle?.adManager?.companyIds).not.toBe( - source.slots[0]?.requests[0]?.adManager.companyIds + source.slots[0]?.requests[0]?.adManager?.companyIds ); expect(snapshot.metadata).not.toBe(source.metadata); expect(snapshot.metadata.droppedAttributionIssues).toBe(2); @@ -385,6 +399,11 @@ describe('GptDiagnosticsApiController', () => { const second = controller.api.snapshot(); expect(second).not.toBe(snapshot); expect(second.slots).not.toBe(snapshot.slots); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.page)).toBe(true); + expect(Object.isFrozen(snapshot.slots)).toBe(true); + expect(Object.isFrozen(snapshot.slots[0]?.requests)).toBe(true); + expect(Object.isFrozen(snapshot.slots[0]?.requests[0]?.durations)).toBe(true); }); it('coalesces store and binding updates and isolates subscribers', () => { @@ -400,7 +419,7 @@ describe('GptDiagnosticsApiController', () => { { show: vi.fn(), hide: vi.fn() }, { now: () => new Date('2026-07-28T00:00:00.000Z'), - schedule: (callback) => scheduled.push(callback), + schedule: scheduleInto(scheduled), } ); controller.api.subscribe(() => { @@ -425,98 +444,136 @@ describe('GptDiagnosticsApiController', () => { expect(listener).toHaveBeenCalledTimes(1); }); - it('gives subscribers isolated copies of one captured snapshot', () => { + it('captures subscriber membership per commit and coalesces to the latest snapshot', () => { const scheduled: Array<() => void> = []; - const sourceListeners = new Set<() => void>(); - const store = fakeApiStore(); - store.subscribe.mockImplementation((listener) => { - sourceListeners.add(listener); - return () => sourceListeners.delete(listener); - }); - store.snapshot.mockReturnValue({ - gptObserved: true, - slots: [ - { - runtimeSlotNumber: 1, - slotElementId: 'ad-slot-example', - adUnitPath: '/example/site/banner', - requests: [ - { - requestNumber: 1, - durations: { requestToResponseMs: 10 }, - incompleteSequence: false, - requestedSlotSizes: [ - [300, 250], - [728, 90], - ], - adManager: { yieldGroupIds: [10], companyIds: [20] }, - trustedServerCreativeFailures: ['cache_fetch_failed' as const], - }, - ], - }, - ], - callbackIssues: [], - attributionIssues: [ - { - reason: 'creative_attempt_expired' as const, - timestampMs: 30, - }, - ], - coverage: emptyCoverage(), - metadata: { - droppedCallbacks: 0, - droppedAttributionIssues: 0, - evictedSlots: 0, - evictedRequestCycles: 0, - }, - }); + const store = new GptDiagnosticsStore({ now: () => 1, schedule: (callback) => callback() }); + const bindings = new FakeBindings(); const controller = new GptDiagnosticsApiController( store, - new FakeBindings(), + bindings, { show: vi.fn(), hide: vi.fn() }, { - now: () => new Date('2026-08-10T00:00:00.000Z'), - schedule: (callback) => scheduled.push(callback), + now: () => new Date('2026-07-28T00:00:00.000Z'), + schedule: scheduleInto(scheduled), } ); - let observedSnapshot: ReturnType | undefined; - - controller.api.subscribe((snapshot) => { - const cycle = snapshot.slots[0]!.requests[0]!; - cycle.durations.requestToResponseMs = 999; - const requestedSlotSizes = cycle.requestedSlotSizes as unknown as Array<[number, number]>; - requestedSlotSizes[0]![0] = 1; - requestedSlotSizes.push([970, 250]); - cycle.adManager!.yieldGroupIds!.push(99); - cycle.trustedServerCreativeFailures!.push('response_post_failed'); - snapshot.attributionIssues?.push({ - reason: 'creative_attempt_unknown', - timestampMs: 40, - }); - snapshot.coverage.slotRequested.observed = 99; - snapshot.metadata.droppedCallbacks = 99; + const first = vi.fn(); + const second = vi.fn(); + const releaseFirst = controller.api.subscribe(first); + const observedSlot = fakeSlot(); + + store.recordSlotRequested(observedSlot); + controller.api.subscribe(second); + releaseFirst(); + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + + store.recordSlotVisibilityChanged(observedSlot, 10); + store.recordSlotVisibilityChanged(observedSlot, 20); + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(second).toHaveBeenCalledOnce(); + expect(second.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + slots: [expect.objectContaining({ currentVisibilityPercentage: 20 })], + }) + ); + }); + + it('excludes a subscriber registered after the store commit but before source microtasks', () => { + const sourceTasks: Array<() => void> = []; + const publicTasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => sourceTasks.push(callback), }); - controller.api.subscribe((snapshot) => { - observedSnapshot = snapshot; + const controller = new GptDiagnosticsApiController( + store, + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() }, + { schedule: scheduleInto(publicTasks) } + ); + + store.recordSlotRequested(fakeSlot()); + const late = vi.fn(); + controller.api.subscribe(late); + while (sourceTasks.length > 0) sourceTasks.shift()?.(); + while (publicTasks.length > 0) publicTasks.shift()?.(); + + expect(late).not.toHaveBeenCalled(); + }); + + it('includes a subscriber registered before the store commit without calling it inline', () => { + const sourceTasks: Array<() => void> = []; + const publicTasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => sourceTasks.push(callback), }); + const controller = new GptDiagnosticsApiController( + store, + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() }, + { schedule: scheduleInto(publicTasks) } + ); + const listener = vi.fn(); + controller.api.subscribe(listener); - for (const listener of sourceListeners) listener(); - expect(scheduled).toHaveLength(1); - scheduled.shift()!(); + store.recordSlotRequested(fakeSlot()); + expect(listener).not.toHaveBeenCalled(); + while (sourceTasks.length > 0) sourceTasks.shift()?.(); + expect(listener).not.toHaveBeenCalled(); + while (publicTasks.length > 0) publicTasks.shift()?.(); - expect(store.snapshot).toHaveBeenCalledTimes(1); - expect(observedSnapshot?.capturedAt).toBe('2026-08-10T00:00:00.000Z'); - const observedCycle = observedSnapshot?.slots[0]?.requests[0]; - expect(observedCycle?.durations.requestToResponseMs).toBe(10); - expect(observedCycle?.requestedSlotSizes).toEqual([ - [300, 250], - [728, 90], - ]); - expect(observedCycle?.adManager?.yieldGroupIds).toEqual([10]); - expect(observedCycle?.trustedServerCreativeFailures).toEqual(['cache_fetch_failed']); - expect(observedSnapshot?.attributionIssues).toHaveLength(1); - expect(observedSnapshot?.coverage.slotRequested.observed).toBe(0); - expect(observedSnapshot?.metadata.droppedCallbacks).toBe(0); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('defers a subscriber registered during dispatch until the next commit', () => { + const publicTasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ now: () => 1, schedule: (callback) => callback() }); + const controller = new GptDiagnosticsApiController( + store, + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() }, + { schedule: scheduleInto(publicTasks) } + ); + const second = vi.fn(); + const first = vi.fn(() => controller.api.subscribe(second)); + controller.api.subscribe(first); + + const observedSlot = fakeSlot(); + store.recordSlotRequested(observedSlot); + expect(first).not.toHaveBeenCalled(); + publicTasks.shift()?.(); + expect(first).toHaveBeenCalledOnce(); + expect(second).not.toHaveBeenCalled(); + + store.recordSlotVisibilityChanged(observedSlot, 10); + publicTasks.shift()?.(); + expect(first).toHaveBeenCalledTimes(2); + expect(second).toHaveBeenCalledOnce(); + }); + + it('validates callability before enforcing the shared 32-subscriber cap', () => { + const controller = new GptDiagnosticsApiController( + new GptDiagnosticsStore({ now: () => 1 }), + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() } + ); + const releases = Array.from({ length: 32 }, () => controller.api.subscribe(() => undefined)); + + expect(() => controller.api.subscribe(null as never)).toThrow(TypeError); + expect(() => controller.api.subscribe(() => undefined)).toThrow( + DiagnosticsSubscriberLimitError + ); + expect(() => controller.api.subscribe(() => undefined)).toThrow( + expect.objectContaining({ code: 'subscriber_capacity', surface: 'gpt' }) + ); + releases[0]?.(); + releases[0]?.(); + expect(controller.api.subscribe(() => undefined)).toEqual(expect.any(Function)); }); it('delegates show and hide without mutating diagnostics data', () => { @@ -587,13 +644,17 @@ describe('GptDiagnosticsApiController', () => { store, bindings, { show: vi.fn(), hide: vi.fn() }, - { schedule: (callback) => scheduled.push(callback) } + { schedule: scheduleInto(scheduled) } ); const listener = vi.fn(); controller.api.subscribe(listener); - controller.destroy(); store.recordSlotRequested(fakeSlot()); + expect(scheduled).toHaveLength(1); + + controller.destroy(); + while (scheduled.length > 0) scheduled.shift()?.(); + store.recordSlotVisibilityChanged(fakeSlot(), 10); bindings.emit(); expect(scheduled).toEqual([]); 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..17cfbe7a7 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 @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { GptDiagnosticsBinding } from '../../../src/core/types'; import type { GptDiagnosticsBindingView } from '../../../src/integrations/gpt_diagnostics/binding'; import { + formatGptDiagnosticsBadgeText, GptDiagnosticsBadgeManager, - gptDiagnosticsBadgeTextForTest, } from '../../../src/integrations/gpt_diagnostics/badges'; import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; @@ -64,6 +64,18 @@ function runFrame(frames: Array<() => void>): void { frame(); } +const gptDiagnosticsBadgeTextForTest = formatGptDiagnosticsBadgeText; + +function queueFrame(frames: Array<() => void>): (callback: () => void) => () => void { + return (callback) => { + frames.push(callback); + return () => { + const index = frames.indexOf(callback); + if (index >= 0) frames.splice(index, 1); + }; + }; +} + beforeEach(() => { document.body.replaceChildren(); Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 }); @@ -105,7 +117,7 @@ describe('GptDiagnosticsBadgeManager', () => { const layer = document.createElement('div'); document.body.append(layer); const manager = new GptDiagnosticsBadgeManager(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); manager.setLayer(layer); runFrame(frames); @@ -245,7 +257,7 @@ describe('GptDiagnosticsBadgeManager', () => { it('uses only GPT-observed lifecycle facts in badge text', () => { expect( - gptDiagnosticsBadgeTextForTest({ + formatGptDiagnosticsBadgeText({ requestNumber: 1, requestedAtMs: 0, responseAtMs: 276, @@ -269,7 +281,7 @@ describe('GptDiagnosticsBadgeManager', () => { 'Filled · Req 728×90, 970×250 · Fill 728×90 · Box 980×270\nResponse 276 ms · Render 42 ms\nViewable after 1 s' ); expect( - gptDiagnosticsBadgeTextForTest({ + formatGptDiagnosticsBadgeText({ requestNumber: 1, isEmpty: false, requestedSlotSizes: [ @@ -291,7 +303,7 @@ describe('GptDiagnosticsBadgeManager', () => { }) ).toBe('Empty'); expect( - gptDiagnosticsBadgeTextForTest({ + formatGptDiagnosticsBadgeText({ requestNumber: 1, renderAtMs: 5, incompleteSequence: false, @@ -299,42 +311,38 @@ describe('GptDiagnosticsBadgeManager', () => { }) ).toBe('Rendered (fill unknown)'); expect( - gptDiagnosticsBadgeTextForTest({ + formatGptDiagnosticsBadgeText({ requestNumber: 1, incompleteSequence: false, durations: {}, }) ).toBe('Pending'); - expect( - gptDiagnosticsBadgeTextForTest({ - requestNumber: 1, - isEmpty: false, - renderAtMs: 5, - incompleteSequence: true, - durations: {}, - }) - ).toBe('Filled\nIncomplete sequence'); - // Assert over rendered text: the delivery vocabulary lives in a helper that - // a `toString()` of this function would not include. - for (const delivery of [ - 'trusted_server_response_sent', - 'trusted_server_selected', - 'candidate_unconfirmed', - 'no_candidate', - 'unknown', - 'pending', - 'not_applicable', - ] as const) { - expect( - gptDiagnosticsBadgeTextForTest({ - requestNumber: 1, - isEmpty: false, - incompleteSequence: false, - durations: {}, - delivery, - }) - ).not.toMatch(/GAM winner|bidder|provenance/i); - } + expect(formatGptDiagnosticsBadgeText.toString()).not.toMatch( + /Trusted Server|GAM winner|Prebid|bidder|provenance/i + ); + }); + + it('rejects a counterfeit bound element instead of accepting DOM-shaped data', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + store.recordSlotRequested(slot('counterfeit')); + const bindings = new FakeBindings(); + const counterfeit = Object.freeze({ + getBoundingClientRect: () => rectangle(10, 10, 300, 250), + isConnected: true, + }) as unknown as HTMLElement; + bindings.set(1, { status: 'bound' }, counterfeit, true); + const layer = document.createElement('div'); + document.body.append(layer); + const manager = new GptDiagnosticsBadgeManager(store, bindings, { + scheduleFrame: queueFrame(frames), + }); + + manager.setLayer(layer); + runFrame(frames); + + expect(layer.querySelector('.tsgd-badge')).toBeNull(); + manager.destroy(); }); it('positions in the overlay layer and coalesces scroll and resize updates', () => { @@ -357,7 +365,7 @@ describe('GptDiagnosticsBadgeManager', () => { const layer = document.createElement('div'); document.body.append(layer); const manager = new GptDiagnosticsBadgeManager(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); manager.setLayer(layer); runFrame(frames); @@ -394,7 +402,7 @@ describe('GptDiagnosticsBadgeManager', () => { const layer = document.createElement('div'); document.body.append(layer); const manager = new GptDiagnosticsBadgeManager(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); manager.setLayer(layer); runFrame(frames); @@ -433,7 +441,7 @@ describe('GptDiagnosticsBadgeManager', () => { MutationObserver: undefined, ResizeObserver: undefined, }), - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); expect(() => { @@ -442,4 +450,56 @@ describe('GptDiagnosticsBadgeManager', () => { }).not.toThrow(); manager.destroy(); }); + + it('cancels a pending badge update on destroy and suppresses a hostile late callback', () => { + const frames: Array<() => void> = []; + const cancel = vi.fn(); + const layer = document.createElement('div'); + document.body.append(layer); + const manager = new GptDiagnosticsBadgeManager(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => { + frames.push(callback); + return cancel; + }, + }); + const update = vi.spyOn(manager, 'update'); + manager.setLayer(layer); + + manager.destroy(); + frames[0]?.(); + + expect(cancel).toHaveBeenCalledOnce(); + expect(update).not.toHaveBeenCalled(); + }); + + it('runs one scheduled badge callback at most once', () => { + const frames: Array<() => void> = []; + const manager = new GptDiagnosticsBadgeManager(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => { + frames.push(callback); + return vi.fn(); + }, + }); + const update = vi.spyOn(manager, 'update'); + manager.setLayer(document.createElement('div')); + + frames[0]?.(); + frames[0]?.(); + + expect(update).toHaveBeenCalledOnce(); + manager.destroy(); + }); + + it('isolates a hostile frame cancellation during destroy', () => { + const cancel = vi.fn(() => { + throw new Error('cancel failed'); + }); + const manager = new GptDiagnosticsBadgeManager(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: () => cancel, + }); + manager.setLayer(document.createElement('div')); + + expect(() => manager.destroy()).not.toThrow(); + expect(cancel).toHaveBeenCalledOnce(); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts index 5a1773a59..5cd81c754 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts @@ -33,13 +33,23 @@ function createStore(): GptDiagnosticsStore { function createManager( store: GptDiagnosticsStore, - scheduleFrame?: (callback: () => void) => void + scheduleFrame?: (callback: () => void) => () => void ): GptDiagnosticsBindingManager { const manager = new GptDiagnosticsBindingManager(store, { scheduleFrame }); managers.push(manager); return manager; } +function queueFrame(frames: Array<() => void>): (callback: () => void) => () => void { + return (callback) => { + frames.push(callback); + return () => { + const index = frames.indexOf(callback); + if (index >= 0) frames.splice(index, 1); + }; + }; +} + function setRectangle( element: HTMLElement, rectangle: { top: number; left: number; width: number; height: number } @@ -108,6 +118,35 @@ describe('GptDiagnosticsBindingManager', () => { }); }); + it('fails closed when the supplied realm has a hostile HTMLElement constructor', () => { + const element = document.createElement('div'); + element.id = 'hostile-realm-slot'; + document.body.append(element); + const store = createStore(); + store.recordSlotRequested(fakeSlot(element.id)); + const hostileWindow = { + CSS: window.CSS, + MutationObserver: undefined, + addEventListener: window.addEventListener.bind(window), + get HTMLElement(): never { + throw new Error('hostile HTMLElement constructor'); + }, + innerHeight: window.innerHeight, + innerWidth: window.innerWidth, + removeEventListener: window.removeEventListener.bind(window), + }; + + const manager = new GptDiagnosticsBindingManager(store, { + window: hostileWindow as never, + }); + managers.push(manager); + + expect(manager.get(1)).toMatchObject({ + binding: { status: 'unbound', reason: 'missing_element' }, + visible: false, + }); + }); + it('reports an empty GPT element ID as unbound without a synthetic DOM ID', () => { const store = createStore(); store.recordSlotRequested(fakeSlot()); @@ -118,7 +157,7 @@ describe('GptDiagnosticsBindingManager', () => { status: 'unbound', reason: 'missing_slot_element_id', }); - expect(store.snapshot().slots[0].slotElementId).toBeUndefined(); + expect(store.snapshot().slots[0]!.slotElementId).toBeUndefined(); }); it('treats duplicate DOM IDs as ambiguous', () => { @@ -258,7 +297,7 @@ describe('GptDiagnosticsBindingManager', () => { document.body.append(element); const store = createStore(); store.recordSlotRequested(fakeSlot('observed')); - const manager = createManager(store, (callback) => frames.push(callback)); + const manager = createManager(store, queueFrame(frames)); const unrelated = document.createElement('div'); unrelated.id = 'unrelated'; @@ -284,7 +323,7 @@ describe('GptDiagnosticsBindingManager', () => { it('coalesces store-driven refreshes to one animation frame', () => { const scheduled: Array<() => void> = []; const store = createStore(); - const manager = createManager(store, (callback) => scheduled.push(callback)); + const manager = createManager(store, queueFrame(scheduled)); const listener = vi.fn(); manager.subscribe(listener); const slot = fakeSlot('scheduled'); @@ -301,4 +340,52 @@ describe('GptDiagnosticsBindingManager', () => { reason: 'missing_element', }); }); + + it('cancels a pending refresh on destroy and suppresses a hostile late callback', () => { + const frames: Array<() => void> = []; + const cancel = vi.fn(); + const store = createStore(); + const manager = createManager(store, (callback) => { + frames.push(callback); + return cancel; + }); + const listener = vi.fn(); + manager.subscribe(listener); + store.recordSlotRequested(fakeSlot('pending-destroy')); + + manager.destroy(); + frames[0]?.(); + + expect(cancel).toHaveBeenCalledOnce(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('runs one scheduled refresh callback at most once', () => { + const frames: Array<() => void> = []; + const store = createStore(); + const manager = createManager(store, (callback) => { + frames.push(callback); + return vi.fn(); + }); + const listener = vi.fn(); + manager.subscribe(listener); + store.recordSlotRequested(fakeSlot('once')); + + frames[0]?.(); + frames[0]?.(); + + expect(listener).toHaveBeenCalledOnce(); + }); + + it('isolates a hostile frame cancellation during destroy', () => { + const store = createStore(); + const cancel = vi.fn(() => { + throw new Error('cancel failed'); + }); + const manager = createManager(store, () => cancel); + store.recordSlotRequested(fakeSlot('hostile-cancel')); + + expect(() => manager.destroy()).not.toThrow(); + expect(cancel).toHaveBeenCalledOnce(); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/data_api.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/data_api.test.ts new file mode 100644 index 000000000..b4cbc00d9 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/data_api.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + GptDiagnosticsDataApiController, + type GptDiagnosticsPresentationControls, +} from '../../../src/integrations/gpt_diagnostics/data_api'; +import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; + +function controls(overrides: Partial = {}) { + return Object.freeze({ + dispose: vi.fn(), + download: vi.fn(), + exportBinding: vi.fn(() => Object.freeze({ status: 'bound' as const })), + hide: vi.fn(), + show: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + ...overrides, + }); +} + +function controller(store = new GptDiagnosticsStore()) { + return new GptDiagnosticsDataApiController(store, { + location: { origin: 'https://publisher.example', pathname: '/article' }, + schedule: (callback) => { + callback(); + return () => undefined; + }, + }); +} + +describe('takeover GPT diagnostics data API', () => { + it('deeply isolates delivery evidence and exports attribution issues', () => { + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const slot = Object.freeze({ + getSlotElementId: () => 'delivery-slot', + getAdUnitPath: () => '/example/delivery-slot', + }); + store.recordSlotRequested(slot, 1); + store.recordSlotRenderEnded( + slot, + { + isEmpty: false, + adManager: { + yieldGroupIds: [10], + companyIds: [20], + }, + }, + 2 + ); + store.recordTrustedServerCreativeRequest('unknown-auction-slot'); + const target = controller(store); + + const snapshot = target.api.snapshot(); + const cycle = snapshot.slots[0]?.requests[0]; + + expect(cycle?.adManager).toEqual({ yieldGroupIds: [10], companyIds: [20] }); + expect(Object.isFrozen(cycle?.adManager)).toBe(true); + expect(Object.isFrozen(cycle?.adManager?.yieldGroupIds)).toBe(true); + expect(Object.isFrozen(cycle?.adManager?.companyIds)).toBe(true); + expect(snapshot.attributionIssues).toEqual([ + expect.objectContaining({ reason: 'creative_request_without_slot' }), + ]); + expect(Object.isFrozen(snapshot.attributionIssues)).toBe(true); + expect(Object.isFrozen(snapshot.attributionIssues?.[0])).toBe(true); + target.destroy(); + }); + + it('coalesces exactly zero, one, and two committed updates to the latest snapshot', () => { + const tasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const target = new GptDiagnosticsDataApiController(store, { + location: { origin: 'https://publisher.example', pathname: '/article' }, + schedule: (callback) => { + tasks.push(callback); + return () => undefined; + }, + }); + const listener = vi.fn(); + target.api.subscribe(listener); + const slot = Object.freeze({ + getSlotElementId: () => 'coalesced-slot', + getAdUnitPath: () => '/example/coalesced-slot', + }); + + expect(tasks).toEqual([]); + store.recordSlotRequested(slot); + expect(tasks).toHaveLength(1); + tasks.shift()?.(); + expect(listener).toHaveBeenCalledOnce(); + + store.recordSlotVisibilityChanged(slot, 10); + store.recordSlotVisibilityChanged(slot, 20); + expect(tasks).toHaveLength(1); + tasks.shift()?.(); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + slots: [expect.objectContaining({ currentVisibilityPercentage: 20 })], + }) + ); + target.destroy(); + }); + + it('captures subscriber ids at commit and suppresses an id unsubscribed before delivery', () => { + const tasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const target = new GptDiagnosticsDataApiController(store, { + location: { origin: 'https://publisher.example', pathname: '/article' }, + schedule: (callback) => { + tasks.push(callback); + return () => undefined; + }, + }); + const first = vi.fn(); + const second = vi.fn(); + const releaseFirst = target.api.subscribe(first); + const slot = Object.freeze({ getSlotElementId: () => 'captured-id-slot' }); + + store.recordSlotRequested(slot); + target.api.subscribe(second); + releaseFirst(); + tasks.shift()?.(); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + + store.recordSlotVisibilityChanged(slot, 25); + tasks.shift()?.(); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledOnce(); + target.destroy(); + }); + + it('keeps a slow listener and its reentrant commit on separate notifier task stacks', () => { + const tasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const target = new GptDiagnosticsDataApiController(store, { + location: { origin: 'https://publisher.example', pathname: '/article' }, + schedule: (callback) => { + tasks.push(callback); + return () => undefined; + }, + }); + const slot = Object.freeze({ getSlotElementId: () => 'slow-listener-slot' }); + let listenerDepth = 0; + let maximumDepth = 0; + const slow = vi.fn(() => { + listenerDepth += 1; + maximumDepth = Math.max(maximumDepth, listenerDepth); + if (slow.mock.calls.length === 1) store.recordSlotVisibilityChanged(slot, 50); + listenerDepth -= 1; + }); + const peer = vi.fn(); + target.api.subscribe(slow); + target.api.subscribe(peer); + + store.recordSlotRequested(slot); + expect(slow).not.toHaveBeenCalled(); + expect(peer).not.toHaveBeenCalled(); + tasks.shift()?.(); + expect(slow).toHaveBeenCalledOnce(); + expect(peer).toHaveBeenCalledOnce(); + expect(tasks).toHaveLength(1); + tasks.shift()?.(); + expect(slow).toHaveBeenCalledTimes(2); + expect(peer).toHaveBeenCalledTimes(2); + expect(maximumDepth).toBe(1); + target.destroy(); + }); + + it('keeps public identity stable and does not spend public subscriber capacity on presentation', () => { + const target = controller(); + const api = target.api; + const presentation = controls(); + const factory = vi.fn((source, attachedApi) => { + expect(source).toEqual( + expect.objectContaining({ + bindingInputs: expect.any(Function), + snapshot: expect.any(Function), + subscribe: expect.any(Function), + }) + ); + expect(attachedApi).toBe(api); + return presentation; + }); + + const detach = target.attachPresentation(factory); + const publicReleases = Array.from({ length: 32 }, () => api.subscribe(vi.fn())); + + expect(() => api.subscribe(vi.fn())).toThrow( + expect.objectContaining({ code: 'subscriber_capacity', surface: 'gpt' }) + ); + expect(target.api).toBe(api); + detach(); + detach(); + expect(presentation.subscribe).toHaveBeenCalledOnce(); + expect(vi.mocked(presentation.subscribe).mock.results[0]?.value).toHaveBeenCalledOnce(); + expect(presentation.dispose).toHaveBeenCalledOnce(); + expect(target.api).toBe(api); + publicReleases.forEach((release) => release()); + target.destroy(); + }); + + it('validates callability before state and rejects reentrant attachment without losing the outer owner', () => { + const target = controller(); + const presentation = controls(); + const nested = vi.fn(); + const detach = target.attachPresentation(() => { + expect(() => target.attachPresentation(nested)).toThrow( + 'GPT diagnostics presentation is unavailable' + ); + return presentation; + }); + + expect(nested).not.toHaveBeenCalled(); + expect(() => target.attachPresentation(null as never)).toThrow( + 'GPT diagnostics presentation factory must be callable' + ); + expect(() => target.attachPresentation(() => controls())).toThrow( + 'GPT diagnostics presentation is unavailable' + ); + detach(); + expect(() => target.attachPresentation(() => controls())).not.toThrow(); + target.destroy(); + }); + + it.each([ + [ + 'malformed controls', + () => Object.freeze({ dispose: vi.fn() }), + 'GPT diagnostics presentation controls are malformed', + ], + [ + 'invalid subscription disposer', + () => controls({ subscribe: vi.fn(() => undefined as never) }), + 'GPT diagnostics presentation disposer is unavailable', + ], + [ + 'throwing subscription', + () => + controls({ + subscribe: vi.fn(() => { + throw new Error('subscription failed'); + }), + }), + 'subscription failed', + ], + ])('rolls back %s without publishing presentation ownership', (_name, create, message) => { + const target = controller(); + const presentation = create(); + + expect(() => target.attachPresentation(() => presentation as never)).toThrow(message); + expect(presentation.dispose).toHaveBeenCalledOnce(); + expect(() => target.attachPresentation(() => controls())).not.toThrow(); + target.destroy(); + }); + + it('releases subscription and controls independently during detach and destroy', () => { + const detachedDispose = vi.fn(); + const target = controller(); + const detach = target.attachPresentation(() => + controls({ + dispose: detachedDispose, + subscribe: vi.fn(() => () => { + throw new Error('hostile subscription release'); + }), + }) + ); + + expect(() => detach()).not.toThrow(); + expect(detachedDispose).toHaveBeenCalledOnce(); + + const destroyedDispose = vi.fn(); + target.attachPresentation(() => + controls({ + dispose: destroyedDispose, + subscribe: vi.fn(() => () => { + throw new Error('hostile destroy release'); + }), + }) + ); + expect(() => target.destroy()).not.toThrow(); + expect(destroyedDispose).toHaveBeenCalledOnce(); + }); + + it.each(['throw', 'invalid disposer'] as const)( + 'contains a notifier scheduler %s and recovers on the next commit', + (failure) => { + const tasks: Array<() => void> = []; + let attempts = 0; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const target = new GptDiagnosticsDataApiController(store, { + location: { origin: 'https://publisher.example', pathname: '/article' }, + schedule: (callback) => { + attempts += 1; + if (attempts === 1) { + if (failure === 'throw') throw new Error('fictional scheduler failure'); + return undefined as never; + } + tasks.push(callback); + return () => undefined; + }, + }); + target.api.subscribe(() => { + throw new Error('fictional subscriber failure'); + }); + const listener = vi.fn(); + target.api.subscribe(listener); + const slot = Object.freeze({ + getSlotElementId: () => 'notifier-slot', + getAdUnitPath: () => '/example/notifier-slot', + }); + + expect(() => store.recordSlotRequested(slot)).not.toThrow(); + expect(listener).not.toHaveBeenCalled(); + expect(() => store.recordSlotVisibilityChanged(slot, 25)).not.toThrow(); + expect(tasks).toHaveLength(1); + expect(() => tasks.shift()?.()).not.toThrow(); + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + slots: [expect.objectContaining({ currentVisibilityPercentage: 25 })], + }) + ); + target.destroy(); + } + ); + + it('makes a retained scheduled notifier inert after controller destruction', () => { + const tasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const target = new GptDiagnosticsDataApiController(store, { + location: { origin: 'https://publisher.example', pathname: '/article' }, + schedule: (callback) => { + tasks.push(callback); + return () => undefined; + }, + }); + const listener = vi.fn(); + target.api.subscribe(listener); + + store.recordSlotRequested(Object.freeze({ getSlotElementId: () => 'stale-notifier-slot' })); + expect(tasks).toHaveLength(1); + target.destroy(); + expect(() => tasks.shift()?.()).not.toThrow(); + expect(listener).not.toHaveBeenCalled(); + }); +}); 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..5971a0380 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,69 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; -import { - installGptDiagnosticsRuntime, - isGptDiagnosticsActive, -} from '../../../src/integrations/gpt_diagnostics'; +import type { GoogletagDiagnosticsFact } from '../../../src/adapters/googletag'; +import { createGptDiagnosticsFactBuffer } from '../../../src/integrations/gpt/diagnostics_facts'; +import { createGptDiagnosticsRuntime } from '../../../src/composition/browser_test_gpt_diagnostics'; import { GPT_DIAGNOSTICS_HOST_ID } from '../../../src/integrations/gpt_diagnostics/overlay'; -interface FakeSlot { - getSlotElementId(): string; - getAdUnitPath(): string; -} - -type Listener = (event: unknown) => void; - -type DiagnosticsTestWindow = NonNullable[0]>; - -const target = window as unknown as DiagnosticsTestWindow; - -function coreApi(): TsjsApi { - return { - version: 'test', - que: [], - addAdUnits: vi.fn(), - renderAdUnit: vi.fn(), - renderAllAdUnits: vi.fn(), - }; -} - -function installGptStub() { - const listeners = new Map(); - const addEventListener = vi.fn((name: string, listener: Listener) => { - const existing = listeners.get(name) ?? []; - existing.push(listener); - listeners.set(name, existing); +function slot(id: string): GoogletagDiagnosticsFact['slot'] { + return Object.freeze({ + token: Object.freeze(Object.create(null) as object), + elementId: id, + adUnitPath: `/example/site/${id}`, }); - const queue = { - push: vi.fn((callback: () => void) => { - callback(); - return 1; - }), - }; - target.googletag = { - cmd: queue, - pubads: () => ({ addEventListener }), - }; - return { - addEventListener, - queue, - emit(name: string, event: Record) { - for (const listener of listeners.get(name) ?? []) listener(event); - }, - }; -} - -function slot(id: string): FakeSlot { - return { - getSlotElementId: () => id, - getAdUnitPath: () => `/example/site/${id}`, - }; } -async function settle(): Promise { - await Promise.resolve(); - await Promise.resolve(); +function fact( + kind: GoogletagDiagnosticsFact['kind'], + observedSlot: GoogletagDiagnosticsFact['slot'], + fields: Partial = {} +): Readonly { + return Object.freeze({ kind, observedAtMs: 1, slot: observedSlot, ...fields }); } beforeEach(() => { @@ -77,181 +32,106 @@ beforeEach(() => { configurable: true, value: { escape: (value: string) => value }, }); - target.tsjs = coreApi(); - delete target.googletag; - delete target.__tsjs_gpt_diagnostics_active; - delete target.__tsjs_gpt_diagnostics_runtime; }); afterEach(() => { - target.__tsjs_gpt_diagnostics_runtime?.destroy(); - delete target.__tsjs_gpt_diagnostics_active; - delete target.__tsjs_gpt_diagnostics_runtime; - delete target.googletag; - delete target.tsjs; vi.unstubAllGlobals(); vi.restoreAllMocks(); document.body.replaceChildren(); }); -describe('GPT diagnostics integration composition', () => { - it('has no inactive side effects', () => { - const originalMutationObserver = window.MutationObserver; +describe('GPT diagnostics runtime', () => { + it('is inert until activation and publishes no legacy global or mutable authority', () => { + const buffer = createGptDiagnosticsFactBuffer(); + const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); + const legacyTarget = window as unknown as Record; - expect(isGptDiagnosticsActive(target)).toBe(false); - expect(installGptDiagnosticsRuntime(target)).toBeUndefined(); - - expect(target.tsjs?.gptDiagnostics).toBeUndefined(); - expect(target.tsjs?.gptDiagnosticsRecorder).toBeUndefined(); - expect(target.googletag).toBeUndefined(); - expect(target.__tsjs_gpt_diagnostics_runtime).toBeUndefined(); + expect(runtime.currentApi()).toBeUndefined(); expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); - expect(window.MutationObserver).toBe(originalMutationObserver); - }); - it('installs one idempotent active runtime and six listeners', () => { - target.__tsjs_gpt_diagnostics_active = true; - const gpt = installGptStub(); - const previousApi = target.tsjs; + const release = runtime.activate(); + const api = runtime.currentApi(); - const first = installGptDiagnosticsRuntime(target); - const second = installGptDiagnosticsRuntime(target); - - expect(first).toBeDefined(); - expect(second).toBe(first); - expect(target.tsjs).toBe(previousApi); - expect(target.tsjs?.gptDiagnostics).toBe(first); - // 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([ - 'recordPrebidRefresh', - 'recordTrustedServerCreativeFailure', - 'recordTrustedServerCreativeRequest', - 'recordTrustedServerCreativeResponse', - 'recordTrustedServerOpportunity', - ]); - expect(gpt.queue.push).toHaveBeenCalledTimes(1); - expect(gpt.addEventListener).toHaveBeenCalledTimes(6); - expect(gpt.addEventListener.mock.calls.map(([name]) => name).sort()).toEqual( - [ - 'impressionViewable', - 'slotOnload', - 'slotRenderEnded', - 'slotRequested', - 'slotResponseReceived', - 'slotVisibilityChanged', - ].sort() + expect(api).toBeDefined(); + expect(Object.isFrozen(api)).toBe(true); + expect(Reflect.ownKeys(api ?? {}).sort()).toEqual( + ['export', 'hide', 'show', 'snapshot', 'subscribe'].sort() + ); + expect(legacyTarget['__tsjs_gpt_diagnostics_active']).toBeUndefined(); + expect(legacyTarget['__tsjs_gpt_diagnostics_runtime']).toBeUndefined(); + expect((legacyTarget['tsjs'] as Record | undefined)?.['gptDiagnostics']).toBe( + undefined ); expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); + + expect(() => runtime.activate()).toThrow(/already active/i); + release(); + release(); + expect(runtime.currentApi()).toBeUndefined(); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); }); - it('keeps capture active while presentation is hidden', async () => { - target.__tsjs_gpt_diagnostics_active = true; - const gpt = installGptStub(); - const api = installGptDiagnosticsRuntime(target)!; + it('replays buffered facts and keeps capture active while presentation is hidden', () => { + const buffer = createGptDiagnosticsFactBuffer(); const observedSlot = slot('hidden-slot'); + buffer.publish(fact('slotRequested', observedSlot)); + buffer.publish(fact('slotResponseReceived', observedSlot)); + const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); + const release = runtime.activate(); + const api = runtime.currentApi(); + if (!api) throw new Error('Expected active diagnostics API'); api.hide(); - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotResponseReceived', { slot: observedSlot }); - gpt.emit('slotRenderEnded', { slot: observedSlot, isEmpty: false, size: [300, 250] }); - await settle(); + buffer.publish( + fact('slotRenderEnded', observedSlot, { + isEmpty: false, + size: Object.freeze([300, 250]), + }) + ); expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); - expect(api.snapshot().slots[0].requests).toHaveLength(1); - expect(api.snapshot().slots[0].requests[0].isEmpty).toBe(false); + expect(api.snapshot().slots[0]?.requests[0]).toMatchObject({ + requestNumber: 1, + isEmpty: false, + size: [300, 250], + }); api.show(); expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).not.toBeNull(); + release(); }); - it('keeps lifecycle, overlap issues, bindings, panel, and export snapshot consistent', async () => { - target.__tsjs_gpt_diagnostics_active = true; - const gpt = installGptStub(); - const element = document.createElement('div'); - element.id = 'lifecycle-slot'; - vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ - left: 20, - top: 100, - right: 320, - bottom: 350, - width: 300, - height: 250, - x: 20, - y: 100, - toJSON: () => ({}), - } as DOMRect); - document.body.append(element); - const api = installGptDiagnosticsRuntime(target)!; - const observedSlot = slot('lifecycle-slot'); - - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotResponseReceived', { slot: observedSlot }); - gpt.emit('slotRenderEnded', { - slot: observedSlot, - isEmpty: false, - size: [300, 250], - isBackfill: true, - }); - gpt.emit('slotOnload', { slot: observedSlot }); - gpt.emit('impressionViewable', { slot: observedSlot }); - gpt.emit('slotVisibilityChanged', { slot: observedSlot, inViewPercentage: 75 }); - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotResponseReceived', { slot: observedSlot }); - gpt.emit('slotRenderEnded', { slot: observedSlot, isEmpty: true }); - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotResponseReceived', { slot: observedSlot }); - await settle(); - - const snapshot = api.snapshot(); - expect(snapshot.slots).toHaveLength(1); - expect(snapshot.slots[0]).toMatchObject({ - slotElementId: 'lifecycle-slot', - adUnitPath: '/example/site/lifecycle-slot', - binding: { status: 'bound' }, - currentVisibilityPercentage: 75, + it('retains adapter callback timing across delayed fact-buffer replay', () => { + const buffer = createGptDiagnosticsFactBuffer(); + const observedSlot = slot('timed-slot'); + buffer.publish(fact('slotRequested', observedSlot, { observedAtMs: 10 })); + buffer.publish(fact('slotResponseReceived', observedSlot, { observedAtMs: 25 })); + const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); + + const release = runtime.activate(); + buffer.publish(fact('slotRenderEnded', observedSlot, { observedAtMs: 30, isEmpty: false })); + + expect(runtime.currentApi()?.snapshot().slots[0]?.requests[0]).toMatchObject({ + requestedAtMs: 10, + responseAtMs: 25, + renderAtMs: 30, + durations: { requestToResponseMs: 15, responseToRenderMs: 5, requestToRenderMs: 20 }, }); - expect(snapshot.slots[0].requests.map((cycle) => cycle.requestNumber)).toEqual([1, 2, 3, 4]); - expect(snapshot.callbackIssues).toContainEqual( - expect.objectContaining({ - kind: 'slotResponseReceived', - disposition: 'ambiguous', - reason: 'overlapping_request_cycles', - }) - ); - expect(snapshot.coverage.slotResponseReceived.observed).toBe( - snapshot.coverage.slotResponseReceived.matched + - snapshot.coverage.slotResponseReceived.unmatched + - snapshot.coverage.slotResponseReceived.ambiguous - ); - expect(document.querySelector(`#${GPT_DIAGNOSTICS_HOST_ID}`)).not.toBeNull(); - expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); - expect(element.getAttributeNames()).toEqual(['id']); + release(); }); - it('removes both diagnostics channels on teardown', () => { - target.__tsjs_gpt_diagnostics_active = true; - installGptStub(); - installGptDiagnosticsRuntime(target); - - expect(target.tsjs?.gptDiagnostics).toBeDefined(); - expect(target.tsjs?.gptDiagnosticsRecorder).toBeDefined(); + it('releases its consumer so replacement activation receives intervening buffered facts', () => { + const buffer = createGptDiagnosticsFactBuffer(); + const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); + const firstRelease = runtime.activate(); + firstRelease(); + const observedSlot = slot('replacement-slot'); + buffer.publish(fact('slotRequested', observedSlot)); - target.__tsjs_gpt_diagnostics_runtime!.destroy(); + const secondRelease = runtime.activate(); - expect(target.tsjs).toBeDefined(); - expect(target.tsjs?.gptDiagnostics).toBeUndefined(); - expect(target.tsjs?.gptDiagnosticsRecorder).toBeUndefined(); - expect(target.__tsjs_gpt_diagnostics_runtime).toBeUndefined(); - }); - - it('leaves no half-initialized API when the core API is unavailable', () => { - target.__tsjs_gpt_diagnostics_active = true; - delete target.tsjs; - - expect(installGptDiagnosticsRuntime(target)).toBeUndefined(); - expect(target.__tsjs_gpt_diagnostics_runtime).toBeUndefined(); - expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + expect(runtime.currentApi()?.snapshot().slots[0]?.slotElementId).toBe('replacement-slot'); + secondRelease(); + buffer.dispose(); }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/module.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/module.test.ts new file mode 100644 index 000000000..b85953f2a --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/module.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createGptDiagnosticsIntegrationRegistration } from '../../../src/integrations/gpt_diagnostics/module'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + PreparedIntegration, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +function capabilities( + subscribe: (listener: (fact: Readonly>) => void) => () => void = vi.fn( + () => vi.fn() + ), + runtimeDocument: unknown = document +) { + return Object.freeze({ + 'runtime.v1': Object.freeze({ document: runtimeDocument }), + 'gpt.events.v1': Object.freeze({ subscribe }), + }); +} + +function prepare( + interfaces: Readonly>, + preparationRelease: Array<() => void> = [] +): PreparedIntegration { + return createGptDiagnosticsIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: Object.freeze({ active: true }), + interfaces, + signal: new AbortController().signal, + onDispose: (callback: () => void) => preparationRelease.push(callback), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; +} + +describe('takeover GPT diagnostics data provider', () => { + it('accepts a valid foreign-realm Document at the registration boundary', () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const foreignDocument = frame.contentDocument; + const foreignWindow = frame.contentWindow; + if (!foreignDocument || !foreignWindow) throw new Error('Expected an iframe document realm'); + const foreignRealm = foreignWindow as Window & typeof globalThis; + expect(foreignDocument).not.toBeInstanceOf(window.Document); + expect(foreignDocument).toBeInstanceOf(foreignRealm.Document); + const releases: Array<() => void> = []; + + expect(() => prepare(capabilities(undefined, foreignDocument), releases)).not.toThrow(); + + releases.reverse().forEach((release) => release()); + frame.remove(); + }); + + it.each([ + ['plain record', Object.freeze({})], + [ + 'counterfeit realm', + Object.freeze({ defaultView: Object.freeze({ Document: class CounterfeitDocument {} }) }), + ], + [ + 'hostile defaultView', + Object.freeze( + Object.defineProperty({}, 'defaultView', { + get: () => { + throw new Error('hostile defaultView'); + }, + }) + ), + ], + ])('rejects a %s runtime Document candidate at the registration boundary', (_name, candidate) => { + expect(() => prepare(capabilities(undefined, candidate))).toThrow( + 'GPT diagnostics requires runtime.v1' + ); + }); + + it('prepares inertly, captures the GPT stream only while active, and exposes no presentation', () => { + const preparationRelease: Array<() => void> = []; + const activationRelease: Array<() => void> = []; + let publish: ((fact: Readonly>) => void) | undefined; + const releaseEvents = vi.fn(); + const subscribe = vi.fn((listener: (fact: Readonly>) => void) => { + publish = listener; + return releaseEvents; + }); + const prepared = prepare(capabilities(subscribe), preparationRelease); + const data = prepared.interfaces?.['gpt_diag.v1'] as { + api: { + snapshot: () => { slots: readonly Readonly>[] }; + }; + attachPresentation: (controls: Readonly>) => () => void; + }; + + expect(Reflect.ownKeys(prepared.interfaces ?? {})).toEqual(['gpt_diag.v1']); + expect(Reflect.ownKeys(data)).toEqual(['api', 'attachPresentation']); + expect(Object.isFrozen(data)).toBe(true); + expect(subscribe).not.toHaveBeenCalled(); + expect(data.api.snapshot().slots).toEqual([]); + expect(document.querySelector('[id^="trusted-server-gpt-diagnostics"]')).toBeNull(); + + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + expect(subscribe).toHaveBeenCalledOnce(); + const fact = Object.freeze({ + kind: 'slotRequested', + observedAtMs: 1, + slot: Object.freeze({ token: Object.freeze({}) }), + }); + publish?.(fact); + expect(data.api.snapshot().slots[0]).toMatchObject({ + runtimeSlotNumber: 1, + binding: { status: 'unbound', reason: 'missing_element' }, + }); + expect(document.querySelector('[id^="trusted-server-gpt-diagnostics"]')).toBeNull(); + + activationRelease.reverse().forEach((callback) => callback()); + expect(releaseEvents).toHaveBeenCalledOnce(); + preparationRelease.reverse().forEach((callback) => callback()); + }); + + it('consumes only runtime.v1 and gpt.events.v1 without inspecting trace capabilities', () => { + const traceRead = vi.fn(() => { + throw new Error('trace capability must remain unobserved'); + }); + const interfaces = Object.freeze( + Object.defineProperty( + { + 'runtime.v1': Object.freeze({ document }), + 'gpt.events.v1': Object.freeze({ subscribe: vi.fn(() => vi.fn()) }), + }, + 'trace.v1', + { enumerable: true, get: traceRead } + ) + ); + + expect(() => prepare(interfaces)).not.toThrow(); + expect(traceRead).not.toHaveBeenCalled(); + }); + + it('pre-registers rollback before the GPT subscription can throw', () => { + const activationRelease: Array<() => void> = []; + const prepared = prepare( + capabilities( + vi.fn(() => { + throw new Error('listener collision'); + }) + ) + ); + + expect(() => + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ) + ).toThrow('listener collision'); + activationRelease.reverse().forEach((callback) => callback()); + const data = prepared.interfaces?.['gpt_diag.v1'] as { + api: { snapshot: () => { slots: readonly unknown[] } }; + }; + expect(data.api.snapshot().slots).toEqual([]); + }); + + it.each([ + ['inactive config', Object.freeze({ active: false }), capabilities()], + ['mutable config', { active: true }, capabilities()], + [ + 'missing GPT event stream', + Object.freeze({ active: true }), + Object.freeze({ + 'runtime.v1': Object.freeze({ document }), + }), + ], + ])('rejects %s during inert preparation', (_name, config, interfaces) => { + const registration = createGptDiagnosticsIntegrationRegistration(RELEASE_ID); + expect(() => + registration.prepare( + Object.freeze({ + config, + interfaces, + signal: new AbortController().signal, + onDispose: vi.fn(), + } satisfies IntegrationPrepareContext) + ) + ).toThrow(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts index 47d82697b..0c25e1ea9 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts @@ -1,438 +1,158 @@ import { describe, expect, it, vi } from 'vitest'; +import type { + GoogletagDiagnosticsFact, + GoogletagDiagnosticsSlotSnapshot, +} from '../../../src/adapters/googletag'; import { GptDiagnosticsObserver, type GptDiagnosticsObserverStore, - type GptObserverWindow, } from '../../../src/integrations/gpt_diagnostics/observer'; -import type { GptDiagnosticsSlotLike } from '../../../src/integrations/gpt_diagnostics/store'; - -const EVENT_NAMES = [ - 'slotRequested', - 'slotResponseReceived', - 'slotRenderEnded', - 'slotOnload', - 'impressionViewable', - 'slotVisibilityChanged', -] as const; - -type EventName = (typeof EVENT_NAMES)[number]; -type EventListener = (event: { slot: GptDiagnosticsSlotLike; [key: string]: unknown }) => void; +import type { GptDiagnosticsFact } from '../../../src/integrations/gpt/diagnostics_facts'; function fakeStore(): GptDiagnosticsObserverStore { return { markGptObserved: vi.fn(), + recordTrustedServerOpportunity: vi.fn(), recordSlotRequested: vi.fn(), recordSlotResponseReceived: vi.fn(), recordSlotRenderEnded: vi.fn(), recordSlotOnload: vi.fn(), recordImpressionViewable: vi.fn(), recordSlotVisibilityChanged: vi.fn(), - recordPublisherRefresh: vi.fn(), - }; -} - -function fakeSlot(): GptDiagnosticsSlotLike { - return { - getSlotElementId: () => 'ad-slot-example', - getAdUnitPath: () => '/example/site/banner', }; } -function controlledGpt() { - const listeners = new Map(); - const addEventListener = vi.fn((name: EventName, listener: EventListener) => { - const current = listeners.get(name) ?? []; - current.push(listener); - listeners.set(name, current); +function fakeSlot(): GoogletagDiagnosticsSlotSnapshot { + return Object.freeze({ + token: Object.freeze(Object.create(null) as object), + elementId: 'ad-slot-example', + adUnitPath: '/example/site/banner', }); - const pubads = { - addEventListener, - refresh: vi.fn(), - }; - const display = vi.fn(); - const defineSlot = vi.fn(); - const cmd: Array<() => void> = []; - const googletag = { - cmd, - pubads: () => pubads, - display, - defineSlot, - }; +} - return { - window: { googletag }, - googletag, - pubads, - listeners, - emit(name: EventName, event: Parameters[0]) { - for (const listener of listeners.get(name) ?? []) listener(event); - }, - }; +function fact( + kind: GoogletagDiagnosticsFact['kind'], + slot: GoogletagDiagnosticsFact['slot'], + fields: Partial = {} +): Readonly { + return Object.freeze({ kind, observedAtMs: 1, slot, ...fields }); } describe('GptDiagnosticsObserver', () => { - it('installs exactly the six documented listeners through googletag.cmd', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - - observer.install(); - - expect(gpt.googletag.cmd).toHaveLength(1); - expect(gpt.pubads.addEventListener).not.toHaveBeenCalled(); - - gpt.googletag.cmd[0](); - - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); - expect(gpt.pubads.addEventListener.mock.calls.map(([name]) => name)).toEqual(EVENT_NAMES); - expect(store.markGptObserved).toHaveBeenCalledTimes(1); - }); - - it('is idempotent before and after command queue execution', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - - observer.install(); - observer.install(); - expect(gpt.googletag.cmd).toHaveLength(1); - - gpt.googletag.cmd[0](); - observer.install(); - gpt.googletag.cmd[0](); - - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); - expect(store.markGptObserved).toHaveBeenCalledTimes(1); - }); - - it('observes publisher refresh slots without changing the delegated call', () => { + it('records requested-size evidence without claiming a GPT callback observation', () => { const store = fakeStore(); - const gpt = controlledGpt(); + const observer = new GptDiagnosticsObserver(store); const slot = fakeSlot(); - const receiver = { refresh: gpt.pubads.refresh }; - const originalRefresh = vi.fn(function (this: unknown, ...args: unknown[]) { - return { receiver: this, args }; - }); - gpt.pubads.refresh = originalRefresh; - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - observer.install(); - gpt.googletag.cmd[0](); - - const result = Reflect.apply(gpt.pubads.refresh, receiver, [ - [slot], - { changeCorrelator: false }, - ]); - - expect(store.recordPublisherRefresh).toHaveBeenCalledWith([slot]); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(result).toEqual({ receiver, args: [[slot], { changeCorrelator: false }] }); - }); - - it('preserves bare, explicit-undefined, malformed, throwing, and nested refresh behavior', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const slot = fakeSlot(); - const secondSlot = fakeSlot(); - const originalRefresh = vi.fn(function (this: unknown, ...args: unknown[]) { - if (args[0] === 'throw') throw new Error('refresh failure'); - return { receiver: this, args }; - }); - const getSlots = vi.fn(() => [slot, null, secondSlot]); - gpt.pubads.refresh = originalRefresh; - Object.assign(gpt.pubads, { getSlots }); - const runtime = { tsjs: {} }; - const observer = new GptDiagnosticsObserver(store, { window: { ...gpt.window, ...runtime } }); - observer.install(); - gpt.googletag.cmd[0](); - observer.install(); - - expect(gpt.pubads.refresh()).toEqual({ receiver: gpt.pubads, args: [] }); - expect(store.recordPublisherRefresh).toHaveBeenLastCalledWith([slot, secondSlot]); - - // GPT treats an omitted, undefined, or null slot list as "refresh all", and - // `refresh(null, opts)` is the documented way to pass options while doing so. - expect(gpt.pubads.refresh(undefined)).toEqual({ receiver: gpt.pubads, args: [undefined] }); - expect(gpt.pubads.refresh(null, { changeCorrelator: false })).toEqual({ - receiver: gpt.pubads, - args: [null, { changeCorrelator: false }], - }); - expect(store.recordPublisherRefresh).toHaveBeenCalledTimes(3); - expect(store.recordPublisherRefresh).toHaveBeenLastCalledWith([slot, secondSlot]); - - getSlots.mockImplementationOnce(() => { - throw new Error('getSlots failure'); - }); - expect(gpt.pubads.refresh()).toEqual({ receiver: gpt.pubads, args: [] }); - expect(() => gpt.pubads.refresh('throw')).toThrow('refresh failure'); - expect( - store.recordPublisherRefresh, - 'a failed slot lookup records nothing' - ).toHaveBeenCalledTimes(3); - - ( - observer as unknown as { window: { tsjs: { prebidRefreshDispatchInProgress?: boolean } } } - ).window.tsjs.prebidRefreshDispatchInProgress = true; - gpt.pubads.refresh([slot]); - expect( - store.recordPublisherRefresh, - 'a Prebid-delegated refresh is not publisher intent' - ).toHaveBeenCalledTimes(3); - expect(originalRefresh).toHaveBeenCalledTimes(6); - }); - - it('delegates when the shared diagnostics context accessor throws', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const slot = fakeSlot(); - const originalRefresh = vi.fn(() => 'delegated'); - gpt.pubads.refresh = originalRefresh; - Object.assign(gpt.pubads, { getSlots: () => [slot] }); - const target = { googletag: gpt.googletag } as unknown as GptObserverWindow; - Object.defineProperty(target, 'tsjs', { - get: () => { - throw new Error('context unavailable'); - }, - }); - const observer = new GptDiagnosticsObserver(store, { window: target }); - observer.install(); - gpt.googletag.cmd[0](); - - expect(gpt.pubads.refresh()).toBe('delegated'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(store.recordPublisherRefresh).not.toHaveBeenCalled(); - }); - - it('creates a command queue and waits when GPT is absent', () => { - const store = fakeStore(); - const delayedWindow: { - googletag?: { - cmd: Array<() => void>; - pubads?: () => { addEventListener: (name: EventName, listener: EventListener) => void }; - }; - } = {}; - const observer = new GptDiagnosticsObserver(store, { window: delayedWindow }); - - observer.install(); - - expect(delayedWindow.googletag?.cmd).toHaveLength(1); - const gpt = controlledGpt(); - delayedWindow.googletag!.pubads = gpt.googletag.pubads; - delayedWindow.googletag!.cmd[0](); - - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); - }); - - it('preserves an already-loaded custom command push contract', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const callbacks: Array<() => void> = []; - const customPush = vi.fn((...next: Array<() => void>) => { - callbacks.push(...next); - for (const callback of next) callback(); - return callbacks.length; - }); - const observer = new GptDiagnosticsObserver(store, { - window: { - googletag: { - cmd: { push: customPush }, - pubads: gpt.googletag.pubads, - }, - }, + const opportunity = Object.freeze({ + kind: 'trustedServerOpportunity' as const, + auctionSlotId: 'fictional-slot', + opportunity: 'renderable_candidate' as const, + requestedSlotSizes: Object.freeze([Object.freeze([300, 250] as const)]), + slot, + trustedServerAuctionId: 'fictional-auction', }); - observer.install(); + observer.consume(opportunity as Readonly); - expect(customPush).toHaveBeenCalledTimes(1); - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); + expect(store.markGptObserved).not.toHaveBeenCalled(); + expect(store.recordTrustedServerOpportunity).toHaveBeenCalledWith( + slot, + 'fictional-slot', + 'renderable_candidate', + 'fictional-auction', + [[300, 250]] + ); }); - it('normalizes allowed callback facts and forwards every event kind', () => { + it('does not claim GPT observation merely because the diagnostics module activated', () => { const store = fakeStore(); - const gpt = controlledGpt(); - const slot = fakeSlot(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - observer.install(); - gpt.googletag.cmd[0](); + const observer = new GptDiagnosticsObserver(store); - gpt.emit('slotRequested', { slot }); - gpt.emit('slotResponseReceived', { slot }); - gpt.emit('slotRenderEnded', { - slot, - isEmpty: false, - size: [300, 250], - isBackfill: true, - slotContentChanged: false, - creativeId: 'must-not-pass-through', - }); - gpt.emit('slotOnload', { slot }); - gpt.emit('impressionViewable', { slot }); - gpt.emit('slotVisibilityChanged', { slot, inViewPercentage: 42 }); + observer.start(); + observer.start(); - expect(store.recordSlotRequested).toHaveBeenCalledWith(slot); - expect(store.recordSlotResponseReceived).toHaveBeenCalledWith(slot); - expect(store.recordSlotRenderEnded).toHaveBeenCalledWith(slot, { - isEmpty: false, - size: [300, 250], - isBackfill: true, - slotContentChanged: false, - }); - expect(store.recordSlotOnload).toHaveBeenCalledWith(slot); - expect(store.recordImpressionViewable).toHaveBeenCalledWith(slot); - expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(slot, 42); + expect(store.markGptObserved).not.toHaveBeenCalled(); }); - it('forwards the Ad Manager identifiers GPT reports for the delivered ad', () => { + it('consumes all six normalized adapter facts', () => { const store = fakeStore(); - const gpt = controlledGpt(); const slot = fakeSlot(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - observer.install(); - gpt.googletag.cmd[0](); - - gpt.emit('slotRenderEnded', { - slot, - isEmpty: false, - lineItemId: 6543210987, - creativeId: 1234567890, - campaignId: 2345678901, - advertiserId: 3456789012, - sourceAgnosticLineItemId: 6543210987, - yieldGroupIds: [11, 12], - companyIds: [], - }); + const observer = new GptDiagnosticsObserver(store); + + observer.consume(fact('slotRequested', slot)); + observer.consume(fact('slotResponseReceived', slot)); + observer.consume( + fact('slotRenderEnded', slot, { + isEmpty: false, + size: Object.freeze([300, 250]), + isBackfill: true, + slotContentChanged: false, + }) + ); + observer.consume(fact('slotOnload', slot)); + observer.consume(fact('impressionViewable', slot)); + observer.consume(fact('slotVisibilityChanged', slot, { inViewPercentage: 42 })); + expect(store.markGptObserved).toHaveBeenCalledOnce(); + expect(store.recordSlotRequested).toHaveBeenCalledWith(slot, 1); + expect(store.recordSlotResponseReceived).toHaveBeenCalledWith(slot, 1); expect(store.recordSlotRenderEnded).toHaveBeenCalledWith( slot, - expect.objectContaining({ - adManager: { - lineItemId: 6543210987, - creativeId: 1234567890, - campaignId: 2345678901, - advertiserId: 3456789012, - sourceAgnosticLineItemId: 6543210987, - yieldGroupIds: [11, 12], - }, - }) + { + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: false, + }, + 1 ); + expect(store.recordSlotOnload).toHaveBeenCalledWith(slot, 1); + expect(store.recordImpressionViewable).toHaveBeenCalledWith(slot, 1); + expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(slot, 42, 1); }); - it('drops malformed Ad Manager identifiers instead of reporting them', () => { + it('passes the immutable adapter callback timestamp through to every store mutation', () => { const store = fakeStore(); - const gpt = controlledGpt(); + const observer = new GptDiagnosticsObserver(store); const slot = fakeSlot(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - observer.install(); - gpt.googletag.cmd[0](); - - gpt.emit('slotRenderEnded', { + const timestamped = Object.freeze({ + kind: 'slotRequested' as const, slot, - isEmpty: false, - lineItemId: null, - creativeId: '1234567890', - campaignId: 0, - advertiserId: 1.5, - yieldGroupIds: 'not-a-list', + observedAtMs: 123.5, }); - expect(store.recordSlotRenderEnded).toHaveBeenCalledWith( - slot, - expect.objectContaining({ adManager: undefined }) - ); + observer.consume(timestamped as Readonly); + + expect(store.recordSlotRequested).toHaveBeenCalledWith(slot, 123.5); }); - it('drops unsupported or invalid rendered sizes', () => { + it('records a malformed visibility fact as unmatched instead of dropping its coverage', () => { const store = fakeStore(); - const gpt = controlledGpt(); - const slot = fakeSlot(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - observer.install(); - gpt.googletag.cmd[0](); + const observer = new GptDiagnosticsObserver(store); - gpt.emit('slotRenderEnded', { slot, isEmpty: false, size: 'fluid' }); + observer.consume(fact('slotVisibilityChanged', fakeSlot())); - expect(store.recordSlotRenderEnded).toHaveBeenCalledWith( - slot, - expect.objectContaining({ size: undefined }) - ); + expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(expect.any(Object), NaN, 1); }); - it('contains callback and Slot accessor failures and warns', () => { + it('contains store and logger failures without interrupting later facts', () => { const store = fakeStore(); vi.mocked(store.recordSlotRequested).mockImplementation(() => { throw new Error('store failed'); }); - const logger = { warn: vi.fn() }; - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window, logger }); - observer.install(); - gpt.googletag.cmd[0](); - const event = { - get slot(): GptDiagnosticsSlotLike { - throw new Error('slot accessor failed'); - }, - }; - - expect(() => gpt.emit('slotRequested', { slot: fakeSlot() })).not.toThrow(); - expect(() => gpt.emit('slotOnload', event)).not.toThrow(); - expect(logger.warn).toHaveBeenCalledTimes(2); - }); - - it('contains command queue and listener installation failures', () => { - const store = fakeStore(); - const logger = { warn: vi.fn() }; - const queueObserver = new GptDiagnosticsObserver(store, { - window: { - googletag: { - cmd: { - push: () => { - throw new Error('queue failed'); - }, - }, - }, - }, - logger, - }); - - expect(() => queueObserver.install()).not.toThrow(); - - const gpt = controlledGpt(); - gpt.pubads.addEventListener.mockImplementation(() => { - throw new Error('listener failed'); - }); - const listenerObserver = new GptDiagnosticsObserver(store, { - window: gpt.window, - logger, - }); - listenerObserver.install(); - - expect(() => gpt.googletag.cmd[0]()).not.toThrow(); - expect(logger.warn).toHaveBeenCalledTimes(2); - }); - - it('wraps only PubAds refresh and leaves unrelated GPT and browser methods intact', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - const references = { - display: gpt.googletag.display, - defineSlot: gpt.googletag.defineSlot, - refresh: gpt.pubads.refresh, - fetch: window.fetch, - XMLHttpRequest: window.XMLHttpRequest, - pushState: window.history.pushState, - replaceState: window.history.replaceState, + const logger = { + warn: vi.fn(() => { + throw new Error('logger failed'); + }), }; + const observer = new GptDiagnosticsObserver(store, { logger }); + const slot = fakeSlot(); - observer.install(); - gpt.googletag.cmd[0](); + expect(() => observer.consume(fact('slotRequested', slot))).not.toThrow(); + expect(() => observer.consume(fact('slotOnload', slot))).not.toThrow(); - expect(gpt.googletag.display).toBe(references.display); - expect(gpt.googletag.defineSlot).toBe(references.defineSlot); - expect(gpt.pubads.refresh).not.toBe(references.refresh); - expect(window.fetch).toBe(references.fetch); - expect(window.XMLHttpRequest).toBe(references.XMLHttpRequest); - expect(window.history.pushState).toBe(references.pushState); - expect(window.history.replaceState).toBe(references.replaceState); + expect(logger.warn).toHaveBeenCalledOnce(); + expect(store.recordSlotOnload).toHaveBeenCalledWith(slot, 1); }); }); 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..b468308d4 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 @@ -75,6 +75,16 @@ function slotArticle(root: ShadowRoot, slotElementId: string): HTMLElement { return article; } +function queueFrame(frames: Array<() => void>): (callback: () => void) => () => void { + return (callback) => { + frames.push(callback); + return () => { + const index = frames.indexOf(callback); + if (index >= 0) frames.splice(index, 1); + }; + }; +} + beforeEach(() => { document.body.replaceChildren(); vi.spyOn(document, 'readyState', 'get').mockReturnValue('complete'); @@ -92,7 +102,7 @@ describe('GptDiagnosticsOverlay', () => { store.recordSlotRequested(slot('early-slot')); let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -235,7 +245,7 @@ describe('GptDiagnosticsOverlay', () => { let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -375,7 +385,7 @@ describe('GptDiagnosticsOverlay', () => { let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -452,7 +462,7 @@ describe('GptDiagnosticsOverlay', () => { const exportSnapshot = vi.fn(); let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onExport: exportSnapshot, onShadowRoot: (createdRoot) => { root = createdRoot; @@ -530,7 +540,7 @@ describe('GptDiagnosticsOverlay', () => { document.body.append(publisherElement); const warn = vi.spyOn(log, 'warn'); const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); runNextFrame(frames); runNextFrame(frames); @@ -559,7 +569,7 @@ describe('GptDiagnosticsOverlay', () => { store.recordSlotRequested(diagnosticSlot); let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -585,7 +595,7 @@ describe('GptDiagnosticsOverlay', () => { const store = new GptDiagnosticsStore(); let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -619,4 +629,50 @@ describe('GptDiagnosticsOverlay', () => { expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); overlay.destroy(); }); + + it('cancels a pending mount frame on destroy and suppresses a hostile late callback', () => { + const frames: Array<() => void> = []; + const cancel = vi.fn(); + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => { + frames.push(callback); + return cancel; + }, + }); + + overlay.destroy(); + frames[0]?.(); + + expect(cancel).toHaveBeenCalledOnce(); + expect(frames).toHaveLength(1); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + }); + + it('runs one scheduled mount callback at most once', () => { + const frames: Array<() => void> = []; + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => { + frames.push(callback); + return vi.fn(); + }, + }); + + frames[0]?.(); + frames[0]?.(); + + expect(frames).toHaveLength(2); + overlay.destroy(); + }); + + it('isolates a hostile frame cancellation during destroy', () => { + const cancel = vi.fn(() => { + throw new Error('cancel failed'); + }); + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: () => cancel, + }); + + expect(() => overlay.destroy()).not.toThrow(); + expect(cancel).toHaveBeenCalledOnce(); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/presentation.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/presentation.test.ts new file mode 100644 index 000000000..c1fa38212 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/presentation.test.ts @@ -0,0 +1,473 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + GptDiagnosticsDataApiController, + type GptDiagnosticsPresentationFactory, +} from '../../../src/integrations/gpt_diagnostics/data_api'; +import { GPT_DIAGNOSTICS_HOST_ID } from '../../../src/integrations/gpt_diagnostics/overlay'; +import { + createDiagnosticsPresentationIntegrationRegistration, + createRenderTracePresentation, +} from '../../../src/integrations/gpt_diagnostics/presentation'; +import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; +import { createRenderTraceStore } from '../../../src/core/trace'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + PreparedIntegration, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); +let frames: Array<() => void> = []; + +beforeEach(() => { + document.body.replaceChildren(); + frames = []; + vi.spyOn(document, 'readyState', 'get').mockReturnValue('complete'); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const frame = () => callback(0); + frames.push(frame); + return frames.length; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + document.body.replaceChildren(); +}); + +function drainFrames(): void { + let count = 0; + while (frames.length > 0 && count < 16) { + frames.shift()?.(); + count += 1; + } + if (frames.length > 0) throw new Error('Diagnostics presentation did not quiesce'); +} + +function presentationInterfaces(runtimeDocument: unknown) { + return Object.freeze({ + 'runtime.v1': Object.freeze({ + boot: () => + Object.freeze({ + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: true, + gpt: Object.freeze({ active: false }), + }), + }), + document: runtimeDocument, + }), + 'trace.presentation.v1': Object.freeze({ + attachPresentation: vi.fn(() => vi.fn()), + }), + }); +} + +function preparePresentation(runtimeDocument: unknown): PreparedIntegration { + return createDiagnosticsPresentationIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces: presentationInterfaces(runtimeDocument), + signal: new AbortController().signal, + onDispose: vi.fn(), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; +} + +describe('deferred GPT diagnostics presentation integration', () => { + it('binds a foreign-realm slot mutation and renders its badge through the registration', async () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const foreignDocument = frame.contentDocument; + const foreignWindow = frame.contentWindow; + if (!foreignDocument || !foreignWindow) throw new Error('Expected an iframe document realm'); + const foreignRealm = foreignWindow as Window & typeof globalThis; + vi.spyOn(foreignDocument, 'readyState', 'get').mockReturnValue('complete'); + Object.defineProperty(foreignWindow, 'CSS', { + configurable: true, + value: Object.freeze({ + escape: (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, '\\$&'), + }), + }); + const replaceChildren = vi.spyOn(foreignRealm.Element.prototype, 'replaceChildren'); + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const slotToken = Object.freeze({ + getAdUnitPath: () => '/foreign/slot', + getSlotElementId: () => 'foreign-mutation-slot', + }); + store.recordSlotRequested(slotToken, 1); + const controller = new GptDiagnosticsDataApiController(store, { + location: foreignWindow.location, + schedule: (callback) => { + callback(); + return () => undefined; + }, + }); + const releases: Array<() => void> = []; + const prepared = createDiagnosticsPresentationIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ + 'runtime.v1': Object.freeze({ + boot: () => + Object.freeze({ + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: false, + gpt: Object.freeze({ active: true }), + }), + }), + document: foreignDocument, + }), + 'trace.presentation.v1': Object.freeze({ + attachPresentation: vi.fn(() => vi.fn()), + }), + 'gpt_diag.v1': Object.freeze({ + api: controller.api, + attachPresentation: (factory: GptDiagnosticsPresentationFactory) => + controller.attachPresentation(factory), + }), + }), + signal: new AbortController().signal, + onDispose: vi.fn(), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (release: () => void) => releases.push(release), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + drainFrames(); + expect(controller.api.snapshot().slots[0]?.binding).toEqual({ + status: 'unbound', + reason: 'missing_element', + }); + + const foreignSlot = foreignDocument.createElement('div'); + foreignSlot.id = 'foreign-mutation-slot'; + vi.spyOn(foreignSlot, 'getBoundingClientRect').mockReturnValue({ + bottom: 260, + height: 250, + left: 10, + right: 310, + top: 10, + width: 300, + x: 10, + y: 10, + toJSON: () => ({}), + } as DOMRect); + expect(foreignSlot).toBeInstanceOf(foreignRealm.Element); + expect(foreignSlot).not.toBeInstanceOf(window.Element); + foreignDocument.body.append(foreignSlot); + + await vi.waitFor(() => { + drainFrames(); + expect(controller.api.snapshot().slots[0]?.binding).toEqual({ status: 'bound' }); + }); + store.recordSlotRenderEnded(slotToken, { isEmpty: false, size: [300, 250] }, 2); + await vi.waitFor(() => { + drainFrames(); + expect(controller.api.snapshot().slots[0]?.requests[0]?.observedSlotSize).toEqual([300, 250]); + }); + const badgeRenderCount = (): number => + replaceChildren.mock.calls.filter((nodes) => + nodes.some( + (node) => node instanceof foreignRealm.Element && node.classList.contains('tsgd-badge') + ) + ).length; + expect(badgeRenderCount()).toBeGreaterThan(0); + + const renderedBeforeDispose = badgeRenderCount(); + releases.reverse().forEach((release) => release()); + expect(replaceChildren.mock.calls[replaceChildren.mock.calls.length - 1]).toEqual([]); + foreignSlot.remove(); + await Promise.resolve(); + drainFrames(); + expect(badgeRenderCount()).toBe(renderedBeforeDispose); + controller.destroy(); + frame.remove(); + }); + + it('accepts a valid foreign-realm Document at the registration boundary', () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const foreignDocument = frame.contentDocument; + const foreignWindow = frame.contentWindow; + if (!foreignDocument || !foreignWindow) throw new Error('Expected an iframe document realm'); + const foreignRealm = foreignWindow as Window & typeof globalThis; + expect(foreignDocument).not.toBeInstanceOf(window.Document); + expect(foreignDocument).toBeInstanceOf(foreignRealm.Document); + + expect(() => preparePresentation(foreignDocument)).not.toThrow(); + + frame.remove(); + }); + + it.each([ + ['plain record', Object.freeze({})], + [ + 'counterfeit realm', + Object.freeze({ defaultView: Object.freeze({ Document: class CounterfeitDocument {} }) }), + ], + [ + 'hostile defaultView', + Object.freeze( + Object.defineProperty({}, 'defaultView', { + get: () => { + throw new Error('hostile defaultView'); + }, + }) + ), + ], + ])('rejects a %s runtime Document candidate at the registration boundary', (_name, candidate) => { + expect(() => preparePresentation(candidate)).toThrow( + 'diagnostics presentation capability graph is malformed' + ); + }); + + it.each(['render trace', 'GPT'] as const)( + 'throws for an invalid %s presentation disposer so the deferred transaction rolls back', + (failedSurface) => { + const traceRelease = vi.fn(); + const gptRelease = vi.fn(); + const attachTrace = vi.fn(() => + failedSurface === 'render trace' ? (undefined as never) : traceRelease + ); + const attachGpt = vi.fn(() => (failedSurface === 'GPT' ? (undefined as never) : gptRelease)); + const releases: Array<() => void> = []; + const runtime = Object.freeze({ + boot: () => + Object.freeze({ + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: true, + gpt: Object.freeze({ active: true }), + }), + }), + document, + }); + const prepared = createDiagnosticsPresentationIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ + 'runtime.v1': runtime, + 'trace.presentation.v1': Object.freeze({ + attachPresentation: attachTrace, + }), + 'gpt_diag.v1': Object.freeze({ + api: Object.freeze({}), + attachPresentation: attachGpt, + }), + }), + signal: new AbortController().signal, + onDispose: vi.fn(), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + + expect(() => + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => releases.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ) + ).toThrow( + failedSurface === 'render trace' + ? 'render trace presentation disposer is unavailable' + : 'GPT diagnostics presentation disposer is unavailable' + ); + expect(attachTrace).toHaveBeenCalledOnce(); + expect(attachGpt).toHaveBeenCalledTimes(failedSurface === 'render trace' ? 0 : 1); + releases.reverse().forEach((release) => release()); + expect(traceRelease).toHaveBeenCalledTimes(failedSurface === 'GPT' ? 1 : 0); + expect(gptRelease).not.toHaveBeenCalled(); + } + ); + + it('uses the target document realm when stamping a render slot', () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const targetDocument = frame.contentDocument; + const targetWindow = frame.contentWindow; + if (!targetDocument || !targetWindow) throw new Error('Expected an iframe document realm'); + const targetRealm = targetWindow as Window & typeof globalThis; + const slot = targetDocument.createElement('div'); + slot.id = 'foreign-realm-slot'; + targetDocument.body.append(slot); + expect(slot).toBeInstanceOf(targetRealm.HTMLElement); + expect(slot).not.toBeInstanceOf(window.HTMLElement); + const renderTrace = createRenderTraceStore(); + renderTrace.record({ + slotId: slot.id, + elementId: slot.id, + path: 'auction', + rendered: true, + injected: true, + visible: true, + }); + + const detach = renderTrace.attachPresentation((source) => + createRenderTracePresentation(source, { document: targetDocument }) + ); + + expect(slot.getAttribute('data-ts-rendered')).toBe('true'); + expect(slot.querySelector('.ts-render-badge')).not.toBeNull(); + detach(); + expect(slot.getAttribute('data-ts-rendered')).toBeNull(); + renderTrace.dispose(); + frame.remove(); + }); + + it('replays and owns render-trace presentation without GPT diagnostics', () => { + const traceTasks: Array<() => void> = []; + const renderTrace = createRenderTraceStore({ + schedule: (callback) => { + traceTasks.push(callback); + return () => { + const index = traceTasks.indexOf(callback); + if (index >= 0) traceTasks.splice(index, 1); + }; + }, + }); + const diagnostics = renderTrace.diagnostics; + const slot = document.createElement('div'); + slot.id = 'render-overlay-only-slot'; + document.body.append(slot); + renderTrace.record({ + slotId: slot.id, + elementId: slot.id, + path: 'ssat', + rendered: true, + injected: true, + visible: true, + }); + const activationRelease: Array<() => void> = []; + const runtime = Object.freeze({ + boot: () => + Object.freeze({ + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: true, + gpt: Object.freeze({ active: false }), + }), + }), + document, + }); + const trace = Object.freeze({ + attachPresentation: renderTrace.attachPresentation, + }); + const prepared = createDiagnosticsPresentationIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ + 'runtime.v1': runtime, + 'trace.presentation.v1': trace, + }), + signal: new AbortController().signal, + onDispose: vi.fn(), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + + expect(traceTasks).toEqual([]); + expect(slot.getAttributeNames().filter((name) => name.startsWith('data-ts-'))).toEqual([]); + expect(document.getElementById('ts-render-trace-panel')).toBeNull(); + + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + + expect(traceTasks).toEqual([]); + expect(slot.getAttribute('data-ts-rendered')).toBe('true'); + expect(slot.querySelector('.ts-render-badge')).not.toBeNull(); + expect(document.getElementById('ts-render-trace-panel')?.textContent).toContain(slot.id); + expect(renderTrace.diagnostics).toBe(diagnostics); + + renderTrace.enrich(1, { bidder: 'later-bidder' }); + expect(traceTasks).toHaveLength(1); + activationRelease.reverse().forEach((release) => release()); + expect(traceTasks).toEqual([]); + expect(slot.getAttributeNames().filter((name) => name.startsWith('data-ts-'))).toEqual([]); + expect(slot.querySelector('.ts-render-badge')).toBeNull(); + expect(document.getElementById('ts-render-trace-panel')).toBeNull(); + expect(renderTrace.diagnostics).toBe(diagnostics); + renderTrace.dispose(); + }); + + it('owns all DOM presentation after activation and releases it without replacing the API', () => { + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const renderTrace = createRenderTraceStore(); + const controller = new GptDiagnosticsDataApiController(store, { + location: window.location, + schedule: (callback) => { + callback(); + return () => undefined; + }, + }); + const api = controller.api; + const data = Object.freeze({ + api, + attachPresentation: (factory: GptDiagnosticsPresentationFactory) => + controller.attachPresentation(factory), + }); + const preparationRelease: Array<() => void> = []; + const activationRelease: Array<() => void> = []; + const prepared = createDiagnosticsPresentationIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ + 'runtime.v1': Object.freeze({ + boot: () => + Object.freeze({ + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: false, + gpt: Object.freeze({ active: true }), + }), + }), + document, + }), + 'trace.presentation.v1': Object.freeze({ + attachPresentation: renderTrace.attachPresentation, + }), + 'gpt_diag.v1': data, + }), + signal: new AbortController().signal, + onDispose: (callback: () => void) => preparationRelease.push(callback), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + + expect(controller.api).toBe(api); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + expect(frames).toEqual([]); + + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + drainFrames(); + + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).not.toBeNull(); + expect(controller.api).toBe(api); + activationRelease.reverse().forEach((release) => release()); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + expect(controller.api).toBe(api); + + preparationRelease.reverse().forEach((release) => release()); + controller.destroy(); + renderTrace.dispose(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/slot_size_observer.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/slot_size_observer.test.ts index 3709221ab..7b958fbb1 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/slot_size_observer.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/slot_size_observer.test.ts @@ -74,7 +74,7 @@ describe('GptDiagnosticsSlotSizeObserver', () => { const getBoundingClientRect = vi.spyOn(element, 'getBoundingClientRect'); getBoundingClientRect.mockReturnValue({ width: 728.4, height: 90.5 } as DOMRect); const requests = [cycle(1, false)]; - requests[0].size = [1, 1]; + requests[0]!.size = [1, 1]; const store = { snapshot: () => snapshot(requests), recordObservedSlotSize: vi.fn(), @@ -91,7 +91,7 @@ describe('GptDiagnosticsSlotSizeObserver', () => { }); expect(store.recordObservedSlotSize).toHaveBeenCalledWith(1, 1, [728, 91]); - expect(requests[0].size).toEqual([1, 1]); + expect(requests[0]!.size).toEqual([1, 1]); getBoundingClientRect.mockReturnValue({ width: 969.6, height: 250.2 } as DOMRect); ResizeObserverMock.instances[ResizeObserverMock.instances.length - 1]!.emit(element); @@ -248,10 +248,10 @@ describe('GptDiagnosticsSlotSizeObserver', () => { } as unknown as SlotSizeTestWindow, scheduleFrame: (callback) => frames.push(callback), }); - const firstObserver = ResizeObserverMock.instances[0]; + const firstObserver = ResizeObserverMock.instances[0]!; requests.push(cycle(2, false)); - listeners[0](); + listeners[0]!(); frames.shift()!(); firstObserver.emit(element); while (frames.length > 0) frames.shift()!(); 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..992938a55 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 @@ -10,7 +10,6 @@ import { MAX_REQUESTED_SLOT_SIZES, MAX_REQUEST_CYCLES_PER_SLOT, MAX_TRUSTED_SERVER_ASSOCIATIONS, - REQUEST_PATH_ATTRIBUTION_WINDOW_MS, TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS, type GptDiagnosticsSlotLike, } from '../../../src/integrations/gpt_diagnostics/store'; @@ -30,32 +29,6 @@ function associateSlot( store.recordTrustedServerOpportunity(slot, auctionSlotId, 'renderable_candidate'); } -function recordCompletedAttempts( - store: GptDiagnosticsStore, - count: number, - prefix: string -): number[] { - const attemptIds: number[] = []; - let remaining = count; - - for (let slotIndex = 0; remaining > 0; slotIndex += 1) { - const slot = fakeSlot(`${prefix}-slot-${slotIndex}`); - const auctionSlotId = `${prefix}-auction-${slotIndex}`; - associateSlot(store, slot, auctionSlotId); - const cycles = Math.min(MAX_REQUEST_CYCLES_PER_SLOT, remaining); - for (let cycleIndex = 0; cycleIndex < cycles; cycleIndex += 1) { - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest(auctionSlotId); - expect(attemptId).toEqual(expect.any(Number)); - attemptIds.push(attemptId!); - store.recordTrustedServerCreativeResponse(attemptId!); - remaining -= 1; - } - } - - return attemptIds; -} - function assertCoverageEquation(store: GptDiagnosticsStore): void { for (const counters of Object.values(store.snapshot().coverage)) { expect(counters.observed).toBe(counters.matched + counters.unmatched + counters.ambiguous); @@ -67,6 +40,17 @@ function last(values: readonly T[]): T | undefined { } describe('GptDiagnosticsStore', () => { + it('adopts an explicit first-display runtime slot number and advances its high-water value', () => { + const store = new GptDiagnosticsStore(); + const adoptedToken = {}; + + store.recordSlotRequested({ token: adoptedToken, runtimeSlotNumber: 5, elementId: 'adopted' }); + store.recordSlotRequested({ token: {}, elementId: 'fresh' }); + + expect(store.snapshot().slots.map(({ runtimeSlotNumber }) => runtimeSlotNumber)).toEqual([ + 5, 6, + ]); + }); it('uses the explicit creative-attempt and attribution retention bounds', () => { expect(CREATIVE_ATTEMPT_WINDOW_MS).toBe(30_000); expect(MAX_CREATIVE_ATTEMPTS).toBe(128); @@ -98,8 +82,8 @@ describe('GptDiagnosticsStore', () => { store.recordSlotVisibilityChanged(slot, 20); const snapshot = store.snapshot(); - const recordedSlot = snapshot.slots[0]; - const cycle = recordedSlot.requests[0]; + const recordedSlot = snapshot.slots[0]!; + const cycle = recordedSlot.requests[0]!; expect(snapshot.gptObserved).toBe(true); expect(recordedSlot).toMatchObject({ @@ -133,51 +117,20 @@ describe('GptDiagnosticsStore', () => { assertCoverageEquation(store); }); - it('matches the unique response-bearing load that arrives before render', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('early-load'); + it('uses adapter callback times even when buffered delivery occurs much later', () => { + const store = new GptDiagnosticsStore({ now: () => 9_999 }); + const slot = fakeSlot('buffered-slot'); - store.recordSlotRequested(slot); - now = 2; - store.recordSlotResponseReceived(slot); - now = 3; - store.recordSlotOnload(slot); - now = 4; - store.recordSlotRenderEnded(slot, { isEmpty: false }); + store.recordSlotRequested(slot, 10); + store.recordSlotResponseReceived(slot, 25); + store.recordSlotRenderEnded(slot, { isEmpty: false }, 30); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle).toMatchObject({ - loadAtMs: 3, - loadObservedBeforeRender: true, - incompleteSequence: false, + expect(store.snapshot().slots[0]?.requests[0]).toMatchObject({ + requestedAtMs: 10, + responseAtMs: 25, + renderAtMs: 30, + durations: { requestToResponseMs: 15, responseToRenderMs: 5, requestToRenderMs: 20 }, }); - expect(cycle.durations.renderToLoadMs).toBeUndefined(); - expect(store.snapshot().callbackIssues).not.toContainEqual( - expect.objectContaining({ kind: 'slotOnload', reason: 'invalid_event_order' }) - ); - }); - - it('keeps no-response loads unmatched and overlapping response-bearing loads ambiguous', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const missingResponse = fakeSlot('missing-load-response'); - store.recordSlotRequested(missingResponse); - store.recordSlotOnload(missingResponse); - const overlapping = fakeSlot('overlapping-load-response'); - now = 2; - store.recordSlotRequested(overlapping); - now = 3; - store.recordSlotResponseReceived(overlapping); - now = 4; - store.recordSlotRequested(overlapping); - now = 5; - store.recordSlotResponseReceived(overlapping); - now = 6; - store.recordSlotOnload(overlapping); - - expect(store.snapshot().coverage.slotOnload).toMatchObject({ unmatched: 1, ambiguous: 1 }); - assertCoverageEquation(store); }); it('matches load and viewability after a render with unknown fill state', () => { @@ -209,9 +162,9 @@ describe('GptDiagnosticsStore', () => { viewableAtMs: 8, durations: { renderToLoadMs: 2, renderToViewableMs: 5 }, }); - expect(emptyCycle.loadAtMs).toBe(8); - expect(emptyCycle.viewableAtMs).toBeUndefined(); - expect(store.snapshot().coverage.slotOnload).toMatchObject({ matched: 2, unmatched: 0 }); + expect(emptyCycle!.loadAtMs).toBeUndefined(); + expect(emptyCycle!.viewableAtMs).toBeUndefined(); + expect(store.snapshot().coverage.slotOnload).toMatchObject({ matched: 1, unmatched: 1 }); expect(store.snapshot().coverage.impressionViewable).toMatchObject({ matched: 1, unmatched: 1, @@ -242,10 +195,10 @@ describe('GptDiagnosticsStore', () => { .snapshot() .slots.map((slot) => slot.requests[0]); - expect(requestingCycle.incompleteSequence).toBe(false); - expect(requestingCycle.responseAtMs).toBeUndefined(); - expect(respondedCycle.incompleteSequence).toBe(false); - expect(respondedCycle.renderAtMs).toBeUndefined(); + expect(requestingCycle!.incompleteSequence).toBe(false); + expect(requestingCycle!.responseAtMs).toBeUndefined(); + expect(respondedCycle!.incompleteSequence).toBe(false); + expect(respondedCycle!.renderAtMs).toBeUndefined(); expect(emptyCycle).toMatchObject({ isEmpty: true, incompleteSequence: false }); }); @@ -260,7 +213,7 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRenderEnded(slot, { isEmpty: request === 1 }); } - expect(store.snapshot().slots[0].requests.map((cycle) => cycle.requestNumber)).toEqual([ + expect(store.snapshot().slots[0]!.requests.map((cycle) => cycle.requestNumber)).toEqual([ 1, 2, 3, ]); assertCoverageEquation(store); @@ -291,8 +244,8 @@ describe('GptDiagnosticsStore', () => { expect(() => store.recordSlotRequested(slot)).not.toThrow(); expect(store.snapshot().slots[0]).toMatchObject({ runtimeSlotNumber: 1 }); - expect(store.snapshot().slots[0].slotElementId).toBeUndefined(); - expect(store.snapshot().slots[0].adUnitPath).toBeUndefined(); + expect(store.snapshot().slots[0]!.slotElementId).toBeUndefined(); + expect(store.snapshot().slots[0]!.adUnitPath).toBeUndefined(); }); it('records callbacks without a request as unmatched issues', () => { @@ -305,7 +258,7 @@ describe('GptDiagnosticsStore', () => { store.recordImpressionViewable(slot); const snapshot = store.snapshot(); - expect(snapshot.slots[0].requests).toEqual([]); + expect(snapshot.slots[0]!.requests).toEqual([]); expect(snapshot.callbackIssues).toHaveLength(4); expect(snapshot.callbackIssues.every((issue) => issue.disposition === 'unmatched')).toBe(true); expect( @@ -325,11 +278,11 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRenderEnded(slot, { isEmpty: false }); const snapshot = store.snapshot(); - expect(snapshot.slots[0].requests).toHaveLength(2); - expect(snapshot.slots[0].requests.every((cycle) => cycle.responseAtMs === undefined)).toBe( + expect(snapshot.slots[0]!.requests).toHaveLength(2); + expect(snapshot.slots[0]!.requests.every((cycle) => cycle.responseAtMs === undefined)).toBe( true ); - expect(snapshot.slots[0].requests.every((cycle) => cycle.renderAtMs === undefined)).toBe(true); + expect(snapshot.slots[0]!.requests.every((cycle) => cycle.renderAtMs === undefined)).toBe(true); expect(snapshot.callbackIssues).toMatchObject([ { kind: 'slotResponseReceived', @@ -357,7 +310,7 @@ describe('GptDiagnosticsStore', () => { store.recordSlotResponseReceived(slot); const snapshot = store.snapshot(); - const cycle = snapshot.slots[0].requests[0]; + const cycle = snapshot.slots[0]!.requests[0]!; expect(cycle.incompleteSequence).toBe(true); expect(cycle.durations.requestToResponseMs).toBe(20); expect(cycle.durations.requestToRenderMs).toBe(10); @@ -389,10 +342,10 @@ describe('GptDiagnosticsStore', () => { let snapshot = store.snapshot(); expect(snapshot.slots).toHaveLength(MAX_DIAGNOSTIC_SLOTS); - expect(snapshot.slots[0].runtimeSlotNumber).toBe(2); + expect(snapshot.slots[0]!.runtimeSlotNumber).toBe(2); expect(snapshot.metadata.evictedSlots).toBe(1); - store.recordSlotResponseReceived(slots[0]); + store.recordSlotResponseReceived(slots[0]!); snapshot = store.snapshot(); expect(snapshot.callbackIssues[snapshot.callbackIssues.length - 1]).toMatchObject({ runtimeSlotNumber: 1, @@ -400,14 +353,14 @@ describe('GptDiagnosticsStore', () => { reason: 'evicted_slot', }); - const retainedSlot = slots[slots.length - 1]; + const retainedSlot = slots[slots.length - 1]!; for (let index = 0; index < MAX_REQUEST_CYCLES_PER_SLOT; index += 1) { store.recordSlotRequested(retainedSlot); } snapshot = store.snapshot(); - const retainedRecord = snapshot.slots[snapshot.slots.length - 1]; + const retainedRecord = snapshot.slots[snapshot.slots.length - 1]!; expect(retainedRecord.requests).toHaveLength(MAX_REQUEST_CYCLES_PER_SLOT); - expect(retainedRecord.requests[0].requestNumber).toBe(2); + expect(retainedRecord.requests[0]!.requestNumber).toBe(2); expect(snapshot.metadata.evictedRequestCycles).toBe(1); const issueSlot = fakeSlot('issues'); @@ -430,23 +383,23 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRequested(retained); } - store.recordSlotVisibilityChanged(slots[0], 10); - store.recordSlotRequested(slots[MAX_DIAGNOSTIC_SLOTS]); + store.recordSlotVisibilityChanged(slots[0]!, 10); + store.recordSlotRequested(slots[MAX_DIAGNOSTIC_SLOTS]!); expect(store.snapshot().slots.some((slot) => slot.runtimeSlotNumber === 1)).toBe(true); expect(store.snapshot().slots.some((slot) => slot.runtimeSlotNumber === 2)).toBe(false); - store.recordSlotResponseReceived(slots[1]); - expect(last(store.snapshot().callbackIssues)).toMatchObject({ + store.recordSlotResponseReceived(slots[1]!); + expect(store.snapshot().callbackIssues.slice(-1)[0]).toMatchObject({ runtimeSlotNumber: 2, reason: 'evicted_slot', }); - store.recordSlotRequested(slots[1]); - store.recordSlotResponseReceived(slots[1]); + 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?.requests[0]).toMatchObject({ requestNumber: 2 }); - expect(reentered?.requests[0].responseAtMs).toBeDefined(); + expect(reentered?.requests[0]!.responseAtMs).toBeDefined(); expect(store.snapshot().slots).toHaveLength(MAX_DIAGNOSTIC_SLOTS); expect(store.snapshot().metadata.evictedSlots).toBe(2); assertCoverageEquation(store); @@ -465,8 +418,8 @@ describe('GptDiagnosticsStore', () => { { runtimeSlotNumber: 1, slotElementId: 'first' }, { runtimeSlotNumber: 2, slotElementId: 'second' }, ]); - inputs[0].slotElementId = 'changed'; - expect(store.bindingInputs()[0].slotElementId).toBe('first'); + inputs[0]!.slotElementId = 'changed'; + expect(store.bindingInputs()[0]!.slotElementId).toBe('first'); }); it('coalesces notifications and isolates throwing subscribers', () => { @@ -494,191 +447,186 @@ describe('GptDiagnosticsStore', () => { expect(goodListener).toHaveBeenCalledTimes(1); }); - it('retains the Ad Manager identifiers GPT reported for the delivered ad', () => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('ad-slot-identity'); - - store.recordSlotRequested(slot); - store.recordSlotResponseReceived(slot); - store.recordSlotRenderEnded(slot, { - isEmpty: false, - adManager: { - lineItemId: 6543210987, - creativeId: 1234567890, - campaignId: 2345678901, - advertiserId: 3456789012, - }, + it('announces correctness commits synchronously while coalescing presentation work', () => { + const scheduled: Array<() => void> = []; + const store = new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => scheduled.push(callback), }); + const commitListener = vi.fn(); + const presentationListener = vi.fn(); + store.subscribeCommits(commitListener); + store.subscribe(presentationListener); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.adManager, 'should keep every reported identifier').toEqual({ - lineItemId: 6543210987, - creativeId: 1234567890, - campaignId: 2345678901, - advertiserId: 3456789012, - }); - expect(cycle.responseClass).toBe('reservation'); - }); + store.markGptObserved(); + store.recordSlotRequested(fakeSlot('commit-membership')); - it('retains an observed outer slot box separately from GPT reported size', () => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('ad-slot-outer-box'); + expect(commitListener).toHaveBeenCalledTimes(2); + expect(presentationListener).not.toHaveBeenCalled(); + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(presentationListener).toHaveBeenCalledOnce(); + }); + it('returns detached snapshot data', () => { + const store = new GptDiagnosticsStore({ now: () => 1 }); + const slot = fakeSlot('detached'); store.recordSlotRequested(slot); - store.recordSlotResponseReceived(slot); - store.recordSlotRenderEnded(slot, { isEmpty: false, size: [1, 1] }); - store.recordObservedSlotSize(1, 1, [728, 90]); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.size).toEqual([1, 1]); - expect(cycle.observedSlotSize).toEqual([728, 90]); + const first = store.snapshot(); + first.slots[0]!.requests[0]!.requestNumber = 999; + first.coverage.slotRequested.matched = 999; + + const second = store.snapshot(); + expect(second.slots[0]!.requests[0]!.requestNumber).toBe(1); + expect(second.coverage.slotRequested.matched).toBe(1); }); + it('ignores malformed publisher refresh inputs without recording intent', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('publisher-refresh-malformed'); - it('rejects a stale prior-cycle outer-box measurement after a refresh', () => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('ad-slot-stale-outer-box'); + expect(() => store.recordPublisherRefresh(null as never)).not.toThrow(); + expect(() => store.recordPublisherRefresh('slots' as never)).not.toThrow(); + expect(() => store.recordPublisherRefresh([null, 7, undefined, slot] as never)).not.toThrow(); store.recordSlotRequested(slot); - store.recordSlotResponseReceived(slot); - store.recordSlotRenderEnded(slot, { isEmpty: false }); - store.recordSlotRequested(slot); - store.recordSlotResponseReceived(slot); - store.recordSlotRenderEnded(slot, { isEmpty: false }); - store.recordObservedSlotSize(1, 1, [300, 250]); - store.recordObservedSlotSize(1, 2, [970, 250]); - - const requests = store.snapshot().slots[0].requests; - expect(requests[0].observedSlotSize).toBeUndefined(); - expect(requests[1].observedSlotSize).toEqual([970, 250]); + expect(store.snapshot().slots[0]!.requests[0]!.requestPath).toBe('publisher_refresh'); + expect(store.snapshot().slots).toHaveLength(1); }); - it('separates a fill without Ad Manager identifiers from a reservation', () => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('ad-slot-default'); - - store.recordSlotRequested(slot); - store.recordSlotRenderEnded(slot, { isEmpty: false }); + it('trims the oldest auction-slot association beyond the retention bound', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const oldest = fakeSlot('association-oldest'); + associateSlot(store, oldest, 'auction-oldest'); + for (let index = 0; index < MAX_TRUSTED_SERVER_ASSOCIATIONS; index += 1) { + associateSlot(store, fakeSlot(`association-${index}`), `auction-${index}`); + } + store.recordSlotRequested(oldest); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.responseClass).toBe('unclassified_non_empty'); - expect(cycle.adManager).toBeUndefined(); + expect(store.recordTrustedServerCreativeRequest('auction-oldest')).toBeUndefined(); + expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_request_without_slot'); + expect(store.recordTrustedServerCreativeRequest('auction-0')).toBeUndefined(); + expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_request_without_cycle'); }); it.each([ { - name: 'a direct renderable candidate', - direct: 'renderable_candidate', - prebid: false, - publisher: false, - expectedPath: 'trusted_server_direct', - expectedOpportunity: 'renderable_candidate', - }, - { - name: 'a direct unrenderable candidate', - direct: 'unrenderable_candidate', - prebid: false, - publisher: false, - expectedPath: 'trusted_server_direct', - expectedOpportunity: 'unrenderable_candidate', - }, - { - name: 'a direct request without a candidate', - direct: 'no_candidate', - prebid: false, - publisher: false, - expectedPath: 'trusted_server_direct', - expectedOpportunity: 'no_candidate', - }, - { - name: 'a Prebid refresh', - direct: undefined, - prebid: true, - publisher: false, - expectedPath: 'prebid_refresh', - expectedOpportunity: undefined, - }, - { - name: 'competing direct and Prebid evidence', - direct: 'renderable_candidate', - prebid: true, - publisher: false, - expectedPath: 'competing', - expectedOpportunity: 'renderable_candidate', - }, - { - name: 'an unattributed request', - direct: undefined, - prebid: false, - publisher: false, - expectedPath: 'unattributed', - expectedOpportunity: undefined, - }, - { - name: 'a publisher refresh', - direct: undefined, - prebid: false, - publisher: true, - expectedPath: 'publisher_refresh', - expectedOpportunity: undefined, + name: 'a render that precedes its response', + kind: 'slotRenderEnded', + record: (store: GptDiagnosticsStore, slot: GptDiagnosticsSlotLike) => + store.recordSlotRenderEnded(slot, { isEmpty: false }), + arrange: (store: GptDiagnosticsStore, slot: GptDiagnosticsSlotLike) => + store.recordSlotResponseReceived(slot), }, { - name: 'competing Prebid and publisher evidence', - direct: undefined, - prebid: true, - publisher: true, - expectedPath: 'competing', - expectedOpportunity: undefined, + name: 'a load that precedes its render', + kind: 'slotOnload', + record: (store: GptDiagnosticsStore, slot: GptDiagnosticsSlotLike) => + store.recordSlotOnload(slot), + arrange: (store: GptDiagnosticsStore, slot: GptDiagnosticsSlotLike) => { + store.recordSlotResponseReceived(slot); + store.recordSlotRenderEnded(slot, { isEmpty: false }); + }, }, { - name: 'competing all source evidence', - direct: 'renderable_candidate', - prebid: true, - publisher: true, - expectedPath: 'competing', - expectedOpportunity: 'renderable_candidate', + name: 'a viewable impression that precedes its render', + kind: 'impressionViewable', + record: (store: GptDiagnosticsStore, slot: GptDiagnosticsSlotLike) => + store.recordImpressionViewable(slot), + arrange: (store: GptDiagnosticsStore, slot: GptDiagnosticsSlotLike) => { + store.recordSlotResponseReceived(slot); + store.recordSlotRenderEnded(slot, { isEmpty: false }); + }, }, - ] as const)( - 'attributes $name without inferring demand ownership', - ({ direct, prebid, publisher, expectedPath, expectedOpportunity }) => { + ])('reports $name as an invalid event order', ({ kind, record, arrange }) => { + let now = 100; + const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); + const slot = fakeSlot(`out-of-order-${kind}`); + + store.recordSlotRequested(slot); + arrange(store, slot); + // A backwards clock is the only way GPT can report a later callback with an + // earlier timestamp; diagnostics record the contradiction rather than hide it. + now = 1; + record(store, slot); + + expect(store.snapshot().slots[0]!.requests[0]!.incompleteSequence).toBe(true); + expect(store.snapshot().callbackIssues).toContainEqual( + expect.objectContaining({ kind, disposition: 'matched', reason: 'invalid_event_order' }) + ); + assertCoverageEquation(store); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY])( + 'rejects the non-finite visibility percentage %s', + (percentage) => { const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('path-slot'); + const slot = fakeSlot('visibility-non-finite'); - if (direct !== undefined) { - store.recordTrustedServerOpportunity(slot, 'auction-slot', direct); - } - if (prebid) store.recordPrebidRefresh([slot]); - if (publisher) store.recordPublisherRefresh([slot]); store.recordSlotRequested(slot); + store.recordSlotVisibilityChanged(slot, percentage); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.requestPath).toBe(expectedPath); - expect(cycle.trustedServerOpportunity).toBe(expectedOpportunity); + const snapshot = store.snapshot(); + expect(snapshot.slots[0]!.currentVisibilityPercentage).toBeUndefined(); + expect(snapshot.slots[0]!.maximumVisibilityPercentage).toBeUndefined(); + expect(snapshot.callbackIssues).toContainEqual( + expect.objectContaining({ + kind: 'slotVisibilityChanged', + disposition: 'unmatched', + reason: 'invalid_visibility_percentage', + }) + ); + assertCoverageEquation(store); } ); - it('consumes direct and Prebid markers exactly once', () => { - let now = 10; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('one-shot'); - - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate'); - store.recordPrebidRefresh([slot]); - store.recordSlotRequested(slot); - now = 11; + it('reports an unknown attempt ID for a creative failure without recording one', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('failure-unknown-attempt'); + associateSlot(store, slot, 'auction-failure-unknown'); store.recordSlotRequested(slot); - const cycles = store.snapshot().slots[0].requests; - expect(cycles).toMatchObject([ - { - requestPath: 'competing', - trustedServerOpportunity: 'renderable_candidate', - }, - { requestPath: 'unattributed' }, + store.recordTrustedServerCreativeFailure(4242, 'cache_fetch_failed'); + + expect(store.snapshot().slots[0]!.requests[0]!.trustedServerCreativeFailures).toBeUndefined(); + expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_unknown'); + }); + + it('keeps one outstanding delivery-boundary timer across a refresh burst', () => { + let now = 0; + const deferred: Array<{ callback: () => void; delayMs: number }> = []; + const store = new GptDiagnosticsStore({ + now: () => now, + schedule: (callback) => callback(), + defer: (callback, delayMs) => deferred.push({ callback, delayMs }), + }); + const slots = Array.from({ length: 8 }, (_, index) => fakeSlot(`burst-${index}`)); + + for (const [index, slot] of slots.entries()) { + now = index; + store.recordTrustedServerOpportunity(slot, `burst-auction-${index}`, 'renderable_candidate'); + store.recordSlotRequested(slot); + store.recordSlotRenderEnded(slot, { isEmpty: false }); + } + + expect(deferred, 'a burst of candidate renders must share one timer').toMatchObject([ + { delayMs: TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS }, ]); - expect(cycles[1].trustedServerOpportunity).toBeUndefined(); + + // Firing at the earliest deadline re-arms once for the next one, never per render. + now = TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; + deferred.shift()!.callback(); + expect(deferred).toMatchObject([{ delayMs: 1 }]); + + now = 7 + TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; + deferred.shift()!.callback(); + expect(deferred, 'no boundary remains once every candidate crossed it').toHaveLength(0); + for (const slot of store.snapshot().slots) { + expect(slot.requests[0]!.delivery).toBe('candidate_unconfirmed'); + } }); - it('retains all configured requested slot sizes on only the correlated next request', () => { + it('retains a detached requested-size vector on only the correlated next request', () => { const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); const slot = fakeSlot('requested-sizes'); const formats: Array<[number, number]> = [ @@ -708,7 +656,37 @@ describe('GptDiagnosticsStore', () => { expect(cycles[1]?.requestedSlotSizes).toBeUndefined(); }); - it('bounds and validates configured requested slot sizes before retaining them', () => { + it('correlates release-private snapshots through their shared opaque slot token', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const token = Object.freeze(Object.create(null) as object); + const opportunitySlot = Object.freeze({ + token, + elementId: 'token-correlated-slot', + adUnitPath: '/example/token-correlated-slot', + }); + const callbackSlot = Object.freeze({ + token, + elementId: 'token-correlated-slot', + adUnitPath: '/example/token-correlated-slot', + }); + + store.recordTrustedServerOpportunity( + opportunitySlot, + 'auction-slot', + 'renderable_candidate', + 'fictional-auction', + [[300, 250]] + ); + store.recordSlotRequested(callbackSlot, 11); + + expect(store.snapshot().slots[0]?.requests[0]).toMatchObject({ + requestedSlotSizes: [[300, 250]], + trustedServerAuctionId: 'fictional-auction', + trustedServerOpportunity: 'renderable_candidate', + }); + }); + + it('bounds and validates requested sizes before retaining them', () => { const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); const slot = fakeSlot('validated-requested-sizes'); const formats: Array<[number, number]> = Array.from( @@ -717,6 +695,8 @@ describe('GptDiagnosticsStore', () => { ); formats[0] = [0, 250]; formats[1] = [300, Number.NaN]; + formats[2] = [1.5, 250]; + formats[3] = [4_097, 250]; store.recordTrustedServerOpportunity( slot, @@ -728,1351 +708,11 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRequested(slot); const requested = store.snapshot().slots[0]!.requests[0]!.requestedSlotSizes; - expect(requested).toHaveLength(MAX_REQUESTED_SLOT_SIZES - 2); + expect(requested).toHaveLength(MAX_REQUESTED_SLOT_SIZES - 4); expect(requested).not.toContainEqual([0, 250]); expect(requested).not.toContainEqual([300, Number.NaN]); + expect(requested).not.toContainEqual([1.5, 250]); + expect(requested).not.toContainEqual([4_097, 250]); expect(requested).not.toContainEqual([MAX_REQUESTED_SLOT_SIZES + 1, 250]); }); - - it('consumes a combined request intent with independent source facts', () => { - let now = 10; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('intent'); - - store.recordTrustedServerOpportunity( - slot, - 'auction-slot', - 'renderable_candidate', - ' auction-123 ' - ); - now = 20; - store.recordPrebidRefresh([slot]); - now = 30; - store.recordPublisherRefresh([slot]); - now = 34; - store.recordSlotRequested(slot); - now = 35; - store.recordSlotRequested(slot); - - const cycles = store.snapshot().slots[0].requests; - expect(cycles).toMatchObject([ - { - requestPath: 'competing', - requestIntentId: 1, - trustedServerOpportunity: 'renderable_candidate', - trustedServerAuctionId: 'auction-123', - opportunityToRequestMs: 24, - }, - { requestPath: 'unattributed' }, - ]); - expect(cycles[1].requestIntentId).toBeUndefined(); - expect(deferred, 'source evidence must not schedule deferred work').toHaveLength(0); - }); - - it('keeps repeated source evidence single-source and increments consumed intent IDs', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const first = fakeSlot('repeat-intent-first'); - const second = fakeSlot('repeat-intent-second'); - store.recordPublisherRefresh([first]); - now = 2; - store.recordPublisherRefresh([first]); - store.recordSlotRequested(first); - now = 3; - store.recordTrustedServerOpportunity(second, 'second-auction', 'no_candidate'); - store.recordPublisherRefresh([second]); - store.recordSlotRequested(second); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - requestPath: 'publisher_refresh', - requestIntentId: 1, - }); - expect(store.snapshot().slots[1].requests[0]).toMatchObject({ - requestPath: 'competing', - requestIntentId: 2, - }); - }); - - it('expires repeated source evidence lazily without scheduling timer work', () => { - let now = 0; - const deferred: Array<{ callback: () => void; delayMs: number }> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback, delayMs) => deferred.push({ callback, delayMs }), - }); - const consumed = fakeSlot('lazy-expiry-consumed'); - const expired = fakeSlot('lazy-expiry-expired'); - - for (let observation = 0; observation < 1_000; observation += 1) { - now = observation; - store.recordPublisherRefresh([consumed, expired]); - } - - expect(deferred, 'a refresh burst must not queue deferred work').toHaveLength(0); - - // The window runs from the newest observation, at t = 999. - now = 999 + REQUEST_PATH_ATTRIBUTION_WINDOW_MS - 1; - store.recordSlotRequested(consumed); - now += 1; - store.recordSlotRequested(expired); - - expect(store.snapshot().slots[0].requests[0].requestPath).toBe('publisher_refresh'); - expect(store.snapshot().slots[1].requests[0].requestPath).toBe('unattributed'); - expect(deferred, 'expiry must stay free of deferred work').toHaveLength(0); - }); - - it('replaces a fully expired intent instead of reviving its intent ID', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('expired-intent-replacement'); - - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate', 'stale'); - now = REQUEST_PATH_ATTRIBUTION_WINDOW_MS; - store.recordPublisherRefresh([slot]); - store.recordSlotRequested(slot); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle).toMatchObject({ requestPath: 'publisher_refresh', requestIntentId: 2 }); - expect( - cycle.trustedServerOpportunity, - 'expired direct evidence must not survive' - ).toBeUndefined(); - expect(cycle.trustedServerAuctionId).toBeUndefined(); - expect(deferred).toHaveLength(0); - }); - - it('derives a replacement from the most recent earlier filled render', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('replacement'); - - store.recordSlotRequested(slot); - now = 2; - store.recordSlotResponseReceived(slot); - now = 3; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - now = 20; - store.recordSlotRequested(slot); - now = 21; - store.recordSlotResponseReceived(slot); - now = 22; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 202 } }); - - expect(store.snapshot().slots[0].requests[1]).toMatchObject({ - replacedRequestNumber: 1, - previousRenderToRequestMs: 17, - previousCreativeId: 101, - creativeChanged: true, - }); - }); - - it('compares primary and source-agnostic GPT creative identities for replacements', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('replacement-fallback-creative'); - store.recordSlotRequested(slot); - now = 2; - store.recordSlotResponseReceived(slot); - now = 3; - store.recordSlotRenderEnded(slot, { - isEmpty: false, - adManager: { sourceAgnosticCreativeId: 101 }, - }); - now = 4; - store.recordSlotRequested(slot); - now = 5; - store.recordSlotResponseReceived(slot); - now = 6; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - - expect(store.snapshot().slots[0].requests[1]).toMatchObject({ - previousCreativeId: 101, - creativeChanged: false, - }); - }); - - it('uses the latest earlier filled render while ignoring empty renders', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('replacement-most-recent-filled'); - - store.recordSlotRequested(slot); - now = 2; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - now = 3; - store.recordSlotRequested(slot); - now = 4; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 202 } }); - now = 5; - store.recordSlotRequested(slot); - now = 6; - store.recordSlotRenderEnded(slot, { isEmpty: true }); - now = 7; - store.recordSlotRequested(slot); - now = 8; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 303 } }); - - const requests = store.snapshot().slots[0].requests; - expect(requests[2].replacedRequestNumber).toBeUndefined(); - expect(requests[3]).toMatchObject({ - replacedRequestNumber: 2, - previousRenderToRequestMs: 3, - previousCreativeId: 202, - creativeChanged: true, - }); - }); - - it('reports one-sided creative IDs without claiming a creative change', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const previousOnly = fakeSlot('replacement-previous-id-only'); - const currentOnly = fakeSlot('replacement-current-id-only'); - - store.recordSlotRequested(previousOnly); - now = 2; - store.recordSlotRenderEnded(previousOnly, { isEmpty: false, adManager: { creativeId: 101 } }); - now = 3; - store.recordSlotRequested(previousOnly); - now = 4; - store.recordSlotRenderEnded(previousOnly, { isEmpty: false }); - - store.recordSlotRequested(currentOnly); - now = 5; - store.recordSlotRenderEnded(currentOnly, { isEmpty: false }); - now = 6; - store.recordSlotRequested(currentOnly); - now = 7; - store.recordSlotRenderEnded(currentOnly, { isEmpty: false, adManager: { creativeId: 202 } }); - - const [previousOnlyCycle] = store.snapshot().slots[0].requests.slice(-1); - const [currentOnlyCycle] = store.snapshot().slots[1].requests.slice(-1); - expect(previousOnlyCycle).toMatchObject({ replacedRequestNumber: 1, previousCreativeId: 101 }); - expect(previousOnlyCycle.creativeChanged).toBeUndefined(); - expect(currentOnlyCycle).toMatchObject({ replacedRequestNumber: 1 }); - expect(currentOnlyCycle.previousCreativeId).toBeUndefined(); - expect(currentOnlyCycle.creativeChanged).toBeUndefined(); - }); - - it('does not infer replacements once the earlier filled cycle has been evicted', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('replacement-evicted'); - - store.recordSlotRequested(slot); - now += 1; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - // Complete every filler cycle so the eviction pushes the only filled render - // out of retention and the final render still matches exactly one cycle. - for (let index = 0; index < MAX_REQUEST_CYCLES_PER_SLOT; index += 1) { - now += 1; - store.recordSlotRequested(slot); - now += 1; - store.recordSlotResponseReceived(slot); - now += 1; - store.recordSlotRenderEnded(slot, { isEmpty: true }); - } - now += 1; - store.recordSlotRequested(slot); - now += 1; - store.recordSlotResponseReceived(slot); - now += 1; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 202 } }); - - const requests = store.snapshot().slots[0].requests; - expect( - requests.some((cycle) => cycle.adManager?.creativeId === 101), - 'the earlier filled cycle should have been evicted' - ).toBe(false); - const latestCycle = last(requests)!; - expect(latestCycle.renderAtMs, 'the final render must have been matched').toBeDefined(); - expect(latestCycle.adManager?.creativeId).toBe(202); - expect(latestCycle.replacedRequestNumber).toBeUndefined(); - expect(latestCycle.previousRenderToRequestMs).toBeUndefined(); - expect(latestCycle.previousCreativeId).toBeUndefined(); - }); - - it('keeps Trusted Server and publisher source evidence separate from replacement facts', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('replacement-source-evidence'); - - store.recordSlotRequested(slot); - now = 2; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - now = 3; - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate'); - store.recordPublisherRefresh([slot]); - store.recordSlotRequested(slot); - now = 4; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 202 } }); - - expect(store.snapshot().slots[0].requests[1]).toMatchObject({ - requestPath: 'competing', - replacedRequestNumber: 1, - previousCreativeId: 101, - creativeChanged: true, - }); - }); - - it('expires request-path markers at the five-second boundary without waiting for timers', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const beforeBoundary = fakeSlot('before-boundary'); - const atBoundary = fakeSlot('at-boundary'); - - for (const slot of [beforeBoundary, atBoundary]) { - store.recordTrustedServerOpportunity( - slot, - `auction-${slot.getSlotElementId?.()}`, - 'no_candidate' - ); - store.recordPrebidRefresh([slot]); - } - - now = REQUEST_PATH_ATTRIBUTION_WINDOW_MS - 1; - store.recordSlotRequested(beforeBoundary); - now = REQUEST_PATH_ATTRIBUTION_WINDOW_MS; - store.recordSlotRequested(atBoundary); - - const [before, expired] = store.snapshot().slots.map((slot) => slot.requests[0]); - expect(before).toMatchObject({ - requestPath: 'competing', - trustedServerOpportunity: 'no_candidate', - }); - expect(expired).toMatchObject({ requestPath: 'unattributed' }); - expect(expired.trustedServerOpportunity).toBeUndefined(); - expect(deferred, 'the boundary must be enforced without marker timers').toHaveLength(0); - }); - - it('keeps the newest evidence when a source is re-observed inside the window', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('re-observed-source'); - - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate'); - store.recordPrebidRefresh([slot]); - now = 100; - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'unrenderable_candidate'); - store.recordPrebidRefresh([slot]); - store.recordSlotRequested(slot); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - requestPath: 'competing', - requestIntentId: 1, - trustedServerOpportunity: 'unrenderable_candidate', - }); - expect(deferred).toHaveLength(0); - }); - - it('expires sources independently and replaces Trusted Server auction metadata', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('independent-expiry'); - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate', 'old'); - now = 1; - store.recordPrebidRefresh([slot]); - now = 2; - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'no_candidate'); - now = REQUEST_PATH_ATTRIBUTION_WINDOW_MS + 1; - store.recordSlotRequested(slot); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - requestPath: 'trusted_server_direct', - trustedServerOpportunity: 'no_candidate', - }); - expect(store.snapshot().slots[0].requests[0].trustedServerAuctionId).toBeUndefined(); - }); - - it('uses replacement Trusted Server evidence for latency and removes an unconsumed final source', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const repeated = fakeSlot('repeated-trusted-server-evidence'); - const unconsumed = fakeSlot('unconsumed-trusted-server-evidence'); - - store.recordTrustedServerOpportunity(repeated, 'auction-slot', 'renderable_candidate'); - now = 40; - store.recordTrustedServerOpportunity(repeated, 'auction-slot', 'no_candidate'); - now = 50; - store.recordSlotRequested(repeated); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - requestPath: 'trusted_server_direct', - trustedServerOpportunity: 'no_candidate', - opportunityToRequestMs: 10, - }); - - now = 60; - store.recordTrustedServerOpportunity(unconsumed, 'other-auction-slot', 'no_candidate'); - now += REQUEST_PATH_ATTRIBUTION_WINDOW_MS; - store.recordSlotRequested(unconsumed); - - const unconsumedCycle = store.snapshot().slots[1].requests[0]; - expect(unconsumedCycle.requestPath).toBe('unattributed'); - expect(unconsumedCycle.requestIntentId).toBeUndefined(); - }); - - it('retains only valid bounded auction IDs without dropping Trusted Server intent', () => { - const valid = 'a'.repeat(256); - const cases: Array<[unknown, string | undefined]> = [ - [valid, valid], - ['', undefined], - [' ', undefined], - [123, undefined], - ['é'.repeat(129), undefined], - ]; - for (const [auctionId, expected] of cases) { - const store = new GptDiagnosticsStore({ now: () => 1, defer: () => undefined }); - const slot = fakeSlot(`auction-id-${String(auctionId).length}`); - store.recordTrustedServerOpportunity( - slot, - 'auction-slot', - 'renderable_candidate', - auctionId as string - ); - store.recordSlotRequested(slot); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.requestPath).toBe('trusted_server_direct'); - expect(cycle.trustedServerAuctionId).toBe(expected); - } - }); - - it('does not mutate an open request cycle when a later direct marker arrives', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('open-cycle'); - - store.recordSlotRequested(slot); - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate'); - - const openCycle = store.snapshot().slots[0].requests[0]; - expect(openCycle).toMatchObject({ requestPath: 'unattributed' }); - expect(openCycle.trustedServerOpportunity).toBeUndefined(); - - store.recordSlotRequested(slot); - expect(store.snapshot().slots[0].requests[1]).toMatchObject({ - requestPath: 'trusted_server_direct', - trustedServerOpportunity: 'renderable_candidate', - }); - }); - - it('ignores malformed diagnostic marker inputs without throwing', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('valid-marker'); - - expect(() => - store.recordTrustedServerOpportunity(null as never, 'auction-slot', 'renderable_candidate') - ).not.toThrow(); - expect(() => - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'invalid' as never) - ).not.toThrow(); - expect(() => store.recordPrebidRefresh(null as never)).not.toThrow(); - expect(() => store.recordPrebidRefresh([null, 1, slot] as never)).not.toThrow(); - - store.recordSlotRequested(slot); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle).toMatchObject({ requestPath: 'prebid_refresh' }); - expect(cycle.trustedServerOpportunity).toBeUndefined(); - }); - - it.each(['renderable_candidate', 'unrenderable_candidate'] as const)( - 'moves an explicit non-empty %s to unconfirmed after one deferred notification', - (opportunity) => { - let now = 10; - const deferred: Array<{ callback: () => void; delayMs: number }> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - schedule: (callback) => callback(), - defer: (callback, delayMs) => deferred.push({ callback, delayMs }), - }); - const listener = vi.fn(); - const slot = fakeSlot(`delivery-${opportunity}`); - store.subscribe(listener); - - store.recordTrustedServerOpportunity(slot, 'auction-slot', opportunity); - store.recordSlotRequested(slot); - expect(deferred, 'recording intent must not defer work').toHaveLength(0); - listener.mockClear(); - - now = 30; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('pending'); - expect(deferred).toHaveLength(1); - expect(deferred[0].delayMs).toBe(TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS); - expect(listener).toHaveBeenCalledTimes(1); - - now = 30 + TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; - deferred[0].callback(); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('candidate_unconfirmed'); - expect(listener).toHaveBeenCalledTimes(2); - expect(deferred).toHaveLength(1); - } - ); - - it.each([ - { - name: 'an explicit no-candidate fill', - opportunity: 'no_candidate', - renderFacts: { isEmpty: false }, - expected: 'no_candidate', - }, - { - name: 'a fill without a direct opportunity', - opportunity: undefined, - renderFacts: { isEmpty: false }, - expected: 'unknown', - }, - { - name: 'a render with omitted fill state', - opportunity: 'renderable_candidate', - renderFacts: {}, - expected: 'unknown', - }, - { - name: 'an empty render', - opportunity: 'renderable_candidate', - renderFacts: { isEmpty: true }, - expected: 'not_applicable', - }, - { - name: 'a pre-render request', - opportunity: 'renderable_candidate', - renderFacts: undefined, - expected: 'not_applicable', - }, - ] as const)( - 'derives $name from observed evidence only', - ({ opportunity, renderFacts, expected }) => { - let now = 10; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('delivery-state'); - - if (opportunity === undefined) { - store.recordPrebidRefresh([slot]); - } else { - store.recordTrustedServerOpportunity(slot, 'auction-slot', opportunity); - } - store.recordSlotRequested(slot); - deferred.shift()?.(); - now = 30; - if (renderFacts !== undefined) store.recordSlotRenderEnded(slot, renderFacts); - - expect(store.snapshot().slots[0].requests[0].delivery).toBe(expected); - expect(deferred, 'should not schedule an attribution-boundary notification').toHaveLength(0); - now = 30 + TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; - expect(store.snapshot().slots[0].requests[0].delivery).toBe(expected); - } - ); - - it.each([ - { name: 'omitted fill state', facts: {}, expected: undefined }, - { name: 'an empty render', facts: { isEmpty: true }, expected: 'empty' }, - { - name: 'an explicit backfill', - facts: { isEmpty: false, isBackfill: true }, - expected: 'backfill', - }, - { - name: 'an explicit reservation', - facts: { isEmpty: false, adManager: { lineItemId: 123 } }, - expected: 'reservation', - }, - { - name: 'a source-agnostic identity confirmed as non-backfill', - facts: { - isEmpty: false, - isBackfill: false, - adManager: { sourceAgnosticLineItemId: 123 }, - }, - expected: 'reservation', - }, - { - name: 'a source-agnostic identity without a backfill fact', - facts: { isEmpty: false, adManager: { sourceAgnosticLineItemId: 123 } }, - expected: 'unclassified_non_empty', - }, - { - name: 'an otherwise unclassified non-empty render', - facts: { isEmpty: false }, - expected: 'unclassified_non_empty', - }, - ] as const)('classifies $name only from explicit render facts', ({ facts, expected }) => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('response-class'); - - store.recordSlotRequested(slot); - store.recordSlotRenderEnded(slot, facts); - - expect(store.snapshot().slots[0].requests[0].responseClass).toBe(expected); - }); - - it('correlates a creative request and response to the selected request cycle', () => { - let now = 10; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('creative-selected'); - associateSlot(store, slot, 'auction-selected'); - store.recordSlotRequested(slot); - - now = 20; - const attemptId = store.recordTrustedServerCreativeRequest('auction-selected'); - expect(attemptId).toEqual(expect.any(Number)); - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - trustedServerCreativeRequestAtMs: 20, - delivery: 'trusted_server_selected', - }); - - now = 25; - store.recordTrustedServerCreativeResponse(attemptId!); - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - trustedServerCreativeRequestAtMs: 20, - trustedServerCreativeResponseAtMs: 25, - delivery: 'trusted_server_response_sent', - }); - }); - - it('accepts late positive creative evidence after the candidate observation timeout', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('late-positive'); - associateSlot(store, slot, 'auction-late'); - store.recordSlotRequested(slot); - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - - now = 1 + TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; - expect(store.snapshot().slots[0].requests[0].delivery).toBe('candidate_unconfirmed'); - - const attemptId = store.recordTrustedServerCreativeRequest('auction-late'); - expect(attemptId).toEqual(expect.any(Number)); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_selected'); - now += 1; - store.recordTrustedServerCreativeResponse(attemptId!); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_response_sent'); - }); - - it('keeps the first request timestamp and live ID across duplicate creative requests', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('creative-retry'); - associateSlot(store, slot, 'auction-retry'); - store.recordSlotRequested(slot); - - now = 5; - const firstId = store.recordTrustedServerCreativeRequest('auction-retry'); - now = 9; - const duplicateId = store.recordTrustedServerCreativeRequest('auction-retry'); - - expect(duplicateId).toBe(firstId); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeRequestAtMs).toBe(5); - }); - - it('records each safe creative failure once in first-observed order and can later succeed', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('creative-failures'); - associateSlot(store, slot, 'auction-failures'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-failures')!; - - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - store.recordTrustedServerCreativeFailure(attemptId, 'missing_render_source'); - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - store.recordTrustedServerCreativeFailure(attemptId, 'invalid_cache_payload'); - store.recordTrustedServerCreativeFailure(attemptId, 'response_post_failed'); - store.recordTrustedServerCreativeFailure(attemptId, 'unsafe_runtime_value' as never); - - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toEqual([ - 'cache_fetch_failed', - 'missing_render_source', - 'invalid_cache_payload', - 'response_post_failed', - ]); - expect(store.snapshot().attributionIssues).toEqual([]); - - now = 1; - store.recordTrustedServerCreativeResponse(attemptId); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_response_sent'); - }); - - it('keeps an asynchronous response on its originating cycle after a newer refresh', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('async-origin'); - associateSlot(store, slot, 'auction-async'); - store.recordSlotRequested(slot); - const firstId = store.recordTrustedServerCreativeRequest('auction-async')!; - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - - now = 2; - store.recordSlotRequested(slot); - now = 3; - store.recordTrustedServerCreativeResponse(firstId); - - const [first, second] = store.snapshot().slots[0].requests; - expect(first).toMatchObject({ - trustedServerCreativeResponseAtMs: 3, - delivery: 'trusted_server_response_sent', - }); - expect(second.trustedServerCreativeResponseAtMs).toBeUndefined(); - }); - - it('provisionally attaches an initial pre-render creative request', () => { - let now = 10; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('provisional'); - associateSlot(store, slot, 'auction-provisional'); - store.recordSlotRequested(slot); - - now = 11; - const attemptId = store.recordTrustedServerCreativeRequest('auction-provisional'); - expect(attemptId).toEqual(expect.any(Number)); - now = 12; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - trustedServerCreativeRequestAtMs: 11, - isEmpty: false, - delivery: 'trusted_server_selected', - }); - expect(store.snapshot().attributionIssues).toEqual([]); - }); - - it('rejects an ambiguous pre-render request when an earlier non-empty cycle is retained', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('ambiguous-creative'); - associateSlot(store, slot, 'auction-ambiguous'); - store.recordSlotRequested(slot); - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - now = 2; - store.recordSlotRequested(slot); - - now = 3; - expect(store.recordTrustedServerCreativeRequest('auction-ambiguous')).toBeUndefined(); - expect(store.snapshot().slots[0].requests[1].trustedServerCreativeRequestAtMs).toBeUndefined(); - expect(store.snapshot().attributionIssues).toEqual([ - expect.objectContaining({ - reason: 'creative_request_ambiguous_cycle', - runtimeSlotNumber: 1, - slotElementId: 'ambiguous-creative', - }), - ]); - }); - - it('accepts positive creative evidence when GPT omitted isEmpty', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('unknown-fill-positive'); - associateSlot(store, slot, 'auction-unknown-fill'); - store.recordSlotRequested(slot); - now = 1; - store.recordSlotRenderEnded(slot, {}); - now = 2; - - expect(store.recordTrustedServerCreativeRequest('auction-unknown-fill')).toEqual( - expect.any(Number) - ); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_selected'); - }); - - it('rejects explicit empty cycles and never falls back to an older compatible cycle', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('empty-current'); - associateSlot(store, slot, 'auction-empty-current'); - store.recordSlotRequested(slot); - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - now = 2; - store.recordSlotRequested(slot); - now = 3; - store.recordSlotRenderEnded(slot, { isEmpty: true }); - - expect(store.recordTrustedServerCreativeRequest('auction-empty-current')).toBeUndefined(); - const [older, current] = store.snapshot().slots[0].requests; - expect(older.trustedServerCreativeRequestAtMs).toBeUndefined(); - expect(current.trustedServerCreativeRequestAtMs).toBeUndefined(); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_request_without_cycle'); - }); - - it('preserves provisional evidence and reports when the cycle later renders empty', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('provisional-empty'); - associateSlot(store, slot, 'auction-provisional-empty'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-provisional-empty'); - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: true }); - - expect(attemptId).toEqual(expect.any(Number)); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeRequestAtMs).toBe(0); - expect(store.snapshot().attributionIssues).toEqual([ - expect.objectContaining({ - reason: 'creative_request_on_empty_cycle', - runtimeSlotNumber: 1, - slotElementId: 'provisional-empty', - }), - ]); - - // The attempt is dead once its cycle rendered empty, so a late response - // cannot claim a Trusted Server delivery against that empty render. - store.recordTrustedServerCreativeResponse(attemptId!); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.trustedServerCreativeResponseAtMs).toBeUndefined(); - expect(cycle.delivery, 'an empty cycle must not report a markup response').toBe( - 'trusted_server_selected' - ); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_evicted'); - }); - - it('preserves provisional evidence when the render omits isEmpty', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('provisional-unknown-fill'); - associateSlot(store, slot, 'auction-provisional-unknown-fill'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-provisional-unknown-fill'); - - now = 1; - store.recordSlotRenderEnded(slot, {}); - - expect(attemptId).toEqual(expect.any(Number)); - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - trustedServerCreativeRequestAtMs: 0, - delivery: 'trusted_server_selected', - }); - expect(store.snapshot().attributionIssues).toEqual([]); - }); - - it('admits a request at the cycle-age boundary and rejects only after it', () => { - let boundaryNow = 0; - const boundaryStore = new GptDiagnosticsStore({ - now: () => boundaryNow, - defer: () => undefined, - }); - const boundarySlot = fakeSlot('cycle-boundary'); - associateSlot(boundaryStore, boundarySlot, 'auction-boundary'); - boundaryStore.recordSlotRequested(boundarySlot); - boundaryNow = CREATIVE_ATTEMPT_WINDOW_MS; - expect(boundaryStore.recordTrustedServerCreativeRequest('auction-boundary')).toEqual( - expect.any(Number) - ); - - let lateNow = 0; - const lateStore = new GptDiagnosticsStore({ now: () => lateNow, defer: () => undefined }); - const lateSlot = fakeSlot('cycle-too-old'); - associateSlot(lateStore, lateSlot, 'auction-too-old'); - lateStore.recordSlotRequested(lateSlot); - lateNow = CREATIVE_ATTEMPT_WINDOW_MS + 1; - expect(lateStore.recordTrustedServerCreativeRequest('auction-too-old')).toBeUndefined(); - expect(last(lateStore.snapshot().attributionIssues)?.reason).toBe( - 'creative_request_without_cycle' - ); - }); - - it('distinguishes missing slot associations from known slots without a request cycle', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const associated = fakeSlot('associated-no-cycle'); - associateSlot(store, associated, 'auction-no-cycle'); - - expect(store.recordTrustedServerCreativeRequest('auction-unknown')).toBeUndefined(); - expect(store.recordTrustedServerCreativeRequest('')).toBeUndefined(); - expect(store.recordTrustedServerCreativeRequest('auction-no-cycle')).toBeUndefined(); - - const issues = store.snapshot().attributionIssues; - expect(issues.map((issue) => issue.reason)).toEqual([ - 'creative_request_without_slot', - 'creative_request_without_slot', - 'creative_request_without_cycle', - ]); - expect(issues[0].runtimeSlotNumber).toBeUndefined(); - expect(issues[0].slotElementId).toBeUndefined(); - expect(issues[2].slotElementId).toBe('associated-no-cycle'); - }); - - it('expires attempts at 30 seconds without replacement or late mutation', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('attempt-expiry'); - associateSlot(store, slot, 'auction-expiry'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-expiry')!; - - now = CREATIVE_ATTEMPT_WINDOW_MS - 1; - expect(store.recordTrustedServerCreativeRequest('auction-expiry')).toBe(attemptId); - now = CREATIVE_ATTEMPT_WINDOW_MS; - expect(store.recordTrustedServerCreativeRequest('auction-expiry')).toBeUndefined(); - store.recordTrustedServerCreativeResponse(attemptId); - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle).toMatchObject({ trustedServerCreativeRequestAtMs: 0 }); - expect(cycle.trustedServerCreativeResponseAtMs).toBeUndefined(); - expect(cycle.trustedServerCreativeFailures).toBeUndefined(); - expect(store.snapshot().attributionIssues.map((issue) => issue.reason)).toEqual([ - 'creative_attempt_expired', - 'creative_attempt_expired', - 'creative_attempt_expired', - ]); - }); - - it('reuses a live attempt after the cycle ages out and expires from creative-request time', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('delayed-attempt-expiry'); - associateSlot(store, slot, 'auction-delayed-attempt-expiry'); - store.recordSlotRequested(slot); - - now = 20_000; - const attemptId = store.recordTrustedServerCreativeRequest('auction-delayed-attempt-expiry'); - expect(attemptId).toEqual(expect.any(Number)); - - now = CREATIVE_ATTEMPT_WINDOW_MS + 1; - expect(store.recordTrustedServerCreativeRequest('auction-delayed-attempt-expiry')).toBe( - attemptId - ); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeRequestAtMs).toBe(20_000); - - now = 20_000 + CREATIVE_ATTEMPT_WINDOW_MS - 1; - expect(store.recordTrustedServerCreativeRequest('auction-delayed-attempt-expiry')).toBe( - attemptId - ); - now = 20_000 + CREATIVE_ATTEMPT_WINDOW_MS; - expect( - store.recordTrustedServerCreativeRequest('auction-delayed-attempt-expiry') - ).toBeUndefined(); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_expired'); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeRequestAtMs).toBe(20_000); - }); - - it('reports unknown IDs and invalidates live attempts on cycle and slot eviction', () => { - const now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - store.recordTrustedServerCreativeResponse(999_999); - - const shiftedSlot = fakeSlot('shifted-attempt'); - associateSlot(store, shiftedSlot, 'auction-shifted'); - store.recordSlotRequested(shiftedSlot); - const shiftedId = store.recordTrustedServerCreativeRequest('auction-shifted')!; - for (let index = 0; index < MAX_REQUEST_CYCLES_PER_SLOT; index += 1) { - store.recordSlotRequested(shiftedSlot); - } - store.recordTrustedServerCreativeResponse(shiftedId); - - const evictedSlot = fakeSlot('lru-attempt'); - associateSlot(store, evictedSlot, 'auction-lru-attempt'); - store.recordSlotRequested(evictedSlot); - const evictedId = store.recordTrustedServerCreativeRequest('auction-lru-attempt')!; - for (let index = 0; index < MAX_DIAGNOSTIC_SLOTS; index += 1) { - store.recordSlotRequested(fakeSlot(`attempt-lru-filler-${index}`)); - } - store.recordTrustedServerCreativeFailure(evictedId, 'response_post_failed'); - - expect(store.snapshot().attributionIssues.map((issue) => issue.reason)).toEqual([ - 'creative_attempt_unknown', - 'creative_attempt_evicted', - 'creative_attempt_evicted', - ]); - }); - - it('treats duplicate writers against a completed attempt as idempotent', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('completed-attempt'); - associateSlot(store, slot, 'auction-completed'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-completed')!; - now = 1; - store.recordTrustedServerCreativeResponse(attemptId); - const completed = store.snapshot(); - - now = 2; - expect(store.recordTrustedServerCreativeRequest('auction-completed')).toBeUndefined(); - store.recordTrustedServerCreativeResponse(attemptId); - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - - expect(store.snapshot().slots[0].requests[0]).toEqual(completed.slots[0].requests[0]); - expect(store.snapshot().attributionIssues).toEqual([]); - }); - - it('does not replace a completed current-cycle attempt after its tombstone is reclaimed', () => { - const now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const sentinelSlot = fakeSlot('completed-current-cycle'); - associateSlot(store, sentinelSlot, 'auction-completed-current-cycle'); - store.recordSlotRequested(sentinelSlot); - const sentinelId = store.recordTrustedServerCreativeRequest('auction-completed-current-cycle')!; - store.recordTrustedServerCreativeResponse(sentinelId); - - recordCompletedAttempts(store, MAX_CREATIVE_ATTEMPTS - 1, 'completed-current-cycle-fill'); - const replacementSlot = fakeSlot('completed-current-cycle-replacement'); - associateSlot(store, replacementSlot, 'auction-completed-current-cycle-replacement'); - store.recordSlotRequested(replacementSlot); - expect( - store.recordTrustedServerCreativeRequest('auction-completed-current-cycle-replacement') - ).toEqual(expect.any(Number)); - - expect( - store.recordTrustedServerCreativeRequest('auction-completed-current-cycle') - ).toBeUndefined(); - expect( - store.recordTrustedServerCreativeRequest('auction-completed-current-cycle') - ).toBeUndefined(); - expect(store.snapshot().attributionIssues).toEqual([]); - expect( - store.snapshot().slots.find((slot) => slot.slotElementId === 'completed-current-cycle') - ?.requests[0] - ).toMatchObject({ - trustedServerCreativeRequestAtMs: 0, - trustedServerCreativeResponseAtMs: 0, - }); - }); - - it('does not replace an expired current-cycle attempt after its tombstone is reclaimed', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const sentinelSlot = fakeSlot('expired-current-cycle'); - associateSlot(store, sentinelSlot, 'auction-expired-current-cycle'); - store.recordSlotRequested(sentinelSlot); - expect(store.recordTrustedServerCreativeRequest('auction-expired-current-cycle')).toEqual( - expect.any(Number) - ); - - now = CREATIVE_ATTEMPT_WINDOW_MS; - recordCompletedAttempts(store, MAX_CREATIVE_ATTEMPTS - 1, 'expired-current-cycle-fill'); - const replacementSlot = fakeSlot('expired-current-cycle-replacement'); - associateSlot(store, replacementSlot, 'auction-expired-current-cycle-replacement'); - store.recordSlotRequested(replacementSlot); - expect( - store.recordTrustedServerCreativeRequest('auction-expired-current-cycle-replacement') - ).toEqual(expect.any(Number)); - - expect( - store.recordTrustedServerCreativeRequest('auction-expired-current-cycle') - ).toBeUndefined(); - expect( - store.recordTrustedServerCreativeRequest('auction-expired-current-cycle') - ).toBeUndefined(); - expect(store.snapshot().attributionIssues.map((issue) => issue.reason)).toEqual([ - 'creative_attempt_unknown', - 'creative_attempt_unknown', - ]); - expect( - store.snapshot().slots.find((slot) => slot.slotElementId === 'expired-current-cycle') - ?.requests[0] - ).toMatchObject({ trustedServerCreativeRequestAtMs: 0 }); - }); - - it('never evicts a live attempt at capacity and lets an unassigned duplicate retry', () => { - let now = 100; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const liveIds: number[] = []; - let created = 0; - - for (let slotIndex = 0; created < MAX_CREATIVE_ATTEMPTS; slotIndex += 1) { - const slot = fakeSlot(`live-capacity-${slotIndex}`); - const auctionSlotId = `auction-live-capacity-${slotIndex}`; - associateSlot(store, slot, auctionSlotId); - const cycles = Math.min(MAX_REQUEST_CYCLES_PER_SLOT, MAX_CREATIVE_ATTEMPTS - created); - for (let cycleIndex = 0; cycleIndex < cycles; cycleIndex += 1) { - store.recordSlotRequested(slot); - liveIds.push(store.recordTrustedServerCreativeRequest(auctionSlotId)!); - created += 1; - } - } - - const rejectedSlot = fakeSlot('live-capacity-rejected'); - associateSlot(store, rejectedSlot, 'auction-live-capacity-rejected'); - store.recordSlotRequested(rejectedSlot); - expect( - store.recordTrustedServerCreativeRequest('auction-live-capacity-rejected') - ).toBeUndefined(); - expect(last(store.snapshot().slots)?.requests[0].trustedServerCreativeRequestAtMs).toBe(100); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_capacity'); - - now = 250; - store.recordTrustedServerCreativeResponse(liveIds[0]); - const retriedId = store.recordTrustedServerCreativeRequest('auction-live-capacity-rejected'); - expect(retriedId).toEqual(expect.any(Number)); - expect(retriedId).not.toBe(liveIds[0]); - expect(last(store.snapshot().slots)?.requests[0].trustedServerCreativeRequestAtMs).toBe(100); - store.recordTrustedServerCreativeResponse(liveIds[1]); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_capacity'); - }); - - it('does not create an already-expired attempt when a capacity retry reaches its boundary', () => { - let now = 100; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - let created = 0; - - for (let slotIndex = 0; created < MAX_CREATIVE_ATTEMPTS; slotIndex += 1) { - const slot = fakeSlot(`boundary-capacity-${slotIndex}`); - const auctionSlotId = `auction-boundary-capacity-${slotIndex}`; - associateSlot(store, slot, auctionSlotId); - const cycles = Math.min(MAX_REQUEST_CYCLES_PER_SLOT, MAX_CREATIVE_ATTEMPTS - created); - for (let cycleIndex = 0; cycleIndex < cycles; cycleIndex += 1) { - store.recordSlotRequested(slot); - expect(store.recordTrustedServerCreativeRequest(auctionSlotId)).toEqual(expect.any(Number)); - created += 1; - } - } - - const rejectedSlot = fakeSlot('boundary-capacity-rejected'); - const rejectedAuctionSlotId = 'auction-boundary-capacity-rejected'; - associateSlot(store, rejectedSlot, rejectedAuctionSlotId); - store.recordSlotRequested(rejectedSlot); - expect(store.recordTrustedServerCreativeRequest(rejectedAuctionSlotId)).toBeUndefined(); - - now += CREATIVE_ATTEMPT_WINDOW_MS; - expect(store.recordTrustedServerCreativeRequest(rejectedAuctionSlotId)).toBeUndefined(); - const snapshot = store.snapshot(); - const rejectedCycle = snapshot.slots.find( - (slot) => slot.slotElementId === 'boundary-capacity-rejected' - )?.requests[0]; - expect(rejectedCycle?.trustedServerCreativeRequestAtMs).toBe(100); - expect(snapshot.attributionIssues.map((issue) => issue.reason)).toEqual([ - 'creative_attempt_capacity', - 'creative_attempt_expired', - ]); - }); - - it('bounds attribution issues separately without changing callback coverage', () => { - const store = new GptDiagnosticsStore({ now: () => 1, defer: () => undefined }); - const beforeCoverage = store.snapshot().coverage; - - for (let index = 0; index < MAX_ATTRIBUTION_ISSUES + 1; index += 1) { - store.recordTrustedServerCreativeResponse(10_000 + index); - } - - const snapshot = store.snapshot(); - expect(snapshot.attributionIssues).toHaveLength(MAX_ATTRIBUTION_ISSUES); - expect(snapshot.metadata.droppedAttributionIssues).toBe(1); - expect(snapshot.callbackIssues).toEqual([]); - expect(snapshot.metadata.droppedCallbacks).toBe(0); - expect(snapshot.coverage).toEqual(beforeCoverage); - assertCoverageEquation(store); - }); - - it('returns detached creative evidence and never exports attempt bookkeeping', () => { - const now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('detached-creative'); - associateSlot(store, slot, 'auction-detached-creative'); - store.recordSlotRequested(slot); - store.recordSlotRenderEnded(slot, { - isEmpty: false, - adManager: { creativeId: 123, yieldGroupIds: [11], companyIds: [22] }, - }); - const attemptId = store.recordTrustedServerCreativeRequest('auction-detached-creative')!; - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - store.recordTrustedServerCreativeResponse(999_999); - - const first = store.snapshot(); - first.slots[0].requests[0].trustedServerCreativeFailures!.push('response_post_failed'); - first.slots[0].requests[0].adManager!.yieldGroupIds!.push(33); - first.slots[0].requests[0].adManager!.companyIds!.push(44); - first.attributionIssues[0].reason = 'creative_attempt_capacity'; - - const second = store.snapshot(); - expect(second.slots[0].requests[0].trustedServerCreativeFailures).toEqual([ - 'cache_fetch_failed', - ]); - expect(second.slots[0].requests[0].adManager).toMatchObject({ - creativeId: 123, - yieldGroupIds: [11], - companyIds: [22], - }); - expect(second.attributionIssues[0].reason).toBe('creative_attempt_unknown'); - const serializedCycle = JSON.stringify(second.slots[0].requests[0]); - expect(serializedCycle).not.toMatch( - /"(?:id|status|expiresAtMs|provisionalBeforeRender|auctionSlotId|attemptId|attemptStatus)"\s*:/ - ); - }); - - it('returns detached snapshot data', () => { - const store = new GptDiagnosticsStore({ now: () => 1 }); - const slot = fakeSlot('detached'); - store.recordSlotRequested(slot); - - const first = store.snapshot(); - first.slots[0].requests[0].requestNumber = 999; - first.coverage.slotRequested.matched = 999; - - const second = store.snapshot(); - expect(second.slots[0].requests[0].requestNumber).toBe(1); - expect(second.coverage.slotRequested.matched).toBe(1); - }); - it('ignores malformed publisher refresh inputs without recording intent', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('publisher-refresh-malformed'); - - expect(() => store.recordPublisherRefresh(null as never)).not.toThrow(); - expect(() => store.recordPublisherRefresh('slots' as never)).not.toThrow(); - expect(() => store.recordPublisherRefresh([null, 7, undefined, slot] as never)).not.toThrow(); - - store.recordSlotRequested(slot); - expect(store.snapshot().slots[0].requests[0].requestPath).toBe('publisher_refresh'); - expect(store.snapshot().slots).toHaveLength(1); - }); - - it('trims the oldest auction-slot association beyond the retention bound', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const oldest = fakeSlot('association-oldest'); - associateSlot(store, oldest, 'auction-oldest'); - for (let index = 0; index < MAX_TRUSTED_SERVER_ASSOCIATIONS; index += 1) { - associateSlot(store, fakeSlot(`association-${index}`), `auction-${index}`); - } - store.recordSlotRequested(oldest); - - expect(store.recordTrustedServerCreativeRequest('auction-oldest')).toBeUndefined(); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_request_without_slot'); - expect(store.recordTrustedServerCreativeRequest('auction-0')).toBeUndefined(); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_request_without_cycle'); - }); - - it.each([ - { - name: 'a render that precedes its response', - kind: 'slotRenderEnded', - record: (store: GptDiagnosticsStore, slot: GptDiagnosticsSlotLike) => - store.recordSlotRenderEnded(slot, { isEmpty: false }), - arrange: (store: GptDiagnosticsStore, slot: GptDiagnosticsSlotLike) => - store.recordSlotResponseReceived(slot), - }, - { - name: 'a load that precedes its render', - kind: 'slotOnload', - record: (store: GptDiagnosticsStore, slot: GptDiagnosticsSlotLike) => - store.recordSlotOnload(slot), - arrange: (store: GptDiagnosticsStore, slot: GptDiagnosticsSlotLike) => { - store.recordSlotResponseReceived(slot); - store.recordSlotRenderEnded(slot, { isEmpty: false }); - }, - }, - { - name: 'a viewable impression that precedes its render', - kind: 'impressionViewable', - record: (store: GptDiagnosticsStore, slot: GptDiagnosticsSlotLike) => - store.recordImpressionViewable(slot), - arrange: (store: GptDiagnosticsStore, slot: GptDiagnosticsSlotLike) => { - store.recordSlotResponseReceived(slot); - store.recordSlotRenderEnded(slot, { isEmpty: false }); - }, - }, - ])('reports $name as an invalid event order', ({ kind, record, arrange }) => { - let now = 100; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot(`out-of-order-${kind}`); - - store.recordSlotRequested(slot); - arrange(store, slot); - // A backwards clock is the only way GPT can report a later callback with an - // earlier timestamp; diagnostics record the contradiction rather than hide it. - now = 1; - record(store, slot); - - expect(store.snapshot().slots[0].requests[0].incompleteSequence).toBe(true); - expect(store.snapshot().callbackIssues).toContainEqual( - expect.objectContaining({ kind, disposition: 'matched', reason: 'invalid_event_order' }) - ); - assertCoverageEquation(store); - }); - - it.each([Number.NaN, Number.POSITIVE_INFINITY])( - 'rejects the non-finite visibility percentage %s', - (percentage) => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('visibility-non-finite'); - - store.recordSlotRequested(slot); - store.recordSlotVisibilityChanged(slot, percentage); - - const snapshot = store.snapshot(); - expect(snapshot.slots[0].currentVisibilityPercentage).toBeUndefined(); - expect(snapshot.slots[0].maximumVisibilityPercentage).toBeUndefined(); - expect(snapshot.callbackIssues).toContainEqual( - expect.objectContaining({ - kind: 'slotVisibilityChanged', - disposition: 'unmatched', - reason: 'invalid_visibility_percentage', - }) - ); - assertCoverageEquation(store); - } - ); - - it('reports an unknown attempt ID for a creative failure without recording one', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('failure-unknown-attempt'); - associateSlot(store, slot, 'auction-failure-unknown'); - store.recordSlotRequested(slot); - - store.recordTrustedServerCreativeFailure(4242, 'cache_fetch_failed'); - - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toBeUndefined(); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_unknown'); - }); - - it('keeps one outstanding delivery-boundary timer across a refresh burst', () => { - let now = 0; - const deferred: Array<{ callback: () => void; delayMs: number }> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - schedule: (callback) => callback(), - defer: (callback, delayMs) => deferred.push({ callback, delayMs }), - }); - const slots = Array.from({ length: 8 }, (_, index) => fakeSlot(`burst-${index}`)); - - for (const [index, slot] of slots.entries()) { - now = index; - store.recordTrustedServerOpportunity(slot, `burst-auction-${index}`, 'renderable_candidate'); - store.recordSlotRequested(slot); - store.recordSlotRenderEnded(slot, { isEmpty: false }); - } - - expect(deferred, 'a burst of candidate renders must share one timer').toMatchObject([ - { delayMs: TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS }, - ]); - - // Firing at the earliest deadline re-arms once for the next one, never per render. - now = TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; - deferred.shift()!.callback(); - expect(deferred).toMatchObject([{ delayMs: 1 }]); - - now = 7 + TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; - deferred.shift()!.callback(); - expect(deferred, 'no boundary remains once every candidate crossed it').toHaveLength(0); - for (const slot of store.snapshot().slots) { - expect(slot.requests[0].delivery).toBe('candidate_unconfirmed'); - } - }); }); 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..2d1d89899 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 @@ -12,7 +12,6 @@ import type { GptDiagnosticsResponseClass, GptDiagnosticsSlotExport, Size, - TsjsApi, } from '../../../src/core/types'; describe('GPT diagnostics public types', () => { @@ -49,31 +48,6 @@ describe('GPT diagnostics public types', () => { expectTypeOf(readOnlyApi).toEqualTypeOf(); }); - it('accepts legacy V1 snapshots without attribution evidence', () => { - const legacySnapshot: GptDiagnosticsExportV1 = { - version: 1, - capturedAt: '2026-08-04T00:00:00.000Z', - page: { origin: 'https://example.com', pathname: '/' }, - slots: [], - callbackIssues: [], - coverage: { - slotRequested: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - slotResponseReceived: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - slotRenderEnded: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - slotOnload: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - impressionViewable: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - slotVisibilityChanged: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - }, - metadata: { - droppedCallbacks: 0, - evictedSlots: 0, - evictedRequestCycles: 0, - }, - }; - - expect(legacySnapshot.version).toBe(1); - }); - it('keeps evidence writers off the operator API and on the internal channel', () => { expectTypeOf().toEqualTypeOf< 'snapshot' | 'export' | 'subscribe' | 'show' | 'hide' @@ -85,10 +59,6 @@ describe('GPT diagnostics public types', () => { | 'recordTrustedServerCreativeResponse' | 'recordTrustedServerCreativeFailure' >(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf< - GptDiagnosticsRecorder | undefined - >(); }); it('represents the versioned allowlist schema', () => { @@ -180,11 +150,9 @@ describe('GPT diagnostics public types', () => { expectTypeOf(evidenceCycle.requestedSlotSizes).toEqualTypeOf | undefined>(); expectTypeOf(evidenceCycle.observedSlotSize).toEqualTypeOf(); expectTypeOf(evidenceSnapshot.attributionIssues).toEqualTypeOf< - GptDiagnosticsAttributionIssue[] | undefined - >(); - expectTypeOf(evidenceSnapshot.metadata.droppedAttributionIssues).toEqualTypeOf< - number | undefined + readonly Readonly[] >(); + expectTypeOf(evidenceSnapshot.metadata.droppedAttributionIssues).toEqualTypeOf(); expectTypeOf().toEqualTypeOf< | 'creative_request_without_slot' | 'creative_request_without_cycle' diff --git a/crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts b/crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts new file mode 100644 index 000000000..40d1fd2fb --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createDataDomeIntegrationRegistration } from '../../src/integrations/datadome/module'; +import { createDidomiIntegrationRegistration } from '../../src/integrations/didomi/module'; +import { createGoogleTagManagerIntegrationRegistration } from '../../src/integrations/google_tag_manager/module'; +import { createLockrIntegrationRegistration } from '../../src/integrations/lockr/module'; +import { createOsanoIntegrationRegistration } from '../../src/integrations/osano/module'; +import { createPermutiveIntegrationRegistration } from '../../src/integrations/permutive/module'; +import { createSourcepointIntegrationRegistration } from '../../src/integrations/sourcepoint/module'; +import { createTestlightIntegrationRegistration } from '../../src/integrations/testlight/module'; +import { + createIntegrationRegistry, + type IntegrationRegistration, +} from '../../src/kernel/integration_registry'; +import { RELEASE_CATALOG } from '../../src/kernel/release_catalog'; + +const RELEASE_ID = 'a'.repeat(64); +const RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; +const registrations: ReadonlyArray< + readonly [string, (release: string) => IntegrationRegistration] +> = Object.freeze([ + ['datadome', createDataDomeIntegrationRegistration] as const, + ['didomi', createDidomiIntegrationRegistration] as const, + ['google_tag_manager', createGoogleTagManagerIntegrationRegistration] as const, + ['lockr', createLockrIntegrationRegistration] as const, + ['osano_consent', createOsanoIntegrationRegistration] as const, + ['permutive_context', createPermutiveIntegrationRegistration] as const, + ['sourcepoint_consent', createSourcepointIntegrationRegistration] as const, + ['testlight', createTestlightIntegrationRegistration] as const, +]); +const configFor = (id: string): unknown => { + if (id === 'didomi') return Object.freeze({ proxyPath: '/integrations/didomi/sdk' }); + if (id === 'sourcepoint_consent') return Object.freeze({ rewriteSdk: true }); + return Object.freeze({}); +}; + +function catalogFor(ids: readonly string[]) { + return Object.freeze( + ids.map((id) => { + const entry = RELEASE_CATALOG.find((candidate) => candidate.id === id); + if (!entry) throw new TypeError(`Missing release catalog row: ${id}`); + return Object.freeze({ + id: entry.id, + phase: entry.phase, + trigger: entry.trigger, + config: entry.config, + consumes: Object.freeze([...entry.consumes]), + provides: Object.freeze([...entry.provides]), + }); + }) + ); +} + +function runtimeCapability() { + return Object.freeze({ + document, + enqueue: (callback: () => void) => { + callback(); + return true; + }, + registerAuctionContext: () => () => undefined, + }); +} + +describe('remaining integration lifecycle modules', () => { + it('activates the provider-owned maximal lifecycle set without foreign runtime authority', async () => { + const order: string[] = []; + const ids = Object.freeze(registrations.map(([id]) => id)); + const foreignActivations = new Map>(); + const foreignStarts = new Map>(); + const interfaces = Object.freeze( + Object.fromEntries( + ids.map((id) => { + const activate = vi.fn(() => vi.fn()); + const start = vi.fn(); + foreignActivations.set(id, activate); + foreignStarts.set(id, start); + return [id, Object.freeze({ activate, start })]; + }) + ) + ); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: RUNTIME_SRC, + integrations: ids.map((id) => ({ id, phase: 'takeover' as const })), + }, + releaseId: RELEASE_ID, + knownIntegrationIds: ids, + catalog: catalogFor(ids), + startedAtMs: 0, + now: () => 0, + runtimeCapability: runtimeCapability(), + getBindings: (id) => ({ config: configFor(id), interfaces }), + }); + for (const [, createRegistration] of registrations) { + expect(registry.register(createRegistration(RELEASE_ID))).toBe(true); + } + + const result = await registry.install({ + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['core', 'publish', 'drain']); + for (const id of ids) { + expect(foreignActivations.get(id)).not.toHaveBeenCalled(); + expect(foreignStarts.get(id)).not.toHaveBeenCalled(); + } + if (result.state === 'kernel') result.dispose(); + }); + + it.each(registrations)( + '%s runs alone without cross-integration authority', + async (id, create) => { + const activate = vi.fn(() => vi.fn()); + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: RUNTIME_SRC, + integrations: [{ id, phase: 'takeover' }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze([id]), + catalog: catalogFor([id]), + startedAtMs: 0, + now: () => 0, + runtimeCapability: runtimeCapability(), + getBindings: () => ({ + config: configFor(id), + interfaces: Object.freeze({ [id]: Object.freeze({ activate, start }) }), + }), + }); + registry.register(create(RELEASE_ID)); + + await expect( + registry.install({ activateCore: vi.fn(), publish: vi.fn(), drainPreload: vi.fn() }) + ).resolves.toMatchObject({ state: 'kernel' }); + expect(activate).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + registry.dispose(); + } + ); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts b/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts new file mode 100644 index 000000000..2581fbd7c --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createLockrRuntime } from '../../../src/integrations/lockr/module'; + +describe('transactional Lockr integration module', () => { + afterEach(() => vi.useRealTimers()); + + it('rewrites a later initialized SDK once and compare-restores its host', async () => { + vi.useFakeTimers(); + const state: { sdk?: { host: string } } = {}; + const resetGuard = vi.fn(); + const runtime = createLockrRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => state.sdk, + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + resetGuard, + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + await vi.advanceTimersByTimeAsync(49); + const sdk = { host: 'https://identity.loc.kr' }; + state.sdk = sdk; + await vi.advanceTimersByTimeAsync(1); + + expect(sdk.host).toBe('https://news.example/integrations/lockr/api'); + sdk.host = 'https://publisher.example/replacement'; + release(); + expect(sdk.host).toBe('https://publisher.example/replacement'); + expect(resetGuard).toHaveBeenCalledOnce(); + }); + + it('stops after 50 readiness checks and owns no later timer', async () => { + vi.useFakeTimers(); + const timedOut = vi.fn(); + const setTimeout = vi.fn((callback: () => void, delay: number) => + window.setTimeout(callback, delay) + ); + const runtime = createLockrRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => undefined, + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + resetGuard: vi.fn(), + setTimeout, + started: vi.fn(), + timedOut, + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + + await vi.advanceTimersByTimeAsync(2_500); + + expect(setTimeout).toHaveBeenCalledTimes(49); + expect(timedOut).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + release(); + }); + + it('cancels readiness work on disposal before the SDK appears', async () => { + vi.useFakeTimers(); + const sdk = { host: 'https://identity.loc.kr' }; + let available = false; + const runtime = createLockrRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => (available ? sdk : undefined), + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + resetGuard: vi.fn(), + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + release(); + available = true; + + await vi.runAllTimersAsync(); + + expect(sdk.host).toBe('https://identity.loc.kr'); + expect(vi.getTimerCount()).toBe(0); + }); + + it('isolates a hostile timer release from SDK and guard cleanup', () => { + const sdk = { host: 'https://identity.loc.kr' }; + let sdkAvailable = false; + const clearTimeout = vi.fn(() => { + throw new Error('publisher clearTimeout failed'); + }); + const resetGuard = vi.fn(() => { + throw new Error('publisher guard reset failed'); + }); + const runtime = createLockrRuntime({ + clearTimeout, + getSdk: () => (sdkAvailable ? sdk : undefined), + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + resetGuard, + setTimeout: (callback) => { + sdkAvailable = true; + callback(); + return 17; + }, + started: vi.fn(), + timedOut: vi.fn(), + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + + expect(sdk.host).toBe('https://news.example/integrations/lockr/api'); + expect(() => release()).not.toThrow(); + expect(() => release()).not.toThrow(); + + expect(clearTimeout).toHaveBeenCalledOnce(); + expect(sdk.host).toBe('https://identity.loc.kr'); + expect(resetGuard).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts b/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts index 891fd5540..d7f2892ae 100644 --- a/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + disposeOsanoConsentMirror, initializeOsanoConsentMirror, mirrorOsanoConsent, - resetOsanoConsentMirrorForTest, } from '../../../src/integrations/osano'; type TestWindow = Window & { @@ -24,7 +24,7 @@ type UspCallback = (data?: { uspString?: string }, success?: boolean) => void; function clearAllCookies(): void { document.cookie.split(';').forEach((cookie) => { - const name = cookie.split('=')[0].trim(); + const name = cookie.split('=')[0]?.trim() ?? ''; if (name) document.cookie = `${name}=; path=/; Max-Age=0`; }); } @@ -80,7 +80,7 @@ function setOsanoStub(): Record void> { describe('integrations/osano consent mirror', () => { beforeEach(() => { - resetOsanoConsentMirrorForTest(); + disposeOsanoConsentMirror(); clearAllCookies(); delete (window as TestWindow).Osano; delete (window as TestWindow).__uspapi; @@ -90,7 +90,7 @@ describe('integrations/osano consent mirror', () => { afterEach(() => { vi.useRealTimers(); - resetOsanoConsentMirrorForTest(); + disposeOsanoConsentMirror(); clearAllCookies(); delete (window as TestWindow).Osano; delete (window as TestWindow).__uspapi; @@ -436,4 +436,33 @@ describe('integrations/osano consent mirror', () => { expect(listeners['osano-cm-consent-saved']).toEqual(expect.any(Function)); expect(getCookie('us_privacy')).toBe('1YN-'); }); + + it('cancels in-flight API timeouts and makes late callbacks inert on disposal', async () => { + vi.useFakeTimers(); + const callbacks = setControlledUspApi(); + const pending = mirrorOsanoConsent(); + + expect(vi.getTimerCount()).toBe(1); + disposeOsanoConsentMirror(); + expect(vi.getTimerCount()).toBe(0); + await expect(pending).resolves.toBe(false); + + callbacks[0]?.({ uspString: 'late-consent' }, true); + await Promise.resolve(); + expect(getCookie('us_privacy')).toBeUndefined(); + expect(getCookie(MARKER_COOKIE)).toBeUndefined(); + }); + + it('does not retain Osano listeners when the vendor exposes no removal API', async () => { + vi.useFakeTimers(); + const addEventListener = vi.fn(); + (window as TestWindow).Osano = { cm: { addEventListener } }; + + initializeOsanoConsentMirror(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(addEventListener).not.toHaveBeenCalled(); + disposeOsanoConsentMirror(); + expect(vi.getTimerCount()).toBe(0); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/osano/module.test.ts b/crates/trusted-server-js/lib/test/integrations/osano/module.test.ts new file mode 100644 index 000000000..0c1dad220 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/osano/module.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createOsanoRuntime } from '../../../src/integrations/osano/module'; + +describe('transactional Osano integration module', () => { + it('keeps activation reversible and starts the consent mirror once after commit', () => { + const initialize = vi.fn(); + const reset = vi.fn(); + const runtime = createOsanoRuntime({ initialize, reset }); + + const release = runtime.activate(undefined); + + expect(initialize).not.toHaveBeenCalled(); + runtime.start(undefined); + runtime.start(undefined); + expect(initialize).toHaveBeenCalledOnce(); + release(); + release(); + expect(reset).toHaveBeenCalledOnce(); + }); + + it('resets partial consent ownership when startup throws', () => { + const reset = vi.fn(); + const runtime = createOsanoRuntime({ + initialize: () => { + throw new Error('listener failed'); + }, + reset, + }); + const release = runtime.activate(undefined); + + expect(() => runtime.start(undefined)).toThrow('listener failed'); + release(); + + expect(reset).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts b/crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts new file mode 100644 index 000000000..bf89c3e1c --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createPermutiveIntegrationRegistration, + createPermutiveRuntime, +} from '../../../src/integrations/permutive/module'; +import type { RuntimeCapabilityV1 } from '../../../src/kernel/runtime'; + +const RELEASE_ID = 'a'.repeat(64); + +describe('transactional Permutive integration module', () => { + afterEach(() => vi.useRealTimers()); + + it('registers one disposable auction-context contributor during activation', () => { + const order: string[] = []; + let contributor: (() => Readonly> | undefined) | undefined; + const runtime = createPermutiveRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => undefined, + getSegments: () => ['11', '22'], + installGuard: () => order.push('guard:install'), + location: { host: 'news.example', protocol: 'https:' }, + registerContext: (candidate) => { + contributor = candidate; + order.push('context:register'); + return () => order.push('context:release'); + }, + resetGuard: () => order.push('guard:reset'), + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + + const release = runtime.activate(undefined); + + expect(contributor?.()).toEqual({ permutive_segments: ['11', '22'] }); + expect(order).toEqual(['guard:install', 'context:register']); + release(); + release(); + expect(order).toEqual(['guard:install', 'context:register', 'context:release', 'guard:reset']); + }); + + it('uses the adopted parser-time segments for the first persistent auction', () => { + localStorage.removeItem('permutive-app'); + let contributor: (() => Readonly> | undefined) | undefined; + const runtime = Object.freeze({ + registerAuctionContext: (_id: string, candidate: typeof contributor) => { + contributor = candidate; + return vi.fn(); + }, + }) as unknown as RuntimeCapabilityV1; + const preparationDisposers: Array<() => void> = []; + const activationDisposers: Array<() => void> = []; + const controller = new AbortController(); + const registration = createPermutiveIntegrationRegistration(RELEASE_ID); + if (registration.phase !== 'takeover') throw new TypeError('Expected takeover registration'); + const prepared = registration.prepareSync({ + config: Object.freeze({}), + interfaces: Object.freeze({ 'runtime.v1': runtime }), + signal: controller.signal, + onDispose: (callback: () => void) => preparationDisposers.push(callback), + }); + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slices: Object.freeze(['first_display', 'permutive_initial']), + parserState: Object.freeze([ + Object.freeze({ + sliceId: 'permutive_initial', + observations: Object.freeze(['segments']), + values: Object.freeze([ + Object.freeze(['segments', JSON.stringify(['initial-one', 'initial-two'])] as const), + ]), + }), + ]), + }), + identities: Object.freeze([]), + }); + + prepared.activate({ + adoption, + afterCommit: vi.fn(), + signal: controller.signal, + onDispose: (callback: () => void) => activationDisposers.push(callback), + }); + + expect(contributor?.()).toEqual({ + permutive_segments: ['initial-one', 'initial-two'], + }); + expect(contributor?.()).toBeUndefined(); + + activationDisposers.reverse().forEach((release) => release()); + preparationDisposers.reverse().forEach((release) => release()); + }); + + it('bounds a context-service segment snapshot even when an injected reader overproduces', () => { + let contributor: (() => Readonly> | undefined) | undefined; + const runtime = createPermutiveRuntime({ + getSegments: () => Array.from({ length: 101 }, (_, index) => `${index}`), + installGuard: vi.fn(), + registerContext: (candidate) => { + contributor = candidate; + return vi.fn(); + }, + resetGuard: vi.fn(), + }); + + const release = runtime.activate(undefined); + const snapshot = contributor?.() as { readonly permutive_segments?: readonly string[] }; + + expect(snapshot.permutive_segments).toHaveLength(100); + expect(Object.isFrozen(snapshot.permutive_segments)).toBe(true); + release(); + }); + + it('rewrites a later SDK config and compare-restores every owned field', async () => { + vi.useFakeTimers(); + const config = { + apiHost: 'api.permutive.com', + apiProtocol: 'https', + cdnBaseUrl: 'cdn.permutive.com', + cdnProtocol: 'https', + secureSignalsApiHost: 'signals.permutive.com', + segmentSyncApiHost: 'sync.permutive.com', + }; + let available = false; + const runtime = createPermutiveRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => (available ? { config } : undefined), + getSegments: () => [], + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + registerContext: () => vi.fn(), + resetGuard: vi.fn(), + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + available = true; + await vi.advanceTimersByTimeAsync(50); + + expect(config).toEqual({ + apiHost: 'news.example/integrations/permutive/api', + apiProtocol: 'https', + cdnBaseUrl: 'news.example/integrations/permutive/cdn', + cdnProtocol: 'https', + secureSignalsApiHost: 'news.example/integrations/permutive/secure-signal', + segmentSyncApiHost: 'news.example/integrations/permutive/sync', + }); + config.apiHost = 'publisher.example/replacement'; + release(); + expect(config).toEqual({ + apiHost: 'publisher.example/replacement', + apiProtocol: 'https', + cdnBaseUrl: 'cdn.permutive.com', + cdnProtocol: 'https', + secureSignalsApiHost: 'signals.permutive.com', + segmentSyncApiHost: 'sync.permutive.com', + }); + }); + + it('rolls back the guard when context registration is refused', () => { + const resetGuard = vi.fn(); + const runtime = createPermutiveRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => undefined, + getSegments: () => [], + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + registerContext: () => undefined, + resetGuard, + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + + expect(() => runtime.activate(undefined)).toThrowError('Permutive context registration failed'); + expect(resetGuard).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/phase-slices.test.ts b/crates/trusted-server-js/lib/test/integrations/phase-slices.test.ts new file mode 100644 index 000000000..6ecdcbfb6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/phase-slices.test.ts @@ -0,0 +1,301 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import type { IntegrationRegistration } from '../../src/kernel/integration_registry'; +import { + MAX_TAKEOVER_MODULES, + MAX_MANIFEST_MODULES, + RELEASE_CATALOG, + selectReleaseCatalog, +} from '../../src/kernel/release_catalog'; + +const RELEASE_ID = 'a'.repeat(64); +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +const EXPECTED_CATALOG = Object.freeze([ + [ + 'render_runtime', + 'takeover', + 'always', + ['runtime.v1'], + [ + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + 'trace.presentation.v1', + 'direct.v1', + ], + ], + [ + 'aps', + 'takeover', + 'integration:aps', + ['runtime.v1', 'slots.v1', 'render.v1', 'messages.v1', 'trace.v1'], + ['aps.v1'], + ], + ['creative', 'takeover', 'creative_guard', ['runtime.v1'], []], + ['datadome', 'takeover', 'integration:datadome', ['runtime.v1'], []], + ['didomi', 'takeover', 'integration:didomi', ['runtime.v1'], []], + ['google_tag_manager', 'takeover', 'integration:google_tag_manager', ['runtime.v1'], []], + [ + 'gpt', + 'takeover', + 'integration:gpt', + ['runtime.v1', 'slots.v1', 'auction.v1', 'render.v1', 'messages.v1', 'trace.v1', 'aps.v1?aps'], + ['gpt.v1', 'gpt.events.v1', 'pbs_cache.baseline.v1'], + ], + [ + 'gpt_diagnostics', + 'takeover', + 'gpt_diagnostics_active', + ['runtime.v1', 'gpt.events.v1'], + ['gpt_diag.v1'], + ], + ['lockr', 'takeover', 'integration:lockr', ['runtime.v1'], []], + ['osano_consent', 'takeover', 'integration:osano', ['runtime.v1'], ['osano_consent.v1']], + [ + 'permutive_context', + 'takeover', + 'integration:permutive', + ['runtime.v1'], + ['permutive_context.v1'], + ], + [ + 'sourcepoint_consent', + 'takeover', + 'integration:sourcepoint', + ['runtime.v1'], + ['sourcepoint_consent.v1'], + ], + [ + 'prebid', + 'takeover', + 'integration:prebid', + ['runtime.v1', 'slots.v1', 'render.v1', 'messages.v1', 'aps.v1?aps'], + ['prebid.v1'], + ], + ['testlight', 'takeover', 'integration:testlight', ['runtime.v1'], []], + [ + 'diagnostics_presentation', + 'deferred', + 'diagnostics_presentation', + ['runtime.v1', 'trace.presentation.v1', 'gpt_diag.v1?gpt_diagnostics_active'], + [], + ], + [ + 'gpt_later', + 'deferred', + 'integration:gpt', + ['runtime.v1', 'slots.v1', 'auction.v1', 'render.v1', 'gpt.v1', 'trace.v1'], + [], + ], + ['osano_lifecycle', 'deferred', 'integration:osano', ['runtime.v1', 'osano_consent.v1'], []], + [ + 'permutive_lifecycle', + 'deferred', + 'integration:permutive', + ['runtime.v1', 'permutive_context.v1'], + [], + ], + [ + 'prebid_later', + 'deferred', + 'prebid_and_gpt', + ['runtime.v1', 'slots.v1', 'gpt.v1', 'prebid.v1'], + [], + ], + [ + 'sourcepoint_lifecycle', + 'deferred', + 'integration:sourcepoint', + ['runtime.v1', 'sourcepoint_consent.v1'], + [], + ], +] as const); + +const DEFERRED_FACTORIES = Object.freeze([ + [ + 'diagnostics_presentation', + '../../src/integrations/gpt_diagnostics/presentation', + 'createDiagnosticsPresentationIntegrationRegistration', + ], + ['gpt_later', '../../src/integrations/gpt/later', 'createGptLaterIntegrationRegistration'], + [ + 'osano_lifecycle', + '../../src/integrations/osano/lifecycle', + 'createOsanoLifecycleIntegrationRegistration', + ], + [ + 'permutive_lifecycle', + '../../src/integrations/permutive/lifecycle', + 'createPermutiveLifecycleIntegrationRegistration', + ], + [ + 'prebid_later', + '../../src/integrations/prebid/later', + 'createPrebidLaterIntegrationRegistration', + ], + [ + 'sourcepoint_lifecycle', + '../../src/integrations/sourcepoint/lifecycle', + 'createSourcepointLifecycleIntegrationRegistration', + ], +] as const); + +function selectedIds(selection: Parameters[0]): readonly string[] { + return selectReleaseCatalog(selection).map(({ id }) => id); +} + +function transitiveSources(entry: string): ReadonlySet { + const visited = new Set(); + const visit = (relative: string): void => { + const normalized = relative.split('\\').join('/'); + if (visited.has(normalized)) return; + visited.add(normalized); + const source = fs.readFileSync(path.join(packageRoot, normalized), 'utf8'); + const expression = /(?:import|export)\s+(?:type\s+)?(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/g; + for (const match of source.matchAll(expression)) { + const request = match[1]; + if (!request?.startsWith('.')) continue; + const base = path.posix.normalize(path.posix.join(path.posix.dirname(normalized), request)); + const candidates = [`${base}.ts`, `${base}.tsx`, path.posix.join(base, 'index.ts')]; + const next = candidates.find((candidate) => fs.existsSync(path.join(packageRoot, candidate))); + if (next) visit(next); + } + }; + visit(entry); + return visited; +} + +describe('canonical takeover and deferred product slices', () => { + it('maps every spec catalog row exactly once with exact phase, predicate, and capabilities', () => { + expect(RELEASE_CATALOG).toHaveLength(MAX_MANIFEST_MODULES); + expect(MAX_MANIFEST_MODULES).toBe(20); + expect(MAX_TAKEOVER_MODULES).toBe(14); + expect(new Set(RELEASE_CATALOG.map(({ id }) => id))).toHaveLength(20); + expect( + RELEASE_CATALOG.map(({ id, phase, include, consumes, provides }) => [ + id, + phase, + include, + [...consumes], + [...provides], + ]) + ).toEqual(EXPECTED_CATALOG); + expect(RELEASE_CATALOG.every(({ obligation }) => obligation.trim().length > 0)).toBe(true); + expect(RELEASE_CATALOG.slice(0, 14).every(({ trigger }) => trigger === null)).toBe(true); + expect( + RELEASE_CATALOG.slice(14).every( + ({ trigger, provides }) => trigger === 'first_display_or_idle' && provides.length === 0 + ) + ).toBe(true); + }); + + it('selects every server-owned inclusion predicate without phase overrides', () => { + expect(selectedIds({ integrations: [] })).toEqual(['render_runtime']); + expect( + selectedIds({ + integrations: ['aps', 'gpt', 'prebid', 'osano', 'permutive', 'sourcepoint'], + creative: { enabled: true, clickGuard: false, renderGuard: true }, + gptDiagnosticsActive: true, + }) + ).toEqual([ + 'render_runtime', + 'aps', + 'creative', + 'gpt', + 'gpt_diagnostics', + 'osano_consent', + 'permutive_context', + 'sourcepoint_consent', + 'prebid', + 'diagnostics_presentation', + 'gpt_later', + 'osano_lifecycle', + 'permutive_lifecycle', + 'prebid_later', + 'sourcepoint_lifecycle', + ]); + expect( + selectedIds({ + integrations: ['prebid'], + creative: { enabled: true, clickGuard: false, renderGuard: false }, + renderTraceOverlay: true, + }) + ).toEqual(['render_runtime', 'prebid', 'diagnostics_presentation']); + expect(() => selectReleaseCatalog({ integrations: ['unknown'] })).toThrow( + 'Unknown integration: unknown' + ); + }); + + it('grants presentation authority to the one deferred presentation slice only', () => { + const presentationConsumers = RELEASE_CATALOG.filter(({ consumes }) => + consumes.some((edge) => edge.startsWith('trace.presentation.v1')) + ); + expect(presentationConsumers.map(({ id }) => id)).toEqual(['diagnostics_presentation']); + for (const id of ['aps', 'gpt', 'gpt_later']) { + expect(RELEASE_CATALOG.find((entry) => entry.id === id)?.consumes).not.toContain( + 'trace.presentation.v1' + ); + } + }); + + it.each(DEFERRED_FACTORIES)( + '%s exports its real release-bound deferred registration', + async (id, request, exportName) => { + const module = (await import(request)) as Record; + const factory = module[exportName]; + expect(factory).toEqual(expect.any(Function)); + const registration = Reflect.apply( + factory as (releaseId: string) => IntegrationRegistration, + undefined, + [RELEASE_ID] + ); + expect(registration).toMatchObject({ abi: 1, id, phase: 'deferred', releaseId: RELEASE_ID }); + expect(Reflect.ownKeys(registration).sort()).toEqual([ + 'abi', + 'id', + 'phase', + 'prepare', + 'releaseId', + ]); + expect(Object.isFrozen(registration)).toBe(true); + } + ); + + it('keeps production core and deferred entry graphs free of test seams and owner duplication', () => { + const coreSources = transitiveSources('src/composition/runtime_transport.ts'); + expect( + [...coreSources].some((source) => /(?:browser_test|\/test\/|ForTest)/.test(source)) + ).toBe(false); + expect( + [...coreSources].filter((source) => source.startsWith('src/integrations/')).sort() + ).toEqual([ + 'src/integrations/render_runtime/module.ts', + 'src/integrations/render_runtime/prebid_selection.ts', + ]); + + for (const [, request] of DEFERRED_FACTORIES) { + const entry = `${request.replace('../../', 'src/').replace(/^src\/src\//, 'src/')}.ts`; + const sources = transitiveSources(entry); + expect([...sources].some((source) => source.startsWith('src/adapters/'))).toBe(false); + expect( + [...sources].some((source) => /composition\/browser(?:_test)?\.ts$/.test(source)) + ).toBe(false); + expect([...sources].some((source) => source.endsWith('kernel/runtime.ts'))).toBe(false); + } + }); + + it('keeps the shared parser route owner in bootstrap instead of duplicating it in product slices', () => { + const routeOwner = 'src/first_display/leaf/browser_route_owner.ts'; + expect(transitiveSources('src/core/bootstrap.ts')).toContain(routeOwner); + for (const slice of ['datadome', 'google_tag_manager', 'lockr', 'permutive', 'sourcepoint']) { + expect(transitiveSources(`src/first_display/slices/${slice}.ts`)).not.toContain(routeOwner); + } + }); +}); 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 deleted file mode 100644 index e98a7d711..000000000 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ /dev/null @@ -1,5619 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -function apsRenderer() { - const bid = envelope.seatbid[0].bid[0]; - return { - type: 'aps' as const, - version: 1 as const, - accountId: 'example-account-id', - bidId: bid.id, - creativeId: 'fictional-creative-id', - tagType: 'iframe' as const, - creativeUrl: bid.ext.creativeurl, - aaxResponse: btoa(JSON.stringify(envelope)), - width: bid.w, - height: bid.h, - }; -} - -/** - * Default external-bundle manifest for tests. Mirrors what the real external - * Prebid.js bundle stamps on `window.__tsjs_prebid_bundle` (see - * build-prebid-external.mjs). Individual tests override and restore it. - */ -const DEFAULT_BUNDLE_MANIFEST = { - adapters: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], - bidderCodes: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], - userIdModules: ['sharedIdSystem'], -}; - -/** Loose bid shape used by the requestBids shim tests. */ -interface TestBid { - bidder: string; - params?: Record; -} - -/** Loose ad unit shape used by the requestBids shim tests. */ -interface TestAdUnit { - code?: string; - bids?: TestBid[]; -} - -/** Window properties the prebid shim reads and writes in these tests. */ -interface InjectedPrebidTestConfig { - accountId?: string; - timeout?: number; - debug?: boolean; - serverSideBidders?: string[]; - clientSideBidders?: string[]; - excludedGamAdUnitPathSuffixes?: unknown; -} - -interface TestGoogletag { - cmd: { push: (fn: () => void) => void }; - pubads: () => unknown; -} - -interface ApsPrebidTestEntry { - adUnitCode: string; - markUsed(): void; -} - -interface PrebidTestWindow { - pbjs?: unknown; - tsjs?: { - apsPrebidRenderers?: Record; - [key: string]: unknown; - }; - googletag?: TestGoogletag; - __tsjs_prebid?: InjectedPrebidTestConfig; - __tsjsPrebidShimInstalled?: boolean; - __tsjs_prebid_bundle?: unknown; - __tsjs_prebid_diagnostics?: { - userIdModules?: { - includedModules: string[]; - configuredUserIdNames: string[]; - missingConfiguredUserIdNames: string[]; - }; - }; -} - -const testWindow = window as unknown as PrebidTestWindow; - -/** Argument type accepted by the shimmed `pbjs.requestBids`. */ -type RequestBidsArg = Parameters['requestBids']>[0]; - -/** The bid adapter spec object registered via `pbjs.registerBidAdapter`. */ -interface TestAdapterSpec { - code: string; - supportedMediaTypes: string[]; - isBidRequestValid: (bid: Record) => boolean; - buildRequests: ( - bidRequests: Array>, - bidderRequest?: Record - ) => { - method: string; - url: string; - data: Record; - options: Record; - }; - interpretResponse: ( - response: Record, - request?: Record - ) => Array>; -} - -// Define mocks using vi.hoisted so they exist before the module under test is -// imported. The shim reads Prebid.js from the `window.pbjs` global (owned by -// the external bundle in production), so tests install the mock there instead -// of mocking module imports. -const { - mockSetConfig, - mockProcessQueue, - mockRequestBids, - mockRegisterBidAdapter, - mockGetUserIdsAsEids, - mockGetConfig, - mockRemoveAdUnit, - mockMarkWinningBidAsUsed, - mockOnEvent, - mockPbjs, -} = vi.hoisted(() => { - const mockSetConfig = vi.fn(); - const mockProcessQueue = vi.fn(); - const mockRequestBids = vi.fn(); - const mockRegisterBidAdapter = vi.fn(); - const mockMarkWinningBidAsUsed = vi.fn(); - const mockOnEvent = vi.fn(); - const mockGetUserIdsAsEids = vi.fn( - () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> - ); - const mockGetConfig = vi.fn(); - - const mockRemoveAdUnit = vi.fn((adUnitCode?: string | string[]) => { - if (!adUnitCode) { - mockPbjs.adUnits = []; - return; - } - const codes = new Set(Array.isArray(adUnitCode) ? adUnitCode : [adUnitCode]); - mockPbjs.adUnits = mockPbjs.adUnits.filter((unit) => !codes.has(unit.code)); - }); - const mockPbjs: { - setConfig: typeof mockSetConfig; - processQueue: typeof mockProcessQueue; - requestBids: typeof mockRequestBids; - registerBidAdapter: typeof mockRegisterBidAdapter; - getUserIdsAsEids: typeof mockGetUserIdsAsEids; - getConfig: typeof mockGetConfig; - removeAdUnit: ReturnType; - markWinningBidAsUsed: typeof mockMarkWinningBidAsUsed; - adUnits: TestAdUnit[]; - setTargetingForGPTAsync?: (adUnitCodes?: string[]) => void; - [key: string]: unknown; - } = { - setConfig: mockSetConfig, - processQueue: mockProcessQueue, - requestBids: mockRequestBids, - registerBidAdapter: mockRegisterBidAdapter, - getUserIdsAsEids: mockGetUserIdsAsEids, - getConfig: mockGetConfig, - removeAdUnit: mockRemoveAdUnit, - markWinningBidAsUsed: mockMarkWinningBidAsUsed, - onEvent: mockOnEvent, - adUnits: [] as TestAdUnit[], - setTargetingForGPTAsync: undefined as ((adUnitCodes?: string[]) => void) | undefined, - que: [] as Array<() => void>, - cmd: [] as Array<() => void>, - }; - - // Install the mock global BEFORE the shim module evaluates — the shim - // captures `window.pbjs` at module scope. - const w = globalThis.window as unknown as { - pbjs?: unknown; - __tsjs_prebid_bundle?: unknown; - }; - w.pbjs = mockPbjs; - w.__tsjs_prebid_bundle = { - adapters: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], - bidderCodes: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], - userIdModules: ['sharedIdSystem'], - }; - - return { - mockSetConfig, - mockProcessQueue, - mockRequestBids, - mockRegisterBidAdapter, - mockGetUserIdsAsEids, - mockGetConfig, - mockRemoveAdUnit, - mockMarkWinningBidAsUsed, - mockOnEvent, - mockPbjs, - }; -}); - -import { - collectBidders, - getInjectedConfig, - auctionBidsToPrebidBids, - installPrebidNpm, - installRefreshHandler, -} from '../../../src/integrations/prebid/index'; -import { installTsAdInit } from '../../../src/integrations/gpt/index'; -import type { AuctionBid } from '../../../src/core/auction'; -import { - claimFirstImpressionForTrustedServer, - consumePublisherFirstImpressionDelivery, - firstImpressionClaim, - observeFirstImpressionGptLifecycle, - registerPublisherFirstImpressionAuctions, - releaseTrustedServerFirstImpressionClaim, - reservePublisherFirstImpressionFallback, -} from '../../../src/core/first_impression'; -import { log } from '../../../src/core/log'; -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'; - -// 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. -beforeEach(() => { - delete testWindow.__tsjsPrebidShimInstalled; -}); - -describe('prebid/collectBidders', () => { - it('returns empty array for empty ad units', () => { - expect(collectBidders([])).toEqual([]); - }); - - it('returns empty array for ad units without bids', () => { - expect(collectBidders([{}, { bids: [] }])).toEqual([]); - }); - - it('collects unique bidders from ad units', () => { - const adUnits = [ - { bids: [{ bidder: 'appnexus' }, { bidder: 'rubicon' }] }, - { bids: [{ bidder: 'appnexus' }, { bidder: 'openx' }] }, - ]; - const result = collectBidders(adUnits); - expect(result).toHaveLength(3); - expect(result).toContain('appnexus'); - expect(result).toContain('rubicon'); - expect(result).toContain('openx'); - }); - - it('skips bids without a bidder field', () => { - const adUnits = [{ bids: [{ bidder: 'kargo' }, {}] }]; - expect(collectBidders(adUnits)).toEqual(['kargo']); - }); -}); - -describe('prebid/getInjectedConfig', () => { - afterEach(() => { - delete testWindow.__tsjs_prebid; - }); - - it('returns undefined when window.__tsjs_prebid is not set', () => { - expect(getInjectedConfig()).toBeUndefined(); - }); - - it('returns the injected config when present', () => { - testWindow.__tsjs_prebid = { accountId: 'server-42', timeout: 2000 }; - expect(getInjectedConfig()).toEqual({ accountId: 'server-42', timeout: 2000 }); - }); -}); - -describe('prebid/auctionBidsToPrebidBids', () => { - it('maps AuctionBid[] to Prebid bid response objects', () => { - const auctionBids: AuctionBid[] = [ - { - impid: 'div-gpt-1', - adm: '
Ad
', - price: 3.5, - width: 300, - height: 250, - seat: 'appnexus', - creativeId: 'cr-123', - adomain: ['example.com'], - }, - ]; - const bidRequests = [{ adUnitCode: 'div-gpt-1', bidId: 'bid-abc' }]; - - const result = auctionBidsToPrebidBids(auctionBids, bidRequests, true); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - requestId: 'bid-abc', - cpm: 3.5, - width: 300, - height: 250, - ad: '
Ad
', - ttl: 300, - creativeId: 'cr-123', - netRevenue: true, - currency: 'USD', - bidderCode: 'appnexus', - meta: { advertiserDomains: ['example.com'] }, - }); - }); - - it('preserves an APS renderer without converting it to executable markup', () => { - const renderer = apsRenderer(); - const auctionBids: AuctionBid[] = [ - { - impid: 'div-aps', - adm: '', - renderer, - price: 1.23, - width: 300, - height: 250, - seat: 'aps', - creativeId: 'fictional-creative-id', - adomain: ['advertiser.example'], - }, - ]; - - const result = auctionBidsToPrebidBids( - auctionBids, - [{ adUnitCode: 'div-aps', bidId: 'prebid-request-id' }], - true - ); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual( - expect.objectContaining({ - requestId: 'prebid-request-id', - bidderCode: 'aps', - ad: '', - trustedServerRenderer: renderer, - meta: expect.objectContaining({ trustedServerRenderer: renderer }), - }) - ); - }); - - it('drops an APS bid whose renderer fails admission validation', () => { - const result = auctionBidsToPrebidBids( - [ - { - impid: 'div-aps', - adm: '', - renderer: { ...apsRenderer(), aaxResponse: 'invalid' }, - price: 1.23, - width: 300, - height: 250, - seat: 'aps', - creativeId: 'fictional-creative-id', - adomain: [], - }, - ], - [{ adUnitCode: 'div-aps', bidId: 'prebid-request-id' }], - true - ); - - expect(result).toEqual([]); - }); - - it('falls back to impid when no matching bidRequest found', () => { - const auctionBids: AuctionBid[] = [ - { - impid: 'div-gpt-2', - adm: '
Ad2
', - price: 2.0, - width: 728, - height: 90, - seat: 'rubicon', - creativeId: 'cr-456', - adomain: [], - }, - ]; - - const result = auctionBidsToPrebidBids(auctionBids, [], true); - - expect(result).toHaveLength(1); - expect(result[0].requestId).toBe('div-gpt-2'); - expect(result[0].cpm).toBe(2.0); - }); - - it('handles multiple bids across different impids', () => { - const auctionBids: AuctionBid[] = [ - { - impid: 'slot-a', - adm: '
A
', - price: 1.0, - width: 300, - height: 250, - seat: 'bidderA', - creativeId: 'cr-a', - adomain: [], - }, - { - impid: 'slot-b', - adm: '
B
', - price: 2.0, - width: 728, - height: 90, - seat: 'bidderB', - creativeId: 'cr-b', - adomain: ['b.com'], - }, - ]; - const bidRequests = [ - { adUnitCode: 'slot-a', bidId: 'req-a' }, - { adUnitCode: 'slot-b', bidId: 'req-b' }, - ]; - - const result = auctionBidsToPrebidBids(auctionBids, bidRequests, true); - - expect(result).toHaveLength(2); - expect(result[0].requestId).toBe('req-a'); - expect(result[1].requestId).toBe('req-b'); - }); -}); - -describe('prebid/installPrebidNpm', () => { - beforeEach(() => { - vi.clearAllMocks(); - // Reset requestBids to the mock so each test starts fresh - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); - mockGetConfig.mockReset(); - document.cookie = 'ts-eids=; Path=/; Max-Age=0'; - delete testWindow.__tsjs_prebid; - delete testWindow.__tsjs_prebid_diagnostics; - delete testWindow.tsjs; - delete mockPbjs['__tsApsBidResponseListenerInstalled']; - delete mockPbjs.bidderSettings; - }); - - afterEach(() => { - delete mockPbjs.bidderSettings; - vi.restoreAllMocks(); - }); - - it('registers the trustedServer bid adapter', () => { - installPrebidNpm(); - - expect(mockRegisterBidAdapter).toHaveBeenCalledTimes(1); - expect(mockRegisterBidAdapter).toHaveBeenCalledWith( - undefined, - 'trustedServer', - expect.objectContaining({ - code: 'trustedServer', - supportedMediaTypes: ['banner'], - isBidRequestValid: expect.any(Function), - buildRequests: expect.any(Function), - interpretResponse: expect.any(Function), - }) - ); - }); - - it('registers normalized APS descriptors at bidAccepted under Prebid generated ad IDs', () => { - installPrebidNpm(); - - const bidAcceptedListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidAccepted' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidAcceptedListener).toBeTypeOf('function'); - - const renderer = apsRenderer(); - const normalizedBid: Record = { - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'prebid-generated-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - meta: { trustedServerRenderer: renderer }, - }; - bidAcceptedListener!(normalizedBid); - - const entry = testWindow.tsjs?.apsPrebidRenderers?.['prebid-generated-ad-id']; - expect(entry).toEqual( - expect.objectContaining({ - adUnitCode: 'div-aps', - renderer, - expiresAt: expect.any(Number), - markUsed: expect.any(Function), - }) - ); - - expect(normalizedBid).not.toHaveProperty('trustedServerRenderer'); - expect(normalizedBid['meta']).not.toHaveProperty('trustedServerRenderer'); - entry?.markUsed(); - expect(mockMarkWinningBidAsUsed).toHaveBeenCalledWith({ - adId: 'prebid-generated-ad-id', - events: true, - }); - }); - - it('keeps bidResponse as a top-level renderer compatibility fallback', () => { - installPrebidNpm(); - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - const renderer = apsRenderer(); - - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'fallback-ad-id', - adUnitCode: 'div-aps', - trustedServerRenderer: renderer, - }); - - expect(testWindow.tsjs?.apsPrebidRenderers?.['fallback-ad-id']?.renderer).toEqual(renderer); - }); - - it('makes failed APS renderer registrations ineligible when zero-CPM bids are allowed', () => { - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - const malformedBid: Record = { - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'malformed-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - cpm: 1.23, - meta: { trustedServerRenderer: { ...apsRenderer(), aaxResponse: 'invalid' } }, - }; - bidResponseListener!(malformedBid); - bidResponseListener!({ - adapterCode: 'publisherAdapter', - bidderCode: 'aps', - adId: 'foreign-ad-id', - adUnitCode: 'div-aps', - trustedServerRenderer: apsRenderer(), - }); - - expect(testWindow.tsjs?.apsPrebidRenderers?.['malformed-ad-id']).toBeUndefined(); - expect(testWindow.tsjs?.apsPrebidRenderers?.['foreign-ad-id']).toBeUndefined(); - expect(malformedBid).not.toHaveProperty('trustedServerRenderer'); - expect(malformedBid['meta']).not.toHaveProperty('trustedServerRenderer'); - // Prebid's allowZeroCpmBids path still requires cpm >= 0. - expect(malformedBid['cpm']).toBe(-1); - expect(warnSpy).toHaveBeenCalledWith( - '[tsjs-prebid] rejected APS renderer capability that failed registration' - ); - }); - - it('registers APS renderer via meta when Prebid strips the custom top-level field', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - const renderer = apsRenderer(); - const [built] = auctionBidsToPrebidBids( - [ - { - impid: 'div-aps', - renderer, - price: 1.0, - width: 300, - height: 250, - seat: 'aps', - creativeId: 'cr-aps', - adomain: [], - }, - ], - [{ adUnitCode: 'div-aps', bidId: 'req-strip' }], - true - ); - - // Prebid delivered the bid with the custom top-level field REMOVED — only - // first-class fields (requestId, meta) survive normalization. - const delivered: Record = { - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'stripped-field-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - requestId: built.requestId, - meta: built.meta, - }; - bidResponseListener!(delivered); - - const entry = testWindow.tsjs?.apsPrebidRenderers?.['stripped-field-ad-id']; - expect(entry).toEqual( - expect.objectContaining({ adUnitCode: 'div-aps', renderer, markUsed: expect.any(Function) }) - ); - // The capability is scrubbed from the delivered bid after registration. - expect(delivered.meta).not.toHaveProperty('trustedServerRenderer'); - }); - - it('registers a distinct renderer for each of multiple APS bids on one imp', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - // Two APS bids for the same imp share a requestId; each built bid must carry - // its own descriptor so neither registration is lost. - const firstRenderer = { ...apsRenderer(), creativeId: 'cr-aps-first' }; - const secondRenderer = { ...apsRenderer(), creativeId: 'cr-aps-second' }; - const sharedBid = { - impid: 'div-aps', - price: 1.0, - width: 300, - height: 250, - seat: 'aps', - adomain: [], - }; - const built = auctionBidsToPrebidBids( - [ - { ...sharedBid, renderer: firstRenderer, creativeId: 'cr-aps-first' }, - { ...sharedBid, renderer: secondRenderer, creativeId: 'cr-aps-second' }, - ], - [{ adUnitCode: 'div-aps', bidId: 'req-shared' }], - true - ); - expect(built).toHaveLength(2); - - for (const [index, bid] of built.entries()) { - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: `shared-imp-ad-id-${index}`, - adUnitCode: 'div-aps', - ttl: 300, - requestId: bid.requestId, - meta: bid.meta, - }); - } - - const registry = testWindow.tsjs?.apsPrebidRenderers; - expect(registry?.['shared-imp-ad-id-0']).toEqual( - expect.objectContaining({ renderer: firstRenderer }) - ); - expect(registry?.['shared-imp-ad-id-1']).toEqual( - expect.objectContaining({ renderer: secondRenderer }) - ); - }); - - it('does not register anything for a stripped bid that carries no meta descriptor', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - // First bid registers through the surviving custom-field path. - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'surviving-field-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - requestId: 'req-reused', - trustedServerRenderer: apsRenderer(), - }); - expect(testWindow.tsjs?.apsPrebidRenderers?.['surviving-field-ad-id']).toBeDefined(); - - // A later field-stripped bid reusing the same requestId has no descriptor of its - // own, so no stale renderer may be registered for it. - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'reused-request-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - requestId: 'req-reused', - meta: { advertiserDomains: [] }, - }); - expect(testWindow.tsjs?.apsPrebidRenderers?.['reused-request-ad-id']).toBeUndefined(); - }); - - it('registers and scrubs on bidAccepted before later events can observe the descriptor', () => { - installPrebidNpm(); - - const bidAcceptedListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidAccepted' - )?.[1] as ((bid: Record) => void) | undefined; - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidAcceptedListener).toBeTypeOf('function'); - expect(bidResponseListener).toBeTypeOf('function'); - - const renderer = apsRenderer(); - const [built] = auctionBidsToPrebidBids( - [ - { - impid: 'div-aps', - renderer, - price: 1.0, - width: 300, - height: 250, - seat: 'aps', - creativeId: 'cr-aps', - adomain: [], - }, - ], - [{ adUnitCode: 'div-aps', bidId: 'req-accepted' }], - true - ); - - // Prebid emits bidAccepted and bidResponse with the same in-place-mutated - // bid object; the bidAccepted pass must register and scrub both carriers. - const accepted: Record = { - ...built, - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'accepted-ad-id', - adUnitCode: 'div-aps', - }; - bidAcceptedListener!(accepted); - - expect(testWindow.tsjs?.apsPrebidRenderers?.['accepted-ad-id']).toEqual( - expect.objectContaining({ adUnitCode: 'div-aps', renderer }) - ); - expect(accepted).not.toHaveProperty('trustedServerRenderer'); - expect(accepted.meta).not.toHaveProperty('trustedServerRenderer'); - - // The later bidResponse pass sees the already-scrubbed object and no-ops. - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - bidResponseListener!(accepted); - expect(testWindow.tsjs?.apsPrebidRenderers?.['accepted-ad-id']).toEqual( - expect.objectContaining({ renderer }) - ); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - it('tolerates a non-object meta value on the bid', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - // A module overwrote meta with a string and there is no top-level field: - // nothing registers and nothing throws. - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'corrupt-meta-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - meta: 'corrupted', - }); - expect(testWindow.tsjs?.apsPrebidRenderers?.['corrupt-meta-ad-id']).toBeUndefined(); - - // With a surviving top-level field the corrupt meta must not block registration. - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'corrupt-meta-with-field-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - meta: 'corrupted', - trustedServerRenderer: apsRenderer(), - }); - expect(testWindow.tsjs?.apsPrebidRenderers?.['corrupt-meta-with-field-ad-id']).toBeDefined(); - }); - - it('does not register malformed or non-trusted APS renderer capabilities', () => { - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - const malformedBid: Record = { - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'malformed-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - trustedServerRenderer: { ...apsRenderer(), aaxResponse: 'invalid' }, - }; - bidResponseListener!(malformedBid); - bidResponseListener!({ - adapterCode: 'publisherAdapter', - bidderCode: 'aps', - adId: 'foreign-ad-id', - adUnitCode: 'div-aps', - trustedServerRenderer: apsRenderer(), - }); - - expect(testWindow.tsjs?.apsPrebidRenderers?.['malformed-ad-id']).toBeUndefined(); - expect(testWindow.tsjs?.apsPrebidRenderers?.['foreign-ad-id']).toBeUndefined(); - expect(malformedBid).not.toHaveProperty('trustedServerRenderer'); - expect(warnSpy).toHaveBeenCalledWith( - '[tsjs-prebid] rejected APS renderer capability that failed registration' - ); - }); - - it('calls setConfig with debug=false by default', () => { - installPrebidNpm(); - - expect(mockSetConfig).toHaveBeenCalledWith(expect.objectContaining({ debug: false })); - }); - - it('respects custom config values', () => { - installPrebidNpm({ - endpoint: '/custom/auction', - timeout: 2000, - debug: true, - }); - - expect(mockSetConfig).toHaveBeenCalledWith( - expect.objectContaining({ debug: true, bidderTimeout: 2000 }) - ); - }); - - it('calls processQueue after configuration', () => { - installPrebidNpm(); - expect(mockProcessQueue).toHaveBeenCalledTimes(1); - }); - - it('reports the User ID modules selected by the generated bundle', () => { - installPrebidNpm(); - - expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ - includedModules: ['sharedIdSystem'], - configuredUserIdNames: [], - missingConfiguredUserIdNames: [], - }); - }); - - it('refreshes late User ID config without repeating missing-module warnings', () => { - installPrebidNpm(); - mockGetConfig.mockImplementation((key?: string) => - key === 'userSync.userIds' ? [{ name: 'sharedId' }, { name: 'pairId' }] : {} - ); - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - - mockPbjs.requestBids({ adUnits: [] }); - mockPbjs.requestBids({ adUnits: [] }); - - expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ - includedModules: ['sharedIdSystem'], - configuredUserIdNames: ['pairId', 'sharedId'], - missingConfiguredUserIdNames: ['pairId'], - }); - expect( - warnSpy.mock.calls.filter(([message]) => String(message).includes('"pairId"')) - ).toHaveLength(1); - }); - - it('returns the pbjs instance', () => { - const result = installPrebidNpm(); - expect(result).toBe(mockPbjs); - }); - - it('installs only once per page via the __tsjsPrebidShimInstalled sentinel', () => { - const first = installPrebidNpm(); - const wrappedRequestBids = mockPbjs.requestBids; - const second = installPrebidNpm(); - - expect(second).toBe(first); - expect(mockRegisterBidAdapter).toHaveBeenCalledTimes(1); - expect(mockPbjs.requestBids).toBe(wrappedRequestBids); - expect(testWindow.__tsjsPrebidShimInstalled).toBe(true); - }); - - it('warns once about an unstamped User ID manifest instead of once per module', () => { - delete testWindow.__tsjs_prebid_bundle; - mockGetConfig.mockImplementation((key?: string) => - key === 'userSync.userIds' ? [{ name: 'sharedId' }, { name: 'pairId' }] : {} - ); - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - - installPrebidNpm(); - mockPbjs.requestBids({ adUnits: [] }); - - expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ - includedModules: [], - configuredUserIdNames: ['pairId', 'sharedId'], - missingConfiguredUserIdNames: [], - }); - const manifestWarnings = warnSpy.mock.calls.filter(([message]) => - String(message).includes('did not stamp a User ID module manifest') - ); - expect(manifestWarnings).toHaveLength(1); - const moduleWarnings = warnSpy.mock.calls.filter(([message]) => - String(message).includes('is not included in the external bundle') - ); - expect(moduleWarnings).toHaveLength(0); - - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - describe('adapter spec', () => { - function getAdapterSpec(): TestAdapterSpec { - installPrebidNpm(); - return mockRegisterBidAdapter.mock.calls[0][2] as TestAdapterSpec; - } - - it('isBidRequestValid always returns true', () => { - const spec = getAdapterSpec(); - expect(spec.isBidRequestValid({})).toBe(true); - }); - - it('buildRequests creates a POST request to /auction', () => { - const spec = getAdapterSpec(); - const bidRequests = [ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]; - - const result = spec.buildRequests(bidRequests); - - expect(result.method).toBe('POST'); - expect(result.url).toBe('/auction'); - expect(result.options).toEqual({ contentType: 'application/json' }); - - const payload = JSON.parse(result.data); - expect(payload.adUnits).toHaveLength(1); - expect(payload.adUnits[0].code).toBe('div-gpt-1'); - expect(payload.eids).toBeUndefined(); - }); - - it('buildRequests includes current Prebid EIDs in the /auction payload', () => { - const spec = getAdapterSpec(); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'id5-sync.com', - uids: [{ id: 'ID5_abc', atype: 1 }], - }, - { - source: 'sharedid.org', - uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], - }, - { - source: 'google.com', - uids: [{ id: 'pair_123', atype: 571187 }], - }, - ]); - - const result = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); - - const payload = JSON.parse(result.data); - expect(payload.eids).toEqual([ - { - source: 'id5-sync.com', - uids: [{ id: 'ID5_abc', atype: 1 }], - }, - { - source: 'sharedid.org', - uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], - }, - { - source: 'google.com', - uids: [{ id: 'pair_123', atype: 571187 }], - }, - ]); - }); - - it('buildRequests clears stale ts-eids cookie when current Prebid EIDs are absent', () => { - const spec = getAdapterSpec(); - document.cookie = 'ts-eids=stale-value'; - mockGetUserIdsAsEids.mockReturnValue([]); - - spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); - - expect(document.cookie).toBe(''); - }); - - it('buildRequests preserves uid ext and sanitizes invalid atype values', () => { - const spec = getAdapterSpec(); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'adserver.org', - uids: [ - { - id: 'uid-with-ext', - atype: 1, - ext: { provider: 'liveintent.com', rtiPartner: 'TDID' }, - }, - { - id: 'uid-bad-atype', - atype: 2_147_483_648, - ext: { keep: true }, - }, - { - id: 'uid-float-atype', - atype: 1.5, - }, - ], - }, - ]); - - const result = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); - - const payload = JSON.parse(result.data); - expect(payload.eids).toEqual([ - { - source: 'adserver.org', - uids: [ - { - id: 'uid-with-ext', - atype: 1, - ext: { provider: 'liveintent.com', rtiPartner: 'TDID' }, - }, - { - id: 'uid-bad-atype', - ext: { keep: true }, - }, - { - id: 'uid-float-atype', - }, - ], - }, - ]); - }); - - it('buildRequests uses custom endpoint when configured', () => { - mockRegisterBidAdapter.mockClear(); - installPrebidNpm({ endpoint: '/custom/auction' }); - const spec = mockRegisterBidAdapter.mock.calls[0][2]; - - const result = spec.buildRequests([ - { - adUnitCode: 'slot1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - }, - ]); - - expect(result.url).toBe('/custom/auction'); - }); - - it('interpretResponse parses seatbid and returns Prebid bids', () => { - const spec = getAdapterSpec(); - - const built = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidId: 'bid-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - }, - ]); - - const serverResponse = { - body: { - seatbid: [ - { - seat: 'appnexus', - bid: [ - { - impid: 'div-gpt-1', - price: 4.5, - adm: '
Creative
', - w: 300, - h: 250, - crid: 'cr-789', - adomain: ['advertiser.com'], - }, - ], - }, - ], - }, - }; - - const bids = spec.interpretResponse(serverResponse, built); - - expect(bids).toHaveLength(1); - expect(bids[0]).toEqual( - expect.objectContaining({ - requestId: 'bid-1', - cpm: 4.5, - width: 300, - height: 250, - ad: '
Creative
', - currency: 'USD', - netRevenue: true, - bidderCode: 'appnexus', - }) - ); - }); - - it('interpretResponse handles empty/missing seatbid', () => { - const spec = getAdapterSpec(); - const built = spec.buildRequests([]); - - expect(spec.interpretResponse({ body: {} }, built)).toEqual([]); - expect(spec.interpretResponse({ body: null }, built)).toEqual([]); - expect(spec.interpretResponse({}, built)).toEqual([]); - }); - - it('keeps request mapping isolated across overlapping auctions', () => { - const spec = getAdapterSpec(); - - const requestA = spec.buildRequests([ - { - adUnitCode: 'slot-a', - bidId: 'bid-a', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - }, - ]); - const requestB = spec.buildRequests([ - { - adUnitCode: 'slot-b', - bidId: 'bid-b', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - }, - ]); - - const responseA = { - body: { - seatbid: [ - { - seat: 'appnexus', - bid: [{ impid: 'slot-a', price: 1.1, adm: '
A
', w: 300, h: 250 }], - }, - ], - }, - }; - const responseB = { - body: { - seatbid: [ - { - seat: 'rubicon', - bid: [{ impid: 'slot-b', price: 2.2, adm: '
B
', w: 300, h: 250 }], - }, - ], - }, - }; - - const bidsA = spec.interpretResponse(responseA, requestA); - const bidsB = spec.interpretResponse(responseB, requestB); - - expect(bidsA[0].requestId).toBe('bid-a'); - expect(bidsB[0].requestId).toBe('bid-b'); - }); - }); - - describe('requestBids shim', () => { - beforeEach(() => { - testWindow.__tsjs_prebid = { - serverSideBidders: ['appnexus', 'rubicon', 'kargo', 'openx'], - }; - }); - - it('limits a global request to opts.adUnitCodes', () => { - const selected = document.createElement('div'); - selected.id = 'selected-global-unit'; - const unselected = document.createElement('div'); - unselected.id = 'unselected-global-unit'; - document.body.append(selected, unselected); - const selectedUnit = { - code: selected.id, - bids: [{ bidder: 'appnexus', params: { placementId: 1 } }], - }; - const unselectedUnit = { - code: unselected.id, - bids: [{ bidder: 'rubicon', params: { accountId: 2 } }], - }; - mockPbjs.adUnits = [selectedUnit, unselectedUnit]; - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ adUnitCodes: [selected.id] } as unknown as RequestBidsArg); - - expect(selectedUnit.bids.map((bid) => bid.bidder)).toEqual(['trustedServer']); - expect(unselectedUnit.bids).toEqual([{ bidder: 'rubicon', params: { accountId: 2 } }]); - expect((testWindow.tsjs as TsjsApi).firstImpression?.slots[selected.id]).toBeDefined(); - expect((testWindow.tsjs as TsjsApi).firstImpression?.slots[unselected.id]).toBeUndefined(); - - selected.remove(); - unselected.remove(); - }); - - it('preserves publisher ts adserverTargeting while adding trustedServer settings', () => { - const publisherTargeting = [{ key: 'ts', val: () => 'publisher-value' }]; - mockPbjs.bidderSettings = { - exampleBidder: { adserverTargeting: publisherTargeting }, - }; - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'exampleBidder', params: {} }] }], - } as unknown as RequestBidsArg); - - const bidderSettings = mockPbjs.bidderSettings as { - exampleBidder: { adserverTargeting: typeof publisherTargeting }; - trustedServer: { - allowAlternateBidderCodes: boolean; - allowedAlternateBidderCodes: string[]; - }; - }; - expect(bidderSettings.exampleBidder.adserverTargeting).toBe(publisherTargeting); - expect(bidderSettings.exampleBidder.adserverTargeting[0].key).toBe('ts'); - expect(bidderSettings.exampleBidder.adserverTargeting[0].val()).toBe('publisher-value'); - expect(bidderSettings.trustedServer).toEqual( - expect.objectContaining({ - allowAlternateBidderCodes: true, - allowedAlternateBidderCodes: ['*'], - }) - ); - }); - - it('injects trustedServer bidder into every ad unit', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { bids: [{ bidder: 'appnexus', params: {} }] }, - { bids: [{ bidder: 'rubicon', params: {} }] }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - // Each ad unit should have trustedServer added - for (const unit of adUnits) { - const hasTsBidder = unit.bids.some((b: TestBid) => b.bidder === 'trustedServer'); - expect(hasTsBidder).toBe(true); - } - - const trustedServerBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer'); - expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: {} }); - expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); - expect(adUnits[1].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); - - // Should call through to original requestBids - expect(mockRequestBids).toHaveBeenCalled(); - }); - - it('does not duplicate trustedServer if already present', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [{ bids: [{ bidder: 'trustedServer', params: {} }] }]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsCount = adUnits[0].bids.filter((b: TestBid) => b.bidder === 'trustedServer').length; - expect(tsCount).toBe(1); - }); - - it('folds only authoritative routes across mixed client, PBS, APS, and standard demand', () => { - testWindow.__tsjs_prebid = { - serverSideBidders: ['pbsRoute', 'standardRoute'], - clientSideBidders: ['exampleBrowser'], - }; - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'exampleBrowser', params: { placement: 'browser' } }, - { bidder: 'pbsRoute', params: { placement: 'pbs' } }, - { bidder: 'aps', params: { slot: 'aps' } }, - { bidder: 'standardRoute', params: { placement: 'standard' } }, - { bidder: 'pbs-provider-id', params: { forbidden: true } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const trustedServerBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer'); - expect(trustedServerBid?.params?.bidderParams).toEqual({ - pbsRoute: { placement: 'pbs' }, - standardRoute: { placement: 'standard' }, - }); - expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual([ - 'exampleBrowser', - 'aps', - 'pbs-provider-id', - 'trustedServer', - ]); - }); - - it('preserves prototype-named server-side bidders as owned JSON properties', () => { - testWindow.__tsjs_prebid = { serverSideBidders: ['__proto__'] }; - const pbjs = installPrebidNpm(); - const adUnits = [ - { - bids: [{ bidder: '__proto__', params: { placement: 'server-owned' } }], - }, - ]; - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const trustedServerBid = adUnits[0].bids.find((bid) => bid.bidder === 'trustedServer'); - const bidderParams = trustedServerBid?.params?.bidderParams as Record; - expect(Object.prototype.hasOwnProperty.call(bidderParams, '__proto__')).toBe(true); - expect(bidderParams['__proto__']).toEqual({ placement: 'server-owned' }); - expect(JSON.parse(JSON.stringify(bidderParams))).toEqual( - Object.fromEntries([['__proto__', { placement: 'server-owned' }]]) - ); - expect(adUnits[0].bids.map((bid) => bid.bidder)).toEqual(['trustedServer']); - }); - - it('does not let returned bidder aliases or APS renderer aliases affect folding', () => { - testWindow.__tsjs_prebid = { serverSideBidders: ['configuredRoute'] }; - const pbjs = installPrebidNpm(); - const adUnits = [ - { - bids: [ - { bidder: 'configuredRoute', params: { placement: 1 } }, - { bidder: 'alternateReturnedSeat', params: { placement: 2 } }, - { bidder: 'apsRendererAlias', params: { placement: 3 } }, - ], - }, - ]; - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const trustedServerBid = adUnits[0].bids.find((bid) => bid.bidder === 'trustedServer'); - expect(trustedServerBid?.params?.bidderParams).toEqual({ - configuredRoute: { placement: 1 }, - }); - expect(adUnits[0].bids.map((bid) => bid.bidder)).toEqual([ - 'alternateReturnedSeat', - 'apsRendererAlias', - 'trustedServer', - ]); - }); - - it('preserves captured bidder params when requestBids runs twice on the same ad unit', () => { - const pbjs = installPrebidNpm(); - - // First auction: inline server-side params supplied by the publisher. - const adUnits = [ - { - code: 'div-1', - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - // Second auction (refresh/re-auction) with the SAME ad unit object: the - // server-side bidder entries were already pruned, so the shim must not - // overwrite the captured params with an empty object. - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const trustedServerBid = adUnits[0].bids.find( - (b: TestBid) => b.bidder === 'trustedServer' - ) as TestBid; - expect(trustedServerBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, - }); - }); - - it('adds bids array to ad units that have none', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [{ code: 'div-1' }] as TestAdUnit[]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - expect(adUnits[0].bids).toHaveLength(1); - expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); - }); - - it('normalizes a truthy non-array bids value without throwing', () => { - const pbjs = installPrebidNpm(); - const adUnits = [ - { code: 'example-malformed-slot', bids: { malformed: true } }, - ] as TestAdUnit[]; - - expect(() => pbjs.requestBids({ adUnits } as unknown as RequestBidsArg)).not.toThrow(); - - expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); - }); - - it('preserves the empty stored-request envelope on initial and repeated requests', () => { - const pbjs = installPrebidNpm(); - const adUnits = [ - { - code: 'stored-slot', - bids: [{ bidder: 'trustedServer', params: { bidderParams: {} } }], - }, - ]; - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); - }); - - it('includes zone from mediaTypes.banner.name in trustedServer params', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - code: 'ad-header-0', - mediaTypes: { banner: { name: 'header', sizes: [[728, 90]] } }, - bids: [{ bidder: 'kargo', params: { placementId: '_abc' } }], - }, - { - code: 'ad-fixed_bottom-0', - mediaTypes: { banner: { name: 'fixed_bottom', sizes: [[728, 90]] } }, - bids: [{ bidder: 'kargo', params: { placementId: '_def' } }], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid0 = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid0.params.zone).toBe('header'); - - const tsBid1 = adUnits[1].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid1.params.zone).toBe('fixed_bottom'); - }); - - it('omits zone when mediaTypes.banner.name is not set', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - code: 'ad-header-0', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - bids: [{ bidder: 'appnexus', params: {} }], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params.zone).toBeUndefined(); - }); - - it('omits zone when ad unit has no mediaTypes', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [{ bids: [{ bidder: 'rubicon', params: {} }] }]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params.zone).toBeUndefined(); - }); - - it('clears stale zone when existing trustedServer bid is reused', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - code: 'ad-header-0', - mediaTypes: { banner: { name: 'header', sizes: [[300, 250]] } }, - bids: [ - { bidder: 'trustedServer', params: { custom: 'keep' } }, - { bidder: 'kargo', params: { placementId: '_abc' } }, - ], - }, - ]; - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - let tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params.zone).toBe('header'); - expect(tsBid.params.custom).toBe('keep'); - - delete adUnits[0].mediaTypes.banner.name; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params.zone).toBeUndefined(); - expect(tsBid.params.custom).toBe('keep'); - }); - - it('falls back to pbjs.adUnits when requestObj has no adUnits', () => { - const pbjs = installPrebidNpm(); - - mockPbjs.adUnits = [{ bids: [{ bidder: 'openx', params: {} }] }] as TestAdUnit[]; - pbjs.requestBids({} as RequestBidsArg); - - const hasTsBidder = (mockPbjs.adUnits[0].bids ?? []).some( - (b: TestBid) => b.bidder === 'trustedServer' - ); - expect(hasTsBidder).toBe(true); - }); - - it('syncs a structured ts-eids cookie after bidsBackHandler', () => { - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'sharedid.org', - uids: [ - { id: 'shared_123', atype: 3 }, - { id: 'shared_456', ext: { provider: 'example' } }, - ], - }, - ]); - - 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(JSON.parse(atob(cookieValue!))).toEqual([ - { - source: 'sharedid.org', - uids: [ - { id: 'shared_123', atype: 3 }, - { id: 'shared_456', ext: { provider: 'example' } }, - ], - }, - ]); - }); - - it('clears ts-eids cookie after bidsBackHandler when no current EIDs remain', () => { - document.cookie = `ts-eids=${btoa(JSON.stringify([{ source: 'sharedid.org', uids: [{ id: 'stale' }] }]))}`; - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - mockGetUserIdsAsEids.mockReturnValue([]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - expect(document.cookie).toBe(''); - }); - }); -}); - -describe('prebid/installPrebidNpm with server-injected config', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); - document.cookie = 'ts-eids=; Path=/; Max-Age=0'; - delete testWindow.__tsjs_prebid; - }); - - afterEach(() => { - delete testWindow.__tsjs_prebid; - }); - - it('reads timeout and debug from window.__tsjs_prebid', () => { - testWindow.__tsjs_prebid = { timeout: 1500, debug: true }; - - installPrebidNpm(); - - expect(mockSetConfig).toHaveBeenCalledWith( - expect.objectContaining({ debug: true, bidderTimeout: 1500 }) - ); - }); - - it('keeps browser timeout and debug independent from multiple PBS routes', () => { - testWindow.__tsjs_prebid = { - timeout: 1750, - debug: false, - serverSideBidders: ['pbsPrimaryRoute', 'pbsSecondaryRoute'], - }; - - installPrebidNpm(); - - expect(mockSetConfig).toHaveBeenCalledWith({ debug: false, bidderTimeout: 1750 }); - }); - - it('explicit config overrides server-injected values', () => { - testWindow.__tsjs_prebid = { timeout: 1500, debug: true }; - - installPrebidNpm({ timeout: 3000, debug: false }); - - expect(mockSetConfig).toHaveBeenCalledWith( - expect.objectContaining({ debug: false, bidderTimeout: 3000 }) - ); - }); - - it('works with no config argument and no injected config', () => { - installPrebidNpm(); - - expect(mockSetConfig).toHaveBeenCalledWith(expect.objectContaining({ debug: false })); - expect(mockProcessQueue).toHaveBeenCalled(); - }); -}); - -describe('prebid/installRefreshHandler', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRequestBids.mockReset(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - mockPbjs.setTargetingForGPTAsync = undefined; - testWindow.tsjs = undefined; - delete testWindow.googletag; - testWindow.__tsjs_prebid = { - serverSideBidders: ['appnexus', 'rubicon', 'kargo', 'openx', 'exampleServer'], - }; - document.body.replaceChildren(); - }); - - afterEach(() => { - testWindow.tsjs = undefined; - delete testWindow.googletag; - delete testWindow.__tsjs_prebid; - document.body.replaceChildren(); - }); - - function attachTestSlot(code: string): void { - const element = document.createElement('div'); - element.id = code; - document.body.appendChild(element); - } - - it('builds refresh ad units from injected slot metadata', () => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [ - [970, 250], - [728, 90], - ], - targeting: { zone: 'homepage', pos: 'atf' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 750, - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - mediaTypes: { - banner: { - name: 'homepage', - sizes: [ - [970, 250], - [728, 90], - ], - }, - }, - bids: [{ bidder: 'trustedServer', params: { zone: 'homepage' } }], - }), - ], - }) - ); - }); - - it('resolves the exact slot when div_ids share a prefix', () => { - // Regression: a single find() with a startsWith() clause returned the - // first slot whose div_id is a prefix of the element id. With div_ids - // "div-ad" and "div-ad-header", refreshing the "div-ad-header" element - // must resolve to the header slot, not the shorter prefix slot. - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'prefix_ad', - gam_unit_path: '/123/prefix', - div_id: 'div-ad', - formats: [[300, 250]], - targeting: { zone: 'prefix' }, - }, - { - id: 'header_ad', - gam_unit_path: '/123/header', - div_id: 'div-ad-header', - formats: [[970, 250]], - targeting: { zone: 'header' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - code: 'div-ad-header', - mediaTypes: { - banner: { - name: 'header', - sizes: [[970, 250]], - }, - }, - }), - ], - }) - ); - }); - - it('scopes the GPT targeting call to the refreshed slot code', () => { - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - // Run the bidsBackHandler synchronously so the targeting call fires. - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - const originalRefresh = vi.fn(); - // Only the header slot is refreshed; the footer slot must be untouched. - const headerSlot = { - getSlotElementId: vi.fn(() => 'div-ad-header'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn().mockReturnThis(), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [headerSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'header_ad', - gam_unit_path: '/123/header', - div_id: 'div-ad-header', - formats: [[728, 90]], - targeting: { zone: 'header' }, - }, - { - id: 'footer_ad', - gam_unit_path: '/123/footer', - div_id: 'div-ad-footer', - formats: [[728, 90]], - targeting: { zone: 'footer' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh([headerSlot]); - - expect(setTargetingForGPTAsync).toHaveBeenCalledTimes(1); - expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['div-ad-header']); - expect(originalRefresh).toHaveBeenCalledWith([headerSlot], undefined); - - mockPbjs.setTargetingForGPTAsync = undefined; - }); - - it('includes configured client-side bidders in refresh ad units', () => { - testWindow.__tsjs_prebid = { - clientSideBidders: ['rubicon'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - // Original publisher ad unit carries a client-side rubicon bid. - mockPbjs.adUnits = [ - { - code: 'div-ad-homepage-header', - bids: [ - { bidder: 'trustedServer', params: {} }, - { bidder: 'rubicon', params: { accountId: 1, siteId: 2, zoneId: 3 } }, - ], - }, - ]; - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - bids: [ - { bidder: 'trustedServer', params: { zone: 'homepage' } }, - { bidder: 'rubicon', params: { accountId: 1, siteId: 2, zoneId: 3 } }, - ], - }), - ], - }) - ); - - delete testWindow.__tsjs_prebid; - mockPbjs.adUnits = []; - }); - - it('preserves raw server-side bidder params in refresh ad units', () => { - // Original publisher ad unit carries an inline server-side appnexus bid that - // the initial auction has not yet folded into the trustedServer bid. - mockPbjs.adUnits = [ - { - code: 'div-ad-homepage-header', - bids: [{ bidder: 'appnexus', params: { placementId: 12345 } }], - }, - ]; - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - bids: [ - { - bidder: 'trustedServer', - params: { - zone: 'homepage', - bidderParams: { appnexus: { placementId: 12345 } }, - }, - }, - ], - }), - ], - }) - ); - - mockPbjs.adUnits = []; - }); - - it('recovers params and client-side bids for container-backed slots by injected div_id', () => { - // A TS-owned GPT slot may be defined on `${div_id}-container`, but the - // publisher's Prebid ad unit is keyed by the inner div_id. The synthetic - // refresh code stays the GPT element id (so GPT can match it), while params - // and client-side bids are recovered from the injected div_id candidate. - testWindow.__tsjs_prebid = { - clientSideBidders: ['rubicon'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - mockPbjs.adUnits = [ - { - code: 'div-ad-x', - bids: [ - { bidder: 'appnexus', params: { placementId: 12345 } }, - { bidder: 'rubicon', params: { accountId: 1 } }, - ], - }, - ]; - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-x-container'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'x_ad', - gam_unit_path: '/123/x', - div_id: 'div-ad-x', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - // Synthetic refresh code stays the GPT element id, not the div_id. - code: 'div-ad-x-container', - bids: [ - { - bidder: 'trustedServer', - params: { - zone: 'homepage', - bidderParams: { appnexus: { placementId: 12345 } }, - }, - }, - { bidder: 'rubicon', params: { accountId: 1 } }, - ], - }), - ], - }) - ); - - delete testWindow.__tsjs_prebid; - mockPbjs.adUnits = []; - }); - - it('recovers server-side bidder params already folded onto the original trustedServer bid', () => { - // After the initial auction, the requestBids shim has folded the publisher's - // server-side params into the original ad unit's trustedServer bid. A later - // refresh must still recover them by code. - mockPbjs.adUnits = [ - { - code: 'div-ad-homepage-header', - bids: [ - { - bidder: 'trustedServer', - params: { bidderParams: { appnexus: { placementId: 12345 } } }, - }, - ], - }, - ]; - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - bids: [ - { - bidder: 'trustedServer', - params: { - zone: 'homepage', - bidderParams: { appnexus: { placementId: 12345 } }, - }, - }, - ], - }), - ], - }) - ); - - mockPbjs.adUnits = []; - }); - - it('auctions refreshed TS initial slots and clears stale TS targeting before refresh', () => { - const originalRefresh = vi.fn(); - const slotTargeting = new Map([ - ['ts_initial', ['1']], - ['zone', ['homepage']], - ]); - const clearTargeting = vi.fn((key: string) => { - slotTargeting.delete(key); - }); - const setTargeting = vi.fn((key: string, value: string | string[]) => { - slotTargeting.set(key, Array.isArray(value) ? value : [value]); - }); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn((key: string) => slotTargeting.get(key) ?? []), - getSizes: vi.fn(() => [ - { getWidth: () => 970, getHeight: () => 250 }, - { getWidth: () => 728, getHeight: () => 90 }, - ]), - clearTargeting, - setTargeting, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - const setTargetingForGPTAsync = vi.fn(() => { - gptSlot.setTargeting('ts', 'prebid-value'); - }); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [ - [970, 250], - [728, 90], - ], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 750, - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - mediaTypes: { - banner: { - name: 'homepage', - sizes: [ - [970, 250], - [728, 90], - ], - }, - }, - bids: [{ bidder: 'trustedServer', params: { zone: 'homepage' } }], - }), - ], - }) - ); - expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(clearTargeting).toHaveBeenCalledWith('hb_bidder'); - expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(clearTargeting).not.toHaveBeenCalledWith('ts'); - expect(originalRefresh).not.toHaveBeenCalled(); - - const bidsBackHandler = mockRequestBids.mock.calls[0][0].bidsBackHandler; - bidsBackHandler(); - - expect(setTargetingForGPTAsync).toHaveBeenCalled(); - expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(slotTargeting.get('ts')).toEqual(['prebid-value']); - expect(originalRefresh).toHaveBeenCalledWith([gptSlot], undefined); - }); - - it('passes an explicitly excluded path directly to GPT after clearing stale targeting', () => { - const originalRefresh = vi.fn(); - const clearTargeting = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-tracking'), - getAdUnitPath: vi.fn(() => '/123/trackingonly'), - getTargeting: vi.fn(() => []), - clearTargeting, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly'], - }; - const options = { changeCorrelator: false }; - - installRefreshHandler(750); - pubads.refresh([gptSlot], options); - - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(clearTargeting).toHaveBeenCalledWith('hb_bidder'); - expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).toHaveBeenCalledWith([gptSlot], options); - }); - - it('passes an all-excluded global refresh directly to GPT', () => { - const originalRefresh = vi.fn(); - const trackingSlot = { - getSlotElementId: vi.fn(() => 'div-ad-tracking'), - getAdUnitPath: vi.fn(() => '/123/trackingonly'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const measurementSlot = { - getSlotElementId: vi.fn(() => 'div-ad-measurement'), - getAdUnitPath: vi.fn(() => '/123/measurement-only'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const targetSlots = [trackingSlot, measurementSlot]; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => targetSlots), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly', '/measurement-only'], - }; - const options = { changeCorrelator: false }; - - installRefreshHandler(750); - pubads.refresh(undefined, options); - - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(trackingSlot.clearTargeting).toHaveBeenCalled(); - expect(measurementSlot.clearTargeting).toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith(undefined, options); - }); - - it('auctions eligible slots and refreshes every slot in a mixed global refresh', () => { - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - const originalRefresh = vi.fn(); - const displaySlot = { - getSlotElementId: vi.fn(() => 'div-ad-display'), - getAdUnitPath: vi.fn(() => '/123/content'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const trackingSlot = { - getSlotElementId: vi.fn(() => 'div-ad-tracking'), - getAdUnitPath: vi.fn(() => '/123/trackingonly'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const targetSlots = [displaySlot, trackingSlot]; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => targetSlots), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly'], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(displaySlot.clearTargeting).toHaveBeenCalled(); - expect(trackingSlot.clearTargeting).toHaveBeenCalled(); - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [expect.objectContaining({ code: 'div-ad-display' })], - }) - ); - expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['div-ad-display']); - expect(originalRefresh).toHaveBeenCalledWith(targetSlots, undefined); - - mockPbjs.setTargetingForGPTAsync = undefined; - }); - - it.each([ - ['a missing path getter', {}], - ['a non-string path', { getAdUnitPath: vi.fn(() => 123) }], - [ - 'a throwing path getter', - { - getAdUnitPath: vi.fn(() => { - throw new Error('path unavailable'); - }), - }, - ], - ])('fails open to an auction for %s', (_description, pathBehavior) => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-display'), - getTargeting: vi.fn(() => []), - ...pathBehavior, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly'], - }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - }); - - it.each([ - ['an empty suffix', ['']], - ['a non-array suffix list', {}], - ])('ignores %s from injected config and runs the refresh auction', (_description, suffixes) => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-display'), - getAdUnitPath: vi.fn(() => '/123/content'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { excludedGamAdUnitPathSuffixes: suffixes }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - }); - - it.each(['/123/TrackingOnly', '/123/trackingonly/'])( - 'uses literal case-sensitive suffix matching for %s', - (adUnitPath) => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-display'), - getAdUnitPath: vi.fn(() => adUnitPath), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly'], - }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - } - ); - - it('passes the adInit internal refresh straight to GPT without a client-side auction', () => { - const originalRefresh = vi.fn(); - const clearTargeting = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - clearTargeting, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { adInitRefreshInProgress: true }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([gptSlot], undefined); - }); - - it('runs a client-side auction for publisher refreshes after adInit completes', () => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { adInitRefreshInProgress: false }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - }); - - it('keeps nested Prebid refreshes Prebid-only and restores the diagnostics context', () => { - const listeners = new Map void>(); - const store = new GptDiagnosticsStore({ defer: () => undefined }); - const explicitSlot = { - getSlotElementId: () => 'nested-explicit', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const bareSlot = { - getSlotElementId: () => 'nested-bare', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - let throwRefresh = false; - let getSlots: () => object[] = () => []; - const originalRefresh = vi.fn((slots?: unknown[]) => { - for (const slot of slots ?? getSlots()) listeners.get('slotRequested')?.({ slot }); - if (throwRefresh) throw new Error('delegated refresh failed'); - return 'delegated refresh result'; - }); - const pubads = { - addEventListener: vi.fn((name: string, listener: (event: { slot: object }) => void) => { - listeners.set(name, listener); - }), - refresh: originalRefresh, - getSlots: vi.fn(() => [bareSlot]), - }; - getSlots = pubads.getSlots; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - gptDiagnosticsRecorder: { - recordPrebidRefresh: (slots: object[]) => store.recordPrebidRefresh(slots), - }, - }; - - new GptDiagnosticsObserver(store).install(); - installRefreshHandler(750); - const pbjs = installPrebidNpm(); - - const prepareDelivery = (code: string) => { - if (!document.getElementById(code)) attachTestSlot(code); - mockRequestBids.mockImplementationOnce((options) => { - options.bidsBackHandler?.(); - }); - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: vi.fn(), - } as unknown as RequestBidsArg); - }; - - prepareDelivery('nested-explicit'); - expect(pubads.refresh([explicitSlot])).toBe('delegated refresh result'); - expect(store.snapshot().slots[0].requests[0].requestPath).toBe('prebid_refresh'); - expect(Object.hasOwn(testWindow.tsjs as object, 'prebidRefreshDispatchInProgress')).toBe(false); - - prepareDelivery('nested-bare'); - expect(pubads.refresh()).toBe('delegated refresh result'); - expect(store.snapshot().slots[1].requests[0].requestPath).toBe('prebid_refresh'); - expect(Object.hasOwn(testWindow.tsjs as object, 'prebidRefreshDispatchInProgress')).toBe(false); - - prepareDelivery('nested-explicit'); - throwRefresh = true; - expect(() => pubads.refresh([explicitSlot])).toThrow('delegated refresh failed'); - expect(Object.hasOwn(testWindow.tsjs as object, 'prebidRefreshDispatchInProgress')).toBe(false); - - testWindow.tsjs = { - adInitRefreshInProgress: true, - gptDiagnosticsRecorder: { - recordPrebidRefresh: (slots: object[]) => store.recordPrebidRefresh(slots), - }, - }; - throwRefresh = false; - expect(pubads.refresh([explicitSlot])).toBe('delegated refresh result'); - expect(store.snapshot().slots[0].requests[2].requestPath).toBe('unattributed'); - }); - - it.each([ - { order: 'diagnostics observer first', diagnosticsFirst: true, expectedPath: 'prebid_refresh' }, - { order: 'Prebid wrapper first', diagnosticsFirst: false, expectedPath: 'competing' }, - ])( - 'attributes a Prebid-consumed refresh as $expectedPath when installed with the $order', - ({ diagnosticsFirst, expectedPath }) => { - const listeners = new Map void>(); - const store = new GptDiagnosticsStore({ defer: () => undefined }); - const slot = { - getSlotElementId: () => 'install-order', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const originalRefresh = vi.fn((slots?: unknown[]) => { - for (const refreshed of slots ?? []) listeners.get('slotRequested')?.({ slot: refreshed }); - return 'delegated refresh result'; - }); - const pubads = { - addEventListener: vi.fn((name: string, listener: (event: { slot: object }) => void) => { - listeners.set(name, listener); - }), - refresh: originalRefresh as (slots?: unknown[], opts?: unknown) => unknown, - getSlots: vi.fn(() => [slot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - gptDiagnosticsRecorder: { - recordPrebidRefresh: (slots: object[]) => store.recordPrebidRefresh(slots), - }, - }; - - // Only the bundle evaluation order enforces this today, so pin both - // outcomes: the diagnostics wrapper must sit inside the Prebid one to see - // the dispatch context that marks a refresh as Prebid's. - if (diagnosticsFirst) { - new GptDiagnosticsObserver(store).install(); - installRefreshHandler(750); - } else { - installRefreshHandler(750); - new GptDiagnosticsObserver(store).install(); - } - const pbjs = installPrebidNpm(); - attachTestSlot('install-order'); - mockRequestBids.mockImplementationOnce((options) => options.bidsBackHandler?.()); - pbjs.requestBids({ - adUnits: [{ code: 'install-order', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: vi.fn(), - } as unknown as RequestBidsArg); - - pubads.refresh([slot]); - - expect(store.snapshot().slots[0].requests[0].requestPath).toBe(expectedPath); - } - ); - - it('keeps the outer dispatch context set across a nested Prebid refresh', () => { - const store = new GptDiagnosticsStore({ defer: () => undefined }); - const slot = { - getSlotElementId: () => 'nested-reentrant', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const contextAfterInner: Array = []; - let reentered = false; - const originalRefresh = vi.fn(() => { - if (!reentered) { - reentered = true; - pubads.refresh([slot]); - contextAfterInner.push( - (testWindow.tsjs as { prebidRefreshDispatchInProgress?: boolean }) - .prebidRefreshDispatchInProgress - ); - } - return 'delegated refresh result'; - }); - const pubads = { - refresh: originalRefresh as (slots?: unknown[], opts?: unknown) => unknown, - getSlots: vi.fn(() => [slot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - gptDiagnosticsRecorder: { - recordPrebidRefresh: (slots: object[]) => store.recordPrebidRefresh(slots), - }, - }; - - const pbjs = installPrebidNpm(); - installRefreshHandler(750); - attachTestSlot('nested-reentrant'); - mockRequestBids.mockImplementation((options) => options.bidsBackHandler?.()); - pbjs.requestBids({ - adUnits: [{ code: 'nested-reentrant', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: vi.fn(), - } as unknown as RequestBidsArg); - - expect(pubads.refresh([slot])).toBe('delegated refresh result'); - // The inner dispatch owns the flag while it runs and must hand it back, or - // the observer would stop attributing every later publisher refresh. - expect(contextAfterInner).toEqual([true]); - expect( - originalRefresh, - 'the nested refresh must reach the delegated call' - ).toHaveBeenCalledTimes(2); - expect(Object.hasOwn(testWindow.tsjs as object, 'prebidRefreshDispatchInProgress')).toBe(false); - }); - - it('restores diagnostics context when its setter mutates and then throws', () => { - const slot = { - getSlotElementId: () => 'mutating-context-setter', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const originalRefresh = vi.fn(); - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [slot]), - }; - const contextTarget: Record = {}; - let throwAfterMutation = true; - testWindow.tsjs = new Proxy(contextTarget, { - set(target, property, value) { - Reflect.set(target, property, value); - if (property === 'prebidRefreshDispatchInProgress' && throwAfterMutation) { - throwAfterMutation = false; - throw new Error('example mutating context setter failure'); - } - return true; - }, - }); - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - mockRequestBids.mockImplementation((options) => options.bidsBackHandler?.()); - - installPrebidNpm(); - installRefreshHandler(750); - pubads.refresh([slot]); - pubads.refresh([slot]); - - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect( - Object.prototype.hasOwnProperty.call(contextTarget, 'prebidRefreshDispatchInProgress') - ).toBe(false); - }); -}); - -describe('prebid publisher snapshots and delivery refreshes', () => { - let deliveryAdIds = new WeakMap(); - let installedGptSlots: Array> = []; - let auctionSequence = 0; - - beforeEach(() => { - vi.clearAllMocks(); - deliveryAdIds = new WeakMap(); - installedGptSlots = []; - auctionSequence = 0; - mockRequestBids.mockReset(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.removeAdUnit = mockRemoveAdUnit; - delete (mockPbjs as unknown as Record).__tsRemoveAdUnitWrapped; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); - // By default the manifest declares all adapters compiled in. - (window as unknown as { __tsjs_prebid_bundle?: unknown }).__tsjs_prebid_bundle = - DEFAULT_BUNDLE_MANIFEST; - mockPbjs.setTargetingForGPTAsync = undefined; - testWindow.__tsjs_prebid = { - serverSideBidders: ['exampleServer', 'exampleFallback'], - }; - testWindow.tsjs = undefined; - delete testWindow.googletag; - document.body.replaceChildren(); - }); - - afterEach(() => { - delete testWindow.__tsjs_prebid; - testWindow.tsjs = undefined; - delete testWindow.googletag; - document.body.replaceChildren(); - }); - - function installGpt(slots: Array>) { - installedGptSlots = slots; - for (const slot of slots) { - if (!slot || typeof slot !== 'object') continue; - const elementId = slot.getSlotElementId?.(); - if (typeof elementId === 'string' && elementId && !document.getElementById(elementId)) { - const element = document.createElement('div'); - element.id = elementId; - document.body.appendChild(element); - } - const originalGetTargeting = slot.getTargeting?.bind(slot); - slot.getTargeting = (key: string) => { - const deliveryAdId = deliveryAdIds.get(slot); - if (key === 'hb_adid' && deliveryAdId) return [deliveryAdId]; - return originalGetTargeting?.(key) ?? []; - }; - } - - const originalRefresh = vi.fn(); - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => slots), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - installRefreshHandler(640); - return { originalRefresh, pubads }; - } - - function refreshAdUnitFromLastRequest(): - | (Record & { code?: string; bids?: TestBid[] }) - | undefined { - const lastCall = mockRequestBids.mock.calls[mockRequestBids.mock.calls.length - 1]; - return lastCall?.[0]?.adUnits?.[0]; - } - - function completePublisherAuction( - opts?: { adUnits?: Array<{ code?: string }>; bidsBackHandler?: (...args: unknown[]) => void }, - options: { auctionId?: string; applyTargeting?: boolean } = {} - ): void { - const auctionId = options.auctionId ?? `example-auction-${auctionSequence++}`; - const bidResponses: Record> }> = {}; - - for (const unit of opts?.adUnits ?? []) { - if (!unit.code) continue; - const adId = `${auctionId}-${unit.code}`; - bidResponses[unit.code] = { - bids: [{ adId, adUnitCode: unit.code, auctionId }], - }; - if (options.applyTargeting !== false) { - const slot = installedGptSlots.find((candidate) => { - const elementId = candidate?.getSlotElementId?.(); - return elementId === unit.code || elementId === `${unit.code}-container`; - }); - if (slot) deliveryAdIds.set(slot, adId); - } - } - - opts?.bidsBackHandler?.(bidResponses, false, auctionId); - } - - it('suppresses every publisher auction registered before the first TS delivery', () => { - const element = document.createElement('div'); - element.id = 'overlapping-first-impression'; - document.body.appendChild(element); - const ts = {} as TsjsApi; - claimFirstImpressionForTrustedServer(ts, element, 100); - const first = registerPublisherFirstImpressionAuctions(ts, [element.id], 101).get(element.id); - const second = registerPublisherFirstImpressionAuctions(ts, [element.id], 102).get(element.id); - - expect(consumePublisherFirstImpressionDelivery(ts, first, 103)).toBe(true); - expect(consumePublisherFirstImpressionDelivery(ts, second, 104)).toBe(true); - expect(registerPublisherFirstImpressionAuctions(ts, [element.id], 105)).toEqual(new Map()); - - element.remove(); - }); - - it('suppresses a correlated TS-owned delivery after the five-second lease', () => { - const element = document.createElement('div'); - element.id = 'late-first-impression'; - document.body.appendChild(element); - const ts = {} as TsjsApi; - claimFirstImpressionForTrustedServer(ts, element, 100); - const token = registerPublisherFirstImpressionAuctions(ts, [element.id], 101).get(element.id); - - expect(consumePublisherFirstImpressionDelivery(ts, token, 5_102)).toBe(true); - - element.remove(); - }); - - it('rejects a connected claim whose element is no longer canonical for its ID', () => { - const element = document.createElement('div'); - element.id = 'replaced-canonical-element'; - document.body.appendChild(element); - const ts = {} as TsjsApi; - claimFirstImpressionForTrustedServer(ts, element, 100); - const token = registerPublisherFirstImpressionAuctions(ts, [element.id], 101).get(element.id); - const replacement = document.createElement('div'); - replacement.id = element.id; - document.body.insertBefore(replacement, element); - - expect(document.getElementById(element.id)).toBe(replacement); - expect(consumePublisherFirstImpressionDelivery(ts, token, 102)).toBe(false); - expect(ts.firstImpression?.slots[element.id]).toBeUndefined(); - - replacement.remove(); - element.remove(); - }); - - it('prunes a claim stored under a registry key that does not match its slot element ID', () => { - const element = document.createElement('div'); - element.id = 'malformed-registry-key-slot'; - document.body.appendChild(element); - const ts = {} as TsjsApi; - const claim = claimFirstImpressionForTrustedServer(ts, element, 100)!; - delete ts.firstImpression!.slots[element.id]; - ts.firstImpression!.slots['wrong-registry-key'] = claim; - - expect(firstImpressionClaim(ts, element)).toBeUndefined(); - expect(ts.firstImpression!.slots['wrong-registry-key']).toBeUndefined(); - - element.remove(); - }); - - it('rejects a connected same-ID TS claim from a foreign document', () => { - const element = document.createElement('div'); - element.id = 'foreign-document-claim-slot'; - document.body.appendChild(element); - const foreignDocument = document.implementation.createHTMLDocument('foreign'); - const foreignElement = foreignDocument.createElement('div'); - foreignElement.id = element.id; - foreignDocument.body.appendChild(foreignElement); - const ts = {} as TsjsApi; - const claim = claimFirstImpressionForTrustedServer(ts, element, 100)!; - const token = registerPublisherFirstImpressionAuctions(ts, [element.id], 101).get(element.id); - claim.element = foreignElement; - - expect(foreignElement.isConnected).toBe(true); - expect(consumePublisherFirstImpressionDelivery(ts, token, 102)).toBe(false); - expect(ts.firstImpression?.slots[element.id]).toBeUndefined(); - expect(claimFirstImpressionForTrustedServer(ts, element, 103)?.element).toBe(element); - - element.remove(); - }); - - it('prunes an ordinary expired publisher registration without a reserved fallback', () => { - const element = document.createElement('div'); - element.id = 'ordinary-expired-publisher-slot'; - document.body.appendChild(element); - const ts = {} as TsjsApi; - const token = registerPublisherFirstImpressionAuctions(ts, [element.id], 100).get(element.id); - - expect(consumePublisherFirstImpressionDelivery(ts, token, 5_101)).toBe(false); - expect(ts.firstImpression?.slots[element.id]).toBeUndefined(); - - element.remove(); - }); - - it('clears a failed fallback reservation before a later ordinary publisher claim expires', () => { - vi.useFakeTimers(); - vi.setSystemTime(100); - try { - const element = document.createElement('div'); - element.id = 'failed-fallback-reservation-slot'; - document.body.appendChild(element); - const ts = {} as TsjsApi; - const originalToken = registerPublisherFirstImpressionAuctions(ts, [element.id]).get( - element.id - ); - expect(originalToken).toBeDefined(); - expect(reservePublisherFirstImpressionFallback(ts, element)).toBe(true); - - vi.advanceTimersByTime(5_001); - const fallbackClaim = claimFirstImpressionForTrustedServer(ts, element)!; - expect(fallbackClaim.owner).toBe('trusted_server'); - expect(fallbackClaim.publisherAuctions[originalToken!]?.suppressDelivery).toBe(true); - - releaseTrustedServerFirstImpressionClaim(ts, element, fallbackClaim); - expect(ts.firstImpression?.slots[element.id]).toBeUndefined(); - expect(ts.firstImpression?.fallbackSlots[element.id]).toBeUndefined(); - - const laterToken = registerPublisherFirstImpressionAuctions(ts, [element.id]).get(element.id); - expect(laterToken).toBeDefined(); - vi.advanceTimersByTime(5_001); - expect(consumePublisherFirstImpressionDelivery(ts, laterToken)).toBe(false); - expect(ts.firstImpression?.slots[element.id]).toBeUndefined(); - - const freshClaim = claimFirstImpressionForTrustedServer(ts, element)!; - expect(freshClaim.publisherAuctions).toEqual({}); - - element.remove(); - } finally { - vi.clearAllTimers(); - vi.useRealTimers(); - } - }); - - it('reserves first impression while a publisher refresh auction is pending', () => { - const code = 'pending-publisher-refresh-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - let completeRefresh: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - completeRefresh = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - - const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; - expect( - claimFirstImpressionForTrustedServer(ts, document.getElementById(code)!) - ).toBeUndefined(); - expect(originalRefresh).not.toHaveBeenCalled(); - - completeRefresh?.(); - - expect(originalRefresh).toHaveBeenCalledOnce(); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('suppresses an original publisher delivery after the lease-boundary TS fallback', () => { - vi.useFakeTimers(); - try { - const code = 'lease-boundary-fallback-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - setTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - let originalPublisherAuction: Parameters[0]; - mockRequestBids.mockImplementation((options) => { - if (!originalPublisherAuction) { - originalPublisherAuction = options; - return; - } - completePublisherAuction(options); - }); - const pbjs = installPrebidNpm(); - const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; - ts.servicesEnabled = true; - ts.adSlots = [ - { - id: 'lease-boundary-fallback-ad', - gam_unit_path: '/123/lease-boundary', - div_id: code, - formats: [[300, 250]], - targeting: {}, - }, - ]; - ts.bids = { - 'lease-boundary-fallback-ad': { - hb_pb: '1.00', - hb_adid: 'trusted-server-fallback-ad', - }, - }; - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh([slot]), - } as unknown as RequestBidsArg); - installTsAdInit(); - ts.adInit!(); - - vi.advanceTimersByTime(5001); - observeFirstImpressionGptLifecycle(ts, document.getElementById(code)!, 'requested'); - expect(originalRefresh).toHaveBeenCalledOnce(); - expect(ts.firstImpression?.slots[code]?.owner).toBe('trusted_server'); - expect(ts.firstImpression?.fallbackSlots[code]).toBe(document.getElementById(code)); - expect(Object.values(ts.firstImpression?.slots[code]?.publisherAuctions ?? {})).toEqual([ - expect.objectContaining({ suppressDelivery: true }), - ]); - - completePublisherAuction(originalPublisherAuction); - expect(originalRefresh).toHaveBeenCalledOnce(); - - deliveryAdIds.delete(slot); - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenCalledTimes(2); - } finally { - vi.clearAllTimers(); - vi.useRealTimers(); - } - }); - - it('suppresses a delayed publisher refresh when TS already owns first impression', () => { - const code = 'pending-ts-owned-refresh-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - setTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; - claimFirstImpressionForTrustedServer(ts, document.getElementById(code)!); - let completeRefresh: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - completeRefresh = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - expect(originalRefresh).not.toHaveBeenCalled(); - - completeRefresh?.(); - - expect(originalRefresh).not.toHaveBeenCalled(); - }); - - it('filters only the TS-owned slot from a delayed mixed publisher refresh', () => { - const tsCode = 'pending-mixed-ts-slot'; - const publisherCode = 'pending-mixed-publisher-slot'; - const tsSlot = { - getSlotElementId: () => tsCode, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - setTargeting: vi.fn(), - }; - const publisherSlot = { - getSlotElementId: () => publisherCode, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([tsSlot, publisherSlot]); - const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; - claimFirstImpressionForTrustedServer(ts, document.getElementById(tsCode)!); - let completeRefresh: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - completeRefresh = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([tsSlot, publisherSlot]); - completeRefresh?.(); - - expect(originalRefresh).toHaveBeenCalledOnce(); - expect(originalRefresh).toHaveBeenCalledWith([publisherSlot], undefined); - }); - - it('filters a TS-owned excluded slot from a delayed mixed publisher refresh', () => { - const eligibleCode = 'pending-mixed-eligible-slot'; - const excludedCode = 'pending-mixed-excluded-slot'; - const eligibleSlot = { - getSlotElementId: () => eligibleCode, - getAdUnitPath: () => '/123/content', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const excludedSlot = { - getSlotElementId: () => excludedCode, - getAdUnitPath: () => '/123/trackingonly', - getTargeting: () => [], - getSizes: () => [[1, 1]], - clearTargeting: vi.fn(), - setTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([eligibleSlot, excludedSlot]); - const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; - claimFirstImpressionForTrustedServer(ts, document.getElementById(excludedCode)!); - testWindow.__tsjs_prebid = { excludedGamAdUnitPathSuffixes: ['/trackingonly'] }; - let completeRefresh: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - completeRefresh = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([eligibleSlot, excludedSlot]); - completeRefresh?.(); - - expect(originalRefresh).toHaveBeenCalledOnce(); - expect(originalRefresh).toHaveBeenCalledWith([eligibleSlot], undefined); - }); - - it('drops delayed delivery and auction slots together after SPA navigation', () => { - const deliveryCode = 'pending-navigation-delivery-slot'; - const auctionCode = 'pending-navigation-auction-slot'; - const deliverySlot = { - getSlotElementId: () => deliveryCode, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const auctionSlot = { - getSlotElementId: () => auctionCode, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([deliverySlot, auctionSlot]); - let completeRefresh: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - if (opts?.adUnits?.[0]?.code === deliveryCode) { - completePublisherAuction(opts); - } else { - completeRefresh = opts.bidsBackHandler; - } - }); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code: deliveryCode, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh([deliverySlot, auctionSlot]), - } as unknown as RequestBidsArg); - ((testWindow.tsjs ??= {}) as unknown as TsjsApi).navGeneration = 1; - completeRefresh?.(); - - expect(originalRefresh).not.toHaveBeenCalled(); - }); - - it('drops a delayed publisher refresh after SPA navigation', () => { - const code = 'pending-previous-navigation-refresh-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - let completeRefresh: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - completeRefresh = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - ((testWindow.tsjs ??= {}) as unknown as TsjsApi).navGeneration = 1; - completeRefresh?.(); - - expect(originalRefresh).not.toHaveBeenCalled(); - }); - - it('drops a delayed publisher refresh after physical element replacement', () => { - const code = 'pending-replaced-refresh-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - let completeRefresh: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - completeRefresh = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - document.getElementById(code)?.remove(); - const replacement = document.createElement('div'); - replacement.id = code; - document.body.appendChild(replacement); - completeRefresh?.(); - - expect(originalRefresh).not.toHaveBeenCalled(); - }); - - it('keeps a delayed bare refresh scoped to its captured slot list', () => { - const firstCode = 'pending-bare-first-slot'; - const laterCode = 'pending-bare-later-slot'; - const firstSlot = { - getSlotElementId: () => firstCode, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const laterSlot = { - getSlotElementId: () => laterCode, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const slots = [firstSlot]; - const { originalRefresh, pubads } = installGpt(slots); - let completeRefresh: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - completeRefresh = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh(); - slots.push(laterSlot); - completeRefresh?.(); - - expect(originalRefresh).toHaveBeenCalledOnce(); - expect(originalRefresh).toHaveBeenCalledWith([firstSlot], undefined); - }); - - it('allows publisher refreshes that start after the TS first impression request', () => { - const code = 'requested-ts-owned-refresh-slot'; - const element = document.createElement('div'); - element.id = code; - document.body.appendChild(element); - const ts = {} as TsjsApi; - claimFirstImpressionForTrustedServer(ts, element); - observeFirstImpressionGptLifecycle(ts, element, 'requested'); - - expect(registerPublisherFirstImpressionAuctions(ts, [code])).toEqual(new Map()); - expect(ts.firstImpression?.slots[code]?.publisherRegistrationClosed).toBe(true); - }); - - it('clears a stale GPT handoff when delegating a post-request publisher refresh', () => { - const code = 'post-request-handoff-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - installedGptSlots = [slot]; - const nativeRefresh = vi.fn(); - const ts = (testWindow.tsjs = {} as unknown as PrebidTestWindow['tsjs']) as unknown as TsjsApi; - const handoff = { - gamUnitPath: '/123/post-request', - formats: [[300, 250] as [number, number]], - divIdPrefix: code, - slotElementId: code, - publisherClaimed: true, - suppressPublisherDisplay: false, - suppressPublisherRefresh: true, - }; - ts.gptSlotHandoffs = { [code]: handoff }; - const innerRefresh = vi.fn((slots?: (typeof slot)[]) => { - if (handoff.suppressPublisherRefresh) { - handoff.suppressPublisherRefresh = false; - return; - } - nativeRefresh(slots); - }); - const pubads = { refresh: innerRefresh, getSlots: () => [slot] }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - const element = document.createElement('div'); - element.id = code; - document.body.appendChild(element); - claimFirstImpressionForTrustedServer(ts, element); - observeFirstImpressionGptLifecycle(ts, element, 'requested'); - installRefreshHandler(640); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(handoff.suppressPublisherRefresh).toBe(false); - expect(nativeRefresh).toHaveBeenCalledWith([slot]); - }); - - it('suppresses an all-excluded refresh while the TS first impression is pending', () => { - const code = 'pending-all-excluded-slot'; - const slot = { - getSlotElementId: () => code, - getAdUnitPath: () => '/123/trackingonly', - getTargeting: () => [], - getSizes: () => [[1, 1]], - clearTargeting: vi.fn(), - setTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; - claimFirstImpressionForTrustedServer(ts, document.getElementById(code)!); - testWindow.__tsjs_prebid = { excludedGamAdUnitPathSuffixes: ['/trackingonly'] }; - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - }); - - it('delegates an all-excluded refresh after the TS first impression request', () => { - const code = 'requested-all-excluded-slot'; - const slot = { - getSlotElementId: () => code, - getAdUnitPath: () => '/123/trackingonly', - getTargeting: () => [], - getSizes: () => [[1, 1]], - clearTargeting: vi.fn(), - }; - installedGptSlots = [slot]; - const nativeRefresh = vi.fn(); - const ts = (testWindow.tsjs = {} as unknown as PrebidTestWindow['tsjs']) as unknown as TsjsApi; - const handoff = { - gamUnitPath: '/123/trackingonly', - formats: [[1, 1] as [number, number]], - divIdPrefix: code, - slotElementId: code, - publisherClaimed: true, - suppressPublisherDisplay: false, - suppressPublisherRefresh: true, - }; - ts.gptSlotHandoffs = { [code]: handoff }; - const innerRefresh = vi.fn((slots?: (typeof slot)[]) => { - if (handoff.suppressPublisherRefresh) { - handoff.suppressPublisherRefresh = false; - return; - } - nativeRefresh(slots); - }); - const pubads = { refresh: innerRefresh, getSlots: () => [slot] }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - const element = document.createElement('div'); - element.id = code; - document.body.appendChild(element); - claimFirstImpressionForTrustedServer(ts, element); - observeFirstImpressionGptLifecycle(ts, element, 'requested'); - testWindow.__tsjs_prebid = { excludedGamAdUnitPathSuffixes: ['/trackingonly'] }; - installRefreshHandler(640); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(handoff.suppressPublisherRefresh).toBe(false); - expect(nativeRefresh).toHaveBeenCalledWith([slot]); - }); - - it('consumes late-handoff suppression when Prebid suppresses the same delivery', () => { - const code = 'composed-suppression-slot'; - const element = document.createElement('div'); - element.id = code; - document.body.appendChild(element); - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - setTargeting: vi.fn(), - }; - installedGptSlots = [slot]; - const nativeRefresh = vi.fn(); - const ts = (testWindow.tsjs = {} as unknown as PrebidTestWindow['tsjs']) as unknown as TsjsApi; - const handoff = { - gamUnitPath: '/123/composed', - formats: [[300, 250] as [number, number]], - divIdPrefix: code, - slotElementId: code, - publisherClaimed: true, - suppressPublisherDisplay: false, - suppressPublisherRefresh: true, - }; - ts.gptSlotHandoffs = { [code]: handoff }; - const innerRefresh = vi.fn((slots?: (typeof slot)[]) => { - if (handoff.suppressPublisherRefresh) { - handoff.suppressPublisherRefresh = false; - return; - } - nativeRefresh(slots); - }); - const pubads = { refresh: innerRefresh, getSlots: () => [slot] }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - claimFirstImpressionForTrustedServer(ts, element); - installRefreshHandler(640); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh([slot]), - } as unknown as RequestBidsArg); - - expect(handoff.suppressPublisherRefresh).toBe(false); - expect(innerRefresh).not.toHaveBeenCalled(); - - pubads.refresh([slot]); - - expect(nativeRefresh).toHaveBeenCalledWith([slot]); - }); - - it('forwards only unsuppressed excluded slots', () => { - const suppressedCode = 'mixed-suppressed-slot'; - const excludedCode = 'mixed-excluded-slot'; - const suppressedSlot = { - getSlotElementId: () => suppressedCode, - getAdUnitPath: () => '/123/content', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - setTargeting: vi.fn(), - }; - const excludedSlot = { - getSlotElementId: () => excludedCode, - getAdUnitPath: () => '/123/trackingonly', - getTargeting: () => [], - getSizes: () => [[1, 1]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([suppressedSlot, excludedSlot]); - const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; - claimFirstImpressionForTrustedServer(ts, document.getElementById(suppressedCode)!); - testWindow.__tsjs_prebid = { excludedGamAdUnitPathSuffixes: ['/trackingonly'] }; - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code: suppressedCode, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh([suppressedSlot, excludedSlot]), - } as unknown as RequestBidsArg); - - expect(originalRefresh).toHaveBeenCalledWith([excludedSlot], undefined); - }); - - it('rejects pending delivery state from a previous navigation', () => { - const code = 'previous-navigation-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - ((testWindow.tsjs ??= {}) as unknown as TsjsApi).navGeneration = 1; - - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('rejects pending delivery state after physical element replacement', () => { - const code = 'replaced-physical-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - document.getElementById(code)?.remove(); - const replacement = document.createElement('div'); - replacement.id = code; - document.body.appendChild(replacement); - - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - function installPrebidRefreshDiagnostics( - implementation?: (slots: Array>) => void - ) { - const recordPrebidRefresh = vi.fn(implementation); - testWindow.tsjs = { gptDiagnosticsRecorder: { recordPrebidRefresh } }; - return recordPrebidRefresh; - } - - it('suppresses one publisher delivery after TS claims first and allows a later refresh', () => { - const code = 'example-ts-first-slot'; - const element = document.createElement('div'); - element.id = code; - document.body.appendChild(element); - try { - const targeting = new Map([ - ['ts_initial', '1'], - ['hb_adid', 'example-ts-ad-id'], - ['hb_pb', '1.25'], - ]); - const slot = { - getSlotElementId: () => code, - getTargeting: (key: string) => { - const value = targeting.get(key); - return value === undefined ? [] : Array.isArray(value) ? value : [value]; - }, - setTargeting: vi.fn((key: string, value: string | string[]) => { - targeting.set(key, value); - return slot; - }), - clearTargeting: vi.fn((key: string) => { - targeting.delete(key); - return slot; - }), - getSizes: () => [[300, 250]], - }; - const ts = (testWindow.tsjs = {} as TsjsApi) as TsjsApi; - const claim = claimFirstImpressionForTrustedServer(ts, element)!; - claim.targeting = Object.fromEntries(targeting); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh([slot], { changeCorrelator: false }), - } as unknown as RequestBidsArg); - - expect(originalRefresh).not.toHaveBeenCalled(); - expect(slot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(slot.setTargeting).toHaveBeenCalledWith('hb_adid', 'example-ts-ad-id'); - expect(ts.firstImpression?.slots[code]?.publisherRegistrationClosed).toBe(true); - - pubads.refresh([slot], { changeCorrelator: false }); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenCalledOnce(); - expect(originalRefresh).toHaveBeenCalledWith([slot], { changeCorrelator: false }); - } finally { - element.remove(); - } - }); - - it('records a publisher delivery refresh immediately before its GPT request', () => { - const slot = { - getSlotElementId: () => 'example-delivery-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-delivery-marker', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => pubads.refresh([slot]), - } as unknown as RequestBidsArg); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('records a completed synthetic refresh immediately before its GPT request', () => { - const slot = { - getSlotElementId: () => 'example-synthetic-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('records every slot in a mixed SRA refresh before its GPT request', () => { - const deliverySlot = { - getSlotElementId: () => 'example-mixed-delivery-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const independentSlot = { - getSlotElementId: () => 'example-mixed-independent-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const targetSlots = [deliverySlot, independentSlot]; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt(targetSlots); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { - code: 'example-mixed-delivery-marker', - bids: [{ bidder: 'exampleServer', params: {} }], - }, - ], - bidsBackHandler: () => pubads.refresh(targetSlots), - } as unknown as RequestBidsArg); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith(targetSlots); - expect(recordPrebidRefresh.mock.calls[0][0][0]).toBe(deliverySlot); - expect(recordPrebidRefresh.mock.calls[0][0][1]).toBe(independentSlot); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(targetSlots, undefined); - }); - - it('records one synthetic timeout fallback before one GPT request', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-timeout-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation(() => undefined); - installPrebidNpm(); - - pubads.refresh([slot]); - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(640); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('records a caught synthetic auction failure before one GPT fallback request', () => { - const slot = { - getSlotElementId: () => 'example-failure-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation(() => { - throw new Error('example auction failure'); - }); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('does not record or refresh again for a late callback after timeout', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-late-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - let bidsBackHandler: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - bidsBackHandler = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - vi.advanceTimersByTime(640); - bidsBackHandler?.(); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('does not record an adInit refresh bypass', () => { - const slot = { - getSlotElementId: () => 'example-adinit-marker-bypass', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = vi.fn(); - testWindow.tsjs = { - adInitRefreshInProgress: true, - gptDiagnosticsRecorder: { recordPrebidRefresh }, - }; - const { originalRefresh, pubads } = installGpt([slot]); - - pubads.refresh([slot]); - - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('does not record empty or invalid refresh passthroughs', () => { - const slot = { - getSlotElementId: () => 'example-invalid-marker-bypass', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - const invalidSlots = [slot, null]; - - pubads.refresh([]); - pubads.refresh(invalidSlots); - - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, invalidSlots, undefined); - }); - - it('does not record a bare refresh when GPT cannot resolve its slot list', () => { - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const originalRefresh = vi.fn(); - const pubads = { refresh: originalRefresh }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - installRefreshHandler(640); - - pubads.refresh(); - - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); - }); - - it('does not record while a synthetic refresh is still waiting for its auction', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-waiting-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - let bidsBackHandler: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - bidsBackHandler = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - - bidsBackHandler?.(); - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('still refreshes with unchanged arguments when diagnostics throws', () => { - const slot = { - getSlotElementId: () => 'example-throwing-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const refreshOptions = { changeCorrelator: false }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(() => { - throw new Error('example diagnostics failure'); - }); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot], refreshOptions); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], refreshOptions); - }); - - it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { - testWindow.__tsjs_prebid = { - clientSideBidders: ['exampleBrowser'], - serverSideBidders: ['exampleServer', 'appnexus'], - }; - const runtimeInstance = 'example-runtime-instance'; - const code = `example-slot-${runtimeInstance}`; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [{ getWidth: () => 320, getHeight: () => 100 }], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - const firstParams = { placement: 'first' }; - const effectiveParams = { placement: 'effective' }; - - pbjs.requestBids({ - adUnits: [ - { - code, - mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, - bids: [ - { bidder: 'exampleServer', params: firstParams }, - { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, - { bidder: 'exampleServer', params: effectiveParams }, - { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, - ], - }, - ], - } as unknown as RequestBidsArg); - effectiveParams.placement = 'changed-after-auction'; - - pubads.refresh([slot]); - - expect(mockPbjs.adUnits).toEqual([]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(refreshAdUnitFromLastRequest()).toEqual({ - code, - mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, - bids: [ - { - bidder: 'trustedServer', - params: { - bidderParams: { exampleServer: { placement: 'effective' } }, - zone: 'example-zone', - }, - }, - { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, - { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, - ], - }); - }); - - it('isolates nested bidder-param objects and arrays from later publisher mutation', () => { - testWindow.__tsjs_prebid = { - clientSideBidders: ['exampleBrowser'], - serverSideBidders: ['exampleServer', 'appnexus'], - }; - const code = 'example-nested-params-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - const serverParams = { - placement: { - rules: [{ label: 'original-rule' }], - sizes: [300, 250], - }, - }; - const browserParams = { - groups: [{ values: ['original-value'] }], - }; - - pbjs.requestBids({ - adUnits: [ - { - code, - bids: [ - { bidder: 'exampleServer', params: serverParams }, - { bidder: 'exampleBrowser', params: browserParams }, - ], - }, - ], - } as unknown as RequestBidsArg); - serverParams.placement.rules[0].label = 'changed-rule'; - serverParams.placement.sizes.push(999); - browserParams.groups[0].values[0] = 'changed-value'; - - pubads.refresh([slot]); - - const expectedBids = [ - { - bidder: 'trustedServer', - params: { - bidderParams: { - exampleServer: { - placement: { - rules: [{ label: 'original-rule' }], - sizes: [300, 250], - }, - }, - }, - }, - }, - { - bidder: 'exampleBrowser', - params: { groups: [{ values: ['original-value'] }] }, - }, - ]; - const firstRefreshBids = refreshAdUnitFromLastRequest().bids; - expect(firstRefreshBids).toEqual(expectedBids); - - firstRefreshBids[0].params.bidderParams.exampleServer.placement.rules[0].label = - 'changed-refresh-rule'; - firstRefreshBids[0].params.bidderParams.exampleServer.placement.sizes.push(777); - firstRefreshBids[1].params.groups[0].values[0] = 'changed-refresh-value'; - pubads.refresh([slot]); - - expect(refreshAdUnitFromLastRequest().bids).toEqual(expectedBids); - }); - - it('keeps snapshots across repeated synthetic refreshes and overwrites newer publisher config', () => { - const code = 'example-dynamic-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { - code, - mediaTypes: { banner: { name: 'example-zone-one', sizes: [[300, 250]] } }, - bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], - }, - ], - } as unknown as RequestBidsArg); - pubads.refresh([slot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ - bidderParams: { exampleServer: { placement: 'one' } }, - zone: 'example-zone-one', - }); - - pubads.refresh([slot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ - bidderParams: { exampleServer: { placement: 'one' } }, - zone: 'example-zone-one', - }); - - pbjs.requestBids({ - adUnits: [ - { - code, - mediaTypes: { banner: { name: 'example-zone-two', sizes: [[300, 250]] } }, - bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], - }, - ], - } as unknown as RequestBidsArg); - pubads.refresh([slot]); - - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ - bidderParams: { exampleServer: { placement: 'two' } }, - zone: 'example-zone-two', - }); - }); - - it('does not cross-contaminate dynamic-code snapshots and retains the global fallback', () => { - const slotOne = { - getSlotElementId: () => 'example-code-one', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const slotTwo = { - getSlotElementId: () => 'example-code-two', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const globalSlot = { - getSlotElementId: () => 'example-global-code', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slotOne, slotTwo, globalSlot]); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { - code: 'example-code-one', - bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], - }, - { - code: 'example-code-two', - bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], - }, - ], - } as unknown as RequestBidsArg); - mockPbjs.adUnits = [ - { - code: 'example-global-code', - bids: [{ bidder: 'exampleFallback', params: { placement: 'global' } }], - }, - ]; - - pubads.refresh([slotOne]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ - exampleServer: { placement: 'one' }, - }); - pubads.refresh([slotTwo]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ - exampleServer: { placement: 'two' }, - }); - pubads.refresh([globalSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ - exampleFallback: { placement: 'global' }, - }); - }); - - it('prefers a rich live unit when a fresh same-code request overwrites the snapshot with empty bids', () => { - testWindow.__tsjs_prebid = { - clientSideBidders: ['exampleBrowser'], - serverSideBidders: ['exampleServer', 'appnexus'], - }; - const code = 'example-live-rich-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const liveUnit = { - code, - bids: [ - { bidder: 'exampleServer', params: { placement: 'live-server' } }, - { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, - ], - }; - mockPbjs.adUnits = [liveUnit]; - const pbjs = installPrebidNpm(); - - pbjs.requestBids(); - pbjs.requestBids({ adUnits: [{ code, bids: [] }] } as unknown as RequestBidsArg); - pubads.refresh([slot]); - - expect(refreshAdUnitFromLastRequest().bids).toEqual([ - { - bidder: 'trustedServer', - params: { bidderParams: { exampleServer: { placement: 'live-server' } } }, - }, - { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, - ]); - }); - - it('filters unowned stored bidder params before snapshot, reuse, and refresh recovery', () => { - testWindow.__tsjs_prebid = { serverSideBidders: ['exampleServer'] }; - const code = 'example-stored-envelope-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - const adUnits = [ - { - code, - bids: [ - { - bidder: 'trustedServer', - params: { - bidderParams: { - exampleServer: { placement: 'authoritative' }, - pbsProviderId: { placement: 'provider' }, - returnedSeatAlias: { placement: 'alias' }, - }, - }, - }, - ], - }, - ]; - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - expect(adUnits[0].bids[0].params?.bidderParams).toEqual({ - exampleServer: { placement: 'authoritative' }, - }); - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - expect(adUnits[0].bids[0].params?.bidderParams).toEqual({ - exampleServer: { placement: 'authoritative' }, - }); - - pubads.refresh([slot]); - expect(refreshAdUnitFromLastRequest().bids[0].params?.bidderParams).toEqual({ - exampleServer: { placement: 'authoritative' }, - }); - }); - - it('does not resurrect an older snapshot when the live unit is intentionally empty', () => { - const code = 'example-live-empty-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: { placement: 'snapshot' } }] }], - } as unknown as RequestBidsArg); - mockPbjs.adUnits = [{ code, bids: [] }]; - pubads.refresh([slot]); - - expect(refreshAdUnitFromLastRequest().bids).toEqual([ - { bidder: 'trustedServer', params: { bidderParams: {} } }, - ]); - }); - - it('evicts snapshots with the matching removeAdUnit lifecycle', () => { - const codes = ['example-remove-one', 'example-remove-two', 'example-remove-all']; - const slots = codes.map((code) => ({ - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - })); - const { pubads } = installGpt(slots); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: codes.map((code) => ({ - code, - bids: [{ bidder: 'exampleServer', params: { placement: code } }], - })), - } as unknown as RequestBidsArg); - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit( - codes[0] - ); - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit([ - codes[1], - ]); - - pubads.refresh([slots[0]]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); - pubads.refresh([slots[1]]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); - pubads.refresh([slots[2]]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ - exampleServer: { placement: codes[2] }, - }); - - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit(); - pubads.refresh([slots[2]]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); - }); - - it('bounds snapshots with LRU eviction while retaining a recently refreshed entry', () => { - const capacity = 256; - const oldestCode = 'example-lru-0'; - const activeCode = `example-lru-${capacity - 1}`; - const oldestSlot = { - getSlotElementId: () => oldestCode, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const activeSlot = { - getSlotElementId: () => activeCode, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([oldestSlot, activeSlot]); - const pbjs = installPrebidNpm(); - - for (let index = 0; index < capacity; index += 1) { - pbjs.requestBids({ - adUnits: [ - { - code: `example-lru-${index}`, - bids: [{ bidder: 'exampleServer', params: { placement: index } }], - }, - ], - } as unknown as RequestBidsArg); - } - - pubads.refresh([activeSlot]); - pbjs.requestBids({ - adUnits: [ - { - code: `example-lru-${capacity}`, - bids: [{ bidder: 'exampleServer', params: { placement: capacity } }], - }, - ], - } as unknown as RequestBidsArg); - - pubads.refresh([oldestSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); - pubads.refresh([activeSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ - exampleServer: { placement: capacity - 1 }, - }); - }); - - it('bypasses explicit covered subset delivery refreshes without clearing targeting', () => { - const slotOne = { - getSlotElementId: () => 'example-covered-one', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const slotTwo = { - getSlotElementId: () => 'example-covered-two-container', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - testWindow.tsjs = { - adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], - }; - const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-covered-one', bids: [{ bidder: 'exampleServer', params: {} }] }, - { code: 'example-covered-two', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => { - pubads.refresh([slotOne]); - pubads.refresh([slotTwo]); - }, - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slotOne.clearTargeting).not.toHaveBeenCalled(); - expect(slotTwo.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [slotOne], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); - }); - - it('registers delivery state for a publisher auction without a bidsBackHandler', () => { - const code = 'example-handlerless-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('preserves one mixed refresh request and its original options', () => { - const deliverySlot = { - getSlotElementId: () => 'example-sra-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const independentSlot = { - getSlotElementId: () => 'example-sra-independent', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const refreshOptions = { changeCorrelator: true }; - const { originalRefresh, pubads } = installGpt([deliverySlot, independentSlot]); - let syntheticBidsBackHandler: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - if (mockRequestBids.mock.calls.length === 1) { - completePublisherAuction(opts); - } else { - syntheticBidsBackHandler = opts.bidsBackHandler; - } - }); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code: 'example-sra-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh([deliverySlot, independentSlot], refreshOptions), - } as unknown as RequestBidsArg); - - expect(originalRefresh).not.toHaveBeenCalled(); - expect(independentSlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - - syntheticBidsBackHandler?.(); - - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([deliverySlot, independentSlot], refreshOptions); - }); - - it('partitions a bare delivery refresh from an unmatched GPT slot', () => { - const coveredSlot = { - getSlotElementId: () => 'example-covered', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const gamOnlySlot = { - getSlotElementId: () => 'example-gam-only-interstitial', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([coveredSlot, gamOnlySlot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh(), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); - expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([coveredSlot, gamOnlySlot], undefined); - }); - - it('keeps explicit unrelated lists synthetic and partitions mixed delivery lists', () => { - const coveredSlot = { - getSlotElementId: () => 'example-covered', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const unrelatedSlot = { - getSlotElementId: () => 'example-unrelated', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([coveredSlot, unrelatedSlot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - pubads.refresh([unrelatedSlot]); - pubads.refresh([coveredSlot, unrelatedSlot]); - }, - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(3); - expect( - mockRequestBids.mock.calls[1][0].adUnits.map((unit: { code?: string }) => unit.code) - ).toEqual(['example-unrelated']); - expect( - mockRequestBids.mock.calls[2][0].adUnits.map((unit: { code?: string }) => unit.code) - ).toEqual(['example-unrelated']); - expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); - expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); - }); - - it('partitions four delivered slots from an unmatched explicit slot', () => { - const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ - getSlotElementId: () => `example-covered-${index}`, - getTargeting: () => [], - clearTargeting: vi.fn(), - })); - const gamOnlySlot = { - getSlotElementId: () => 'example-gam-only-interstitial', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const refreshSlots = [...coveredSlots, gamOnlySlot]; - const { originalRefresh, pubads } = installGpt(refreshSlots); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: coveredSlots.map((_, index) => ({ - code: `example-covered-${index}`, - bids: [{ bidder: 'exampleServer', params: { placement: index } }], - })), - bidsBackHandler: () => pubads.refresh(refreshSlots), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - coveredSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); - expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); - }); - - it('expires an unconsumed publisher delivery before a later refresh', () => { - vi.useFakeTimers(); - try { - const code = 'example-expired-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - vi.advanceTimersByTime(5001); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('expires an unconsumed targeted delivery before a later refresh', () => { - vi.useFakeTimers(); - try { - const code = 'example-expired-targeted-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - vi.advanceTimersByTime(5001); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('correlates a targeted delivery refresh after more than one second without a timer race', () => { - vi.useFakeTimers(); - try { - const code = 'example-delayed-delivery'; - const auctionId = 'example-delayed-auction'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { auctionId, applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - setTimeout(() => { - deliveryAdIds.set(slot, `${auctionId}-${code}`); - pubads.refresh([slot]); - }, 1500); - }, - } as unknown as RequestBidsArg); - - vi.advanceTimersByTime(1500); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('correlates null and no-argument targeting with a custom GPT slot match', () => { - const code = 'example-custom-matched-code'; - const slot = { - getSlotElementId: () => 'example-different-gpt-slot', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const publisherElement = document.createElement('div'); - publisherElement.id = code; - publisherElement.appendChild(document.getElementById('example-different-gpt-slot')!); - document.body.appendChild(publisherElement); - let auctionId = 'example-null-auction'; - const setTargetingForGPTAsync = vi.fn(() => { - deliveryAdIds.set(slot, `${auctionId}-${code}`); - }); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { auctionId, applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - ( - pbjs as unknown as { setTargetingForGPTAsync: (codes?: string[]) => void } - ).setTargetingForGPTAsync(null, () => () => true); - pubads.refresh([slot]); - }, - } as unknown as RequestBidsArg); - - auctionId = 'example-no-argument-auction'; - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - ( - pbjs as unknown as { setTargetingForGPTAsync: (codes?: string[]) => void } - ).setTargetingForGPTAsync(); - pubads.refresh([slot]); - }, - } as unknown as RequestBidsArg); - - expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(1, null, expect.any(Function)); - expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(2); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); - mockPbjs.setTargetingForGPTAsync = undefined; - }); - - it('correlates requested no-bid slots without manufacturing unrelated bid state', () => { - const slot = { - getSlotElementId: () => 'example-no-bid-delivery', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation( - (opts?: { bidsBackHandler?: (...args: unknown[]) => void }) => { - opts?.bidsBackHandler?.({ 'example-no-bid-delivery': { bids: [null, {}] } }, false, 'bad'); - } - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-no-bid-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => pubads.refresh([slot]), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('bounds code-only delivery correlation to one suppressed independent refresh', () => { - const code = 'example-code-only-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - // Model an initial impression rendered with display() after an auction - // that did not apply hb_adid targeting. Its code-only state is unconsumed. - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - }); - - it('does not use code fallback when a slot has an unmatched hb_adid', () => { - const code = 'example-stale-targeting'; - const slot = { - getSlotElementId: () => code, - getTargeting: (key: string) => (key === 'hb_adid' ? ['example-stale-ad-id'] : []), - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh([slot]), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('uses an independent auction when a pending hb_adid exceeds the capacity bound', () => { - const capacity = 2048; - const code = 'example-capacity-delivery'; - const oldestAdId = 'example-capacity-ad-0'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => { - if (mockRequestBids.mock.calls.length === 1) { - opts.bidsBackHandler?.({ - [code]: { - bids: Array.from({ length: capacity + 1 }, (_, index) => ({ - adId: `example-capacity-ad-${index}`, - adUnitCode: code, - })), - }, - }); - return; - } - completePublisherAuction(opts); - }); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - deliveryAdIds.set(slot, oldestAdId); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('bypasses a mixed explicit delivery list spanning nested contexts', () => { - const outerSlot = { - getSlotElementId: () => 'example-outer-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const innerSlot = { - getSlotElementId: () => 'example-inner-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const gamOnlySlot = { - getSlotElementId: () => 'example-gam-only-interstitial', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const refreshSlots = [innerSlot, outerSlot, gamOnlySlot]; - const { originalRefresh, pubads } = installGpt(refreshSlots); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => { - pbjs.requestBids({ - adUnits: [ - { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => pubads.refresh(refreshSlots), - } as unknown as RequestBidsArg); - }, - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(3); - expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); - expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); - expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); - }); - - it('correlates a microtask refresh by its requested code without targeting', async () => { - const slot = { - getSlotElementId: () => 'example-deferred-refresh', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - let deferredRefresh: Promise | undefined; - - pbjs.requestBids({ - adUnits: [ - { code: 'example-deferred-refresh', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => { - deferredRefresh = Promise.resolve().then(() => pubads.refresh([slot])); - }, - } as unknown as RequestBidsArg); - await deferredRefresh; - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('correlates targeting and refresh deferred together to a microtask', async () => { - const code = 'example-targeted-microtask'; - const auctionId = 'example-targeted-microtask-auction'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { auctionId, applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - let deferredRefresh: Promise | undefined; - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - deferredRefresh = Promise.resolve().then(() => { - deliveryAdIds.set(slot, `${auctionId}-${code}`); - pubads.refresh([slot]); - }); - }, - } as unknown as RequestBidsArg); - await deferredRefresh; - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('preserves a sibling registration after consuming an exact overlapping delivery', () => { - const code = 'example-overlapping-code'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - for (let index = 0; index < 2; index += 1) { - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => {}, - } as unknown as RequestBidsArg); - } - - deliveryAdIds.set(slot, `example-auction-0-${code}`); - pubads.refresh([slot]); - deliveryAdIds.set(slot, `example-auction-1-${code}`); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); - }); - - it('does not guess between ordinary overlapping code-only registrations', () => { - const code = 'example-ambiguous-code-only'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - for (let index = 0; index < 2; index += 1) { - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => {}, - } as unknown as RequestBidsArg); - } - - deliveryAdIds.delete(slot); - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(3); - - deliveryAdIds.set(slot, `example-auction-0-${code}`); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(3); - expect(originalRefresh).toHaveBeenCalledTimes(2); - }); - - it('fails closed without consuming TS-owned ambiguous code-only registrations', () => { - const code = 'example-ts-ambiguous-code-only'; - const element = document.createElement('div'); - element.id = code; - document.body.appendChild(element); - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - setTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const ts = (testWindow.tsjs ??= {}) as unknown as TsjsApi; - claimFirstImpressionForTrustedServer(ts, element); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - for (let index = 0; index < 2; index += 1) { - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => {}, - } as unknown as RequestBidsArg); - } - - deliveryAdIds.delete(slot); - pubads.refresh([slot]); - deliveryAdIds.set(slot, `example-auction-0-${code}`); - pubads.refresh([slot]); - deliveryAdIds.set(slot, `example-auction-1-${code}`); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(originalRefresh).not.toHaveBeenCalled(); - element.remove(); - }); - - it('filters invalid explicit entries without duplicating or leaking a valid delivery', () => { - const code = 'example-valid-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => - pubads.refresh([slot, undefined, null] as unknown as Array>), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot, undefined, null], undefined); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - }); - - it('does not mutate reused publisher request options', () => { - const code = 'example-reused-request'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - const request = { - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - }; - - pbjs.requestBids(request as unknown as RequestBidsArg); - pbjs.requestBids(request as unknown as RequestBidsArg); - pubads.refresh([slot]); - - expect(request).not.toHaveProperty('bidsBackHandler'); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('falls back to one GPT refresh when a synthetic auction throws', () => { - const slot = { - getSlotElementId: () => 'example-throwing-refresh', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - mockRequestBids.mockImplementation(() => { - throw new Error('example synthetic failure'); - }); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(setTargetingForGPTAsync).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('applies targeting before falling back when a synthetic auction never calls back', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-missing-refresh-callback', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - mockRequestBids.mockImplementation(() => undefined); - installPrebidNpm(); - - pubads.refresh([slot]); - expect(originalRefresh).not.toHaveBeenCalled(); - vi.advanceTimersByTime(640); - - expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['example-missing-refresh-callback']); - expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('applies fallback targeting once and ignores a late synthetic callback', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-late-refresh-callback', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - let syntheticBidsBackHandler: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - syntheticBidsBackHandler = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - vi.advanceTimersByTime(640); - syntheticBidsBackHandler?.(); - - expect(setTargetingForGPTAsync).toHaveBeenCalledTimes(1); - expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['example-late-refresh-callback']); - expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('completes a synthetic refresh when targeting throws', () => { - const slot = { - getSlotElementId: () => 'example-throwing-targeting', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockPbjs.setTargetingForGPTAsync = vi.fn(() => { - throw new Error('example targeting failure'); - }); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('does not stack the removeAdUnit lifecycle wrapper across installation', () => { - const pbjs = installPrebidNpm(); - installPrebidNpm(); - - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit( - 'example-reinstalled-slot' - ); - - expect(mockRemoveAdUnit).toHaveBeenCalledTimes(1); - }); - - it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { - const outerSlot = { - getSlotElementId: () => 'example-outer-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const innerSlot = { - getSlotElementId: () => 'example-inner-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([outerSlot, innerSlot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => { - pbjs.requestBids({ - adUnits: [ - { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => pubads.refresh([innerSlot]), - } as unknown as RequestBidsArg); - pubads.refresh([outerSlot]); - }, - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); - expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [outerSlot], undefined); - }); - - it('cleans delivery context after a publisher callback throws', () => { - const slot = { - getSlotElementId: () => 'example-throwing-callback', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - expect(() => - pbjs.requestBids({ - adUnits: [ - { - code: 'example-throwing-callback', - bids: [{ bidder: 'exampleServer', params: {} }], - }, - ], - bidsBackHandler: () => { - throw new Error('example callback failure'); - }, - } as unknown as RequestBidsArg) - ).toThrow('example callback failure'); - - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - }); - - it('completes an internal synthetic refresh once without recursion', () => { - const slot = { - getSlotElementId: () => 'example-independent-refresh', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); -}); - -describe('prebid/client-side bidders', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); - // By default the manifest declares all adapters compiled in. - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - delete testWindow.__tsjs_prebid; - }); - - afterEach(() => { - delete testWindow.__tsjs_prebid; - }); - - it('excludes client-side bidders from trustedServer bidderParams', () => { - testWindow.__tsjs_prebid = { - clientSideBidders: ['rubicon'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - { bidder: 'kargo', params: { placementId: 'k1' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid).toBeDefined(); - // rubicon should NOT be in bidderParams — it runs client-side - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - kargo: { placementId: 'k1' }, - }); - }); - - it('preserves client-side bidder bids as standalone entries', () => { - testWindow.__tsjs_prebid = { - clientSideBidders: ['rubicon'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - // rubicon bid should remain untouched as a standalone entry - const rubiconBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'rubicon') as TestBid; - expect(rubiconBid).toBeDefined(); - expect(rubiconBid.params).toEqual({ accountId: 'abc' }); - expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); - }); - - it('handles multiple client-side bidders', () => { - testWindow.__tsjs_prebid = { - clientSideBidders: ['rubicon', 'openx'], - serverSideBidders: ['appnexus', 'exampleServer'], - }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - { bidder: 'openx', params: { unit: '456' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - // Only appnexus should be in bidderParams - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - }); - - // Both client-side bidders should remain - expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'rubicon')).toBeDefined(); - expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'openx')).toBeDefined(); - expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); - }); - - it('leaves all unowned bidders in browser demand when no routes are configured', () => { - testWindow.__tsjs_prebid = { serverSideBidders: [] }; - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params?.bidderParams).toEqual({}); - expect(adUnits[0].bids.map((bid) => bid.bidder)).toEqual([ - 'appnexus', - 'rubicon', - 'trustedServer', - ]); - }); - - it('behaves normally when client-side bidders list is empty', () => { - testWindow.__tsjs_prebid = { - clientSideBidders: [], - serverSideBidders: ['appnexus', 'rubicon'], - }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, - }); - }); - - it('still injects trustedServer when all bidders are client-side', () => { - testWindow.__tsjs_prebid = { - clientSideBidders: ['rubicon', 'appnexus'], - serverSideBidders: ['openx', 'exampleServer'], - }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'rubicon', params: { accountId: 'abc' } }, - { bidder: 'appnexus', params: { placementId: 123 } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - // trustedServer should still be present (even with empty bidderParams) - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid).toBeDefined(); - expect(tsBid.params.bidderParams).toEqual({}); - }); - - it('logs error when a client-side bidder has no adapter in the external bundle', () => { - // rubicon is compiled into the external bundle, but openx is not - testWindow.__tsjs_prebid_bundle = { - ...DEFAULT_BUNDLE_MANIFEST, - adapters: ['rubicon'], - bidderCodes: ['rubicon'], - }; - testWindow.__tsjs_prebid = { - clientSideBidders: ['rubicon', 'openx'], - serverSideBidders: ['appnexus', 'exampleServer'], - }; - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - installPrebidNpm(); - - // Should log an error for the missing adapter. - // log.error() uses styled console output: console.error('%c[tsjs]%c ...:', style, reset, ...args) - // so the actual message is the 4th argument. - const errorCalls = errorSpy.mock.calls; - const hasOpenxError = errorCalls.some((args) => - args.some( - (a) => - typeof a === 'string' && - a.includes('client-side bidder "openx" has no adapter in the external Prebid bundle') - ) - ); - expect(hasOpenxError).toBe(true); - - // The error should point at the operator surface: the CLI config key, - // not the internal build script. - const pointsAtBundleConfig = errorCalls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('[integrations.prebid.bundle].adapters')) - ); - expect(pointsAtBundleConfig).toBe(true); - - // Should NOT log an error for the compiled-in adapter - const hasRubiconError = errorCalls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('client-side bidder "rubicon"')) - ); - expect(hasRubiconError).toBe(false); - - errorSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('accepts alias bidder codes stamped in bidderCodes', () => { - // The adf module registers adf plus the adform/adformOpenRTB aliases; - // the module-name list alone would flag them as missing. - testWindow.__tsjs_prebid_bundle = { - ...DEFAULT_BUNDLE_MANIFEST, - adapters: ['adf'], - bidderCodes: ['adf', 'adform', 'adformOpenRTB'], - }; - testWindow.__tsjs_prebid = { - clientSideBidders: ['adform'], - serverSideBidders: ['appnexus', 'exampleServer'], - }; - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - installPrebidNpm(); - - const hasAdapterError = errorSpy.mock.calls.some((args) => - args.some( - (a) => typeof a === 'string' && a.includes('has no adapter in the external Prebid bundle') - ) - ); - expect(hasAdapterError).toBe(false); - - errorSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('rejects a module file stem that is not a registered bidder code', () => { - // a1MediaBidAdapter.js registers a1media — configuring the file stem - // must be flagged even though the module itself is compiled in. - testWindow.__tsjs_prebid_bundle = { - ...DEFAULT_BUNDLE_MANIFEST, - adapters: ['a1Media'], - bidderCodes: ['a1media'], - }; - testWindow.__tsjs_prebid = { - clientSideBidders: ['a1Media'], - serverSideBidders: ['appnexus', 'exampleServer'], - }; - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - installPrebidNpm(); - - const hasAdapterError = errorSpy.mock.calls.some((args) => - args.some( - (a) => - typeof a === 'string' && - a.includes('client-side bidder "a1Media" has no adapter in the external Prebid bundle') - ) - ); - expect(hasAdapterError).toBe(true); - - errorSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('treats a malformed manifest as unstamped instead of throwing', () => { - // The manifest is a plain window global any page script can overwrite. - testWindow.__tsjs_prebid_bundle = { adapters: 'rubicon', userIdModules: 42 }; - testWindow.__tsjs_prebid = { - clientSideBidders: ['rubicon'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - expect(() => installPrebidNpm()).not.toThrow(); - - const hasManifestWarn = warnSpy.mock.calls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('did not stamp an adapter manifest')) - ); - expect(hasManifestWarn).toBe(true); - - warnSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('warns when the external bundle stamped no adapter manifest', () => { - delete testWindow.__tsjs_prebid_bundle; - testWindow.__tsjs_prebid = { - clientSideBidders: ['rubicon'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - installPrebidNpm(); - - const hasManifestWarn = warnSpy.mock.calls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('did not stamp an adapter manifest')) - ); - expect(hasManifestWarn).toBe(true); - - warnSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('does not log errors when all client-side bidders have adapters', () => { - testWindow.__tsjs_prebid = { - clientSideBidders: ['rubicon'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - installPrebidNpm(); - - const hasAdapterError = errorSpy.mock.calls.some((args) => - args.some( - (a) => typeof a === 'string' && a.includes('has no adapter in the external Prebid bundle') - ) - ); - expect(hasAdapterError).toBe(false); - - errorSpy.mockRestore(); - }); -}); - -describe('prebid/self-init without the external bundle', () => { - afterEach(() => { - // Restore the module registry and the full mock global for later suites. - testWindow.pbjs = mockPbjs; - delete testWindow.googletag; - vi.resetModules(); - }); - - it('keeps basic Prebid enabled when only the APS lifecycle API is unavailable', async () => { - vi.resetModules(); - const registerBidAdapter = vi.fn(); - const onEvent = vi.fn(); - const originalRequestBids = vi.fn(); - const compatiblePbjs = { - ...mockPbjs, - registerBidAdapter, - onEvent, - requestBids: originalRequestBids, - markWinningBidAsUsed: undefined, - que: [] as Array<() => void>, - cmd: [] as Array<() => void>, - }; - testWindow.pbjs = compatiblePbjs; - const pubads = { refresh: vi.fn() }; - const cmdPush = vi.fn((callback: () => void) => callback()); - testWindow.googletag = { cmd: { push: cmdPush }, pubads: () => pubads }; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await import('../../../src/integrations/prebid/index'); - - expect(registerBidAdapter).toHaveBeenCalledTimes(1); - expect(compatiblePbjs.requestBids).not.toBe(originalRequestBids); - - const adapter = registerBidAdapter.mock.calls[0][2] as TestAdapterSpec; - const convertedBids = adapter.interpretResponse( - { - body: { - seatbid: [ - { - seat: 'appnexus', - bid: [ - { - impid: 'ordinary-slot', - price: 2.5, - adm: '
ordinary creative
', - w: 300, - h: 250, - }, - ], - }, - { - seat: 'aps', - bid: [ - { - impid: 'aps-slot', - price: 3.5, - ext: { trusted_server: { renderer: apsRenderer() } }, - }, - ], - }, - ], - }, - }, - { - tsjsBidRequests: [ - { adUnitCode: 'ordinary-slot', bidId: 'ordinary-request' }, - { adUnitCode: 'aps-slot', bidId: 'aps-request' }, - ], - } - ); - - expect(convertedBids).toHaveLength(1); - expect(convertedBids[0]).toEqual( - expect.objectContaining({ - requestId: 'ordinary-request', - bidderCode: 'appnexus', - ad: '
ordinary creative
', - }) - ); - expect(convertedBids).not.toEqual( - expect.arrayContaining([expect.objectContaining({ bidderCode: 'aps' })]) - ); - expect(onEvent).not.toHaveBeenCalledWith('bidResponse', expect.any(Function)); - expect( - warnSpy.mock.calls.some((args) => - args.some( - (value) => typeof value === 'string' && value.includes('APS renderer bids disabled') - ) - ) - ).toBe(true); - expect(testWindow.__tsjsPrebidShimInstalled).toBe(true); - - warnSpy.mockRestore(); - }); -}); - -describe('prebid self-init user ID module timing', () => { - const userSyncCallCount = () => - mockSetConfig.mock.calls.filter(([arg]) => arg && typeof arg === 'object' && 'userSync' in arg) - .length; - - const setReadyState = (value: DocumentReadyState) => { - Object.defineProperty(document, 'readyState', { value, configurable: true }); - }; - - beforeEach(() => { - vi.resetModules(); - mockSetConfig.mockClear(); - }); - - afterEach(() => { - setReadyState('complete'); - }); - - it('installs user ID modules immediately when the bundle loads after window load', async () => { - // The GPT slim loader appends this bundle from a window.load handler, so - // the document is already complete — a load listener would never fire. - setReadyState('complete'); - - await import('../../../src/integrations/prebid/index'); - - expect(userSyncCallCount()).toBeGreaterThan(0); - }); - - it('defers user ID modules to window load when the document is still loading', async () => { - setReadyState('loading'); - - await import('../../../src/integrations/prebid/index'); - - expect(userSyncCallCount()).toBe(0); - - window.dispatchEvent(new Event('load')); - expect(userSyncCallCount()).toBe(1); - - // { once: true } — a second load event must not reinstall. - window.dispatchEvent(new Event('load')); - expect(userSyncCallCount()).toBe(1); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/later.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/later.test.ts new file mode 100644 index 000000000..9f1ef515c --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/later.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createPrebidLaterIntegrationRegistration } from '../../../src/integrations/prebid/later'; +import type { PrebidRefreshPolicy } from '../../../src/integrations/prebid/refresh'; +import { createTestNavigationIdentityIssuer } from '../../../src/kernel/identity'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + PreparedIntegration, +} from '../../../src/kernel/integration_registry'; +import { createRuntimeSession } from '../../../src/kernel/sessions'; + +const RELEASE_ID = 'a'.repeat(64); +const CONFIG = Object.freeze({ + accountId: 'fictional-account', + timeout: 1_000, + debug: false, + bidders: Object.freeze(['server']), + clientSideBidders: Object.freeze(['client']), + excludedGamAdUnitPathSuffixes: Object.freeze(['/excluded']), +}); + +function harness(acceptPolicy = true) { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(4); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected current navigation'); + const navigation = navigationResult.value; + const physicalSlot = Object.freeze({ id: 'physical-slot' }); + const directAuctionUnit = Object.freeze({ + code: 'slot-one', + mediaTypes: Object.freeze({ + banner: Object.freeze({ sizes: Object.freeze([Object.freeze([300, 250])]) }), + }), + bids: Object.freeze([ + Object.freeze({ + bidder: 'trustedServer', + params: Object.freeze({ + bidderParams: Object.freeze({ + server: Object.freeze({ placement: 'server-owned' }), + }), + }), + }), + Object.freeze({ bidder: 'client', params: Object.freeze({ placement: 'publisher-client' }) }), + ]), + }); + const clearTargeting = vi.fn(); + const gptOperationDispose = vi.fn(); + const googletagRun = vi.fn((command: (facade: object) => unknown) => + Object.freeze({ + result: Promise.resolve( + command( + Object.freeze({ + adUnitPath: () => '/network/eligible', + clearTargeting, + }) + ) + ), + dispose: gptOperationDispose, + }) + ); + const requestBids = vi.fn((request: Readonly>) => { + const callback = request.bidsBackHandler; + if (typeof callback === 'function') callback(); + }); + const setTargetingForGpt = vi.fn(); + const prebidOperationDispose = vi.fn(); + const prebidRun = vi.fn((command: (facade: object) => unknown) => + Object.freeze({ + result: Promise.resolve( + command( + Object.freeze({ + requestBids, + setTargetingForGpt, + }) + ) + ), + dispose: prebidOperationDispose, + }) + ); + let policy: PrebidRefreshPolicy | undefined; + const policyRelease = vi.fn(); + const installRefreshPolicy = vi.fn((candidate: PrebidRefreshPolicy) => { + policy = candidate; + return acceptPolicy ? policyRelease : undefined; + }); + const directAuctionUnitForSlot = vi.fn((slot: object) => + slot === physicalSlot ? directAuctionUnit : undefined + ); + const activationDisposers: Array<() => void> = []; + const registration = createPrebidLaterIntegrationRegistration(RELEASE_ID); + const prepared = registration.prepare( + Object.freeze({ + config: CONFIG, + interfaces: Object.freeze({ + 'runtime.v1': Object.freeze({}), + 'slots.v1': Object.freeze({}), + 'gpt.v1': Object.freeze({ + adapter: Object.freeze({ run: googletagRun }), + directAuctionUnitForSlot, + installRefreshPolicy, + navigation: () => navigation, + }), + 'prebid.v1': Object.freeze({ + adapter: Object.freeze({ run: prebidRun }), + clientSideBidders: CONFIG.clientSideBidders, + excludedGamAdUnitPathSuffixes: CONFIG.excludedGamAdUnitPathSuffixes, + }), + }), + onDispose: () => undefined, + signal: new AbortController().signal, + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + const activation = Object.freeze({ + afterCommit: vi.fn(), + onDispose: (callback: () => void) => activationDisposers.push(callback), + signal: new AbortController().signal, + } satisfies IntegrationActivationContext); + + return { + activation, + activationDisposers, + clearTargeting, + directAuctionUnitForSlot, + googletagRun, + installRefreshPolicy, + physicalSlot, + policy: () => policy, + policyRelease, + prebidRun, + registration, + requestBids, + runtime, + setTargetingForGpt, + prepared, + }; +} + +describe('RCJ-PREBID-04 deferred refresh ownership', () => { + it('has no initial-admission effect and runs one detached refresh after activation', async () => { + const owner = harness(); + + expect(owner.registration).toMatchObject({ id: 'prebid_later', phase: 'deferred' }); + expect(owner.installRefreshPolicy).not.toHaveBeenCalled(); + expect(owner.googletagRun).not.toHaveBeenCalled(); + expect(owner.prebidRun).not.toHaveBeenCalled(); + expect(owner.directAuctionUnitForSlot).not.toHaveBeenCalled(); + + owner.prepared.activate(owner.activation); + expect(owner.installRefreshPolicy).toHaveBeenCalledOnce(); + expect(owner.prebidRun).not.toHaveBeenCalled(); + + const completion = owner + .policy() + ?.prepare(Object.freeze({ slots: Object.freeze([owner.physicalSlot]) })); + expect(completion).toBeDefined(); + await Promise.resolve(completion); + + expect(owner.googletagRun).toHaveBeenCalledOnce(); + expect(owner.directAuctionUnitForSlot).toHaveBeenCalledExactlyOnceWith(owner.physicalSlot); + expect(owner.prebidRun).toHaveBeenCalledOnce(); + expect(owner.requestBids).toHaveBeenCalledOnce(); + expect(owner.setTargetingForGpt).toHaveBeenCalledExactlyOnceWith(['slot-one']); + expect(owner.requestBids.mock.calls[0]?.[0]).toMatchObject({ + adUnits: [ + { + code: 'slot-one', + bids: [ + { + bidder: 'trustedServer', + params: { bidderParams: { server: { placement: 'server-owned' } } }, + }, + { bidder: 'client', params: { placement: 'publisher-client' } }, + ], + }, + ], + }); + + owner.activationDisposers.reverse().forEach((dispose) => dispose()); + expect(owner.policyRelease).toHaveBeenCalledOnce(); + owner.runtime.dispose(); + }); + + it('fails closed when GPT already has a refresh owner without starting another auction', () => { + const owner = harness(false); + + expect(() => owner.prepared.activate(owner.activation)).toThrow( + 'Prebid refresh policy is duplicated' + ); + expect(owner.installRefreshPolicy).toHaveBeenCalledOnce(); + expect(owner.googletagRun).not.toHaveBeenCalled(); + expect(owner.prebidRun).not.toHaveBeenCalled(); + + owner.activationDisposers.reverse().forEach((dispose) => dispose()); + expect(owner.policyRelease).not.toHaveBeenCalled(); + owner.runtime.dispose(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts new file mode 100644 index 000000000..920e4061f --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -0,0 +1,1972 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { GoogletagAdapter } from '../../../src/adapters/googletag'; +import { + PrebidAdmissionContractError, + type PrebidAdapter, + type PrebidFacade, +} from '../../../src/adapters/prebid'; +import { + createPrebidIntegrationRegistration, + type PreparedTrustedBidV1, +} from '../../../src/integrations/prebid/module'; +import { + createPrebidSelectionCoordinator, + publishPrebidBid, + type PrebidBidPublicationInput, +} from '../../../src/integrations/render_runtime/prebid_selection'; +import { + createPrebidRefreshPolicy, + createPrebidSyntheticRefreshRunner, + preparePrebidRegisteredRefreshAuction, +} from '../../../src/integrations/prebid/refresh'; +import { createTestNavigationIdentityIssuer } from '../../../src/kernel/identity'; +import { + createIntegrationRegistry, + type IntegrationActivationContext, + type IntegrationInstallCallbacks, + type IntegrationRegistration, +} from '../../../src/kernel/integration_registry'; +import { createRuntimeSession, type RenderAttemptScope } from '../../../src/kernel/sessions'; +import { + createCommittedArtifactStore, + createRenderAttempt, + type RenderAttempt, +} from '../../../src/services/render'; +import { createReservationService } from '../../../src/services/reservations'; + +const RELEASE_ID = 'a'.repeat(64); + +function manifest(ids: readonly string[]) { + return { + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => ({ id, phase: 'takeover' as const })), + }; +} + +function registration( + id: string, + prepare: IntegrationRegistration['prepare'] +): IntegrationRegistration { + return Object.freeze({ + abi: 1, + id, + phase: 'takeover', + releaseId: RELEASE_ID, + prepareSync: () => Object.freeze({ activate: () => undefined }), + prepare, + }); +} + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +function recursivelyFrozen(candidate: unknown, seen = new Set()): boolean { + if (candidate === null || (typeof candidate !== 'object' && typeof candidate !== 'function')) { + return typeof candidate !== 'number' || Number.isFinite(candidate); + } + if (typeof candidate === 'function' || seen.has(candidate) || !Object.isFrozen(candidate)) { + return false; + } + const prototype = Object.getPrototypeOf(candidate); + if ( + prototype !== Object.prototype && + prototype !== null && + !(Array.isArray(candidate) && prototype === Array.prototype) + ) { + return false; + } + seen.add(candidate); + return Reflect.ownKeys(candidate).every((key) => { + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + return ( + descriptor !== undefined && 'value' in descriptor && recursivelyFrozen(descriptor.value, seen) + ); + }); +} + +function createLegacyPrebidRegistrationForTest(_releaseId: string): IntegrationRegistration { + return registration('prebid', ({ config, interfaces }) => { + if (!recursivelyFrozen(config)) throw new TypeError('Prebid test config is invalid'); + const runtime = interfaces['prebid'] as + Readonly<{ activate?: () => unknown; start?: (config: unknown) => void }> | undefined; + if ( + !runtime || + !Object.isFrozen(runtime) || + typeof runtime.activate !== 'function' || + typeof runtime.start !== 'function' + ) { + throw new TypeError('Prebid test runtime is unavailable'); + } + const activate = runtime.activate; + const start = runtime.start; + return Object.freeze({ + activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { + const release = activate(); + if (typeof release !== 'function') { + throw new TypeError('Prebid test runtime disposer is unavailable'); + } + onDispose(release as () => void); + afterCommit(() => start(config)); + }, + }); + }); +} + +type TrustedServerBidder = Readonly<{ + callBids: ( + request: Readonly, + addBidResponse: (adUnitCode: string, bid: Readonly>) => void, + done: () => void + ) => void; +}>; + +function recursivelyFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) recursivelyFreeze(child); + Object.freeze(value); + } + return value; +} + +function productionPrebidBinding(userIdModules: readonly object[]) { + const listeners = new Map void>>(); + const responses = new Map>[]>(); + let bidder: TrustedServerBidder | undefined; + let highest: readonly object[] = Object.freeze([]); + const responseFor = (adUnitCode: string) => { + const response = [...(responses.get(adUnitCode) ?? [])] as object[] & { bids: object[] }; + response.bids = response; + return response; + }; + const pbjs = { + addAdUnits: vi.fn(), + getBidResponsesForAdUnitCode: vi.fn((adUnitCode: string) => responseFor(adUnitCode)), + getHighestCpmBids: vi.fn(() => [...highest]), + offEvent: vi.fn((type: string, listener: (event: unknown) => void) => { + listeners.get(type)?.delete(listener); + }), + onEvent: vi.fn((type: string, listener: (event: unknown) => void) => { + const current = listeners.get(type) ?? new Set(); + current.add(listener); + listeners.set(type, current); + }), + processQueue: vi.fn(), + registerBidAdapter: vi.fn((factory: () => TrustedServerBidder) => { + bidder = factory(); + }), + renderAd: vi.fn(), + requestBids: vi.fn(), + setTargetingForGPTAsync: vi.fn(), + que: Object.freeze({ + push: (command: () => void) => { + command(); + return 1; + }, + }), + }; + const stamp = recursivelyFreeze({ + abi: 1, + artifactReleaseId: 'b'.repeat(64), + prebidVersion: '10.26.0', + moduleStems: ['alphaBidAdapter', 'sharedIdSystem'], + bidderCodes: ['alpha'], + bidderAliases: [], + userIdModules: [...userIdModules], + }); + Object.defineProperty(pbjs, '__trustedServerArtifactV1', { + configurable: false, + enumerable: false, + value: stamp, + writable: false, + }); + return Object.freeze({ + addResponse: (adUnitCode: string, bid: Readonly>): void => { + responses.set(adUnitCode, Object.freeze([bid])); + for (const listener of listeners.get('bidResponse') ?? []) listener(bid); + }, + bidder: () => bidder, + emit: (type: string, event: unknown): void => { + for (const listener of listeners.get(type) ?? []) listener(event); + }, + pbjs, + select: (bids: readonly object[]): void => { + highest = Object.freeze([...bids]); + }, + }); +} + +function projectedPrebidConfig() { + return Object.freeze({ + accountId: 'publisher', + timeout: 1_000, + debug: false, + bidders: Object.freeze(['alpha']), + clientSideBidders: Object.freeze(['alpha']), + excludedGamAdUnitPathSuffixes: Object.freeze(['/excluded']), + }); +} + +function initialProductionPrebidHarness(userIdModules: readonly object[]) { + const binding = productionPrebidBinding(userIdModules); + (window as unknown as { pbjs?: unknown }).pbjs = binding.pbjs; + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(9); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected initial navigation'); + const navigation = navigationResult.value; + const renderSource = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
production-prebid
', + width: 300, + height: 250, + }); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: 'slot-one', + provider: 'aps', + upstreamBidId: 'upstream-one', + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trustedServer' }), + rendererReservationId: `r1_${'p'.repeat(22)}`, + renderSource, + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'auction-one', + results: Object.freeze([ + Object.freeze({ + slot: bid.slot, + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + bids: Object.freeze([bid]), + }); + if (!navigation.installAuctionProjection(projection)) throw new Error('Expected projection'); + const reservations = createReservationService({ + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as typeof renderSource) + : undefined, + }); + const artifacts = createCommittedArtifactStore(); + const registerPucGamAttempt = vi.fn((_input: unknown) => true); + const createAttempt = (owner: RenderAttemptScope) => + createRenderAttempt({ + artifacts, + owner, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as typeof renderSource) + : undefined, + reservations, + }); + const renderCapability = Object.freeze({ + createPrebidSelectionCoordinator: () => + createPrebidSelectionCoordinator({ + activateAttempt: ({ attempt, owner, preparedBid }) => + registerPucGamAttempt( + Object.freeze({ + artifact: Object.freeze({ + kind: 'puc' as const, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: attempt.navigationGeneration, + dispose: () => undefined, + }), + attempt, + owner, + reservationId: preparedBid.bid.adId, + }) + ), + createAttempt, + reservations, + }), + navigation, + projection, + publishPrebidBid: (input: Omit) => + publishPrebidBid({ ...input, navigation, reservations }), + }); + const preparationDisposers: Array<() => void> = []; + const activationDisposers: Array<() => void> = []; + const afterCommit: Array<() => void> = []; + const controller = new AbortController(); + return Object.freeze({ + activationContext: Object.freeze({ + afterCommit: (callback: () => void) => afterCommit.push(callback), + onDispose: (callback: () => void) => activationDisposers.push(callback), + signal: controller.signal, + }), + afterCommit, + bid, + binding, + config: projectedPrebidConfig(), + dispose: () => { + for (let index = activationDisposers.length - 1; index >= 0; index -= 1) { + activationDisposers[index]?.(); + } + for (let index = preparationDisposers.length - 1; index >= 0; index -= 1) { + preparationDisposers[index]?.(); + } + reservations.dispose(); + artifacts.dispose(); + runtime.dispose(); + delete (window as unknown as { pbjs?: unknown }).pbjs; + }, + interfaces: Object.freeze({ + 'runtime.v1': Object.freeze({ document }), + 'slots.v1': Object.freeze({}), + 'render.v1': renderCapability, + 'messages.v1': Object.freeze({}), + 'aps.v1': Object.freeze({}), + }), + navigation, + prepareContext: Object.freeze({ + config: projectedPrebidConfig(), + interfaces: Object.freeze({ + 'runtime.v1': Object.freeze({ document }), + 'slots.v1': Object.freeze({}), + 'render.v1': renderCapability, + 'messages.v1': Object.freeze({}), + 'aps.v1': Object.freeze({}), + }), + onDispose: (callback: () => void) => preparationDisposers.push(callback), + signal: controller.signal, + }), + registerPucGamAttempt, + reservations, + }); +} + +describe('production Prebid takeover registration', () => { + afterEach(() => { + delete (window as unknown as { pbjs?: unknown }).pbjs; + }); + + it('accepts only the exact server-projected Prebid browser configuration', async () => { + const harness = initialProductionPrebidHarness([]); + try { + const prepared = await createPrebidIntegrationRegistration(RELEASE_ID).prepare( + harness.prepareContext + ); + const capability = prepared.interfaces?.['prebid.v1'] as + Readonly<{ adapter?: PrebidAdapter }> | undefined; + expect(capability?.adapter?.bindingStatus()).toBe('present'); + } finally { + harness.dispose(); + } + }); + + it('rejects legacy or publisher-only Prebid config fields during preparation', async () => { + const harness = initialProductionPrebidHarness([]); + try { + const config = Object.freeze({ + ...projectedPrebidConfig(), + requiredUserIdModules: Object.freeze([]), + }); + expect(() => + createPrebidIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ ...harness.prepareContext, config }) + ) + ).toThrow('Prebid integration config is invalid'); + } finally { + harness.dispose(); + } + }); + + it('adopts the sealed initial Prebid slice without replaying an initial bid publication', async () => { + const harness = initialProductionPrebidHarness([ + Object.freeze({ + moduleName: 'sharedIdSystem', + configNames: Object.freeze(['sharedId']), + eidSources: Object.freeze(['sharedid.org']), + }), + ]); + try { + const prepared = await createPrebidIntegrationRegistration(RELEASE_ID).prepare( + harness.prepareContext + ); + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slices: Object.freeze(['first_display', 'prebid_initial']), + parserState: Object.freeze([ + Object.freeze({ + sliceId: 'prebid_initial', + observations: Object.freeze(['protocol_version']), + values: Object.freeze([Object.freeze(['protocol_version', 1] as const)]), + }), + ]), + }), + identities: Object.freeze([]), + }); + + prepared.activate(Object.freeze({ ...harness.activationContext, adoption })); + for (const callback of harness.afterCommit) callback(); + + expect(harness.binding.bidder()).toEqual(expect.any(Object)); + expect(harness.registerPucGamAttempt).not.toHaveBeenCalled(); + } finally { + harness.dispose(); + } + }); + + it('rejects a takeover that omits the selected initial Prebid state', async () => { + const harness = initialProductionPrebidHarness([]); + try { + const prepared = await createPrebidIntegrationRegistration(RELEASE_ID).prepare( + harness.prepareContext + ); + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slices: Object.freeze(['first_display', 'prebid_initial']), + parserState: Object.freeze([]), + }), + identities: Object.freeze([]), + }); + + expect(() => + prepared.activate(Object.freeze({ ...harness.activationContext, adoption })) + ).toThrow('Prebid first-display adoption is invalid'); + } finally { + harness.dispose(); + } + }); + + it('publishes the initial TS winner and promotes its exact selection through render.v1', async () => { + const harness = initialProductionPrebidHarness([ + Object.freeze({ + moduleName: 'sharedIdSystem', + configNames: Object.freeze(['sharedId']), + eidSources: Object.freeze(['sharedid.org']), + }), + ]); + try { + const prepared = await createPrebidIntegrationRegistration(RELEASE_ID).prepare( + harness.prepareContext + ); + prepared.activate(harness.activationContext); + for (const callback of harness.afterCommit) callback(); + const bidder = harness.binding.bidder(); + if (!bidder) throw new Error('Expected trustedServer bidder registration'); + const done = vi.fn(); + let admitted: Readonly> | undefined; + bidder.callBids( + Object.freeze({ + auctionId: 'auction-one', + bids: Object.freeze([ + Object.freeze({ + adUnitCode: 'slot-one', + adUnitId: 'unit-one', + auctionId: 'auction-one', + bidId: 'request-one', + src: 'client', + transactionId: 'transaction-one', + }), + ]), + }), + (adUnitCode, response) => { + const enriched = Object.freeze({ ...response, adUnitCode }); + admitted = enriched; + harness.binding.addResponse(adUnitCode, enriched); + }, + done + ); + expect(done).toHaveBeenCalledOnce(); + expect(admitted).toMatchObject({ + adId: harness.bid.rendererReservationId, + bidderCode: 'trustedServer', + requestId: 'request-one', + }); + const selected = admitted; + if (!selected) throw new Error('Expected admitted TS bid'); + harness.binding.select([ + Object.freeze({ + ...selected, + adUnitCode: 'slot-one', + auctionId: 'auction-one', + }), + ]); + expect(harness.reservations.recognize(harness.bid.rendererReservationId)).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + harness.binding.emit('auctionEnd', Object.freeze({ auctionId: 'auction-one' })); + expect(harness.binding.pbjs.getHighestCpmBids).toHaveBeenCalledOnce(); + expect(harness.registerPucGamAttempt).toHaveBeenCalledOnce(); + expect(harness.reservations.recognize(harness.bid.rendererReservationId)).toMatchObject({ + state: 'renderable', + }); + } finally { + harness.dispose(); + } + }); +}); + +describe('transactional test-composition Prebid boundary', () => { + it('prepares inertly, activates reversible listeners, and starts only after commit', async () => { + const config = Object.freeze({ clientSideBidders: Object.freeze(['rubicon']) }); + const order: string[] = []; + const start = vi.fn((received: unknown) => { + order.push('start'); + expect(received).toBe(config); + }); + const release = vi.fn(() => order.push('release')); + const activate = vi.fn(() => { + order.push('prebid:activate'); + return release; + }); + let finishPreparation: (() => void) | undefined; + const preparationGate = new Promise((resolve) => { + finishPreparation = resolve; + }); + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid', 'gate']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid', 'gate']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ prebid: Object.freeze({ activate, start }) }), + }), + }); + registry.register(createLegacyPrebidRegistrationForTest(RELEASE_ID)); + registry.register( + registration('gate', async () => { + order.push('gate:prepare'); + await preparationGate; + return Object.freeze({ activate: () => order.push('gate:activate') }); + }) + ); + + const installing = registry.install(callbacks(order)); + await vi.waitFor(() => expect(order).toEqual(['gate:prepare'])); + expect(start).not.toHaveBeenCalled(); + + finishPreparation?.(); + const result = await installing; + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'gate:prepare', + 'core', + 'prebid:activate', + 'gate:activate', + 'publish', + 'start', + 'drain', + ]); + expect(activate).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(release).toHaveBeenCalledTimes(1); + }); + + it('unwinds Prebid activation before fallback when a later module fails', async () => { + const release = vi.fn(); + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid', 'broken']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid', 'broken']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ + prebid: Object.freeze({ activate: () => release, start }), + }), + }), + }); + registry.register(createLegacyPrebidRegistrationForTest(RELEASE_ID)); + registry.register( + registration('broken', () => ({ + activate: () => { + throw new Error('fictional activation failure'); + }, + })) + ); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(release).toHaveBeenCalledTimes(1); + expect(start).not.toHaveBeenCalled(); + }); + + it('does not start when reversible Prebid activation fails', async () => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ + prebid: Object.freeze({ + activate: () => { + throw new Error('fictional listener activation failure'); + }, + start, + }), + }), + }), + }); + registry.register(createLegacyPrebidRegistrationForTest(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(start).not.toHaveBeenCalled(); + }); + + it('fails preparation without effects when the composition omits the Prebid boundary', async () => { + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + }); + registry.register(createLegacyPrebidRegistrationForTest(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + }); + + it.each([ + [ + 'accessor', + Object.freeze( + Object.defineProperty({}, 'externalBundleUrl', { + enumerable: true, + get: () => '/publisher-controlled', + }) + ), + ], + ['mutable nested data', Object.freeze({ nested: {} })], + ['non-plain data', Object.freeze({ value: Object.freeze(new Date(0)) })], + ])('rejects %s configuration during inert preparation', async (_caseName, config) => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + prebid: Object.freeze({ activate: () => vi.fn(), start }), + }), + }), + }); + registry.register(createLegacyPrebidRegistrationForTest(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(start).not.toHaveBeenCalled(); + }); + + it('isolates post-commit startup failure to the Prebid module', async () => { + const start = vi.fn(() => { + throw new Error('fictional Prebid startup failure'); + }); + const runtimeFailures: unknown[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid']), + startedAtMs: 0, + now: () => 0, + onRuntimeFailure: (failure) => runtimeFailures.push(failure), + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ + prebid: Object.freeze({ activate: () => vi.fn(), start }), + }), + }), + }); + registry.register(createLegacyPrebidRegistrationForTest(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'kernel', + runtimeFailures: [{ id: 'prebid', phase: 'after_commit' }], + }); + expect(start).toHaveBeenCalledTimes(1); + expect(runtimeFailures).toEqual([{ id: 'prebid', phase: 'after_commit' }]); + }); +}); + +describe('RCJ-PREBID-04 refresh policy', () => { + function refreshHarness( + excludedGamAdUnitPathSuffixes: readonly string[] | (() => readonly string[]) + ) { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(3); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation'); + const navigation = navigationResult.value; + const clearCalls: Array = []; + const operationDisposals: Array> = []; + const googletag = { + run: vi.fn((command: (gpt: object) => unknown) => { + const dispose = vi.fn(); + operationDisposals.push(dispose); + const facade = Object.freeze({ + adUnitPath: (slot: object) => { + const getter = Reflect.get(slot, 'getAdUnitPath'); + if (typeof getter !== 'function') return undefined; + return Reflect.apply(getter, slot, []); + }, + clearTargeting: (slot: object, key: string) => { + clearCalls.push([slot, key]); + const clear = Reflect.get(slot, 'clearTargeting'); + if (typeof clear === 'function') return Reflect.apply(clear, slot, [key]); + return undefined; + }, + }); + return Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose, + }); + }), + }; + const auctionDisposals: Array> = []; + const runSyntheticAuction = vi.fn((_slots: readonly object[]) => { + const dispose = vi.fn(); + auctionDisposals.push(dispose); + return Object.freeze({ completion: Promise.resolve(), dispose }); + }); + const policy = createPrebidRefreshPolicy({ + currentNavigation: () => navigation, + excludedGamAdUnitPathSuffixes, + googletag: googletag as unknown as Pick, + runSyntheticAuction, + }); + return { + auctionDisposals, + clearCalls, + navigation, + operationDisposals, + policy, + runSyntheticAuction, + runtime, + }; + } + + it('clears every target then filters only literal case-sensitive suffix matches', async () => { + const harness = refreshHarness(['/tracking']); + const excluded = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => '/network/tracking'), + }; + const caseMismatch = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => '/network/Tracking'), + }; + const trailingSlash = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => '/network/tracking/'), + }; + const missing = { clearTargeting: vi.fn() }; + const nonString = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => 42), + }; + const throwing = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => { + throw new Error('path unavailable'); + }), + }; + const clearFailure = { + clearTargeting: vi.fn((key: string) => { + if (key === 'hb_adid') throw new Error('clear unavailable'); + }), + getAdUnitPath: vi.fn(() => '/network/tracking'), + }; + const slots = Object.freeze([ + excluded, + caseMismatch, + trailingSlash, + missing, + nonString, + throwing, + clearFailure, + ]); + + await harness.policy.prepare( + Object.freeze({ requestedSlots: slots, slots, options: Object.freeze({ exact: true }) }) + ); + + const expectedKeys = [ + 'ts_initial', + 'hb_pb', + 'hb_bidder', + 'hb_adid', + 'hb_cache_host', + 'hb_cache_path', + ]; + for (const slot of slots) { + expect( + harness.clearCalls.filter(([target]) => target === slot).map(([, key]) => key) + ).toEqual(expectedKeys); + } + expect(harness.runSyntheticAuction).toHaveBeenCalledExactlyOnceWith( + [caseMismatch, trailingSlash, missing, nonString, throwing, clearFailure], + harness.navigation + ); + harness.policy.dispose(); + harness.runtime.dispose(); + }); + + it('skips the synthetic auction when all targets are excluded', async () => { + const harness = refreshHarness(['/skip']); + const slots = Object.freeze([ + { getAdUnitPath: () => '/one/skip' }, + { getAdUnitPath: () => '/two/skip' }, + ]); + + await harness.policy.prepare( + Object.freeze({ requestedSlots: undefined, slots, options: undefined }) + ); + + expect(harness.runSyntheticAuction).not.toHaveBeenCalled(); + expect(harness.clearCalls).toHaveLength(slots.length * 6); + harness.policy.dispose(); + harness.runtime.dispose(); + }); + + it('reads the configured exclusion snapshot only when the activated policy prepares', async () => { + let configuredSuffixes: readonly string[] = Object.freeze([]); + const harness = refreshHarness(() => configuredSuffixes); + configuredSuffixes = Object.freeze(['/configured-after-activation']); + const slot = Object.freeze({ getAdUnitPath: () => '/network/configured-after-activation' }); + + await harness.policy.prepare( + Object.freeze({ requestedSlots: Object.freeze([slot]), slots: Object.freeze([slot]) }) + ); + + expect(harness.runSyntheticAuction).not.toHaveBeenCalled(); + expect(harness.clearCalls).toHaveLength(6); + harness.policy.dispose(); + harness.runtime.dispose(); + }); + + it('settles pending work on navigation abort and ignores a late auction completion', async () => { + const harness = refreshHarness([]); + let finishAuction!: () => void; + const auction = new Promise((resolve) => { + finishAuction = resolve; + }); + const auctionDispose = vi.fn(); + harness.runSyntheticAuction.mockReturnValue( + Object.freeze({ completion: auction, dispose: auctionDispose }) + ); + const slot = Object.freeze({ getAdUnitPath: () => '/eligible' }); + const completion = harness.policy.prepare( + Object.freeze({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + options: undefined, + }) + ); + await vi.waitFor(() => expect(harness.runSyntheticAuction).toHaveBeenCalledOnce()); + + harness.runtime.replaceNavigation(); + await expect(completion).resolves.toBeUndefined(); + expect(harness.operationDisposals[0]).toHaveBeenCalledOnce(); + expect(auctionDispose).toHaveBeenCalledOnce(); + finishAuction(); + await auction; + await Promise.resolve(); + expect(harness.runSyntheticAuction).toHaveBeenCalledOnce(); + harness.policy.dispose(); + }); + + it('settles pending work when the refresh policy is disposed', async () => { + const harness = refreshHarness([]); + let finishAuction!: () => void; + const auction = new Promise((resolve) => { + finishAuction = resolve; + }); + const auctionDispose = vi.fn(); + harness.runSyntheticAuction.mockReturnValue( + Object.freeze({ completion: auction, dispose: auctionDispose }) + ); + const slot = Object.freeze({ getAdUnitPath: () => '/eligible' }); + const completion = harness.policy.prepare( + Object.freeze({ requestedSlots: Object.freeze([slot]), slots: Object.freeze([slot]) }) + ); + await vi.waitFor(() => expect(harness.runSyntheticAuction).toHaveBeenCalledOnce()); + + harness.policy.dispose(); + harness.policy.dispose(); + await expect(completion).resolves.toBeUndefined(); + expect(harness.operationDisposals[0]).toHaveBeenCalledOnce(); + expect(auctionDispose).toHaveBeenCalledOnce(); + finishAuction(); + await auction; + harness.runtime.dispose(); + }); +}); + +describe('RCJ-PREBID-04 adapter-backed synthetic refresh runner', () => { + it('routes detached server and client bids without consulting publisher Prebid state', () => { + const slot = Object.freeze({ id: 'slot-a' }); + const serverParams = Object.freeze({ placement: 'current' }); + const unit = Object.freeze({ + code: 'slot-a', + mediaTypes: Object.freeze({ + banner: Object.freeze({ sizes: Object.freeze([Object.freeze([300, 250])]) }), + }), + bids: Object.freeze([ + Object.freeze({ + bidder: 'trustedServer', + params: Object.freeze({ + bidderParams: Object.freeze({ + client: Object.freeze({ stale: true }), + preserved: Object.freeze({ placement: 'folded' }), + server: Object.freeze({ placement: 'stale' }), + }), + zone: 'news', + }), + }), + Object.freeze({ bidder: 'server', params: serverParams }), + Object.freeze({ bidder: 'client', params: Object.freeze({ placement: 'browser' }) }), + ]), + }); + + const prepared = preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze(['client']), + resolveAdUnit: (candidate) => (candidate === slot ? unit : undefined), + slots: Object.freeze([slot]), + }); + + expect(prepared).toEqual({ + adUnitCodes: ['slot-a'], + adUnits: [ + { + code: 'slot-a', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + preserved: { placement: 'folded' }, + server: { placement: 'current' }, + }, + zone: 'news', + }, + }, + { bidder: 'client', params: { placement: 'browser' } }, + ], + }, + ], + }); + expect(Object.isFrozen(prepared?.adUnits)).toBe(true); + expect(Object.isFrozen(prepared?.adUnits[0])).toBe(true); + }); + + it('preserves legacy last-write precedence when folded params follow direct bids', () => { + const slot = Object.freeze({ id: 'slot-order' }); + const unit = Object.freeze({ + code: 'slot-order', + mediaTypes: Object.freeze({ banner: Object.freeze({ sizes: Object.freeze([]) }) }), + bids: Object.freeze([ + Object.freeze({ + bidder: 'server', + params: Object.freeze({ placement: 'direct-first' }), + }), + Object.freeze({ + bidder: 'trustedServer', + params: Object.freeze({ + bidderParams: Object.freeze({ + preserved: Object.freeze({ placement: 'folded-only' }), + server: Object.freeze({ placement: 'folded-last' }), + }), + }), + }), + ]), + }); + + const prepared = preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze([]), + resolveAdUnit: () => unit, + slots: Object.freeze([slot]), + }); + + expect(prepared?.adUnits).toEqual([ + { + code: 'slot-order', + mediaTypes: { banner: { sizes: [] } }, + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + server: { placement: 'folded-last' }, + preserved: { placement: 'folded-only' }, + }, + }, + }, + ], + }, + ]); + const bidderParams = ( + prepared?.adUnits[0] as { + bids: readonly [{ params: { bidderParams: Readonly> } }]; + } + ).bids[0].params.bidderParams; + expect(Object.keys(bidderParams)).toEqual(['server', 'preserved']); + }); + + it('fails closed when detached registrations contain duplicate trustedServer bids', () => { + const slot = Object.freeze({ id: 'slot-duplicate-trusted' }); + const trustedBid = Object.freeze({ + bidder: 'trustedServer', + params: Object.freeze({ bidderParams: Object.freeze({}) }), + }); + const unit = Object.freeze({ + code: 'slot-duplicate-trusted', + mediaTypes: Object.freeze({ banner: Object.freeze({ sizes: Object.freeze([]) }) }), + bids: Object.freeze([trustedBid, trustedBid]), + }); + + expect( + preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze([]), + resolveAdUnit: () => unit, + slots: Object.freeze([slot]), + }) + ).toBeUndefined(); + }); + + it('keeps deterministic order while resolving duplicate direct and client bids', () => { + const slot = Object.freeze({ id: 'slot-duplicates' }); + const unit = Object.freeze({ + code: 'slot-duplicates', + mediaTypes: Object.freeze({ banner: Object.freeze({ sizes: Object.freeze([]) }) }), + bids: Object.freeze([ + Object.freeze({ bidder: 'alpha', params: Object.freeze({ sequence: 1 }) }), + Object.freeze({ bidder: 'client', params: Object.freeze({ sequence: 1 }) }), + Object.freeze({ bidder: 'beta', params: Object.freeze({ sequence: 1 }) }), + Object.freeze({ bidder: 'alpha', params: Object.freeze({ sequence: 2 }) }), + Object.freeze({ bidder: 'client', params: Object.freeze({ sequence: 2 }) }), + ]), + }); + + const prepared = preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze(['client']), + resolveAdUnit: () => unit, + slots: Object.freeze([slot]), + }); + + expect(prepared?.adUnits).toEqual([ + { + code: 'slot-duplicates', + mediaTypes: { banner: { sizes: [] } }, + bids: [ + { + bidder: 'trustedServer', + params: { bidderParams: { alpha: { sequence: 2 }, beta: { sequence: 1 } } }, + }, + { bidder: 'client', params: { sequence: 1 } }, + { bidder: 'client', params: { sequence: 2 } }, + ], + }, + ]); + const bidderParams = ( + prepared?.adUnits[0] as { + bids: readonly [{ params: { bidderParams: Readonly> } }]; + } + ).bids[0].params.bidderParams; + expect(Object.keys(bidderParams)).toEqual(['alpha', 'beta']); + }); + + it('returns a recursively frozen synthetic refresh preparation', () => { + const slot = Object.freeze({ id: 'slot-frozen' }); + const unit = Object.freeze({ + code: 'slot-frozen', + mediaTypes: Object.freeze({ + banner: Object.freeze({ sizes: Object.freeze([Object.freeze([300, 250])]) }), + }), + bids: Object.freeze([ + Object.freeze({ + bidder: 'server', + params: Object.freeze({ + placement: Object.freeze({ + rules: Object.freeze([Object.freeze({ label: 'frozen' })]), + }), + }), + }), + ]), + }); + const prepared = preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze([]), + resolveAdUnit: () => unit, + slots: Object.freeze([slot]), + }); + const seen = new Set(); + const expectRecursivelyFrozen = (value: unknown): void => { + if (value === null || typeof value !== 'object' || seen.has(value)) return; + seen.add(value); + expect(Object.isFrozen(value)).toBe(true); + for (const child of Object.values(value)) expectRecursivelyFrozen(child); + }; + + expect(prepared).toBeDefined(); + expectRecursivelyFrozen(prepared); + }); + + it('fails closed when a physical slot has no detached registered ad unit', () => { + const slot = Object.freeze({ id: 'unregistered' }); + + expect( + preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze([]), + resolveAdUnit: () => undefined, + slots: Object.freeze([slot]), + }) + ).toBeUndefined(); + }); + + function runnerHarness(options: Readonly<{ requestThrows?: boolean }> = {}) { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(4); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation'); + const navigation = navigationResult.value; + const order: string[] = []; + let requestOptions: + | Readonly<{ + adUnits: readonly object[]; + bidsBackHandler: () => void; + timeout: number; + }> + | undefined; + const facade = Object.freeze({ + requestBids: vi.fn((received: unknown) => { + order.push('request'); + if (options.requestThrows) throw new Error('request unavailable'); + requestOptions = received as typeof requestOptions; + }), + setTargetingForGpt: vi.fn((codes: readonly string[]) => { + order.push(`target:${codes.join(',')}`); + }), + }) as unknown as Readonly; + const adapterDispose = vi.fn(); + const prebid = Object.freeze({ + run: vi.fn((command: (prebid: Readonly) => unknown) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: adapterDispose, + }) + ), + }) as unknown as Pick; + let deadline: (() => void) | undefined; + const timerHandle = Object.freeze({}); + const clear = vi.fn(); + const slot = Object.freeze({ id: 'slot-a' }); + const adUnit = Object.freeze({ code: 'slot-a', bids: Object.freeze([]) }); + const prepareAuction = vi.fn(() => + Object.freeze({ + adUnitCodes: Object.freeze(['slot-a']), + adUnits: Object.freeze([adUnit]), + }) + ); + const runner = createPrebidSyntheticRefreshRunner({ + prebid, + prepareAuction, + scheduler: Object.freeze({ + clear, + set: (callback: () => void, milliseconds: number) => { + expect(milliseconds).toBe(1_500); + deadline = callback; + return timerHandle; + }, + }), + }); + return { + adapterDispose, + clear, + deadline: () => deadline, + facade, + navigation, + order, + prepareAuction, + requestOptions: () => requestOptions, + runner, + runtime, + slot, + timerHandle, + }; + } + + it('requests eligible ad units then applies only their scoped targeting before completion', async () => { + const harness = runnerHarness(); + const operation = harness.runner(Object.freeze([harness.slot]), harness.navigation); + + expect(harness.order).toEqual(['request']); + expect(harness.prepareAuction).toHaveBeenCalledExactlyOnceWith( + [harness.slot], + harness.navigation + ); + expect(harness.requestOptions()).toMatchObject({ + adUnits: [{ code: 'slot-a', bids: [] }], + timeout: 1_500, + }); + harness.requestOptions()?.bidsBackHandler(); + await expect(operation.completion).resolves.toBeUndefined(); + + expect(harness.order).toEqual(['request', 'target:slot-a']); + expect(harness.clear).toHaveBeenCalledExactlyOnceWith(harness.timerHandle); + expect(harness.adapterDispose).toHaveBeenCalledOnce(); + harness.runtime.dispose(); + }); + + it('uses one targeting/settlement latch for timeout, disposal, and late callbacks', async () => { + const timedOut = runnerHarness(); + const timedOutOperation = timedOut.runner(Object.freeze([timedOut.slot]), timedOut.navigation); + const lateTimeoutCallback = timedOut.requestOptions()?.bidsBackHandler; + timedOut.deadline()?.(); + await expect(timedOutOperation.completion).resolves.toBeUndefined(); + lateTimeoutCallback?.(); + expect(timedOut.order).toEqual(['request', 'target:slot-a']); + expect(timedOut.adapterDispose).toHaveBeenCalledOnce(); + timedOut.runtime.dispose(); + + const disposed = runnerHarness(); + const disposedOperation = disposed.runner(Object.freeze([disposed.slot]), disposed.navigation); + const lateDisposedCallback = disposed.requestOptions()?.bidsBackHandler; + disposedOperation.dispose(); + disposedOperation.dispose(); + await expect(disposedOperation.completion).resolves.toBeUndefined(); + lateDisposedCallback?.(); + disposed.deadline()?.(); + expect(disposed.order).toEqual(['request']); + expect(disposed.adapterDispose).toHaveBeenCalledOnce(); + disposed.runtime.dispose(); + }); + + it('forwards completion without targeting when requestBids throws', async () => { + const harness = runnerHarness({ requestThrows: true }); + const operation = harness.runner(Object.freeze([harness.slot]), harness.navigation); + + await expect(operation.completion).resolves.toBeUndefined(); + expect(harness.order).toEqual(['request']); + expect(harness.facade.setTargetingForGpt).not.toHaveBeenCalled(); + expect(harness.adapterDispose).toHaveBeenCalledOnce(); + expect(harness.deadline()).toBeUndefined(); + harness.runtime.dispose(); + }); +}); + +describe('ordered Prebid bid publication', () => { + function preparePublication() { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(1); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation'); + const navigation = navigationResult.value; + const reservationId = `r1_${'a'.repeat(22)}`; + const renderSource = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
private creative
', + width: 300, + height: 250, + }); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: 'slot-one', + provider: 'aps', + upstreamBidId: 'upstream-one', + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trustedServer' }), + rendererReservationId: reservationId, + renderSource, + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'auction-one', + results: Object.freeze([ + Object.freeze({ + slot: bid.slot, + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + bids: Object.freeze([bid]), + }); + expect(navigation.installAuctionProjection(projection)).toBe(true); + const reservations = createReservationService({ + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as typeof renderSource) + : undefined, + }); + const generatedBid = Object.freeze({ + requestId: 'prebid-request-one', + adId: 'prebid-generated-id', + cpm: bid.cpm, + width: 300, + height: 250, + }); + const order: string[] = []; + const admitTrustedBid = vi.fn((_preparedBid: Readonly) => { + order.push('admit'); + expect(reservations.recognize(reservationId)).toMatchObject({ + recognized: true, + state: 'awaiting_prebid_selection', + }); + return 'admitted' as const; + }); + const trackAdmittedBid = vi.fn(() => { + order.push('track'); + return true; + }); + const input: PrebidBidPublicationInput = { + admitTrustedBid, + auctionId: 'auction-one', + adUnitCode: bid.slot, + bid, + generatedBid, + navigation, + reservations: { + registerPrebidLease: (registrationInput) => { + order.push('reservation'); + return reservations.registerPrebidLease(registrationInput); + }, + tombstonePrebidLease: reservations.tombstonePrebidLease, + }, + trackAdmittedBid, + }; + return { + admitTrustedBid, + bid, + generatedBid, + input, + navigation, + order, + reservationId, + reservations, + runtime, + trackAdmittedBid, + }; + } + + it('registers the lease before exposing one capability-free frozen bid', () => { + const publication = preparePublication(); + + const result = publishPrebidBid(publication.input); + + expect(result.ok).toBe(true); + expect(publication.order).toEqual(['reservation', 'admit', 'track']); + expect(publication.admitTrustedBid).toHaveBeenCalledTimes(1); + const prepared = publication.admitTrustedBid.mock.calls[0]?.[0]; + if (!prepared) throw new Error('Expected prepared bid'); + expect(prepared).toMatchObject({ + auctionId: 'auction-one', + adUnitCode: 'slot-one', + bid: { + requestId: 'prebid-request-one', + adId: publication.reservationId, + cpm: 1.25, + width: 300, + height: 250, + ad: '', + ttl: 300, + creativeId: 'upstream-one', + netRevenue: true, + currency: 'USD', + bidderCode: 'trustedServer', + meta: { + advertiserDomains: [], + tsAuctionId: 'auction-one', + tsBidId: 'upstream-one', + }, + }, + }); + expect(Object.isFrozen(prepared)).toBe(true); + expect(Object.isFrozen(prepared.bid)).toBe(true); + expect(Object.isFrozen(prepared.bid.meta)).toBe(true); + expect(JSON.stringify(prepared)).not.toContain('private creative'); + expect(publication.generatedBid.adId).toBe('prebid-generated-id'); + publication.runtime.dispose(); + }); + + it('suppresses a partially published bid or failed selection tracking as a contract violation', () => { + const partial = preparePublication(); + expect( + publishPrebidBid({ + ...partial.input, + admitTrustedBid: () => { + throw new PrebidAdmissionContractError(); + }, + }) + ).toEqual({ ok: false, reason: 'prebid_contract_violation' }); + expect(partial.reservations.recognize(partial.reservationId)).toMatchObject({ + state: 'prebid_contract_violation', + }); + partial.runtime.dispose(); + + const untracked = preparePublication(); + expect(publishPrebidBid({ ...untracked.input, trackAdmittedBid: () => false })).toEqual({ + ok: false, + reason: 'prebid_contract_violation', + }); + expect(untracked.reservations.recognize(untracked.reservationId)).toMatchObject({ + state: 'prebid_contract_violation', + }); + untracked.runtime.dispose(); + }); + + it.each([ + ['not admitted', () => 'not_admitted' as const, 'prebid_admission_failed'], + [ + 'throw', + () => { + throw new Error('fictional Prebid failure'); + }, + 'prebid_admission_failed', + ], + ['partial publication', () => 'partially_admitted', 'prebid_contract_violation'], + ])('tombstones an admission that reports %s', (_caseName, admission, reason) => { + const publication = preparePublication(); + + expect(publishPrebidBid({ ...publication.input, admitTrustedBid: admission })).toEqual({ + ok: false, + reason, + }); + expect(publication.reservations.recognize(publication.reservationId)).toMatchObject({ + recognized: true, + state: reason, + }); + publication.runtime.dispose(); + }); + + it('fails before exposure on collision and leaves the generated identity untouched', () => { + const publication = preparePublication(); + expect( + publication.reservations.registerPrebidLease({ + reservationId: publication.reservationId, + slot: publication.bid.slot, + navigation: publication.navigation, + auctionId: 'auction-one', + adUnitCode: publication.bid.slot, + renderSource: publication.bid.renderSource, + winnerContext: Object.freeze({ selectedCpm: publication.bid.cpm }), + prebidBid: Object.freeze({ cpm: publication.bid.cpm }), + }) + ).toMatchObject({ ok: true }); + + expect(publishPrebidBid(publication.input)).toEqual({ + ok: false, + reason: 'reservation_collision', + }); + expect(publication.admitTrustedBid).not.toHaveBeenCalled(); + expect(publication.generatedBid.adId).toBe('prebid-generated-id'); + publication.runtime.dispose(); + }); + + it('rejects a stale projected bid and malformed generated response before registration', () => { + const stale = preparePublication(); + expect(publishPrebidBid({ ...stale.input, auctionId: 'other-auction' })).toEqual({ + ok: false, + reason: 'winner_not_renderable', + }); + expect(stale.order).toEqual([]); + stale.runtime.dispose(); + + const malformed = preparePublication(); + expect(publishPrebidBid({ ...malformed.input, generatedBid: { cpm: 1.25 } })).toEqual({ + ok: false, + reason: 'descriptor_invalid', + }); + expect(malformed.order).toEqual([]); + malformed.runtime.dispose(); + }); +}); + +describe('Prebid selection coordination', () => { + function prepareSelection( + options: Readonly<{ + activateResult?: boolean; + synchronousTimer?: boolean; + throwCreateAttempt?: boolean; + throwFail?: boolean; + throwPromotion?: boolean; + }> = {} + ) { + let now = 0; + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(7); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation'); + const navigation = navigationResult.value; + const reservations = createReservationService({ + now: () => now, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as Readonly<{ type: 'aps' | 'adm'; version: 1 }>) + : undefined, + }); + const artifacts = createCommittedArtifactStore(); + const attempts: RenderAttempt[] = []; + const promotions: Array> = []; + const attemptOwners: RenderAttemptScope[] = []; + const timers = new Map void>(); + const cleared: object[] = []; + const activateAttempt = vi.fn(() => options.activateResult ?? true); + const coordinator = createPrebidSelectionCoordinator({ + activateAttempt, + createAttempt: (owner) => { + if (options.throwCreateAttempt) throw new Error('attempt factory failed'); + attemptOwners.push(owner); + const result = createRenderAttempt({ + artifacts, + owner, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as Readonly<{ type: 'aps' | 'adm'; version: 1 }>) + : undefined, + reservations, + }); + if (result.ok) { + attempts.push(result.value); + if (options.throwFail) { + return Object.freeze({ + ok: true as const, + value: Object.freeze({ + ...result.value, + fail: () => { + throw new Error('attempt failure settlement failed'); + }, + }), + }); + } + } + return result; + }, + reservations: { + promotePrebidSelection: (input) => { + if (options.throwPromotion) throw new Error('promotion failed'); + const result = reservations.promotePrebidSelection(input); + promotions.push(result); + return result; + }, + tombstone: reservations.tombstone, + tombstonePrebidGroup: reservations.tombstonePrebidGroup, + }, + scheduler: { + clear: (handle) => { + cleared.push(handle as object); + timers.delete(handle as object); + }, + set: (callback, milliseconds) => { + expect(milliseconds).toBe(10_000); + const handle = Object.freeze({}); + timers.set(handle, callback); + if (options.synchronousTimer) callback(); + return handle; + }, + }, + }); + const admitted = (idCharacter: string, adUnitCode = 'slot-one') => { + const reservationId = `r1_${idCharacter.repeat(22)}`; + const bid = Object.freeze({ + requestId: `request-${idCharacter}`, + adId: reservationId, + cpm: 1.25, + width: 300, + height: 250, + ad: '' as const, + ttl: 300 as const, + creativeId: `creative-${idCharacter}`, + netRevenue: true as const, + currency: 'USD' as const, + bidderCode: 'trustedServer' as const, + meta: Object.freeze({ + advertiserDomains: Object.freeze([] as string[]), + tsAuctionId: 'auction-one', + tsBidId: `bid-${idCharacter}`, + }), + }); + const prepared = Object.freeze({ auctionId: 'auction-one', adUnitCode, bid }); + const renderSource = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: `
${idCharacter}
`, + width: 300, + height: 250, + }); + expect( + reservations.registerPrebidLease({ + reservationId, + slot: adUnitCode, + navigation, + auctionId: prepared.auctionId, + adUnitCode, + renderSource, + winnerContext: Object.freeze({ selectedCpm: bid.cpm }), + prebidBid: bid, + }) + ).toMatchObject({ ok: true }); + expect(coordinator.track(prepared, navigation)).toBe(!options.synchronousTimer); + return prepared; + }; + return { + admitted, + activateAttempt, + attempts, + attemptOwners, + cleared, + coordinator, + navigation, + promotions, + reservations, + runtime, + setNow: (value: number) => { + now = value; + }, + timers, + }; + } + + it('contains a hostile publication failure settlement and releases its ephemeral owner', () => { + const harness = prepareSelection({ throwFail: true }); + + expect( + harness.coordinator.settlePublicationFailure( + harness.navigation, + 'auction-one', + 'slot-one', + 'prebid_admission_failed' + ) + ).toBe(false); + expect(harness.attempts[0]?.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'navigation_disposed', + }); + expect(harness.navigation.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + batches: 0, + }); + harness.runtime.dispose(); + }); + + it('promotes only the exact selected TS id and suppresses its group losers', () => { + const harness = prepareSelection(); + const selected = harness.admitted('a'); + const losing = harness.admitted('b'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + ]), + }) + ); + + expect(harness.attempts).toHaveLength(1); + expect(harness.promotions).toEqual([expect.objectContaining({ ok: true })]); + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ + state: 'renderable', + }); + expect(harness.reservations.recognize(losing.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attemptOwners[0]?.winnerContext).toEqual({ selectedCpm: 1.25 }); + expect(harness.attempts[0]?.winnerContext).toBeUndefined(); + expect(harness.activateAttempt).toHaveBeenCalledTimes(1); + expect(harness.timers).toHaveLength(0); + harness.runtime.dispose(); + }); + + it('tombstones a selected reservation when its PUC attempt cannot activate', () => { + const harness = prepareSelection({ activateResult: false }); + const selected = harness.admitted('f'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + ]), + }) + ); + + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ state: 'stale' }); + expect(harness.attempts[0]?.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'prebid_contract_violation', + }); + harness.runtime.dispose(); + }); + + it('marks the whole TS group unselected when native Prebid wins', () => { + const harness = prepareSelection(); + const losing = harness.admitted('c'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + adId: 'native-prebid-id', + adUnitCode: 'slot-one', + auctionId: 'auction-one', + cpm: 9, + }), + ]), + }) + ); + + expect(harness.reservations.recognize(losing.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attempts).toEqual([]); + expect(harness.timers).toHaveLength(0); + harness.runtime.dispose(); + }); + + it('fails closed when the pinned single-unit winner query is ambiguous', () => { + const harness = prepareSelection(); + const selected = harness.admitted('i'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + Object.freeze({ + adId: 'native-prebid-id', + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + cpm: selected.bid.cpm, + }), + ]), + }) + ); + + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attempts).toEqual([]); + expect(harness.timers).toHaveLength(0); + harness.runtime.dispose(); + }); + + it('times out a missing auctionEnd and cancels the watchdog on navigation disposal', () => { + const timedOut = prepareSelection(); + const bid = timedOut.admitted('d'); + timedOut.setNow(9_999); + expect(timedOut.timers.size).toBe(1); + [...timedOut.timers.values()][0]?.(); + expect(timedOut.reservations.recognize(bid.bid.adId)).toMatchObject({ + state: 'prebid_selection_timeout', + }); + timedOut.runtime.dispose(); + + const disposed = prepareSelection(); + const disposedBid = disposed.admitted('e'); + disposed.runtime.replaceNavigation(); + expect(disposed.reservations.recognize(disposedBid.bid.adId)).toMatchObject({ + state: 'aborted', + }); + expect(disposed.timers).toHaveLength(0); + }); + + it('aborts every ad unit in one exact auction and releases each short lease at expiry', () => { + const harness = prepareSelection(); + const first = harness.admitted('j', 'slot-one'); + const second = harness.admitted('k', 'slot-two'); + + harness.coordinator.abort(harness.navigation, 'auction-one'); + + expect(harness.reservations.recognize(first.bid.adId)).toMatchObject({ state: 'aborted' }); + expect(harness.reservations.recognize(second.bid.adId)).toMatchObject({ state: 'aborted' }); + expect(harness.timers).toHaveLength(0); + expect(harness.navigation.snapshotInventoryForTest().batches).toBe(0); + + harness.setNow(10_000); + expect(harness.reservations.recognize(first.bid.adId)).toEqual({ recognized: false }); + expect(harness.reservations.recognize(second.bid.adId)).toEqual({ recognized: false }); + expect(harness.reservations.snapshotInventoryForTest().size).toBe(0); + harness.runtime.dispose(); + }); + + it('selects independently across multiple ad units without promoting either group loser', () => { + const harness = prepareSelection(); + const first = harness.admitted('l', 'slot-one'); + const firstLoser = harness.admitted('m', 'slot-one'); + const second = harness.admitted('n', 'slot-two'); + const secondLoser = harness.admitted('o', 'slot-two'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: (adUnitCode?: string) => { + const selected = adUnitCode === 'slot-one' ? first : second; + return Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + ]); + }, + }) + ); + + expect(harness.reservations.recognize(first.bid.adId)).toMatchObject({ state: 'renderable' }); + expect(harness.reservations.recognize(second.bid.adId)).toMatchObject({ state: 'renderable' }); + expect(harness.reservations.recognize(firstLoser.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.reservations.recognize(secondLoser.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attempts).toHaveLength(2); + expect(harness.timers).toHaveLength(0); + harness.runtime.dispose(); + }); + + it('rolls back a scheduler that invokes the deadline before timer publication returns', () => { + const harness = prepareSelection({ synchronousTimer: true }); + const bid = harness.admitted('g'); + + expect(harness.reservations.recognize(bid.bid.adId)).toMatchObject({ + state: 'prebid_selection_timeout', + }); + expect(harness.timers).toHaveLength(0); + expect(harness.navigation.snapshotInventoryForTest().batches).toBe(0); + harness.runtime.dispose(); + }); + + it.each([ + { failure: 'attempt creation', options: { throwCreateAttempt: true } }, + { failure: 'reservation promotion', options: { throwPromotion: true } }, + ])('fails closed when $failure throws during selection', ({ options }) => { + const harness = prepareSelection(options); + const selected = harness.admitted('h'); + + expect(() => + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + ]), + }) + ) + ).not.toThrow(); + + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attempts[0]?.snapshot().outcome).toEqual( + options.throwPromotion + ? { outcome: 'failed', reason: 'prebid_contract_violation' } + : undefined + ); + expect(harness.timers).toHaveLength(0); + expect(harness.navigation.snapshotInventoryForTest().batches).toBe(0); + harness.runtime.dispose(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts new file mode 100644 index 000000000..aaae60d1f --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + PrebidAdapter, + PrebidEventFacade, + PrebidFacade, + PrebidTrustedServerAuctionV1, +} from '../../../src/adapters/prebid'; +import { createPrebidStartup } from '../../../src/integrations/prebid/startup'; +import type { GptRefreshPolicy } from '../../../src/integrations/gpt/startup'; + +describe('Prebid startup bridge', () => { + it('installs one reversible bidder/event operation before starting the external boundary', async () => { + let bidderListener: ((auction: Readonly) => void) | undefined; + let auctionEndListener: + ((event: unknown, prebid: Readonly) => void) | undefined; + const operationDispose = vi.fn(); + const releaseBidder = vi.fn(); + const releaseAuctionEnd = vi.fn(); + const order: string[] = []; + const eventFacade = Object.freeze({ highestBids: vi.fn(() => Object.freeze([])) }); + const facade = Object.freeze({ + registerTrustedServerBidder: vi.fn( + (listener: (auction: Readonly) => void) => { + order.push('register-bidder'); + bidderListener = listener; + return () => { + order.push('release-bidder'); + releaseBidder(); + }; + } + ), + subscribe: vi.fn( + ( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ) => { + expect(eventType).toBe('auctionEnd'); + order.push('subscribe-auction-end'); + auctionEndListener = listener; + return () => { + order.push('release-auction-end'); + releaseAuctionEnd(); + }; + } + ), + }) as unknown as Readonly; + const run = vi.fn((command: (prebid: Readonly) => unknown) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: operationDispose, + }) + ); + const notifyReady = vi.fn(); + const adapter = Object.freeze({ run, notifyReady }) as unknown as PrebidAdapter; + const onAuction = vi.fn(); + const onAuctionEnd = vi.fn(); + const dispose = vi.fn(); + const start = vi.fn(); + const startup = createPrebidStartup({ + dispose, + onAuction, + onAuctionEnd, + prebid: adapter, + start, + }); + + const release = startup.activate(); + await Promise.resolve(); + + expect(run).toHaveBeenCalledTimes(1); + expect(order).toEqual(['subscribe-auction-end']); + expect(facade.registerTrustedServerBidder).not.toHaveBeenCalled(); + expect(facade.subscribe).toHaveBeenCalledTimes(1); + const event = Object.freeze({ auctionId: 'auction-one' }); + auctionEndListener?.(event, eventFacade); + expect(onAuctionEnd).toHaveBeenCalledExactlyOnceWith(event, eventFacade); + + const config = Object.freeze({ externalBundleUrl: '/prebid.js' }); + startup.start(config); + await Promise.resolve(); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + expect(notifyReady).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(2); + expect(facade.registerTrustedServerBidder).toHaveBeenCalledTimes(1); + expect(order).toEqual(['subscribe-auction-end', 'register-bidder']); + const auction = Object.freeze({ + auctionId: 'auction-one', + bids: Object.freeze([]), + complete: vi.fn(), + }); + bidderListener?.(auction); + expect(onAuction).toHaveBeenCalledExactlyOnceWith(auction); + + release(); + release(); + expect(operationDispose).toHaveBeenCalledTimes(2); + expect(releaseAuctionEnd).toHaveBeenCalledTimes(1); + expect(releaseBidder).toHaveBeenCalledTimes(1); + expect(order).toEqual([ + 'subscribe-auction-end', + 'register-bidder', + 'release-bidder', + 'release-auction-end', + ]); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('releases effects that settle after the runtime owner is already disposed', async () => { + let resolveOperation!: (release: () => void) => void; + const result = new Promise<() => void>((resolve) => { + resolveOperation = resolve; + }); + const operationDispose = vi.fn(); + const run = vi.fn(() => + Object.freeze({ status: 'present' as const, result, dispose: operationDispose }) + ); + const dispose = vi.fn(); + const startup = createPrebidStartup({ + dispose, + onAuction: vi.fn(), + onAuctionEnd: vi.fn(), + prebid: Object.freeze({ run, notifyReady: vi.fn() }) as unknown as PrebidAdapter, + }); + const releaseEffects = vi.fn(); + + const release = startup.activate(); + release(); + resolveOperation(releaseEffects); + await Promise.resolve(); + + expect(operationDispose).toHaveBeenCalledTimes(1); + expect(releaseEffects).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('installs the TS auctionEnd listener before startup can add a publisher callback', async () => { + const listeners: Array<(event: unknown, prebid: Readonly) => void> = []; + const order: string[] = []; + const eventFacade = Object.freeze({ highestBids: vi.fn(() => Object.freeze([])) }); + const facade = Object.freeze({ + registerTrustedServerBidder: vi.fn(() => vi.fn()), + subscribe: vi.fn( + ( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ) => { + expect(eventType).toBe('auctionEnd'); + listeners.push(listener); + return vi.fn(); + } + ), + }) as unknown as Readonly; + const run = vi.fn((command: (prebid: Readonly) => unknown) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: vi.fn(), + }) + ); + const startup = createPrebidStartup({ + dispose: vi.fn(), + onAuction: vi.fn(), + onAuctionEnd: () => order.push('trusted-server'), + prebid: Object.freeze({ run, notifyReady: vi.fn() }) as unknown as PrebidAdapter, + start: () => { + listeners.push(() => order.push('publisher')); + }, + }); + + startup.activate(); + await Promise.resolve(); + startup.start(Object.freeze({})); + await Promise.resolve(); + const event = Object.freeze({ auctionId: 'auction-one' }); + for (const listener of listeners) listener(event, eventFacade); + + expect(order).toEqual(['trusted-server', 'publisher']); + }); + + it('installs, configures, and releases one runtime-owned GPT refresh policy', async () => { + const order: string[] = []; + const operationDispose = vi.fn(); + const facade = Object.freeze({ + registerTrustedServerBidder: vi.fn(() => vi.fn()), + subscribe: vi.fn(() => vi.fn()), + }) as unknown as Readonly; + const prebid = Object.freeze({ + notifyReady: vi.fn(), + run: vi.fn((command: (prebid: Readonly) => unknown) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: operationDispose, + }) + ), + }) as unknown as Pick; + const policy = Object.freeze({ prepare: vi.fn(), dispose: vi.fn() }); + const releasePolicy = vi.fn(() => order.push('release-policy')); + const install = vi.fn((_policy: GptRefreshPolicy) => { + order.push('install-policy'); + return releasePolicy; + }); + const configure = vi.fn((_config: unknown) => order.push('configure-policy')); + const start = vi.fn(() => order.push('start-prebid')); + const startup = createPrebidStartup({ + dispose: vi.fn(), + onAuction: vi.fn(), + onAuctionEnd: vi.fn(), + prebid, + refresh: Object.freeze({ configure, install, policy }), + start, + }); + + const release = startup.activate(); + expect(install).toHaveBeenCalledExactlyOnceWith(policy); + const config = Object.freeze({ excludedGamAdUnitPathSuffixes: Object.freeze(['/skip']) }); + startup.start(config); + expect(configure).toHaveBeenCalledExactlyOnceWith(config); + expect(order).toEqual(['install-policy', 'configure-policy', 'start-prebid']); + + release(); + release(); + expect(policy.dispose).toHaveBeenCalledOnce(); + expect(releasePolicy).toHaveBeenCalledOnce(); + }); + + it('unwinds the adapter and policy when GPT refuses a second refresh owner', () => { + const operationDispose = vi.fn(); + const prebid = Object.freeze({ + notifyReady: vi.fn(), + run: vi.fn(() => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(vi.fn()), + dispose: operationDispose, + }) + ), + }) as unknown as Pick; + const policy = Object.freeze({ prepare: vi.fn(), dispose: vi.fn() }); + const dispose = vi.fn(); + const startup = createPrebidStartup({ + dispose, + onAuction: vi.fn(), + onAuctionEnd: vi.fn(), + prebid, + refresh: Object.freeze({ + install: vi.fn(() => undefined), + policy, + }), + }); + + expect(() => startup.activate()).toThrow('Prebid refresh policy is unavailable'); + expect(operationDispose).toHaveBeenCalledOnce(); + expect(policy.dispose).toHaveBeenCalledOnce(); + expect(dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts new file mode 100644 index 000000000..8991f11a4 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts @@ -0,0 +1,1051 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + PreparedIntegration, +} from '../../../src/kernel/integration_registry'; +import type { + RuntimeAuctionContextService, + RuntimeCapabilityV1, +} from '../../../src/kernel/runtime'; +import { + adoptInitialRenderArtifactsFromHandoff, + adoptInitialRenderStateFromHandoff, + createRenderRuntimeIntegrationRegistration, +} from '../../../src/integrations/render_runtime/module'; +import { log } from '../../../src/core/log'; +import { + createArtifactHostPositionLeaseRegistry, + createCommittedArtifactStore, + type CommittedRenderArtifact, + type RenderAttempt, +} from '../../../src/services/render'; +import type { NavigationSession } from '../../../src/kernel/sessions'; + +const RELEASE_ID = 'a'.repeat(64); + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('render_runtime provider', () => { + it('adopts first-display replay tombstones and trace high-water state', () => { + const adoptFirstDisplayIdentityState = vi.fn(() => true); + const adoptTombstones = vi.fn(() => true); + const adoptTrace = vi.fn(() => true); + const navigationGeneration = {}; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + highWater: Object.freeze({ + nextAttemptOrdinal: 7, + nextNavigationAttemptOrdinal: 6, + navigationAttemptPrefix: 'CAcGBQQDAgE', + reservationClockEpochMs: 50, + }), + tombstones: Object.freeze([ + Object.freeze({ + expiresAtMs: 250, + kind: 'reservation', + ordinal: 4, + value: `r1_${'a'.repeat(22)}`, + }), + Object.freeze({ expiresAtMs: 90, kind: 'ticket', ordinal: 2, value: 'ticket' }), + ]), + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([ + Object.freeze({ + records: Object.freeze([Object.freeze({ ordinal: 1, state: 'completed' as const })]), + slotId: 'slot-1', + token: 'gt1_1', + }), + ]), + trace: Object.freeze({ + nextSequence: 9, + slots: Object.freeze([ + Object.freeze({ + bindings: Object.freeze([ + Object.freeze({ + atMs: 4, + cycleOrdinal: 1, + historySequence: 8, + state: 'completed' as const, + token: 'gt1_1', + }), + ]), + impressions: 3, + slotId: 'slot-1', + }), + ]), + }), + }), + identities: Object.freeze([]), + }); + + expect( + adoptInitialRenderStateFromHandoff( + adoption, + { adoptFirstDisplayIdentityState, generation: navigationGeneration }, + { adoptFirstDisplayTombstones: adoptTombstones }, + { adoptFirstDisplay: adoptTrace } + ) + ).toBe(adoption); + expect(adoptFirstDisplayIdentityState).toHaveBeenCalledExactlyOnceWith('CAcGBQQDAgE', 7); + expect(adoptTrace).toHaveBeenCalledWith({ + navigationGeneration, + nextSequence: 9, + slots: [ + { + bindings: [ + { + cycleOrdinal: 1, + historySequence: 8, + state: 'completed', + token: 'gt1_1', + }, + ], + impressions: 3, + records: [ + { + at: 4, + count: 3, + elementId: 'div-1', + injected: true, + path: 'ssat', + rendered: true, + seq: 8, + servedFrom: 'inline', + slotId: 'slot-1', + }, + ], + slotId: 'slot-1', + }, + ], + }); + expect(adoptTombstones).toHaveBeenCalledWith({ + clockEpochMs: 50, + tombstones: [{ expiresAtMs: 250, reservationId: `r1_${'a'.repeat(22)}` }], + }); + }); + + it('adopts transferred DOM artifacts without removing them on rollback, then arms commit ownership', () => { + const host = document.createElement('div'); + host.id = 'div-1'; + const frame = document.createElement('iframe'); + frame.srcdoc = 'Fictional creative'; + host.append(frame); + document.body.append(host); + const physicalSlot = Object.freeze({}); + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + kind: 'gpt_adm' as const, + owner: 'trusted_server' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([physicalSlot, frame]), + }); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: vi.fn(() => + Object.freeze({ + ok: true as const, + value: Object.freeze({ + id: `a1_${'A'.repeat(22)}`, + navigationGeneration, + }), + }) + ), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: vi.fn(() => batch), + isCurrent: () => true, + }) as unknown as NavigationSession; + + const rollbackStore = createCommittedArtifactStore(); + expect( + adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + rollbackStore, + createArtifactHostPositionLeaseRegistry(), + document + ) + ).toBeDefined(); + rollbackStore.dispose(); + expect(frame.isConnected).toBe(true); + + const committedStore = createCommittedArtifactStore(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + committedStore, + createArtifactHostPositionLeaseRegistry(), + document + ); + expect(committed?.adoption).toBe(adoption); + committed?.arm(); + committedStore.dispose(); + expect(frame.isConnected).toBe(false); + expect(batch.dispose).toHaveBeenCalledTimes(2); + }); + + it('adopts a publisher-owned PUC shell without claiming its DOM lifetime', () => { + const host = document.createElement('div'); + host.id = 'div-1'; + const publisherWrapper = document.createElement('div'); + const frame = document.createElement('iframe'); + frame.src = 'https://publisher.example/universal-creative'; + publisherWrapper.append(frame); + document.body.append(host, publisherWrapper); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: vi.fn(() => + Object.freeze({ + ok: true as const, + value: Object.freeze({ id: `a1_${'A'.repeat(22)}`, navigationGeneration }), + }) + ), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: vi.fn(() => batch), + isCurrent: () => true, + }) as unknown as NavigationSession; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + kind: 'gpt_adm' as const, + owner: 'publisher' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([Object.freeze({}), frame]), + }); + const store = createCommittedArtifactStore(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + store, + createArtifactHostPositionLeaseRegistry(), + document + ); + + expect(committed).toBeDefined(); + committed?.arm(); + frame.style.setProperty('visibility', 'hidden'); + expect(store.sweep()).toBe(1); + expect(frame.isConnected).toBe(true); + expect(batch.dispose).toHaveBeenCalledOnce(); + }); + + it.each(['reparented', 'frame_style'] as const)( + 'retires an adopted APS mount on %s loss and compare-restores only owned style', + (mutation) => { + const host = document.createElement('div'); + host.id = 'div-1'; + host.style.setProperty('position', 'relative'); + const frame = document.createElement('iframe'); + frame.setAttribute('sandbox', 'allow-scripts'); + frame.src = 'https://example.com/renderer'; + host.append(frame); + document.body.append(host); + const physicalSlot = Object.freeze({}); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: vi.fn(() => + Object.freeze({ + ok: true as const, + value: Object.freeze({ id: `a1_${'A'.repeat(22)}`, navigationGeneration }), + }) + ), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: vi.fn(() => batch), + isCurrent: () => true, + }) as unknown as NavigationSession; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: '', + hostPositionPriority: '', + kind: 'aps' as const, + owner: 'trusted_server' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([physicalSlot, frame]), + }); + const store = createCommittedArtifactStore(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + store, + createArtifactHostPositionLeaseRegistry(), + document + ); + expect(committed).toBeDefined(); + committed?.arm(); + + if (mutation === 'reparented') { + const publisherContainer = document.createElement('div'); + document.body.append(publisherContainer); + publisherContainer.append(frame); + } else { + frame.style.setProperty('visibility', 'hidden'); + } + + expect(store.sweep()).toBe(1); + expect(frame.isConnected).toBe(false); + expect(host.style.getPropertyValue('position')).toBe(''); + expect(store.sweep()).toBe(0); + } + ); + + it('transfers an adopted APS host-position lease through persistent replacement', () => { + const host = document.createElement('div'); + host.id = 'div-1'; + host.style.setProperty('position', 'relative'); + const frame = document.createElement('iframe'); + frame.src = 'https://example.com/renderer'; + host.append(frame); + document.body.append(host); + const physicalSlot = Object.freeze({}); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: vi.fn(() => + Object.freeze({ + ok: true as const, + value: Object.freeze({ id: `a1_${'A'.repeat(22)}`, navigationGeneration }), + }) + ), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: vi.fn(() => batch), + isCurrent: () => true, + }) as unknown as NavigationSession; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: '', + hostPositionPriority: '', + kind: 'aps' as const, + owner: 'trusted_server' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([physicalSlot, frame]), + }); + const store = createCommittedArtifactStore(); + const positions = createArtifactHostPositionLeaseRegistry(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + store, + positions, + document + ); + expect(committed).toBeDefined(); + committed?.arm(); + const predecessor = store.current('slot-1'); + if (!predecessor) throw new Error('should adopt the first-display APS artifact'); + let replacementDisposed = false; + const replacement: CommittedRenderArtifact = Object.freeze({ + attemptId: `a1_${'B'.repeat(22)}`, + kind: 'aps_mount' as const, + navigationGeneration, + slot: 'slot-1', + dispose: () => { + if (replacementDisposed) return; + replacementDisposed = true; + positions.release(replacement); + }, + }); + + expect(positions.inherit(replacement, predecessor, host)).toBe(true); + expect(positions.claim(replacement)).toBe(true); + expect(store.promote(replacement)).toBe(true); + expect(frame.isConnected).toBe(false); + expect(host.style.getPropertyValue('position')).toBe('relative'); + + expect(store.release(replacement)).toBe(true); + expect(host.style.getPropertyValue('position')).toBe(''); + }); + + it('does not restore an adopted APS host style after a publisher replaces it', () => { + const host = document.createElement('div'); + host.id = 'div-1'; + host.style.setProperty('position', 'relative'); + const frame = document.createElement('iframe'); + host.append(frame); + document.body.append(host); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: () => + Object.freeze({ + ok: true as const, + value: Object.freeze({ id: `a1_${'A'.repeat(22)}`, navigationGeneration }), + }), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: () => batch, + isCurrent: () => true, + }) as unknown as NavigationSession; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: '', + hostPositionPriority: '', + kind: 'aps' as const, + owner: 'trusted_server' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([Object.freeze({}), frame]), + }); + const store = createCommittedArtifactStore(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + store, + createArtifactHostPositionLeaseRegistry(), + document + ); + expect(committed).toBeDefined(); + committed?.arm(); + + host.style.setProperty('position', 'absolute', 'important'); + expect(store.sweep()).toBe(1); + expect(frame.isConnected).toBe(false); + expect(host.style.getPropertyValue('position')).toBe('absolute'); + expect(host.style.getPropertyPriority('position')).toBe('important'); + }); + + it('rolls back prepared resources without unbound disposer failures', () => { + const release: Array<() => void> = []; + const warn = vi.spyOn(log, 'warn').mockImplementation(() => undefined); + const runtime = Object.freeze({ + attachAuctionContextService: () => () => undefined, + boot: () => + Object.freeze({ + auctionProjection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([]), + }), + slots: Object.freeze([]), + bids: Object.freeze([]), + }), + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: false, + gpt: Object.freeze({ active: false }), + }), + manifest: Object.freeze({ + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'b'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'takeover' as const }), + ]), + }), + }), + document, + enqueue: () => true, + generation: Object.freeze({}), + protectFirstDisplayAttemptBatch: vi.fn(() => true), + registerAuctionContext: () => () => undefined, + } satisfies RuntimeCapabilityV1); + + createRenderRuntimeIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ 'runtime.v1': runtime }), + signal: new AbortController().signal, + onDispose: (callback: () => void) => release.push(callback), + } satisfies IntegrationPrepareContext) + ); + release.reverse().forEach((callback) => callback()); + + expect(warn).not.toHaveBeenCalledWith('render_runtime disposal failed', expect.anything()); + warn.mockRestore(); + }); + + it('stages the seven real capabilities inertly and activates direct registration once', async () => { + const release: Array<() => void> = []; + const activationRelease: Array<() => void> = []; + const protect = vi.fn(() => true); + let contextService: RuntimeAuctionContextService | undefined; + const runtime = Object.freeze({ + attachAuctionContextService: (service: RuntimeAuctionContextService) => { + if (contextService) return undefined; + contextService = service; + return () => { + if (contextService === service) contextService = undefined; + }; + }, + boot: () => + Object.freeze({ + auctionProjection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([]), + }), + slots: Object.freeze([]), + bids: Object.freeze([]), + }), + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: false, + gpt: Object.freeze({ active: false }), + }), + manifest: Object.freeze({ + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'b'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'takeover' as const }), + Object.freeze({ id: 'permutive_context', phase: 'takeover' as const }), + ]), + }), + }), + document, + enqueue: () => true, + generation: Object.freeze({}), + protectFirstDisplayAttemptBatch: protect, + registerAuctionContext: ( + integrationId: string, + contributor: () => Readonly> | undefined + ) => contextService?.register(integrationId, contributor), + } satisfies RuntimeCapabilityV1); + const registration = createRenderRuntimeIntegrationRegistration(RELEASE_ID); + const prepared = registration.prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ 'runtime.v1': runtime }), + signal: new AbortController().signal, + onDispose: (callback: () => void) => release.push(callback), + } satisfies IntegrationPrepareContext) + ); + if ('then' in Object(prepared)) throw new Error('render_runtime preparation must be sync'); + const exactPrepared = prepared as PreparedIntegration; + const interfaces = exactPrepared.interfaces; + expect(Reflect.ownKeys(interfaces ?? {})).toEqual([ + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + 'trace.presentation.v1', + 'direct.v1', + ]); + const direct = interfaces?.['direct.v1'] as { + addAdUnits: (candidate: unknown) => unknown; + requestAds: (candidate?: unknown) => Promise; + }; + const slotCapability = interfaces?.['slots.v1'] as { + attachPhysicalService: (service: object) => () => void; + snapshot: () => readonly Readonly<{ registeredSlotId: string }>[]; + }; + expect(() => + direct.addAdUnits({ + code: 'programmatic', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'fictional', params: {} }], + }) + ).toThrow(); + + exactPrepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + expect( + direct.addAdUnits({ + code: 'programmatic', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'fictional', params: {} }], + }) + ).toEqual({ registered: ['programmatic'] }); + const physicalRecords: Array>> = []; + const physicalService = Object.freeze({ + register: vi.fn( + (_owner: object, registrations: readonly Readonly>[]) => { + physicalRecords.push( + ...registrations.map((registration) => + Object.freeze({ + ...registration, + navigationGeneration: Object.freeze({}), + domAliases: registration['domAliases'] ?? Object.freeze([]), + }) + ) + ); + return Object.freeze({ ok: true as const, records: Object.freeze([...physicalRecords]) }); + } + ), + snapshotRegisteredSlots: vi.fn(() => Object.freeze([...physicalRecords])), + }); + const releasePhysical = slotCapability.attachPhysicalService(physicalService); + expect(physicalService.register).toHaveBeenCalledOnce(); + expect(slotCapability.snapshot().map(({ registeredSlotId }) => registeredSlotId)).toEqual([ + 'programmatic', + ]); + releasePhysical(); + expect(slotCapability.snapshot().map(({ registeredSlotId }) => registeredSlotId)).toEqual([ + 'programmatic', + ]); + const releaseContext = runtime.registerAuctionContext('permutive_context', () => + Object.freeze({ permutive_segments: Object.freeze(['segment-one']) }) + ); + expect(releaseContext).toBeTypeOf('function'); + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => + Object.freeze({ + id: 'auction-one', + cur: 'USD', + seatbid: Object.freeze([]), + ext: Object.freeze({ + trusted_server: Object.freeze({ + slot_results: Object.freeze({ + version: 1, + auctionId: 'auction-one', + results: Object.freeze([ + Object.freeze({ slot: 'programmatic', outcome: 'no_bid' as const }), + ]), + }), + }), + }), + }), + } as Response); + await expect(direct.requestAds({ slots: ['programmatic'] })).resolves.toEqual({ + slots: [{ slot: 'programmatic', path: 'primary', outcome: 'no_bid' }], + }); + expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toMatchObject({ + config: { permutive_segments: ['segment-one'] }, + }); + fetcher.mockRestore(); + releaseContext?.(); + + activationRelease.reverse().forEach((callback) => callback()); + release.reverse().forEach((callback) => callback()); + expect(() => + direct.addAdUnits({ + code: 'late', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }) + ).toThrow(); + }); + + it('rejects renderer and APS-message registration until activation and removes exact registrations', () => { + const release: Array<() => void> = []; + const activationRelease: Array<() => void> = []; + const runtime = Object.freeze({ + attachAuctionContextService: () => () => undefined, + boot: () => + Object.freeze({ + auctionProjection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([]), + }), + slots: Object.freeze([]), + bids: Object.freeze([]), + }), + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: false, + gpt: Object.freeze({ active: false }), + }), + manifest: Object.freeze({ + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'b'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'takeover' as const }), + ]), + }), + }), + document, + enqueue: () => true, + generation: Object.freeze({}), + protectFirstDisplayAttemptBatch: vi.fn(() => true), + registerAuctionContext: () => () => undefined, + } satisfies RuntimeCapabilityV1); + const prepared = createRenderRuntimeIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ 'runtime.v1': runtime }), + signal: new AbortController().signal, + onDispose: (callback: () => void) => release.push(callback), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + const render = prepared.interfaces?.['render.v1'] as { + attachPucGamAttemptRegistrar: (registrar: (input: unknown) => boolean) => () => void; + createAttempt: ( + owner: Readonly> + ) => Readonly<{ ok: boolean; value?: RenderAttempt }>; + createSlotOperation: ( + input: Readonly<{ primary: RenderAttempt }> + ) => Readonly<{ ok: true; value: object }> | Readonly<{ ok: false; reason: string }>; + navigation: { + createAuctionBatch: (auctionId: string) => + | { + createRenderAttempt: ( + slot: string + ) => Readonly<{ ok: boolean; value?: Readonly> }>; + } + | undefined; + }; + registerPucGamAttempt: (input: unknown) => boolean; + registerRenderer: ( + type: 'aps', + renderer: (attempt: RenderAttempt, container: HTMLElement) => boolean + ) => () => void; + }; + const messages = prepared.interfaces?.['messages.v1'] as { + messaging: { + parseProtocolMessage: (kind: 'apsEnvelope', candidate: unknown) => object | undefined; + }; + registerApsValidation: (validation: Readonly>) => () => void; + }; + const renderer = vi.fn(() => true); + const origin = window.location.origin; + const rendererUrl = new URL('/integrations/aps/renderer/v2', origin).href; + const validation = Object.freeze({ + expectedPublisherOrigin: origin, + expectedRendererUrl: rendererUrl, + validateApsRenderer: vi.fn(() => true), + }); + const envelope = Object.freeze({ + version: 1, + nonce: `n1_${'a'.repeat(22)}`, + publisherOrigin: origin, + renderer: Object.freeze({ + type: 'aps', + version: 1, + accountId: 'account', + bidId: 'bid', + tagType: 'iframe', + creativeUrl: 'https://example.test/creative', + width: 300, + height: 250, + aaxResponse: 'response', + }), + }); + + expect(() => render.registerRenderer('aps', renderer)).toThrow('inactive'); + expect(() => render.attachPucGamAttemptRegistrar(() => true)).toThrow('unavailable'); + expect(render.registerPucGamAttempt(Object.freeze({}))).toBe(false); + expect(() => messages.registerApsValidation(validation)).toThrow('inactive'); + expect(messages.messaging.parseProtocolMessage('apsEnvelope', envelope)).toBeUndefined(); + + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + const batch = render.navigation.createAuctionBatch('cross-bundle-render-capability'); + const owner = batch?.createRenderAttempt('slot-one'); + expect(owner?.ok).toBe(true); + const attempt = render.createAttempt(owner?.value ?? Object.freeze({})); + expect(attempt.ok).toBe(true); + expect(render.createSlotOperation({ primary: attempt.value as RenderAttempt })).toMatchObject({ + ok: true, + }); + const hostileCause = new Error('publisher-owned validation trap'); + const hostileValidation = new Proxy(Object.freeze({}), { + getPrototypeOf: () => { + throw hostileCause; + }, + }); + let validationError: unknown; + try { + messages.registerApsValidation(hostileValidation); + } catch (error) { + validationError = error; + } + expect(validationError).toBeInstanceOf(TypeError); + expect(validationError).toMatchObject({ + message: 'APS message validation is malformed', + cause: hostileCause, + }); + expect(Object.keys(validationError as object)).not.toContain('cause'); + const pucAttempt = Object.freeze({ marker: 'exact-attempt' }); + const pucRegistrar = vi.fn(() => true); + const releasePucRegistrar = render.attachPucGamAttemptRegistrar(pucRegistrar); + expect(render.registerPucGamAttempt(pucAttempt)).toBe(true); + expect(pucRegistrar).toHaveBeenCalledExactlyOnceWith(pucAttempt); + expect(() => render.attachPucGamAttemptRegistrar(() => true)).toThrow('duplicated'); + releasePucRegistrar(); + expect(render.registerPucGamAttempt(pucAttempt)).toBe(false); + const releaseThrowingPucRegistrar = render.attachPucGamAttemptRegistrar(() => { + throw new Error('contained GPT owner failure'); + }); + expect(render.registerPucGamAttempt(pucAttempt)).toBe(false); + const releaseRenderer = render.registerRenderer('aps', renderer); + const releaseValidation = messages.registerApsValidation(validation); + expect(messages.messaging.parseProtocolMessage('apsEnvelope', envelope)).toEqual(envelope); + expect(() => render.registerRenderer('aps', vi.fn())).toThrow('duplicated'); + expect(() => messages.registerApsValidation(validation)).toThrow('duplicated'); + + releaseRenderer(); + releaseValidation(); + const replacementRenderer = vi.fn(() => false); + const releaseReplacement = render.registerRenderer('aps', replacementRenderer); + const releaseReplacementValidation = messages.registerApsValidation(validation); + releaseRenderer(); + releaseValidation(); + expect(() => render.registerRenderer('aps', vi.fn())).toThrow('duplicated'); + expect(() => messages.registerApsValidation(validation)).toThrow('duplicated'); + + activationRelease.reverse().forEach((callback) => callback()); + releaseThrowingPucRegistrar(); + expect(render.registerPucGamAttempt(pucAttempt)).toBe(false); + expect(() => render.registerRenderer('aps', vi.fn())).toThrow('inactive'); + expect(() => messages.registerApsValidation(validation)).toThrow('inactive'); + expect(messages.messaging.parseProtocolMessage('apsEnvelope', envelope)).toBeUndefined(); + releaseReplacement(); + releaseReplacementValidation(); + release.reverse().forEach((callback) => callback()); + }); + + it('publishes the data-only render trace through the private capability and public diagnostics', () => { + const release: Array<() => void> = []; + const activationRelease: Array<() => void> = []; + const runtime = Object.freeze({ + attachAuctionContextService: () => () => undefined, + boot: () => + Object.freeze({ + auctionProjection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([ + Object.freeze({ slot: 'slot-one', outcome: 'no_bid' as const }), + ]), + }), + slots: Object.freeze([ + Object.freeze({ + slot: 'slot-one', + gamUnitPath: '/123/slot-one', + divId: 'slot-one', + formats: Object.freeze([Object.freeze([300, 250])]), + targeting: Object.freeze({}), + }), + ]), + bids: Object.freeze([]), + }), + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: true, + gpt: Object.freeze({ active: false }), + }), + manifest: Object.freeze({ + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'b'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'takeover' as const }), + ]), + }), + }), + document, + enqueue: () => true, + generation: Object.freeze({}), + protectFirstDisplayAttemptBatch: vi.fn(() => true), + registerAuctionContext: () => () => undefined, + } satisfies RuntimeCapabilityV1); + const prepared = createRenderRuntimeIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ 'runtime.v1': runtime }), + signal: new AbortController().signal, + onDispose: (callback: () => void) => release.push(callback), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + const trace = prepared.interfaces?.['trace.v1'] as { + diagnostics: { + current: () => Readonly>>>; + }; + observations: { publish: (observation: Readonly>) => boolean }; + record: (record: Readonly>) => Readonly>; + }; + const tracePresentation = prepared.interfaces?.['trace.presentation.v1'] as { + attachPresentation: (factory: (source: object) => object) => () => void; + }; + const direct = prepared.interfaces?.['direct.v1'] as { + diagnostics: { renderTrace: object }; + }; + const slots = prepared.interfaces?.['slots.v1'] as { + attachPhysicalService: (service: object) => () => void; + }; + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + let physicalRecords: readonly Readonly>[] = Object.freeze([]); + const physicalService = Object.freeze({ + register: ( + owner: { generation: object }, + registrations: readonly Readonly>[] + ) => { + physicalRecords = Object.freeze( + registrations.map((registration) => + Object.freeze({ + ...registration, + domAliases: registration['domAliases'] ?? Object.freeze([]), + navigationGeneration: owner.generation, + traceToken: 'gt1_1', + }) + ) + ); + return Object.freeze({ ok: true as const, records: physicalRecords }); + }, + resolveDomAlias: (alias: string) => + physicalRecords.find((record) => + (record['domAliases'] as readonly string[]).includes(alias) + ), + resolveRegisteredSlot: (slotId: string) => + physicalRecords.find((record) => record['registeredSlotId'] === slotId), + snapshotRegisteredSlots: () => physicalRecords, + }); + const releasePhysicalService = slots.attachPhysicalService(physicalService); + + expect(Reflect.ownKeys(trace)).toEqual([ + 'record', + 'enrich', + 'prune', + 'diagnostics', + 'observations', + ]); + expect(Object.isFrozen(trace)).toBe(true); + expect(Reflect.ownKeys(trace.observations)).toEqual(['publish']); + expect('attachPresentation' in trace).toBe(false); + expect(Reflect.ownKeys(tracePresentation)).toEqual(['attachPresentation']); + expect(Object.isFrozen(tracePresentation)).toBe(true); + expect(tracePresentation.attachPresentation).toBeTypeOf('function'); + expect(direct.diagnostics.renderTrace).toBe(trace.diagnostics); + expect(document.getElementById('ts-render-trace-panel')).toBeNull(); + expect( + trace.observations.publish( + Object.freeze({ + kind: 'render_attempt', + attemptId: 'attempt-one', + slotId: 'slot-one', + path: 'auction', + rendered: true, + injected: true, + servedFrom: 'inline', + state: 'accepted', + outcome: Object.freeze({ outcome: 'accepted' }), + }) + ) + ).toBe(true); + expect(trace.diagnostics.current()['slot-one']).toMatchObject({ + slotId: 'slot-one', + path: 'auction', + rendered: true, + injected: true, + servedFrom: 'inline', + }); + expect( + trace.observations.publish( + Object.freeze({ + kind: 'slotRequested', + slot: Object.freeze({ token: 'gt1_1', cycleOrdinal: 1, elementId: 'slot-one' }), + }) + ) + ).toBe(true); + expect( + trace.observations.publish( + Object.freeze({ + kind: 'slotRenderEnded', + slot: Object.freeze({ token: 'gt1_1', cycleOrdinal: 1, elementId: 'slot-one' }), + isEmpty: false, + }) + ) + ).toBe(true); + expect(trace.diagnostics.current()['slot-one']).toMatchObject({ + count: 2, + path: 'gam-refresh', + rendered: true, + servedFrom: 'gam', + }); + expect(document.getElementById('ts-render-trace-panel')).toBeNull(); + + activationRelease.reverse().forEach((callback) => callback()); + releasePhysicalService(); + release.reverse().forEach((callback) => callback()); + expect(trace.diagnostics.current()).toEqual({}); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts index fc29e14c2..e640024d5 100644 --- a/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts @@ -1,21 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mirrorSourcepointConsent } from '../../../src/integrations/sourcepoint'; - -type SourcepointWindow = Window & { - __tsjs_sourcepoint?: { - rewriteSdk?: boolean; - }; - __tsjs_installSourcepointGuard?: unknown; -}; +import { + disposeSourcepointConsentMirror, + initializeSourcepointConsentMirror, + mirrorSourcepointConsent, +} from '../../../src/integrations/sourcepoint/consent_mirror'; +import { createSourcepointRuntime } from '../../../src/integrations/sourcepoint/module'; describe('Sourcepoint integration initialization', () => { - let win: SourcepointWindow; - beforeEach(async () => { - win = window as SourcepointWindow; - delete win.__tsjs_sourcepoint; - const guard = await import('../../../src/integrations/sourcepoint/script_guard'); guard.resetGuardState(); }); @@ -23,37 +16,22 @@ describe('Sourcepoint integration initialization', () => { afterEach(async () => { const guard = await import('../../../src/integrations/sourcepoint/script_guard'); guard.resetGuardState(); - delete win.__tsjs_sourcepoint; - delete win.__tsjs_installSourcepointGuard; }); it('installs the guard when rewriteSdk is enabled', async () => { - vi.resetModules(); - win.__tsjs_sourcepoint = { rewriteSdk: true }; - const guard = await import('../../../src/integrations/sourcepoint/script_guard'); - await import('../../../src/integrations/sourcepoint/index'); + const release = createSourcepointRuntime().activate(Object.freeze({ rewriteSdk: true })); expect(guard.isGuardInstalled()).toBe(true); + release(); }); it('skips the guard when rewriteSdk is disabled', async () => { - vi.resetModules(); - win.__tsjs_sourcepoint = { rewriteSdk: false }; - const guard = await import('../../../src/integrations/sourcepoint/script_guard'); - await import('../../../src/integrations/sourcepoint/index'); + const release = createSourcepointRuntime().activate(Object.freeze({ rewriteSdk: false })); expect(guard.isGuardInstalled()).toBe(false); - }); - - it('defaults to installing the guard when rewriteSdk is missing for backward compatibility', async () => { - vi.resetModules(); - - const guard = await import('../../../src/integrations/sourcepoint/script_guard'); - await import('../../../src/integrations/sourcepoint/index'); - - expect(guard.isGuardInstalled()).toBe(true); + release(); }); }); @@ -71,7 +49,7 @@ function sourcepointPayload(gppString = 'DBABLA~BVQqAAAAAgA.QA', applicableSecti describe('integrations/sourcepoint', () => { function clearAllCookies(): void { document.cookie.split(';').forEach((c) => { - const name = c.split('=')[0].trim(); + const name = c.split('=')[0]?.trim() ?? ''; if (name) document.cookie = `${name}=; path=/; Max-Age=0`; }); } @@ -83,11 +61,13 @@ describe('integrations/sourcepoint', () => { beforeEach(() => { // Clear cookies and localStorage before each test. + disposeSourcepointConsentMirror(); clearAllCookies(); localStorage.clear(); }); afterEach(() => { + disposeSourcepointConsentMirror(); vi.useRealTimers(); Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); clearAllCookies(); @@ -277,7 +257,7 @@ describe('integrations/sourcepoint', () => { JSON.stringify(sourcepointPayload('initial-gpp', [7])) ); - mirrorSourcepointConsent(); + initializeSourcepointConsentMirror(); localStorage.setItem( '_sp_user_consent_12345', JSON.stringify(sourcepointPayload('updated-gpp', [8])) @@ -294,7 +274,7 @@ describe('integrations/sourcepoint', () => { JSON.stringify(sourcepointPayload('initial-gpp', [7])) ); - mirrorSourcepointConsent(); + initializeSourcepointConsentMirror(); localStorage.removeItem('_sp_user_consent_12345'); window.dispatchEvent(new Event('focus')); @@ -309,7 +289,7 @@ describe('integrations/sourcepoint', () => { localStorage.clear(); clearAllCookies(); - await import('../../../src/integrations/sourcepoint'); + initializeSourcepointConsentMirror(); localStorage.setItem( '_sp_user_consent_12345', @@ -328,13 +308,13 @@ describe('integrations/sourcepoint', () => { clearAllCookies(); Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); - const sourcepoint = await import('../../../src/integrations/sourcepoint'); + initializeSourcepointConsentMirror(); localStorage.setItem( '_sp_user_consent_12345', JSON.stringify(sourcepointPayload('manual-gpp', [7])) ); - expect(sourcepoint.mirrorSourcepointConsent()).toBe(true); + expect(mirrorSourcepointConsent()).toBe(true); localStorage.setItem( '_sp_user_consent_12345', @@ -354,7 +334,7 @@ describe('integrations/sourcepoint', () => { clearAllCookies(); Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); - await import('../../../src/integrations/sourcepoint'); + initializeSourcepointConsentMirror(); localStorage.setItem( '_sp_user_consent_12345', diff --git a/crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts new file mode 100644 index 000000000..0a582b508 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createSourcepointIntegrationRegistration, + createSourcepointRuntime, +} from '../../../src/integrations/sourcepoint/module'; +import { createIntegrationRegistry } from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); +const RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; +const SOURCEPOINT_INTEGRATION_ID = 'sourcepoint_consent'; + +describe('transactional Sourcepoint integration module', () => { + it.each([true, false])( + 'owns the optional SDK guard and consent mirror when rewriteSdk=%s', + (rewriteSdk) => { + const order: string[] = []; + const runtime = createSourcepointRuntime({ + initializeConsentMirror: () => order.push('start:consent'), + installGuard: () => order.push('activate:guard'), + resetConsentMirror: () => order.push('dispose:consent'), + resetGuard: () => order.push('dispose:guard'), + }); + const config = Object.freeze({ rewriteSdk }); + + const release = runtime.activate(config); + runtime.start(config); + release(); + release(); + + expect(order).toEqual( + rewriteSdk + ? ['activate:guard', 'start:consent', 'dispose:consent', 'dispose:guard'] + : ['start:consent', 'dispose:consent'] + ); + } + ); + + it.each([ + ['missing', undefined], + ['mutable', { rewriteSdk: true }], + ['wrong type', Object.freeze({ rewriteSdk: 'yes' })], + ['extra', Object.freeze({ rewriteSdk: true, legacy: true })], + ])('rejects %s boot config before activation', async (_name, config) => { + const activate = vi.fn(() => vi.fn()); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: RUNTIME_SRC, + integrations: [{ id: SOURCEPOINT_INTEGRATION_ID, phase: 'takeover' }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze([SOURCEPOINT_INTEGRATION_ID]), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + [SOURCEPOINT_INTEGRATION_ID]: Object.freeze({ activate, start: vi.fn() }), + }), + }), + }); + registry.register(createSourcepointIntegrationRegistration(RELEASE_ID)); + + await expect( + registry.install({ activateCore: vi.fn(), publish: vi.fn(), drainPreload: vi.fn() }) + ).resolves.toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(activate).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts b/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts new file mode 100644 index 000000000..136f2b60f --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTestlightRuntime } from '../../../src/integrations/testlight/module'; + +describe('transactional Testlight integration module', () => { + it('bridges preexisting and later callbacks once while isolating invalid and throwing work', () => { + const calls: string[] = []; + const first = () => calls.push('first'); + const throwing = () => { + calls.push('throwing'); + throw new Error('publisher callback failed'); + }; + const second = () => calls.push('second'); + const beforeCommit = () => calls.push('before-commit'); + const afterCommit = () => calls.push('after-commit'); + const original = [first, 'invalid', throwing, second]; + const target = { testlight: { publisher: true, que: original } }; + const enqueue = vi.fn((callback: () => void) => callback()); + const runtime = createTestlightRuntime({ enqueue, started: vi.fn(), target }); + + const release = runtime.activate(undefined); + target.testlight.que.push(beforeCommit); + expect(calls).toEqual([]); + + runtime.start(undefined); + target.testlight.que.push(afterCommit); + + expect(calls).toEqual(['first', 'throwing', 'second', 'before-commit', 'after-commit']); + expect(enqueue).toHaveBeenCalledTimes(5); + release(); + release(); + expect(target.testlight).toEqual({ publisher: true, que: original }); + }); + + it('returns callbacks added during activation to the publisher queue on rollback', () => { + const original = [vi.fn()]; + const later = vi.fn(); + const target = { testlight: { que: original } }; + const runtime = createTestlightRuntime({ + enqueue: vi.fn(), + started: vi.fn(), + target, + }); + + const release = runtime.activate(undefined); + target.testlight.que.push(later); + release(); + + expect(target.testlight.que).toBe(original); + expect(original).toEqual([expect.any(Function), later]); + }); + + it('returns the captured native push result after forwarding a later callback', () => { + const callback = vi.fn(); + const target = { testlight: { que: [] as unknown[] } }; + const runtime = createTestlightRuntime({ + enqueue: (candidate) => candidate(), + started: vi.fn(), + target, + }); + + const release = runtime.activate(undefined); + runtime.start(undefined); + + expect(target.testlight.que.push(callback)).toBe(1); + expect(callback).toHaveBeenCalledOnce(); + expect(target.testlight.que).toHaveLength(0); + + release(); + }); + + it('does not overwrite a publisher queue replacement during disposal', () => { + const target = { testlight: { que: [] as unknown[] } }; + const runtime = createTestlightRuntime({ + enqueue: vi.fn(), + started: vi.fn(), + target, + }); + const release = runtime.activate(undefined); + const replacement: unknown[] = []; + target.testlight.que = replacement; + + release(); + + expect(target.testlight.que).toBe(replacement); + }); + + it('preserves publisher fields added to a runtime-created global', () => { + const target: { testlight?: { publisher?: boolean; que?: unknown[] } } = {}; + const runtime = createTestlightRuntime({ + enqueue: vi.fn(), + started: vi.fn(), + target, + }); + const release = runtime.activate(undefined); + if (!target.testlight) throw new Error('should create the Testlight global'); + target.testlight.publisher = true; + + release(); + + expect(target.testlight).toEqual({ publisher: true }); + }); + + it('snapshots queue data without invoking a publisher iterator', () => { + const callback = vi.fn(); + const original = [callback]; + Object.defineProperty(original, Symbol.iterator, { + configurable: true, + value: () => { + throw new Error('publisher iterator must remain inert'); + }, + }); + const target = { testlight: { que: original } }; + const enqueue = vi.fn((candidate: () => void) => candidate()); + const runtime = createTestlightRuntime({ enqueue, started: vi.fn(), target }); + + const release = runtime.activate(undefined); + expect(() => runtime.start(undefined)).not.toThrow(); + + expect(callback).toHaveBeenCalledOnce(); + release(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts new file mode 100644 index 000000000..6702a1d1d --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createDiagnosticsIngress, + type DiagnosticsObservation, +} from '../../src/kernel/diagnostics'; + +function scalarRecord(valueCount: number): Record { + return Object.fromEntries( + Array.from({ length: valueCount }, (_, index) => [`value${index}`, index]) + ); +} + +function nestedRecord(depth: number): Record { + const root: Record = {}; + let cursor = root; + for (let index = 0; index < depth; index += 1) { + const child: Record = {}; + cursor['child'] = child; + cursor = child; + } + return root; +} + +function primitiveLeafRecord(depth: number, value: unknown): Record { + const root: Record = {}; + let cursor = root; + for (let currentDepth = 1; currentDepth < depth; currentDepth += 1) { + const child: Record = {}; + cursor['child'] = child; + cursor = child; + } + cursor['leaf'] = value; + return root; +} + +describe('kernel diagnostics ingress', () => { + it('exposes only the exact frozen core-owned facade', () => { + const ingress = createDiagnosticsIngress({ reduce: vi.fn() }); + + expect(Object.isFrozen(ingress)).toBe(true); + expect(Reflect.ownKeys(ingress).sort()).toEqual(['dispose', 'publish']); + expect('subscribe' in ingress).toBe(false); + expect('consumerIds' in ingress).toBe(false); + expect('capacity' in ingress).toBe(false); + expect('queue' in ingress).toBe(false); + expect('scheduler' in ingress).toBe(false); + expect('timer' in ingress).toBe(false); + expect('overflow' in ingress).toBe(false); + }); + + it('copies ordinary and null-prototype data trees into fresh deeply frozen values', () => { + const reduced: DiagnosticsObservation[] = []; + const ingress = createDiagnosticsIngress({ + reduce: (observation) => reduced.push(observation), + }); + const nested = Object.assign(Object.create(null) as Record, { + label: '診断✓', + }); + const array = [nested, null, true, 3.25]; + const candidate = { array, name: 'publisher-value' }; + + expect(ingress.publish(candidate)).toBe(true); + expect(reduced).toHaveLength(1); + const accepted = reduced[0]!; + expect(accepted).not.toBe(candidate); + expect(Object.getPrototypeOf(accepted)).toBeNull(); + expect(Object.isFrozen(accepted)).toBe(true); + expect(accepted['array']).not.toBe(array); + expect(Object.isFrozen(accepted['array'])).toBe(true); + const acceptedArray = accepted['array'] as readonly unknown[]; + expect(acceptedArray[0]).not.toBe(nested); + expect(Object.getPrototypeOf(acceptedArray[0])).toBeNull(); + expect(Object.isFrozen(acceptedArray[0])).toBe(true); + expect(acceptedArray).toEqual([{ label: '診断✓' }, null, true, 3.25]); + }); + + it('accepts exactly 512 nodes and rejects 513 before reducer entry', () => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + + expect(ingress.publish(scalarRecord(510))).toBe(true); + expect(ingress.publish(scalarRecord(511))).toBe(true); + expect(ingress.publish(scalarRecord(512))).toBe(false); + expect(reduce).toHaveBeenCalledTimes(2); + }); + + it('accepts depth sixteen and rejects depth seventeen before reducer entry', () => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + + expect(ingress.publish(nestedRecord(15))).toBe(true); + expect(ingress.publish(nestedRecord(16))).toBe(true); + expect(ingress.publish(nestedRecord(17))).toBe(false); + expect(reduce).toHaveBeenCalledTimes(2); + }); + + it.each([ + [15, null, true], + [16, false, true], + [16, 42.25, true], + [16, '診断✓', true], + [17, null, false], + [17, false, false], + [17, 42.25, false], + [17, '診断✓', false], + ])('enforces the depth boundary for primitive leaf depth %i', (depth, value, accepted) => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + + expect(ingress.publish(primitiveLeafRecord(depth, value))).toBe(accepted); + expect(reduce).toHaveBeenCalledTimes(accepted ? 1 : 0); + }); + + it('enforces UTF-8 property-name and string byte limits including multibyte input', () => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + const property127 = 'a'.repeat(127); + const property128 = 'é'.repeat(64); + const property129 = `${'é'.repeat(64)}a`; + const string4095 = 'a'.repeat(4095); + const string4096 = 'é'.repeat(2048); + const string4097 = `${'é'.repeat(2048)}a`; + + expect(ingress.publish({ [property127]: string4095 })).toBe(true); + expect(ingress.publish({ [property128]: string4096 })).toBe(true); + expect(ingress.publish({ [property129]: 'value' })).toBe(false); + expect(ingress.publish({ value: string4097 })).toBe(false); + expect(reduce).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['sparse array', Object.assign(new Array(2), { 0: 'first' })], + ['array extra property', Object.assign(['first'], { extra: true })], + ['undefined', { value: undefined }], + // @ts-expect-error The runtime supports this hostile input even though the build target does not. + ['bigint', { value: 1n }], + ['function', { value: () => undefined }], + ['symbol value', { value: Symbol('fictional') }], + ['nonfinite number', { value: Number.POSITIVE_INFINITY }], + ['custom prototype', Object.freeze(new (class FictionalValue {})())], + ])('rejects %s values before reducer entry', (_label, candidate) => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + + expect(ingress.publish(candidate as Record)).toBe(false); + expect(reduce).not.toHaveBeenCalled(); + }); + + it('rejects aliases, cycles, accessors, symbols, and non-enumerable record fields', () => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + const shared = { value: true }; + const cycle: Record = {}; + cycle['self'] = cycle; + const accessor = Object.defineProperty({}, 'value', { + enumerable: true, + get: vi.fn(() => true), + }); + const symbol = Object.defineProperty({}, Symbol('fictional'), { + enumerable: true, + value: true, + }); + const hidden = Object.defineProperty({}, 'hidden', { + enumerable: false, + value: true, + }); + + expect(ingress.publish({ first: shared, second: shared })).toBe(false); + expect(ingress.publish(cycle)).toBe(false); + expect(ingress.publish(accessor)).toBe(false); + expect(ingress.publish(symbol)).toBe(false); + expect(ingress.publish(hidden)).toBe(false); + expect(reduce).not.toHaveBeenCalled(); + }); + + it('fails closed on hostile reflection and injected copy or freeze failures', () => { + const reduce = vi.fn(); + const reportError = vi.fn(() => { + throw new Error('fictional reporter failure'); + }); + const ingress = createDiagnosticsIngress({ reduce, reportError }); + const hostile = new Proxy( + {}, + { + getPrototypeOf: () => { + throw new Error('fictional prototype trap'); + }, + } + ); + + expect(() => ingress.publish(hostile)).not.toThrow(); + expect(ingress.publish(hostile)).toBe(false); + const defineProperty = vi.spyOn(Object, 'defineProperty').mockImplementationOnce(() => { + throw new Error('fictional copy failure'); + }); + expect(ingress.publish({ acceptedShape: true })).toBe(false); + defineProperty.mockRestore(); + const freeze = vi.spyOn(Object, 'freeze').mockImplementationOnce(() => { + throw new Error('fictional freeze failure'); + }); + expect(ingress.publish({ acceptedShape: true })).toBe(false); + freeze.mockRestore(); + expect(reduce).not.toHaveBeenCalled(); + }); + + it('returns true after acceptance even when reducer and reporter throw', () => { + const reportError = vi.fn(() => { + throw new Error('fictional reporter failure'); + }); + const ingress = createDiagnosticsIngress({ + reduce: () => { + throw new Error('fictional reducer failure'); + }, + reportError, + }); + + expect(() => ingress.publish({ accepted: true })).not.toThrow(); + expect(ingress.publish({ accepted: true })).toBe(true); + expect(reportError).toHaveBeenCalledTimes(2); + }); + + it('disposes idempotently and makes retained runtime publishers inert', () => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + const retainedPublish = ingress.publish; + + expect(retainedPublish({ sequence: 1 })).toBe(true); + ingress.dispose(); + ingress.dispose(); + expect(retainedPublish({ sequence: 2 })).toBe(false); + expect(reduce).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/disposable.test.ts b/crates/trusted-server-js/lib/test/kernel/disposable.test.ts new file mode 100644 index 000000000..bfb60dd66 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/disposable.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DisposableStack, TerminalLatch } from '../../src/kernel/disposable'; + +describe('DisposableStack', () => { + it('aborts and disposes in reverse order exactly once while isolating failures', () => { + const calls: string[] = []; + const errors: unknown[] = []; + const stack = new DisposableStack((error) => errors.push(error)); + + stack.onDispose(() => calls.push('first')); + stack.onDispose(() => { + calls.push('second'); + throw new Error('fictional disposer failure'); + }); + stack.onDispose(() => calls.push('third')); + stack.signal.addEventListener('abort', () => calls.push('abort')); + + stack.dispose(); + stack.dispose(); + + expect(stack.disposed).toBe(true); + expect(stack.signal.aborted).toBe(true); + expect(calls).toEqual(['abort', 'third', 'second', 'first']); + expect(errors).toHaveLength(1); + }); + + it('runs a disposer registered after disposal immediately and isolates its failure', () => { + const calls: string[] = []; + const onError = vi.fn(); + const stack = new DisposableStack(onError); + stack.dispose(); + + stack.onDispose(() => calls.push('late')); + stack.onDispose(() => { + throw new Error('late fictional failure'); + }); + + expect(calls).toEqual(['late']); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('observes a rejecting async disposer without delaying terminal disposal', async () => { + const onError = vi.fn(); + const stack = new DisposableStack(onError); + stack.onDispose(async () => { + throw new Error('fictional async disposer failure'); + }); + + stack.dispose(); + + expect(stack.disposed).toBe(true); + expect(stack.signal.aborted).toBe(true); + await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + }); +}); + +describe('TerminalLatch', () => { + it('lets only the first terminal result win and disposes before completion', async () => { + const events: string[] = []; + const latch = new TerminalLatch<{ outcome: string }>(); + latch.onDispose(() => events.push('disposed')); + latch.completion.then(() => events.push('completed')); + + expect(latch.trySettle({ outcome: 'accepted' })).toBe(true); + expect(latch.trySettle({ outcome: 'failed' })).toBe(false); + expect(latch.terminal).toBe(true); + expect(latch.value).toEqual({ outcome: 'accepted' }); + await expect(latch.completion).resolves.toEqual({ outcome: 'accepted' }); + expect(events).toEqual(['disposed', 'completed']); + }); + + it('supports undefined as a terminal value without reopening the latch', async () => { + const latch = new TerminalLatch(); + + expect(latch.trySettle(undefined)).toBe(true); + expect(latch.terminal).toBe(true); + expect(latch.trySettle(undefined)).toBe(false); + await expect(latch.completion).resolves.toBeUndefined(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/fallback.test.ts b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts new file mode 100644 index 000000000..a899198d9 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from 'vitest'; + +import { snapshotTsjsBootV1 } from '../../src/core/contracts/boot'; +import { + buildFallbackBoot, + buildKernelBoot, + trustedArtifactOrigin, +} from '../../src/kernel/fallback'; + +const RELEASE_ID = 'a'.repeat(64); +const TRUSTED_RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; + +function opaqueDocument(stamp: PropertyDescriptor | undefined): Document { + const view = { location: { origin: 'null' } } as Record; + if (stamp) Object.defineProperty(view, '__tsCreativeOrigin', stamp); + return { defaultView: view } as unknown as Document; +} + +describe('takeover artifact origin', () => { + it('accepts only the immutable own-data creative stamp for an opaque document', () => { + expect( + trustedArtifactOrigin( + opaqueDocument({ + configurable: false, + enumerable: false, + value: 'https://publisher.example', + writable: false, + }) + ) + ).toBe('https://publisher.example'); + }); + + it.each([ + undefined, + { configurable: true, enumerable: false, value: 'https://publisher.example', writable: false }, + { configurable: false, enumerable: false, value: 'https://publisher.example', writable: true }, + { configurable: false, enumerable: true, value: 'https://publisher.example', writable: false }, + { configurable: false, enumerable: false, get: () => 'https://publisher.example' }, + { + configurable: false, + enumerable: false, + value: 'https://attacker.example/path', + writable: false, + }, + ])('rejects an absent, mutable, accessor-backed, or non-origin creative stamp', (stamp) => { + expect(trustedArtifactOrigin(opaqueDocument(stamp))).toBeUndefined(); + }); +}); + +function manifest(ids: readonly string[]) { + return { + version: 1 as const, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => + id === 'diagnostics_presentation' + ? { + id, + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + src: `/static/tsjs=tsjs-${id}.min.js?v=${'d'.repeat(64)}`, + } + : { id, phase: 'takeover' as const } + ), + }; +} + +function boot( + creative: unknown, + diagnostics: Readonly<{ + renderTraceOverlay: boolean; + gptActive: boolean; + }> = { renderTraceOverlay: false, gptActive: false } +) { + return { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + integrations: { version: 1, entries: [] }, + creative, + diagnostics: { + version: 1, + renderTraceOverlay: diagnostics.renderTraceOverlay, + gpt: { active: diagnostics.gptActive }, + }, + }; +} + +function completeBoot( + ids: readonly string[], + creative: unknown, + diagnostics?: Readonly<{ renderTraceOverlay: boolean; gptActive: boolean }> +) { + return snapshotTsjsBootV1( + { + abi: 1, + releaseId: RELEASE_ID, + manifest: manifest(ids), + ...boot(creative, diagnostics), + }, + RELEASE_ID + ); +} + +describe('kernel boot creative ABI', () => { + it.each([ + { version: 1, enabled: false, clickGuard: true, renderGuard: false }, + { version: 1, enabled: false, clickGuard: false, renderGuard: true }, + ])('rejects disabled creative with an enabled guard bit', (creative) => { + expect(completeBoot([], creative)).toBeUndefined(); + }); + + it('rejects a null-prototype creative record', () => { + const creative = Object.assign(Object.create(null) as object, { + version: 1, + enabled: false, + clickGuard: false, + renderGuard: false, + }); + + expect(completeBoot([], creative)).toBeUndefined(); + }); + + it.each([ + ['enabled no-guard creative with a manifest member', true, ['creative']], + ['enabled creative with duplicate manifest members', true, ['creative', 'creative']], + ['disabled creative with a manifest member', false, ['creative']], + ] as const)('rejects %s', (_caseName, enabled, ids) => { + expect( + completeBoot(ids, { version: 1, enabled, clickGuard: false, renderGuard: false }) + ).toBeUndefined(); + }); + + it('accepts enabled creative with both guards false without a manifest member', () => { + const snapshot = completeBoot([], { + version: 1, + enabled: true, + clickGuard: false, + renderGuard: false, + }); + const accepted = snapshot + ? buildKernelBoot(RELEASE_ID, snapshot.manifest, snapshot) + : undefined; + + expect(accepted?.creative).toEqual({ + version: 1, + enabled: true, + clickGuard: false, + renderGuard: false, + }); + expect(Object.isFrozen(accepted?.creative)).toBe(true); + }); +}); + +describe('terminal fallback boot manifest', () => { + it('constructs the exact safe manifest from the independently trusted takeover source', () => { + const fallback = buildFallbackBoot(RELEASE_ID, TRUSTED_RUNTIME_SRC) as { + readonly manifest: unknown; + }; + + expect(fallback.manifest).toEqual({ + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: TRUSTED_RUNTIME_SRC, + integrations: [], + }); + }); + + it('refuses to construct a fallback boot without an independently trusted takeover source', () => { + expect(buildFallbackBoot(RELEASE_ID, undefined as never)).toBeUndefined(); + }); + + it('publishes the exact phase-aware fallback manifest with the accepted takeover source', () => { + const acceptedManifest = manifest(['render_runtime', 'diagnostics_presentation']); + const fallback = buildFallbackBoot(RELEASE_ID, acceptedManifest.runtimeSrc) as { + readonly manifest: unknown; + }; + + expect(fallback.manifest).toEqual({ + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: acceptedManifest.runtimeSrc, + integrations: [], + }); + expect(Reflect.ownKeys(fallback.manifest as object).sort()).toEqual([ + 'firstDisplay', + 'integrations', + 'releaseId', + 'runtimeSrc', + 'version', + ]); + expect(Object.isFrozen(fallback.manifest)).toBe(true); + }); +}); + +describe('kernel boot diagnostics presentation membership', () => { + const disabledCreative = Object.freeze({ + version: 1, + enabled: false, + clickGuard: false, + renderGuard: false, + }); + + it.each([ + { renderTraceOverlay: false, gptActive: false, presentation: false }, + { renderTraceOverlay: true, gptActive: false, presentation: true }, + { renderTraceOverlay: false, gptActive: true, presentation: true }, + { renderTraceOverlay: true, gptActive: true, presentation: true }, + ])( + 'accepts diagnostics_presentation iff overlay=$renderTraceOverlay or GPT=$gptActive', + ({ renderTraceOverlay, gptActive, presentation }) => { + const ids = [ + ...(gptActive ? ['gpt_diagnostics'] : []), + ...(presentation ? ['diagnostics_presentation'] : []), + ]; + + const snapshot = completeBoot(ids, disabledCreative, { renderTraceOverlay, gptActive }); + expect(snapshot).toBeDefined(); + expect( + snapshot ? buildKernelBoot(RELEASE_ID, snapshot.manifest, snapshot) : undefined + ).toBeDefined(); + } + ); + + it.each([ + { renderTraceOverlay: false, gptActive: false, presentation: true }, + { renderTraceOverlay: true, gptActive: false, presentation: false }, + { renderTraceOverlay: false, gptActive: true, presentation: false }, + { renderTraceOverlay: true, gptActive: true, presentation: false }, + ])( + 'rejects the inverse diagnostics_presentation membership for overlay=$renderTraceOverlay and GPT=$gptActive', + ({ renderTraceOverlay, gptActive, presentation }) => { + const ids = [ + ...(gptActive ? ['gpt_diagnostics'] : []), + ...(presentation ? ['diagnostics_presentation'] : []), + ]; + + expect( + completeBoot(ids, disabledCreative, { renderTraceOverlay, gptActive }) + ).toBeUndefined(); + } + ); + + it('accepts the complete server-shaped phase-aware boot manifest', () => { + const expectedManifest = manifest(['render_runtime']); + const candidate = snapshotTsjsBootV1( + { + abi: 1, + releaseId: RELEASE_ID, + manifest: expectedManifest, + ...boot(disabledCreative), + }, + RELEASE_ID + ); + + expect(candidate).toBeDefined(); + expect(buildKernelBoot(RELEASE_ID, candidate!.manifest, candidate)).toBeDefined(); + }); + + it.each([ + [19, true], + [20, true], + [21, false], + ] as const)('accepts at most %i complete server manifest integrations', (count, accepted) => { + const expectedManifest = manifest( + Array.from({ length: count }, (_, index) => `integration_${index + 1}`) + ); + const candidate = snapshotTsjsBootV1( + { + abi: 1, + releaseId: RELEASE_ID, + manifest: expectedManifest, + ...boot(disabledCreative), + }, + RELEASE_ID + ); + + expect( + candidate ? buildKernelBoot(RELEASE_ID, candidate.manifest, candidate) !== undefined : false + ).toBe(accepted); + }); + + it('rejects a complete boot whose phase-aware manifest differs from the accepted manifest', () => { + const expectedManifest = manifest(['render_runtime']); + const candidateManifest = { + ...expectedManifest, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'e'.repeat(64)}`, + }; + + expect( + buildKernelBoot(RELEASE_ID, expectedManifest, { + abi: 1, + releaseId: RELEASE_ID, + manifest: candidateManifest, + ...boot(disabledCreative), + }) + ).toBeUndefined(); + }); + + it.each(['diagnostics root', 'diagnostics GPT child'] as const)( + 'rejects a null-prototype %s', + (target) => { + const candidate = boot(disabledCreative); + const diagnostics = + target === 'diagnostics root' + ? Object.assign(Object.create(null) as object, candidate.diagnostics) + : { + ...candidate.diagnostics, + gpt: Object.assign(Object.create(null) as object, candidate.diagnostics.gpt), + }; + + expect( + snapshotTsjsBootV1( + { + abi: 1, + releaseId: RELEASE_ID, + manifest: manifest([]), + ...candidate, + diagnostics, + }, + RELEASE_ID + ) + ).toBeUndefined(); + } + ); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/identity.test.ts b/crates/trusted-server-js/lib/test/kernel/identity.test.ts new file mode 100644 index 000000000..82876fb83 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/identity.test.ts @@ -0,0 +1,330 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createBrowserNavigationIdentityIssuer, + createFirstDisplayNavigationIdentityIssuerFromSource, + createTestNavigationIdentityIssuer, + mintTestBootstrapNonce, + mintTestLifecycleTicket, + mintTestRendererNonce, + type RandomValuesSource, +} from '../../src/kernel/identity'; + +function decodeIdentity(value: string): Buffer { + return Buffer.from(value.slice(3), 'base64url'); +} + +function deterministicSource(bytes: readonly number[]): { + readonly source: RandomValuesSource; + readonly calls: ReturnType; +} { + let offset = 0; + const calls = vi.fn((target: Uint8Array): Uint8Array => { + for (let index = 0; index < target.length; index += 1) { + target[index] = bytes[offset % bytes.length] ?? 0; + offset += 1; + } + return target; + }); + return { source: calls, calls }; +} + +describe('navigation identity issuer', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('draws one eight-byte prefix and increments a big-endian u64 ordinal once per attempt', () => { + const { source, calls } = deterministicSource([0, 1, 2, 3, 4, 5, 6, 7]); + const created = createTestNavigationIdentityIssuer({ getRandomValues: source }); + + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an identity issuer'); + + const first = created.value.mintAttemptId(); + const second = created.value.mintAttemptId(); + + expect(first).toEqual({ ok: true, value: 'a1_AAECAwQFBgcAAAAAAAAAAQ' }); + expect(second).toEqual({ ok: true, value: 'a1_AAECAwQFBgcAAAAAAAAAAg' }); + expect(first.ok && decodeIdentity(first.value)).toEqual( + Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 0, 0, 0, 0, 0, 0, 0, 1]) + ); + expect(second.ok && decodeIdentity(second.value)).toEqual( + Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 0, 0, 0, 0, 0, 0, 0, 2]) + ); + expect(first.ok && first.value).toHaveLength(25); + expect(second.ok && second.value).toHaveLength(25); + expect(calls).toHaveBeenCalledOnce(); + expect(calls.mock.calls[0]?.[0]).toHaveLength(8); + expect(created.value.snapshotOrdinalForTest()).toEqual([0, 2]); + }); + + it('uses the compact initial-display issuer for the same bounded wire identities', () => { + const { source, calls } = deterministicSource([0, 1, 2, 3, 4, 5, 6, 7]); + const created = createFirstDisplayNavigationIdentityIssuerFromSource(source); + + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an initial-display identity issuer'); + + expect(created.value.mintAttemptId()).toEqual({ + ok: true, + value: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + }); + expect(created.value.mintAttemptId()).toEqual({ + ok: true, + value: 'a1_AAECAwQFBgcAAAAAAAAAAg', + }); + expect(created.value.snapshotPrefix()).toBe('AAECAwQFBgc'); + expect(calls).toHaveBeenCalledOnce(); + expect(calls.mock.calls[0]?.[0]).toHaveLength(8); + }); + + it('adopts the next first-display ordinal once before minting', () => { + const { source } = deterministicSource([0, 1, 2, 3, 4, 5, 6, 7]); + const created = createTestNavigationIdentityIssuer({ getRandomValues: source }); + if (!created.ok) throw new Error('Expected an identity issuer'); + + expect(created.value.adoptNextAttemptOrdinal(9)).toBe(true); + expect(created.value.mintAttemptId()).toEqual({ + ok: true, + value: 'a1_AAECAwQFBgcAAAAAAAAACQ', + }); + expect(created.value.adoptNextAttemptOrdinal(10)).toBe(false); + expect(created.value.snapshotOrdinalForTest()).toEqual([0, 9]); + }); + + it('adopts the exact first-display prefix and next ordinal as one state transition', () => { + const { source } = deterministicSource([0, 1, 2, 3, 4, 5, 6, 7]); + const created = createTestNavigationIdentityIssuer({ getRandomValues: source }); + if (!created.ok) throw new Error('Expected an identity issuer'); + + expect(created.value.adoptFirstDisplayState('CAcGBQQDAgE', 9)).toBe(true); + expect(created.value.mintAttemptId()).toEqual({ + ok: true, + value: 'a1_CAcGBQQDAgEAAAAAAAAACQ', + }); + expect(created.value.adoptFirstDisplayState('AAECAwQFBgc', 10)).toBe(false); + }); + + it('owns an immutable copy of the source-filled navigation prefix', () => { + let sourceBuffer: Uint8Array | undefined; + const created = createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.set([0, 1, 2, 3, 4, 5, 6, 7]); + sourceBuffer = target; + return target; + }, + }); + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an identity issuer'); + + sourceBuffer?.fill(255); + + expect(created.value.mintAttemptId()).toEqual({ + ok: true, + value: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + }); + }); + + it('survives detachment of the source-filled navigation prefix buffer', () => { + let sourceBuffer: Uint8Array | undefined; + const created = createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.set([0, 1, 2, 3, 4, 5, 6, 7]); + sourceBuffer = target; + return target; + }, + }); + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an identity issuer'); + if (!sourceBuffer) throw new Error('Expected the source buffer'); + + structuredClone(sourceBuffer.buffer, { transfer: [sourceBuffer.buffer] }); + + expect(sourceBuffer.byteLength).toBe(0); + expect(created.value.mintAttemptId()).toEqual({ + ok: true, + value: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + }); + }); + + it('contains mint buffer and view failures behind the typed identity failure', () => { + const failure = vi.fn(); + const { source } = deterministicSource([0, 1, 2, 3, 4, 5, 6, 7]); + const created = createTestNavigationIdentityIssuer({ + getRandomValues: source, + onFailure: failure, + }); + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an identity issuer'); + vi.stubGlobal( + 'DataView', + class { + public constructor() { + throw new Error('sensitive detached view failure'); + } + } + ); + + expect(created.value.mintAttemptId()).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(failure.mock.calls).toEqual([['identity_generation_failed']]); + expect(created.value.snapshotOrdinalForTest()).toEqual([0, 0]); + }); + + it('fails closed when a mint view silently leaves ordinal bytes unwritten', () => { + const failure = vi.fn(); + const { source } = deterministicSource([0, 1, 2, 3, 4, 5, 6, 7]); + const created = createTestNavigationIdentityIssuer({ + getRandomValues: source, + onFailure: failure, + }); + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an identity issuer'); + vi.stubGlobal( + 'DataView', + class { + public setUint32(): void {} + } + ); + + expect(created.value.mintAttemptId()).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(failure.mock.calls).toEqual([['identity_generation_failed']]); + expect(created.value.snapshotOrdinalForTest()).toEqual([0, 0]); + }); + + it('issues the final ordinal once and then fails forever without wrapping', () => { + const { source } = deterministicSource([8, 7, 6, 5, 4, 3, 2, 1]); + const failure = vi.fn(); + const created = createTestNavigationIdentityIssuer({ + getRandomValues: source, + initialOrdinal: [0xffff_ffff, 0xffff_fffe], + onFailure: failure, + }); + + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an identity issuer'); + + expect(created.value.mintAttemptId()).toMatchObject({ ok: true }); + expect(created.value.snapshotOrdinalForTest()).toEqual([0xffff_ffff, 0xffff_ffff]); + expect(created.value.mintAttemptId()).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(created.value.mintAttemptId()).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(created.value.snapshotOrdinalForTest()).toEqual([0xffff_ffff, 0xffff_ffff]); + expect(failure).toHaveBeenCalledTimes(2); + expect(failure.mock.calls).toEqual([ + ['identity_generation_failed'], + ['identity_generation_failed'], + ]); + }); + + it('fails before creating an issuer when browser crypto is missing or throws', () => { + vi.stubGlobal('crypto', undefined); + expect(createBrowserNavigationIdentityIssuer()).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + + vi.stubGlobal('crypto', { + getRandomValues: () => { + throw new Error('unavailable'); + }, + }); + expect(createBrowserNavigationIdentityIssuer()).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + }); + + it('reports prefix failures without exposing raw bytes or identities', () => { + const failure = vi.fn(); + const created = createTestNavigationIdentityIssuer({ + getRandomValues: () => { + throw new Error('sensitive source failure'); + }, + onFailure: failure, + }); + + expect(created).toEqual({ ok: false, reason: 'identity_generation_failed' }); + expect(failure.mock.calls).toEqual([['identity_generation_failed']]); + }); +}); + +describe('fresh capability identities', () => { + it('encodes each lifecycle ticket from sixteen fresh CSPRNG bytes', () => { + const { source, calls } = deterministicSource([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + ]); + + const first = mintTestLifecycleTicket(source); + const second = mintTestLifecycleTicket(source); + + expect(first).toEqual({ ok: true, value: 't1_AAECAwQFBgcICQoLDA0ODw' }); + expect(second).toEqual({ ok: true, value: 't1_AAECAwQFBgcICQoLDA0ODw' }); + expect(first.ok && first.value).toHaveLength(25); + expect(first.ok && decodeIdentity(first.value)).toHaveLength(16); + expect(calls).toHaveBeenCalledTimes(2); + expect(calls.mock.calls[0]?.[0]).not.toBe(calls.mock.calls[1]?.[0]); + }); + + it('encodes each renderer nonce from sixteen fresh CSPRNG bytes', () => { + const { source, calls } = deterministicSource([ + 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, + ]); + + const result = mintTestRendererNonce(source); + + expect(result).toEqual({ ok: true, value: 'n1_Dw4NDAsKCQgHBgUEAwIBAA' }); + expect(result.ok && result.value).toHaveLength(25); + expect(result.ok && decodeIdentity(result.value)).toHaveLength(16); + expect(calls).toHaveBeenCalledOnce(); + expect(calls.mock.calls[0]?.[0]).toHaveLength(16); + }); + + it('keeps bootstrap and renderer nonce roles cryptographically distinct', () => { + const { source, calls } = deterministicSource([ + 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, + ]); + + const bootstrap = mintTestBootstrapNonce(source); + const renderer = mintTestRendererNonce(source); + + expect(bootstrap).toEqual({ ok: true, value: 'b1_Dw4NDAsKCQgHBgUEAwIBAA' }); + expect(renderer).toEqual({ ok: true, value: 'n1_Dw4NDAsKCQgHBgUEAwIBAA' }); + expect(bootstrap.ok && bootstrap.value).not.toBe(renderer.ok && renderer.value); + expect(calls).toHaveBeenCalledTimes(2); + }); + + it('maps ticket and nonce source failures without leaking source values', () => { + const failure = vi.fn(); + const source = () => { + throw new Error('sensitive source failure'); + }; + + expect(mintTestLifecycleTicket(source, failure)).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(mintTestBootstrapNonce(source, failure)).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(mintTestRendererNonce(source, failure)).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(failure.mock.calls).toEqual([ + ['identity_generation_failed'], + ['identity_generation_failed'], + ['identity_generation_failed'], + ]); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts new file mode 100644 index 000000000..ddcbba5e6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts @@ -0,0 +1,1948 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { BootManifestV1 } from '../../src/core/types'; +import { EMBEDDED_MAX_MANIFEST_MODULES } from '../../src/kernel/contracts/release_capacity'; +import { + createIntegrationRegistry as createIntegrationRegistryOwner, + snapshotIntegrationRegistration, + type IntegrationPrepareContext, + type IntegrationRegistryOptions, + type TakeoverIntegrationRegistration, +} from '../../src/kernel/integration_registry'; +import { snapshotPersistentFirstDisplayAdoptionV1 } from '../../src/shared/takeover'; +import { failedFirstDisplayTakeover } from '../first_display/helpers/compact_takeover'; + +const RELEASE_ID = 'a'.repeat(64); +const OTHER_RELEASE_ID = 'b'.repeat(64); + +type TestRegistryOptions = Omit & { + readonly knownIntegrationIds?: readonly string[]; +}; + +function manifestIds(candidate: unknown): readonly string[] { + if (typeof candidate !== 'object' || candidate === null) return Object.freeze([]); + const integrations = (candidate as { integrations?: unknown }).integrations; + if (!Array.isArray(integrations)) return Object.freeze([]); + + const ids: string[] = []; + for (let index = 0; index < integrations.length; index += 1) { + const entry = integrations[index] as { id?: unknown } | undefined; + if (typeof entry?.id === 'string') ids.push(entry.id); + } + return Object.freeze([...new Set(ids)]); +} + +function createIntegrationRegistry(options: TestRegistryOptions) { + const knownIntegrationIds = options.knownIntegrationIds ?? manifestIds(options.manifest); + return createIntegrationRegistryOwner({ + ...options, + knownIntegrationIds, + catalog: Object.freeze( + knownIntegrationIds.map((id) => + Object.freeze({ + id, + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }) + ) + ), + }); +} + +function manifest(ids: readonly string[]): BootManifestV1 { + return { + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => ({ id, phase: 'takeover' as const })), + }; +} + +function registration( + id: string, + hooks: Partial = {} +): TakeoverIntegrationRegistration { + const prepare = hooks.prepare ?? (() => Object.freeze({ activate: () => undefined })); + return { + abi: 1, + id, + phase: 'takeover', + releaseId: RELEASE_ID, + prepareSync: hooks.prepareSync ?? (() => Object.freeze({ activate: () => undefined })), + prepare, + ...hooks, + }; +} + +async function install( + registry: ReturnType, + order: string[] = [] +) { + return registry.install({ + activateCore: () => undefined, + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }); +} + +afterEach(() => { + vi.useRealTimers(); + document.head.replaceChildren(); + Object.defineProperty(document, 'currentScript', { configurable: true, value: null }); +}); + +describe('integration manifest and registration admission', () => { + it('offers one synchronous activation/commit barrier after every preparation completes', async () => { + const order: string[] = []; + let adoption: unknown; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepareSync: () => { + throw new Error('agent takeover must not use prepareSync'); + }, + prepare: () => { + order.push('module:prepare'); + return { + activate: ({ adoption: received, afterCommit }) => { + expect(received).toBe(adoption); + order.push('module:activate'); + afterCommit(() => order.push('after-commit')); + }, + }; + }, + }) + ); + + const result = await registry.install({ + prepareCore: () => order.push('core:prepare'), + activateCore: () => order.push('core:activate'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + coordinateTakeover: (prepared) => { + expect(Object.isFrozen(prepared)).toBe(true); + expect(order).toEqual(['core:prepare', 'module:prepare']); + const candidate = failedFirstDisplayTakeover(RELEASE_ID); + const handoff = prepared.validateHandoff( + candidate.capture || { + version: 1, + releaseId: RELEASE_ID, + generation: 1, + projectionDigest: 'b'.repeat(64), + integrationConfigDigest: 'c'.repeat(64), + slices: ['first_display'], + slots: [ + { + id: 'slot-1', + aliases: [], + domId: 'div-1', + gamPath: '/123/slot-1', + formats: [[300, 250]], + owner: 'trusted_server', + outcome: 'failed', + targeting: [], + targetingOwnership: [], + committedArtifact: 'none', + gptToken: null, + }, + ], + attempts: [ + { + id: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + slotId: 'slot-1', + ordinal: 1, + state: 'failed', + reason: 'internal_error', + }, + ], + tombstones: [], + artifacts: [], + parserState: [], + gptDiagnostics: { facts: [], overflowCount: 0, dropCount: 0 }, + timing: { + bidsScriptMs: 0, + firstDisplayMs: null, + terminalMs: 0, + paintMs: 0, + }, + highWater: { + navigationAttemptPrefix: 'AAECAwQFBgc', + nextNavigationAttemptOrdinal: 2, + nextAttemptOrdinal: 2, + nextSlotRegistrationOrdinal: 2, + reservationClockEpochMs: 0, + nextReservationOrdinal: 1, + nextTicketOrdinal: 1, + }, + cycles: [], + trace: { + nextSequence: 1, + nextGlobalSlotOrdinal: 2, + slots: [{ slotId: 'slot-1', impressions: 0, bindings: [] }], + }, + mutationRevision: 0, + }, + { + version: 1, + releaseId: RELEASE_ID, + generation: 1, + projectionDigest: 'b'.repeat(64), + integrationConfigDigest: 'c'.repeat(64), + slices: ['first_display'], + slotCount: 1, + outcomeCount: 1, + capabilities: [], + objectKinds: [], + }, + candidate.boot + ); + expect(handoff).toBeDefined(); + adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: handoff!, + identities: Object.freeze([]), + }); + expect(snapshotPersistentFirstDisplayAdoptionV1(adoption)).toBe(adoption); + prepared.activate(adoption); + expect(() => prepared.activate()).toThrow(); + expect(order).toEqual([ + 'core:prepare', + 'module:prepare', + 'core:activate', + 'module:activate', + ]); + prepared.commit(); + }, + }); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'core:prepare', + 'module:prepare', + 'core:activate', + 'module:activate', + 'publish', + 'after-commit', + 'drain', + ]); + }); + + it('prepares, activates, and commits a no-agent runtime without yielding', async () => { + const order: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + for (const id of ['gpt', 'prebid']) { + expect( + registry.register( + registration(id, { + prepareSync: () => { + order.push(`prepareSync:${id}`); + return Object.freeze({ + activate: () => order.push(`activate:${id}`), + }); + }, + prepare: () => { + throw new Error('no-agent runtime must not use prepare'); + }, + }) + ) + ).toBe(true); + } + queueMicrotask(() => order.push('microtask')); + + const result = registry.installSync({ + activateCore: () => order.push('activate:core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'prepareSync:gpt', + 'prepareSync:prebid', + 'activate:core', + 'activate:gpt', + 'activate:prebid', + 'publish', + 'drain', + ]); + await Promise.resolve(); + expect(order[order.length - 1]).toBe('microtask'); + }); + + it('rejects a thenable returned by no-agent prepareSync before activation', () => { + const activate = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepareSync: () => Promise.resolve(Object.freeze({ activate })) as never, + }) + ); + + expect( + registry.installSync({ + activateCore: activate, + publish: vi.fn(), + drainPreload: vi.fn(), + }) + ).toEqual({ state: 'fallback', reason: 'bundle_partial' }); + expect(activate).not.toHaveBeenCalled(); + }); + + it('fails closed when a takeover coordinator returns without committing', async () => { + const activate = vi.fn(); + const publish = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ activate }), + }) + ); + + await expect( + registry.install({ + activateCore: () => undefined, + publish, + drainPreload: () => undefined, + coordinateTakeover: () => undefined, + }) + ).resolves.toEqual({ state: 'fallback', reason: 'bundle_partial' }); + expect(activate).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + + it('accepts only the exact six-field takeover registrar ABI', () => { + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + const exact = registration('gpt'); + + expect(Reflect.ownKeys(exact)).toEqual([ + 'abi', + 'id', + 'phase', + 'releaseId', + 'prepareSync', + 'prepare', + ]); + expect(registry.register(exact)).toBe(true); + }); + + it('admits the exact five-field deferred shape and rejects prepareSync on deferred code', () => { + const deferred = Object.freeze({ + abi: 1 as const, + id: 'gpt_later', + phase: 'deferred' as const, + releaseId: RELEASE_ID, + prepare: () => Object.freeze({ activate: () => undefined }), + }); + + expect(snapshotIntegrationRegistration(deferred)).toMatchObject({ + id: 'gpt_later', + phase: 'deferred', + }); + expect( + snapshotIntegrationRegistration({ ...deferred, prepareSync: deferred.prepare }) + ).toBeUndefined(); + }); + + it.each([ + ['old three-field ABI', { id: 'gpt', release: RELEASE_ID, prepare: vi.fn() }], + ['missing abi', { id: 'gpt', phase: 'takeover', releaseId: RELEASE_ID, prepare: vi.fn() }], + ['missing prepareSync', { ...registration('gpt'), prepareSync: undefined }], + ['unknown field', { ...registration('gpt'), unexpected: true }], + ['wrong phase', { ...registration('gpt'), phase: 'deferred' }], + ['custom prototype', Object.assign(Object.create({ inherited: true }), registration('gpt'))], + ['null prototype', Object.assign(Object.create(null), registration('gpt'))], + ])('rejects %s without invoking module code', async (_name, candidate) => { + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(candidate)).toBe(false); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + const prepare = (candidate as { prepare?: unknown }).prepare; + if (vi.isMockFunction(prepare)) expect(prepare).not.toHaveBeenCalled(); + }); + + it('authenticates every takeover registration to the captured connected core script', () => { + const runtimeScript = document.createElement('script'); + runtimeScript.id = 'trustedserver-js'; + runtimeScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + document.head.append(runtimeScript); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: runtimeScript, + }); + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }), + ]), + takeoverScript: runtimeScript, + document, + knownIntegrationIds: Object.freeze(['gpt']), + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(registration('gpt'))).toBe(true); + }); + + it.each(['different current script', 'disconnected script', 'wrong exact source'])( + 'rejects a takeover registration from a %s', + (failure) => { + const runtimeScript = document.createElement('script'); + runtimeScript.id = 'trustedserver-js'; + runtimeScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + document.head.append(runtimeScript); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: runtimeScript, + }); + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }), + ]), + takeoverScript: runtimeScript, + document, + knownIntegrationIds: Object.freeze(['gpt']), + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + if (failure === 'different current script') { + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: document.createElement('script'), + }); + } else if (failure === 'disconnected script') { + runtimeScript.remove(); + } else { + runtimeScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; + } + + expect(registry.register(registration('gpt'))).toBe(false); + expect(registry.state).toBe('failed'); + } + ); + + it('exposes only a frozen facade while mutable registry state stays in a closure', () => { + const registry = createIntegrationRegistry({ + manifest: manifest([]), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(Object.isFrozen(registry)).toBe(true); + expect(Reflect.ownKeys(registry).sort()).toEqual([ + 'dispose', + 'install', + 'installSync', + 'manifest', + 'prepareDeferred', + 'register', + 'state', + ]); + expect('registrations' in registry).toBe(false); + expect('prepared' in registry).toBe(false); + registry.dispose(); + }); + + it('rejects an integration array with executable iteration without invoking it', async () => { + const iterator = vi.fn(function* () { + for (let index = 0; index < 21; index += 1) { + yield { id: `module_${index}`, phase: 'takeover' }; + } + }); + const integrations: unknown[] = []; + Object.defineProperty(integrations, Symbol.iterator, { value: iterator }); + const registry = createIntegrationRegistry({ + manifest: { version: 1, releaseId: RELEASE_ID, integrations }, + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(iterator).not.toHaveBeenCalled(); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + }); + + it.each([ + ['non-object', null], + ['wrong version', { ...manifest([]), version: 2 }], + ['extra manifest field', { ...manifest([]), unexpected: true }], + ['wrong release grammar', { ...manifest([]), releaseId: 'ABC' }], + ['malformed id', { ...manifest([]), integrations: [{ id: 'Uppercase', required: true }] }], + [ + 'unknown integration field', + { ...manifest([]), integrations: [{ id: 'gpt', required: true, optional: false }] }, + ], + ['non-required entry', { ...manifest([]), integrations: [{ id: 'gpt', required: false }] }], + [ + 'duplicate id', + { + ...manifest([]), + integrations: [ + { id: 'gpt', required: true }, + { id: 'gpt', required: true }, + ], + }, + ], + [ + 'over generated capacity', + manifest( + Array.from({ length: EMBEDDED_MAX_MANIFEST_MODULES + 1 }, (_, index) => `module_${index}`) + ), + ], + ])('rejects a malformed manifest: %s', async (_name, candidate) => { + const registry = createIntegrationRegistry({ + manifest: candidate, + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(registration('gpt'))).toBe(false); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + }); + + it('requires the embedded release, manifest release, and bundle release to match', async () => { + const registry = createIntegrationRegistry({ + manifest: { ...manifest(['gpt']), releaseId: OTHER_RELEASE_ID }, + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(registration('gpt'))).toBe(false); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + }); + + it('rejects a syntactically valid manifest id outside the frozen core bundle inventory', async () => { + const prepare = vi.fn(() => ({ activate: () => undefined })); + const registry = createIntegrationRegistry({ + manifest: manifest(['evil']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt']), + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(registration('evil', { prepare }))).toBe(false); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect(prepare).not.toHaveBeenCalled(); + }); + + it.each([ + ['unknown id', registration('unknown')], + ['wrong bundle release', registration('gpt', { releaseId: OTHER_RELEASE_ID })], + ])('quarantines %s before prepare is called', async (_name, candidate) => { + const prepare = vi.fn(candidate.prepare); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register({ ...candidate, prepare })).toBe(false); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect(prepare).not.toHaveBeenCalled(); + }); + + it('rejects registration accessors without invoking bundle code during collection', async () => { + const prepareGetter = vi.fn(() => () => ({ activate: () => undefined })); + const candidate = Object.defineProperties( + {}, + { + id: { value: 'gpt', enumerable: true }, + release: { value: RELEASE_ID, enumerable: true }, + prepare: { get: prepareGetter, enumerable: true }, + } + ); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(candidate)).toBe(false); + expect(prepareGetter).not.toHaveBeenCalled(); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + }); + + it('rejects duplicate registration without invoking either module', async () => { + const firstPrepare = vi.fn(() => ({ activate: () => undefined })); + const secondPrepare = vi.fn(() => ({ activate: () => undefined })); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(registration('gpt', { prepare: firstPrepare }))).toBe(true); + expect(registry.register(registration('gpt', { prepare: secondPrepare }))).toBe(false); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect(firstPrepare).not.toHaveBeenCalled(); + expect(secondPrepare).not.toHaveBeenCalled(); + }); + + it('rejects a takeover registration that skips the next manifest entry', async () => { + const prepare = vi.fn(() => ({ activate: () => undefined })); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(registration('prebid', { prepare }))).toBe(false); + expect(registry.state).toBe('failed'); + expect(prepare).not.toHaveBeenCalled(); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + }); + + it('snapshots accepted registration code so retained objects cannot swap it later', async () => { + const acceptedPrepare = vi.fn(() => ({ activate: () => undefined })); + const swappedPrepare = vi.fn(() => ({ + activate: () => { + throw new Error('must never execute'); + }, + })); + const candidate = { + abi: 1 as const, + id: 'gpt', + phase: 'takeover' as const, + releaseId: RELEASE_ID, + prepareSync: acceptedPrepare, + prepare: acceptedPrepare, + }; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(candidate)).toBe(true); + candidate.id = 'unknown'; + candidate.releaseId = OTHER_RELEASE_ID; + candidate.prepareSync = swappedPrepare; + candidate.prepare = swappedPrepare; + + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + expect(acceptedPrepare).toHaveBeenCalledTimes(1); + expect(swappedPrepare).not.toHaveBeenCalled(); + }); + + it('waits for required modules registered after install starts without early execution', async () => { + const order: string[] = []; + const gptPrepare = vi.fn(() => { + order.push('prepare:gpt'); + return { activate: () => order.push('activate:gpt') }; + }); + const prebidPrepare = vi.fn(() => { + order.push('prepare:prebid'); + return { activate: () => order.push('activate:prebid') }; + }); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register(registration('gpt', { prepare: gptPrepare })); + + const installed = install(registry, order); + await Promise.resolve(); + expect(registry.state).toBe('collecting'); + expect(order).toEqual([]); + expect(registry.register(registration('prebid', { prepare: prebidPrepare }))).toBe(true); + + await expect(installed).resolves.toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'prepare:gpt', + 'prepare:prebid', + 'activate:gpt', + 'activate:prebid', + 'publish', + 'drain', + ]); + }); + + it('fails missing required modules only at the shared boot deadline', async () => { + vi.useFakeTimers(); + let now = 0; + const prepare = vi.fn(() => ({ activate: () => undefined })); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => now, + }); + registry.register(registration('gpt', { prepare })); + + const installed = install(registry); + await vi.advanceTimersByTimeAsync(9_999); + expect(registry.state).toBe('collecting'); + expect(prepare).not.toHaveBeenCalled(); + now = 10_000; + await vi.advanceTimersByTimeAsync(1); + + await expect(installed).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(prepare).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('accepts exactly 14 takeover modules in manifest order', async () => { + const ids = Array.from({ length: 14 }, (_, index) => `module_${index}`); + const order: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(ids), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + for (const id of ids) { + expect( + registry.register( + registration(id, { + prepare: () => { + order.push(`prepare:${id}`); + return { activate: () => order.push(`activate:${id}`) }; + }, + }) + ) + ).toBe(true); + } + + await expect(install(registry, order)).resolves.toMatchObject({ state: 'kernel' }); + expect(order.slice(0, 14)).toEqual(ids.map((id) => `prepare:${id}`)); + expect(order.slice(14, 28)).toEqual(ids.map((id) => `activate:${id}`)); + expect(order.slice(28)).toEqual(['publish', 'drain']); + }); +}); + +describe('integration preparation and activation transaction', () => { + it('stages only declared provider capabilities for later takeover consumers', async () => { + const gpt = Object.freeze({ kind: 'gpt' }); + let consumerInterfaces: Readonly> | undefined; + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze(['gpt.v1']), + }), + Object.freeze({ + id: 'prebid', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze(['gpt.v1']), + provides: Object.freeze([]), + }), + ]), + knownIntegrationIds: Object.freeze(['gpt', 'prebid']), + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + runtimeCapability: Object.freeze({ kind: 'runtime' }), + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: ({ interfaces }) => { + expect(Reflect.ownKeys(interfaces)).toEqual(['runtime.v1']); + return { activate: () => undefined, interfaces: Object.freeze({ 'gpt.v1': gpt }) }; + }, + }) + ); + registry.register( + registration('prebid', { + prepare: ({ interfaces }) => { + consumerInterfaces = interfaces; + return { activate: () => undefined, interfaces: Object.freeze({}) }; + }, + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + expect(consumerInterfaces).toEqual(Object.freeze({ 'gpt.v1': gpt })); + expect(Object.isFrozen(consumerInterfaces)).toBe(true); + expect(Reflect.ownKeys(consumerInterfaces ?? {})).toEqual(['gpt.v1']); + }); + + it('prepares a deferred consumer from committed takeover capabilities only', async () => { + const gpt = Object.freeze({ kind: 'gpt' }); + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze(['gpt.v1']), + }), + Object.freeze({ + id: 'gpt_later', + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + consumes: Object.freeze(['gpt.v1']), + provides: Object.freeze([]), + }), + ]), + knownIntegrationIds: Object.freeze(['gpt', 'gpt_later']), + manifest: { + ...manifest(['gpt']), + integrations: Object.freeze([ + Object.freeze({ id: 'gpt', phase: 'takeover' as const }), + Object.freeze({ + id: 'gpt_later', + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + src: `/static/tsjs=tsjs-gpt_later.min.js?v=${'d'.repeat(64)}`, + }), + ]), + }, + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: () => undefined, + interfaces: Object.freeze({ 'gpt.v1': gpt }), + }), + }) + ); + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + + const prepare = vi.fn(({ interfaces }: IntegrationPrepareContext) => { + expect(interfaces).toEqual(Object.freeze({ 'gpt.v1': gpt })); + return { activate: () => undefined }; + }); + const prepared = registry.prepareDeferred( + { ...registration('gpt_later', { prepare }), phase: 'deferred' }, + Object.freeze({ + signal: new AbortController().signal, + onDispose: vi.fn(), + }) + ); + + expect(prepare).toHaveBeenCalledTimes(1); + expect(prepared).toMatchObject({ activate: expect.any(Function) }); + }); + + it.each([ + ['missing declared key', Object.freeze({})], + ['unknown key', Object.freeze({ 'gpt.v1': Object.freeze({}), 'other.v1': Object.freeze({}) })], + ['mutable facade', Object.freeze({ 'gpt.v1': {} })], + [ + 'custom facade prototype', + Object.freeze({ 'gpt.v1': Object.freeze(Object.create({ inherited: true })) }), + ], + ])('rejects provider interfaces with a %s', async (_name, interfaces) => { + const prepareConsumer = vi.fn(() => ({ + activate: () => undefined, + interfaces: Object.freeze({}), + })); + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze(['gpt.v1']), + }), + Object.freeze({ + id: 'prebid', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze(['gpt.v1']), + provides: Object.freeze([]), + }), + ]), + knownIntegrationIds: Object.freeze(['gpt', 'prebid']), + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ activate: () => undefined, interfaces }), + }) + ); + registry.register(registration('prebid', { prepare: prepareConsumer })); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(prepareConsumer).not.toHaveBeenCalled(); + }); + + it('prepares core-owned bindings before module preparation and activates afterward', async () => { + const order: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + getBindings: () => { + order.push('bindings'); + return { config: Object.freeze({}), interfaces: Object.freeze({}) }; + }, + }); + registry.register( + registration('gpt', { + prepare: () => { + order.push('module:prepare'); + return { activate: () => order.push('module:activate') }; + }, + }) + ); + + const result = await registry.install({ + prepareCore: () => order.push('core:prepare'), + activateCore: () => order.push('core:activate'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'core:prepare', + 'bindings', + 'module:prepare', + 'core:activate', + 'module:activate', + 'publish', + 'drain', + ]); + }); + + it('unwinds core-prepared resources when later module preparation fails', async () => { + const release = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => { + throw new Error('fictional preparation failure'); + }, + }) + ); + + const result = await registry.install({ + prepareCore: ({ onDispose }) => onDispose(release), + activateCore: vi.fn(), + publish: vi.fn(), + drainPreload: vi.fn(), + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('collects without execution, prepares sequentially, and commits in exact order', async () => { + const order: string[] = []; + const contexts: IntegrationPrepareContext[] = []; + let finishGpt: (() => void) | undefined; + const gptPrepared = new Promise((resolve) => { + finishGpt = resolve; + }); + const frozenConfig = Object.freeze({ enabled: true }); + const frozenInterfaces = Object.freeze({ adapter: Object.freeze({ kind: 'fake' }) }); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + getBindings: (id) => ({ + config: id === 'gpt' ? frozenConfig : Object.freeze({ enabled: false }), + interfaces: frozenInterfaces, + }), + }); + registry.register( + registration('gpt', { + prepare: async (context) => { + contexts.push(context); + order.push('prepare:gpt:start'); + await gptPrepared; + order.push('prepare:gpt:end'); + return { + activate: (activation) => { + order.push('activate:gpt'); + activation.afterCommit(() => order.push('after:gpt')); + }, + }; + }, + }) + ); + registry.register( + registration('prebid', { + prepare: (context) => { + contexts.push(context); + order.push('prepare:prebid'); + return { + activate: (activation) => { + order.push('activate:prebid'); + activation.afterCommit(() => order.push('after:prebid')); + }, + }; + }, + }) + ); + + expect(order).toEqual([]); + const installed = install(registry, order); + await vi.waitFor(() => expect(order).toEqual(['prepare:gpt:start'])); + expect(order).not.toContain('prepare:prebid'); + finishGpt?.(); + + await expect(installed).resolves.toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'prepare:gpt:start', + 'prepare:gpt:end', + 'prepare:prebid', + 'activate:gpt', + 'activate:prebid', + 'publish', + 'after:gpt', + 'after:prebid', + 'drain', + ]); + expect(contexts).toHaveLength(2); + expect(Object.isFrozen(contexts[0])).toBe(true); + expect(contexts[0]?.config).toBe(frozenConfig); + expect(contexts[0]?.interfaces).toBe(frozenInterfaces); + }); + + it('closes a synchronous preparation context before detached microtasks can use it', async () => { + const lateDisposer = vi.fn(); + let lateError: unknown; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: (context) => { + queueMicrotask(() => { + try { + context.onDispose(lateDisposer); + } catch (error) { + lateError = error; + } + }); + return { activate: () => undefined }; + }, + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + await Promise.resolve(); + expect(lateError).toBeInstanceOf(Error); + expect(lateDisposer).not.toHaveBeenCalled(); + }); + + it('rejects a prepared activation accessor without invoking it or publishing', async () => { + const owner = new AbortController(); + const activateGetter = vi.fn(() => { + owner.abort(); + return () => undefined; + }); + const publish = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + signal: owner.signal, + }); + registry.register( + registration('gpt', { + prepare: () => + Object.defineProperty({}, 'activate', { + get: activateGetter, + enumerable: true, + }) as { activate: () => void }, + }) + ); + + const result = await registry.install({ + activateCore: () => undefined, + publish, + drainPreload: vi.fn(), + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(activateGetter).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + + it('rejects a frozen interface container that exposes a mutable adapter facade', async () => { + const prepare = vi.fn(() => ({ activate: () => undefined })); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ adapter: { mutable: true } }), + }), + }); + registry.register(registration('gpt', { prepare })); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(prepare).not.toHaveBeenCalled(); + }); + + it('snapshots each prepared activation before preparing a later module', async () => { + const acceptedActivate = vi.fn(); + const swappedActivate = vi.fn(() => { + throw new Error('must never execute'); + }); + const prepared = { activate: acceptedActivate }; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register(registration('gpt', { prepare: () => prepared })); + registry.register( + registration('prebid', { + prepare: () => { + prepared.activate = swappedActivate; + return { activate: () => undefined }; + }, + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + expect(acceptedActivate).toHaveBeenCalledTimes(1); + expect(swappedActivate).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'synchronous throw', + () => { + throw new Error('fictional prepare throw'); + }, + ], + ['asynchronous rejection', () => Promise.reject(new Error('fictional prepare rejection'))], + ])('unwinds a preparation %s as bundle_partial', async (_name, prepare) => { + const disposed: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: (context) => { + context.onDispose(() => disposed.push('prepared')); + return prepare(); + }, + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(disposed).toEqual(['prepared']); + }); + + it('aborts a pending preparation at the shared deadline and ignores its late continuation', async () => { + vi.useFakeTimers(); + let now = 0; + let finishPrepare: ((value: { activate: () => void }) => void) | undefined; + let context: IntegrationPrepareContext | undefined; + const activate = vi.fn(); + const lateDispose = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => now, + }); + registry.register( + registration('gpt', { + prepare: (receivedContext) => { + context = receivedContext; + return new Promise((resolve) => { + finishPrepare = resolve; + }); + }, + }) + ); + + const installed = install(registry); + await vi.advanceTimersByTimeAsync(9_999); + expect(registry.state).toBe('preparing'); + now = 10_000; + await vi.advanceTimersByTimeAsync(1); + await expect(installed).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(context?.signal.aborted).toBe(true); + context?.onDispose(lateDispose); + expect(lateDispose).toHaveBeenCalledTimes(1); + + finishPrepare?.({ activate }); + await Promise.resolve(); + expect(activate).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('aborts preparation through the caller signal and leaves no late activation', async () => { + const owner = new AbortController(); + const activate = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + signal: owner.signal, + }); + registry.register( + registration('gpt', { + prepare: ({ signal }) => + new Promise((resolve) => { + signal.addEventListener('abort', () => resolve({ activate })); + }), + }) + ); + + const installed = install(registry); + owner.abort(); + await expect(installed).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + }); + + it('observes a rejected preparation promise returned after synchronous abort', async () => { + const owner = new AbortController(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + signal: owner.signal, + }); + registry.register( + registration('gpt', { + prepare: () => { + owner.abort(); + return Promise.reject(new Error('fictional late preparation rejection')); + }, + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + await Promise.resolve(); + }); + + it('turns a registration attempt during preparation into abi_mismatch', async () => { + let finishPrepare: ((value: { activate: () => void }) => void) | undefined; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => + new Promise((resolve) => { + finishPrepare = resolve; + }), + }) + ); + + const installed = install(registry); + await vi.waitFor(() => expect(registry.state).toBe('preparing')); + expect(registry.register(registration('unknown'))).toBe(false); + finishPrepare?.({ activate: () => undefined }); + + await expect(installed).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + }); + + it('unwinds activated and prepared resources in reverse order on activation failure', async () => { + const order: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + for (const id of ['gpt', 'prebid']) { + registry.register( + registration(id, { + prepare: (preparation) => { + preparation.onDispose(() => order.push(`dispose:prepare:${id}`)); + return { + activate: (activation) => { + activation.onDispose(() => order.push(`dispose:activate:${id}`)); + order.push(`activate:${id}`); + if (id === 'prebid') throw new Error('fictional activation failure'); + }, + }; + }, + }) + ); + } + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(order).toEqual([ + 'activate:gpt', + 'activate:prebid', + 'dispose:activate:prebid', + 'dispose:prepare:prebid', + 'dispose:activate:gpt', + 'dispose:prepare:gpt', + ]); + }); + + it('activates reversible core effects first and unwinds them after every module', async () => { + const order: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + for (const id of ['gpt', 'prebid']) { + registry.register( + registration(id, { + prepare: () => ({ + activate: ({ onDispose }) => { + onDispose(() => order.push(`dispose:${id}`)); + order.push(`activate:${id}`); + if (id === 'prebid') throw new Error('fictional later activation failure'); + }, + }), + }) + ); + } + + const result = await registry.install({ + activateCore: ({ onDispose }) => { + onDispose(() => order.push('dispose:core')); + order.push('activate:core'); + }, + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(order).toEqual([ + 'activate:core', + 'activate:gpt', + 'activate:prebid', + 'dispose:prebid', + 'dispose:gpt', + 'dispose:core', + ]); + }); + + it.each([ + ['deadline crossing', ({ setNow }: { setNow: (value: number) => void }) => setNow(10_000)], + ['async rejection', () => Promise.reject(new Error('fictional core rejection'))], + ])('rejects a core activation %s before module activation', async (_name, activate) => { + let now = 0; + const moduleActivate = vi.fn(); + const publish = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => now, + }); + registry.register(registration('gpt', { prepare: () => ({ activate: moduleActivate }) })); + + const result = await registry.install({ + activateCore: () => activate({ setNow: (value) => (now = value) }), + publish, + drainPreload: vi.fn(), + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(moduleActivate).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + + it('cannot commit after activation synchronously aborts the owner', async () => { + const owner = new AbortController(); + const live = { wrapper: 'publisher' }; + const publish = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + signal: owner.signal, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: ({ onDispose }) => { + const previous = live.wrapper; + onDispose(() => { + if (live.wrapper === 'tsjs') live.wrapper = previous; + }); + live.wrapper = 'tsjs'; + owner.abort(); + live.wrapper = 'tsjs'; + }, + }), + }) + ); + + const result = await registry.install({ + activateCore: () => undefined, + publish, + drainPreload: vi.fn(), + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(publish).not.toHaveBeenCalled(); + expect(live.wrapper).toBe('publisher'); + }); + + it('cannot commit after an activation attempts late bundle registration', async () => { + const live = { wrapper: 'publisher' }; + const publish = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: ({ onDispose }) => { + const previous = live.wrapper; + onDispose(() => { + if (live.wrapper === 'tsjs') live.wrapper = previous; + }); + live.wrapper = 'tsjs'; + expect(registry.register(registration('unknown'))).toBe(false); + live.wrapper = 'tsjs'; + }, + }), + }) + ); + + const result = await registry.install({ + activateCore: () => undefined, + publish, + drainPreload: vi.fn(), + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'abi_mismatch' }); + expect(publish).not.toHaveBeenCalled(); + expect(live.wrapper).toBe('publisher'); + }); + + it('restores reversible effects before fallback publication', async () => { + const live = { wrapper: 'publisher' }; + const observations: string[] = []; + const irreversibleWork = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => { + observations.push(`prepare:${live.wrapper}`); + return { + activate: ({ onDispose }) => { + const previous = live.wrapper; + onDispose(() => { + if (live.wrapper === 'tsjs') live.wrapper = previous; + }); + live.wrapper = 'tsjs'; + }, + }; + }, + }) + ); + registry.register( + registration('prebid', { + prepare: () => ({ + activate: ({ afterCommit }) => { + afterCommit(irreversibleWork); + throw new Error('later fictional failure'); + }, + }), + }) + ); + + const result = await registry.install({ + activateCore: () => undefined, + publish: () => observations.push(`publish:${live.wrapper}`), + drainPreload: () => observations.push('drain'), + }); + + expect(result).toMatchObject({ state: 'fallback' }); + expect(live.wrapper).toBe('publisher'); + expect(observations).toEqual(['prepare:publisher']); + expect(irreversibleWork).not.toHaveBeenCalled(); + }); + + it('rejects asynchronous kernel publication and observes its rejection', async () => { + const drainPreload = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest([]), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + const result = await registry.install({ + activateCore: () => undefined, + publish: async () => { + throw new Error('fictional asynchronous publication rejection'); + }, + drainPreload, + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(drainPreload).not.toHaveBeenCalled(); + await Promise.resolve(); + }); + + it.each([9_999, 10_000, 10_001])( + 'checks the monotonic deadline after activation at %i ms', + async (activationReturnMs) => { + let now = 0; + const order: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => now, + }); + const prepare = () => ({ + activate: () => { + order.push('activate'); + now = activationReturnMs; + }, + }); + registry.register( + registration('gpt', { + prepareSync: prepare, + prepare, + }) + ); + + const result = registry.installSync({ + activateCore: () => undefined, + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }); + if (activationReturnMs < 10_000) { + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['activate', 'publish', 'drain']); + } else { + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(order).toEqual(['activate']); + } + } + ); + + it('checks the deadline again immediately before handoff', async () => { + let checks = 0; + const activateCore = vi.fn(); + const publish = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest([]), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => (checks++ < 5 ? 9_999 : 10_000), + }); + + expect( + registry.installSync({ + activateCore, + publish, + drainPreload: vi.fn(), + }) + ).toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activateCore).toHaveBeenCalledOnce(); + expect(publish).not.toHaveBeenCalled(); + expect(checks).toBe(6); + }); + + it('treats an asynchronous activation as a synchronous barrier violation', async () => { + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: async () => { + throw new Error('fictional async activation rejection'); + }, + }), + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + }); + + it('turns a second afterCommit registration into bundle_partial', async () => { + const staged = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: ({ afterCommit }) => { + afterCommit(staged); + afterCommit(staged); + }, + }), + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(staged).not.toHaveBeenCalled(); + }); + + it('latches duplicate afterCommit as bundle_partial even when module code catches the throw', async () => { + const first = vi.fn(); + const second = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: ({ afterCommit }) => { + try { + afterCommit(first); + afterCommit(second); + } catch { + // A bundle cannot swallow a registry contract violation and commit. + } + }, + }), + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + }); + + it('isolates afterCommit failure to its module and keeps the committed kernel', async () => { + const order: string[] = []; + const runtimeFailures: unknown[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + onRuntimeFailure: (failure) => runtimeFailures.push(failure), + }); + registry.register( + registration('gpt', { + prepare: ({ onDispose }) => { + onDispose(() => order.push('dispose:gpt')); + return { + activate: ({ afterCommit }) => + afterCommit(() => { + order.push('after:gpt'); + throw new Error('fictional post-commit failure'); + }), + }; + }, + }) + ); + registry.register( + registration('prebid', { + prepare: () => ({ + activate: ({ afterCommit }) => afterCommit(() => order.push('after:prebid')), + }), + }) + ); + + const result = await install(registry, order); + + expect(result).toMatchObject({ + state: 'kernel', + runtimeFailures: [{ id: 'gpt', phase: 'after_commit' }], + }); + expect(runtimeFailures).toEqual([{ id: 'gpt', phase: 'after_commit' }]); + expect(Object.isFrozen(runtimeFailures[0])).toBe(true); + expect(order).toEqual(['publish', 'after:gpt', 'dispose:gpt', 'after:prebid', 'drain']); + expect(registry.state).toBe('committed'); + }); + + it('observes a rejecting asynchronous preload drain without undoing commit', async () => { + const onDisposalError = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest([]), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + onDisposalError, + }); + + const result = await registry.install({ + activateCore: () => undefined, + publish: () => undefined, + drainPreload: async () => { + throw new Error('fictional asynchronous preload rejection'); + }, + }); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(registry.state).toBe('committed'); + await vi.waitFor(() => expect(onDisposalError).toHaveBeenCalledTimes(1)); + expect(registry.state).toBe('committed'); + }); + + it('refuses late registration after fallback or commit without invoking module code', async () => { + const fallbackRegistry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 10_000, + }); + await install(fallbackRegistry); + const fallbackPrepare = vi.fn(); + expect(fallbackRegistry.register(registration('gpt', { prepare: fallbackPrepare }))).toBe( + false + ); + + const committedRegistry = createIntegrationRegistry({ + manifest: manifest([]), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + await install(committedRegistry); + const committedPrepare = vi.fn(); + expect(committedRegistry.register(registration('gpt', { prepare: committedPrepare }))).toBe( + false + ); + + expect(fallbackPrepare).not.toHaveBeenCalled(); + expect(committedPrepare).not.toHaveBeenCalled(); + }); + + it('documents the same-thread limitation by completing only after activate returns', async () => { + let returned = false; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: () => { + expect(registry.state).toBe('activating'); + returned = true; + }, + }), + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + expect(returned).toBe(true); + }); + + it('memoizes installation before any synchronous callback can reenter it', async () => { + const phases: string[] = []; + const reentrantPromises: Promise[] = []; + const ignoredPublish = vi.fn(); + const ignoredDrain = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + const reenter = () => { + reentrantPromises.push( + registry.install({ + activateCore: vi.fn(), + publish: ignoredPublish, + drainPreload: ignoredDrain, + }) + ); + }; + registry.register( + registration('gpt', { + prepare: () => { + phases.push('prepare'); + reenter(); + return { + activate: () => { + phases.push('activate'); + reenter(); + }, + }; + }, + }) + ); + + const installed = registry.install({ + activateCore: () => { + phases.push('core'); + reenter(); + }, + publish: () => { + phases.push('publish'); + reenter(); + }, + drainPreload: () => phases.push('drain'), + }); + + await expect(installed).resolves.toMatchObject({ state: 'kernel' }); + expect(reentrantPromises).toHaveLength(4); + for (const promise of reentrantPromises) expect(promise).toBe(installed); + expect(phases).toEqual(['prepare', 'core', 'activate', 'publish', 'drain']); + expect(ignoredPublish).not.toHaveBeenCalled(); + expect(ignoredDrain).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts b/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts new file mode 100644 index 000000000..0192b0eae --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, +} from '../../src/kernel/integration_registry'; +import { createLifecycleIntegrationRegistration } from '../../src/kernel/lifecycle_module'; + +const RELEASE_ID = 'a'.repeat(64); +const RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; +const TEST_INTEGRATION_ID = 'datadome'; + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +function registry(config: unknown, runtime: unknown) { + return createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: RUNTIME_SRC, + integrations: [{ id: TEST_INTEGRATION_ID, phase: 'takeover' }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze([TEST_INTEGRATION_ID]), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ [TEST_INTEGRATION_ID]: runtime }), + }), + }); +} + +describe('shared integration lifecycle module', () => { + it('prepares inertly, activates reversibly, and starts only after publication', async () => { + const order: string[] = []; + const config = Object.freeze({ nested: Object.freeze({ enabled: true }) }); + const release = vi.fn(() => order.push('release')); + const activate = vi.fn((received: unknown) => { + expect(received).toBe(config); + order.push('activate'); + return release; + }); + const start = vi.fn((received: unknown) => { + expect(received).toBe(config); + order.push('start'); + }); + const runtime = Object.freeze({ activate, start }); + const owner = registry(config, runtime); + owner.register(createLifecycleIntegrationRegistration(TEST_INTEGRATION_ID, RELEASE_ID)); + + const result = await owner.install(callbacks(order)); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['core', 'activate', 'publish', 'start', 'drain']); + if (result.state === 'kernel') result.dispose(); + expect(release).toHaveBeenCalledOnce(); + }); + + it.each([ + ['mutable root', { enabled: true }], + ['mutable nested value', Object.freeze({ nested: { enabled: true } })], + ['accessor', Object.freeze(Object.defineProperty({}, 'enabled', { get: () => true }))], + ['function', Object.freeze(() => undefined)], + ])('rejects %s configuration before activation', async (_name, config) => { + const activate = vi.fn(() => vi.fn()); + const owner = registry(config, Object.freeze({ activate, start: vi.fn() })); + owner.register(createLifecycleIntegrationRegistration(TEST_INTEGRATION_ID, RELEASE_ID)); + + await expect(owner.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + }); + + it('rejects extra runtime authority and unwinds activation when startup peers fail', async () => { + const activate = vi.fn(() => vi.fn()); + const owner = registry( + Object.freeze({}), + Object.freeze({ activate, start: vi.fn(), publish: vi.fn() }) + ); + owner.register(createLifecycleIntegrationRegistration(TEST_INTEGRATION_ID, RELEASE_ID)); + + await expect(owner.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + }); + + it.each([ + { selected: true, state: true, expected: 'kernel' }, + { selected: true, state: false, expected: 'fallback' }, + { selected: false, state: false, expected: 'kernel' }, + ] as const)( + 'validates selected first-display parser state before lifecycle activation ($selected, $state)', + async ({ selected, state, expected }) => { + const activate = vi.fn(() => vi.fn()); + const owner = registry(Object.freeze({}), Object.freeze({ activate, start: vi.fn() })); + owner.register( + createLifecycleIntegrationRegistration(TEST_INTEGRATION_ID, RELEASE_ID, { + firstDisplaySliceId: 'datadome_initial', + validateFirstDisplayState: (candidate) => + candidate.values.find(([key]) => key === 'route_guard')?.[1] === 'datadome', + }) + ); + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: { + version: 1, + releaseId: RELEASE_ID, + generation: 1, + projectionDigest: 'b'.repeat(64), + integrationConfigDigest: 'c'.repeat(64), + slices: Object.freeze( + selected ? ['first_display', 'datadome_initial'] : ['first_display'] + ), + slots: [ + { + id: 'slot-1', + aliases: [], + domId: 'slot-1', + gamPath: '/123/slot-1', + formats: [[300, 250]], + owner: 'trusted_server', + outcome: 'failed', + targeting: [], + targetingOwnership: [], + committedArtifact: 'none', + gptToken: null, + }, + ], + attempts: [ + { + id: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + slotId: 'slot-1', + ordinal: 1, + state: 'failed', + reason: 'internal_error', + }, + ], + tombstones: [], + artifacts: [], + parserState: Object.freeze( + state + ? [ + Object.freeze({ + sliceId: 'datadome_initial', + observations: Object.freeze(['route_guard']), + values: Object.freeze([Object.freeze(['route_guard', 'datadome'] as const)]), + }), + ] + : [] + ), + gptDiagnostics: { facts: [], overflowCount: 0, dropCount: 0 }, + timing: { bidsScriptMs: 1, firstDisplayMs: null, terminalMs: 2, paintMs: 3 }, + highWater: { + navigationAttemptPrefix: 'AAECAwQFBgc', + nextNavigationAttemptOrdinal: 2, + nextAttemptOrdinal: 2, + nextSlotRegistrationOrdinal: 2, + reservationClockEpochMs: 0, + nextReservationOrdinal: 1, + nextTicketOrdinal: 1, + }, + cycles: [], + trace: { + nextSequence: 1, + nextGlobalSlotOrdinal: 1, + slots: [{ slotId: 'slot-1', impressions: 0, bindings: [] }], + }, + mutationRevision: 0, + }, + identities: Object.freeze([]), + }); + const slices = selected ? ['first_display', 'datadome_initial'] : ['first_display']; + const compactCapture = { + captureVersion: 1, + releaseId: RELEASE_ID, + generation: 1, + data: [ + 'b'.repeat(64), + 'c'.repeat(64), + slices, + [['failed', 'internal_error', null, null]], + [], + [], + [], + state ? [['datadome_initial', [['route_guard', 'datadome']]]] : [], + [[], 0, 0], + [1, null, 2, 3], + [2, 0, 1, 1], + [], + 1, + 1, + ], + mutationRevision: 0, + identityCount: 0, + }; + const boot = { + abi: 1, + releaseId: RELEASE_ID, + manifest: {}, + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'slot-1', outcome: 'failed', reason: 'internal_error' }], + }, + slots: [ + { + slot: 'slot-1', + gamUnitPath: '/123/slot-1', + divId: 'slot-1', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [], + }, + integrations: {}, + creative: {}, + diagnostics: {}, + }; + + const result = await owner.install({ + ...callbacks([]), + coordinateTakeover: (prepared) => { + const handoff = prepared.validateHandoff( + compactCapture, + { + version: 1, + releaseId: RELEASE_ID, + generation: 1, + projectionDigest: 'b'.repeat(64), + integrationConfigDigest: 'c'.repeat(64), + slices, + slotCount: 1, + outcomeCount: 1, + capabilities: [], + objectKinds: [], + }, + boot + ); + if (!handoff) throw new Error('should validate lifecycle handoff'); + prepared.activate(Object.freeze({ ...adoption, handoff })); + prepared.commit(); + }, + }); + + expect(result.state).toBe(expected); + expect(activate).toHaveBeenCalledTimes(expected === 'kernel' ? 1 : 0); + } + ); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/phase_loader.test.ts b/crates/trusted-server-js/lib/test/kernel/phase_loader.test.ts new file mode 100644 index 000000000..a0fa52fd8 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/phase_loader.test.ts @@ -0,0 +1,616 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { BootManifestV1 } from '../../src/core/types'; +import { + createDeferredPhaseLoader, + createProtectedFirstDisplayGate, + type DeferredPhaseLoaderOptions, + type PhaseScheduler, +} from '../../src/kernel/phase_loader'; + +const RELEASE_ID = 'a'.repeat(64); +const HASH = 'b'.repeat(64); + +function scheduler(options: { idle?: boolean } = {}): { + readonly frames: FrameRequestCallback[]; + readonly idle: Array<() => void>; + readonly value: PhaseScheduler; +} { + const frames: FrameRequestCallback[] = []; + const idle: Array<() => void> = []; + return { + frames, + idle, + value: { + cancelAnimationFrame: vi.fn(), + clearTimeout, + requestAnimationFrame: (callback) => { + frames.push(callback); + return frames.length; + }, + ...(options.idle + ? { + cancelIdleCallback: vi.fn(), + requestIdleCallback: (callback: () => void) => { + idle.push(callback); + return idle.length; + }, + } + : {}), + setTimeout, + }, + }; +} + +function deferredManifest(ids: readonly string[]): BootManifestV1 { + return Object.freeze({ + version: 1, + releaseId: RELEASE_ID, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${HASH}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'takeover' as const }), + ...ids.map((id) => + Object.freeze({ + id, + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + src: `/static/tsjs=tsjs-${id}.min.js?v=${HASH}`, + }) + ), + ]), + }); +} + +function deferredRegistration( + id: string, + prepare = vi.fn(() => Object.freeze({ activate: () => undefined })) +): object { + return Object.freeze({ + abi: 1, + id, + phase: 'deferred', + releaseId: RELEASE_ID, + prepare, + }); +} + +afterEach(() => { + vi.useRealTimers(); + document.head.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe('protected first-display paint gate', () => { + it('adopts an agent-owned protected paint and proceeds directly to idle', async () => { + const platform = scheduler({ idle: true }); + const markPaint = vi.fn(); + const gate = createProtectedFirstDisplayGate({ + document, + markPaint, + paintAlreadyRecorded: true, + scheduler: platform.value, + }); + + gate.commit(); + + expect(platform.frames).toEqual([]); + expect(platform.idle).toHaveLength(1); + expect(markPaint).not.toHaveBeenCalled(); + expect(gate.protectAttemptBatch([Promise.resolve()])).toBe(false); + platform.idle.shift()?.(); + await expect(gate.ready).resolves.toBe(true); + }); + + it('releases a no-attempt page only at 10 seconds, after two frames and idle', async () => { + vi.useFakeTimers(); + const platform = scheduler({ idle: true }); + const marks: string[] = []; + const gate = createProtectedFirstDisplayGate({ + document, + markPaint: () => marks.push('paint'), + scheduler: platform.value, + }); + let released = false; + void gate.ready.then(() => (released = true)); + + gate.commit(); + await vi.advanceTimersByTimeAsync(9_999); + expect(released).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(platform.frames).toHaveLength(1); + platform.frames.shift()?.(10_000); + expect(platform.frames).toHaveLength(1); + expect(marks).toEqual([]); + platform.frames.shift()?.(10_016); + expect(marks).toEqual(['paint']); + expect(released).toBe(false); + expect(platform.idle).toHaveLength(1); + platform.idle.shift()?.(); + await Promise.resolve(); + expect(released).toBe(true); + }); + + it('protects the first batch created at 9,999 ms until every terminal latch settles', async () => { + vi.useFakeTimers(); + const platform = scheduler({ idle: true }); + let settle: (() => void) | undefined; + const terminal = new Promise((resolve) => (settle = resolve)); + const gate = createProtectedFirstDisplayGate({ document, scheduler: platform.value }); + + gate.commit(); + await vi.advanceTimersByTimeAsync(9_999); + expect(gate.protectAttemptBatch(Object.freeze([terminal]))).toBe(true); + await vi.advanceTimersByTimeAsync(10_001); + expect(platform.frames).toEqual([]); + settle?.(); + await Promise.resolve(); + await Promise.resolve(); + expect(platform.frames).toHaveLength(1); + platform.frames.shift()?.(20_000); + platform.frames.shift()?.(20_016); + platform.idle.shift()?.(); + await expect(gate.ready).resolves.toBe(true); + }); + + it('uses a post-paint 50 ms fallback only when requestIdleCallback is unavailable', async () => { + vi.useFakeTimers(); + const platform = scheduler(); + const gate = createProtectedFirstDisplayGate({ document, scheduler: platform.value }); + let released = false; + void gate.ready.then(() => (released = true)); + + gate.commit(); + await vi.advanceTimersByTimeAsync(10_000); + platform.frames.shift()?.(10_000); + platform.frames.shift()?.(10_016); + await vi.advanceTimersByTimeAsync(49); + expect(released).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(released).toBe(true); + }); + + it.each([10_000, 10_001])( + 'does not protect a first attempt created at %i ms after the no-attempt release', + async (createdAtMs) => { + vi.useFakeTimers(); + const platform = scheduler({ idle: true }); + const gate = createProtectedFirstDisplayGate({ document, scheduler: platform.value }); + gate.commit(); + + await vi.advanceTimersByTimeAsync(createdAtMs); + expect(gate.protectAttemptBatch([Promise.resolve()])).toBe(false); + } + ); + + it('freezes the first protected batch and waits for every one of its members', async () => { + vi.useFakeTimers(); + const platform = scheduler({ idle: true }); + let settleFirst: (() => void) | undefined; + let settleSecond: (() => void) | undefined; + const first = new Promise((resolve) => (settleFirst = resolve)); + const second = new Promise((resolve) => (settleSecond = resolve)); + const gate = createProtectedFirstDisplayGate({ document, scheduler: platform.value }); + gate.commit(); + + expect(gate.protectAttemptBatch([first, second])).toBe(true); + expect(gate.protectAttemptBatch([Promise.resolve()])).toBe(false); + settleFirst?.(); + await Promise.resolve(); + await Promise.resolve(); + expect(platform.frames).toEqual([]); + settleSecond?.(); + await Promise.resolve(); + await Promise.resolve(); + expect(platform.frames).toHaveLength(1); + }); + + it('waits for visibility and two frames when a hidden page becomes visible first', async () => { + vi.useFakeTimers(); + const platform = scheduler({ idle: true }); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); + const gate = createProtectedFirstDisplayGate({ document, scheduler: platform.value }); + gate.commit(); + await vi.advanceTimersByTimeAsync(10_000); + await vi.advanceTimersByTimeAsync(1_999); + expect(platform.frames).toEqual([]); + + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); + document.dispatchEvent(new Event('visibilitychange')); + expect(platform.frames).toHaveLength(1); + platform.frames.shift()?.(12_000); + platform.frames.shift()?.(12_016); + platform.idle.shift()?.(); + await expect(gate.ready).resolves.toBe(true); + }); + + it('uses the two-second hidden timeout without requesting a frame', async () => { + vi.useFakeTimers(); + const platform = scheduler({ idle: true }); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); + const gate = createProtectedFirstDisplayGate({ document, scheduler: platform.value }); + gate.commit(); + await vi.advanceTimersByTimeAsync(11_999); + expect(platform.idle).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(platform.frames).toEqual([]); + expect(platform.idle).toHaveLength(1); + platform.idle.shift()?.(); + await expect(gate.ready).resolves.toBe(true); + }); +}); + +describe('authenticated deferred module loading', () => { + it('starts every module in manifest order without awaiting a sibling', async () => { + const prepare = vi.fn(); + const takeover = document.createElement('script'); + takeover.nonce = 'response-nonce'; + const loader = createDeferredPhaseLoader({ + runtimeScript: takeover, + document, + prepare, + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later', 'prebid_later']), + releaseId: RELEASE_ID, + }); + + await Promise.resolve(); + const scripts = [...document.head.querySelectorAll('script')]; + expect(scripts.map((script) => new URL(script.src).pathname)).toEqual([ + '/static/tsjs=tsjs-gpt_later.min.js', + '/static/tsjs=tsjs-prebid_later.min.js', + ]); + expect(scripts.every((script) => script.async && script.nonce === 'response-nonce')).toBe(true); + expect(loader.state('gpt_later')).toBe('loading'); + expect(loader.state('prebid_later')).toBe('loading'); + }); + + it('requires one exact registration from the exact connected current script', async () => { + const prepare = vi.fn((registration, owner) => + registration.prepare( + Object.freeze({ + config: Object.freeze({}), + interfaces: Object.freeze({}), + signal: owner.signal, + onDispose: owner.onDispose, + }) + ) + ); + const takeover = document.createElement('script'); + const loader = createDeferredPhaseLoader({ + runtimeScript: takeover, + document, + prepare, + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + const script = document.head.querySelector('script'); + expect(script).not.toBeNull(); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + + const registration = deferredRegistration('gpt_later'); + expect(loader.register(registration)).toBe(true); + script?.dispatchEvent(new Event('load')); + await vi.waitFor(() => expect(prepare).toHaveBeenCalledOnce()); + expect(loader.state('gpt_later')).toBe('ready'); + expect(loader.register(registration)).toBe(false); + }); + + it('ignores a publisher currentScript shadow and still admits the genuine deferred artifact', async () => { + const prepare = vi.fn(() => + Object.freeze({ activate: () => undefined }) + ); + let trustedCurrentScript: HTMLScriptElement | null = null; + const loader = createDeferredPhaseLoader({ + runtimeScript: document.createElement('script'), + currentScript: () => trustedCurrentScript, + document, + prepare, + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + const expected = document.head.querySelector('script'); + expect(expected).not.toBeNull(); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: expected, + }); + const publisherPrepare = vi.fn(() => Object.freeze({ activate: () => undefined })); + + expect(loader.register(deferredRegistration('gpt_later', publisherPrepare))).toBe(false); + expect(loader.state('gpt_later')).toBe('loading'); + expect(loader.reason('gpt_later')).toBeUndefined(); + expect(publisherPrepare).not.toHaveBeenCalled(); + + trustedCurrentScript = expected; + expect(loader.register(deferredRegistration('gpt_later'))).toBe(true); + expected?.dispatchEvent(new Event('load')); + await vi.waitFor(() => expect(loader.state('gpt_later')).toBe('ready')); + expect(prepare).toHaveBeenCalledOnce(); + }); + + it('isolates a failed module while a sibling reaches ready', async () => { + const prepare = vi.fn(async (registration: { readonly id: string }) => { + if (registration.id === 'gpt_later') throw new Error('fictional module failure'); + return Object.freeze({ activate: () => undefined }); + }); + const loader = createDeferredPhaseLoader({ + runtimeScript: document.createElement('script'), + document, + prepare, + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later', 'prebid_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + const scripts = [...document.head.querySelectorAll('script')]; + for (const [index, id] of ['gpt_later', 'prebid_later'].entries()) { + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: scripts[index], + }); + expect(loader.register(deferredRegistration(id))).toBe(true); + scripts[index]?.dispatchEvent(new Event('load')); + } + + await vi.waitFor(() => expect(loader.state('prebid_later')).toBe('ready')); + expect(loader.state('gpt_later')).toBe('unavailable'); + expect(loader.reason('gpt_later')).toBe('prepare_failed'); + }); + + it('classifies exact URL mutation before insertion as policy_blocked', async () => { + const takeover = document.createElement('script'); + const originalCreate = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation(((name: string) => { + const element = originalCreate(name); + if (name === 'script') { + Object.defineProperty(element, 'src', { + configurable: true, + get: () => 'https://publisher.example/mutated.js', + set: () => undefined, + }); + } + return element; + }) as typeof document.createElement); + const loader = createDeferredPhaseLoader({ + runtimeScript: takeover, + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + + await vi.waitFor(() => expect(loader.state('gpt_later')).toBe('unavailable')); + expect(loader.reason('gpt_later')).toBe('policy_blocked'); + expect(document.head.querySelector('script')).toBeNull(); + }); + + it.each([ + ['error', 'load_error'], + ['load', 'load_without_registration'], + ] as const)('classifies a script %s without accepted registration', async (event, reason) => { + const loader = createDeferredPhaseLoader({ + runtimeScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + document.head.querySelector('script')?.dispatchEvent(new Event(event)); + + await vi.waitFor(() => expect(loader.state('gpt_later')).toBe('unavailable')); + expect(loader.reason('gpt_later')).toBe(reason); + }); + + it('rejects registration after the expected node is removed or replaced', async () => { + const loader = createDeferredPhaseLoader({ + runtimeScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + const expected = document.head.querySelector('script'); + const replacement = document.createElement('script'); + expected?.replaceWith(replacement); + Object.defineProperty(document, 'currentScript', { configurable: true, value: expected }); + + expect(loader.register(deferredRegistration('gpt_later'))).toBe(false); + expect(loader.reason('gpt_later')).toBe('registration_rejected'); + }); + + it.each([ + [ + 'activation', + () => + Object.freeze({ + activate: () => { + throw new Error('activation'); + }, + }), + 'activation_failed', + ], + [ + 'after commit', + () => + Object.freeze({ + activate: ({ afterCommit }: { afterCommit: (callback: () => void) => void }) => + afterCommit(() => { + throw new Error('after commit'); + }), + }), + 'after_commit_failed', + ], + ] as const)('classifies an %s failure at its exact stage', async (_name, prepared, reason) => { + const loader = createDeferredPhaseLoader({ + runtimeScript: document.createElement('script'), + document, + prepare: () => prepared(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + const script = document.head.querySelector('script'); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + expect(loader.register(deferredRegistration('gpt_later'))).toBe(true); + script?.dispatchEvent(new Event('load')); + + await vi.waitFor(() => expect(loader.state('gpt_later')).toBe('unavailable')); + expect(loader.reason('gpt_later')).toBe(reason); + }); + + it('keeps the shared module alive after one caller deadline expires', async () => { + vi.useFakeTimers(); + const loader = createDeferredPhaseLoader({ + runtimeScript: document.createElement('script'), + document, + prepare: () => Object.freeze({ activate: () => undefined }), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + const caller = loader.waitFor('gpt_later', 100); + await vi.advanceTimersByTimeAsync(100); + await expect(caller).resolves.toBe('caller_timeout'); + expect(loader.state('gpt_later')).toBe('loading'); + + const script = document.head.querySelector('script'); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + expect(loader.register(deferredRegistration('gpt_later'))).toBe(true); + script?.dispatchEvent(new Event('load')); + await expect(loader.waitFor('gpt_later', 100)).resolves.toBe('ready'); + }); + + it('retires a hung shared module at its independent ten-second deadline', async () => { + vi.useFakeTimers(); + const loader = createDeferredPhaseLoader({ + runtimeScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(9_999); + expect(loader.state('gpt_later')).toBe('loading'); + await vi.advanceTimersByTimeAsync(1); + expect(loader.state('gpt_later')).toBe('unavailable'); + expect(loader.reason('gpt_later')).toBe('module_timeout'); + }); + + it('does not start after the owning gate is disposed', async () => { + const gate = createProtectedFirstDisplayGate({ document, scheduler: scheduler().value }); + const loader = createDeferredPhaseLoader({ + runtimeScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: gate.ready, + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + + gate.dispose(); + await Promise.resolve(); + await Promise.resolve(); + expect(document.head.querySelector('script')).toBeNull(); + expect(loader.reason('gpt_later')).toBe('disposed'); + }); + + it('uses window origin rather than a hostile document base URL', async () => { + const base = document.createElement('base'); + base.href = 'https://attacker.example/subtree/'; + document.head.append(base); + createDeferredPhaseLoader({ + runtimeScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + + expect(document.head.querySelector('script')?.src).toBe( + `${window.location.origin}/static/tsjs=tsjs-gpt_later.min.js?v=${HASH}` + ); + }); + + it('creates the fixed Trusted Types policy once and admits only canonical absolute URLs', async () => { + const createPolicy = vi.fn((_name: string, rules: { createScriptURL(value: string): string }) => + Object.freeze({ createScriptURL: rules.createScriptURL }) + ); + Object.defineProperty(window, 'trustedTypes', { + configurable: true, + value: Object.freeze({ createPolicy }), + }); + createDeferredPhaseLoader({ + runtimeScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later', 'prebid_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + + expect(createPolicy).toHaveBeenCalledOnce(); + expect(createPolicy).toHaveBeenCalledWith( + 'trusted-server#tsjs-v1', + expect.objectContaining({ createScriptURL: expect.any(Function) }) + ); + const rules = createPolicy.mock.calls[0]?.[1]; + expect(() => rules?.createScriptURL('https://attacker.example/x.js')).toThrow(); + }); + + it('reuses the bootstrap Trusted Types capability without creating the fixed policy twice', async () => { + const runtimeScript = document.createElement('script'); + const createScriptURL = vi.fn((value: string) => value); + const createPolicy = vi.fn(() => { + throw new TypeError('duplicate policy'); + }); + Object.defineProperty(window, 'trustedTypes', { + configurable: true, + value: Object.freeze({ createPolicy }), + }); + createDeferredPhaseLoader({ + runtimeScript, + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + trustedScriptUrl: createScriptURL, + }); + await Promise.resolve(); + + expect(createPolicy).not.toHaveBeenCalled(); + expect(createScriptURL).toHaveBeenCalledWith( + `${window.location.origin}/static/tsjs=tsjs-gpt_later.min.js?v=${HASH}` + ); + }); + + it('copies no nonce when the takeover script has no nonempty nonce', async () => { + createDeferredPhaseLoader({ + runtimeScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + expect(document.head.querySelector('script')?.nonce).toBe(''); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts b/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts new file mode 100644 index 000000000..e86d35516 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it } from 'vitest'; + +import { FIRST_DISPLAY_CONTRACT_IDS } from '../../src/shared/first_display_contracts'; +import * as releaseCatalog from '../../src/kernel/release_catalog'; +import { + FIRST_DISPLAY_CATALOG, + MAX_FIRST_DISPLAY_SLICES, + MAX_TAKEOVER_MODULES, + MAX_MANIFEST_MODULES, + MINIMAL_TAKEOVER_IDS, + REFERENCE_TAKEOVER_IDS, + RELEASE_CATALOG, + selectFirstDisplayCatalog, + selectReleaseCatalog, + validateReleaseCatalog, + type ReleaseCatalogEntry, +} from '../../src/kernel/release_catalog'; + +const EXPECTED = [ + ['render_runtime', 'runtime', 'takeover', null, 'always', null], + ['aps', 'APS', 'takeover', null, 'integration:aps', 'aps'], + ['creative', 'creative', 'takeover', null, 'creative_guard', 'creative'], + ['datadome', 'DataDome', 'takeover', null, 'integration:datadome', 'datadome'], + ['didomi', 'Didomi', 'takeover', null, 'integration:didomi', 'didomi'], + [ + 'google_tag_manager', + 'GTM/GA', + 'takeover', + null, + 'integration:google_tag_manager', + 'google_tag_manager', + ], + ['gpt', 'GPT', 'takeover', null, 'integration:gpt', 'gpt'], + ['gpt_diagnostics', 'diagnostics', 'takeover', null, 'gpt_diagnostics_active', 'diagnostics'], + ['lockr', 'Lockr', 'takeover', null, 'integration:lockr', 'lockr'], + ['osano_consent', 'Osano', 'takeover', null, 'integration:osano', 'osano'], + ['permutive_context', 'Permutive', 'takeover', null, 'integration:permutive', 'permutive'], + [ + 'sourcepoint_consent', + 'Sourcepoint', + 'takeover', + null, + 'integration:sourcepoint', + 'sourcepoint', + ], + ['prebid', 'Prebid', 'takeover', null, 'integration:prebid', 'prebid'], + ['testlight', 'Testlight', 'takeover', null, 'integration:testlight', 'testlight'], + [ + 'diagnostics_presentation', + 'diagnostics', + 'deferred', + 'first_display_or_idle', + 'diagnostics_presentation', + null, + ], + ['gpt_later', 'GPT', 'deferred', 'first_display_or_idle', 'integration:gpt', 'gpt'], + ['osano_lifecycle', 'Osano', 'deferred', 'first_display_or_idle', 'integration:osano', 'osano'], + [ + 'permutive_lifecycle', + 'Permutive', + 'deferred', + 'first_display_or_idle', + 'integration:permutive', + 'permutive', + ], + ['prebid_later', 'Prebid', 'deferred', 'first_display_or_idle', 'prebid_and_gpt', 'prebid'], + [ + 'sourcepoint_lifecycle', + 'Sourcepoint', + 'deferred', + 'first_display_or_idle', + 'integration:sourcepoint', + 'sourcepoint', + ], +] as const; + +describe('canonical release catalog', () => { + it('pins the exact fourteen first-display rows and closed server-owned selection', () => { + expect(FIRST_DISPLAY_CONTRACT_IDS).toEqual(FIRST_DISPLAY_CATALOG.map(({ id }) => id)); + expect(FIRST_DISPLAY_CATALOG.map(({ order, id }) => [order, id])).toEqual([ + [1, 'first_display'], + [2, 'render_owner_initial'], + [3, 'aps_initial'], + [4, 'creative_initial'], + [5, 'datadome_initial'], + [6, 'didomi_initial'], + [7, 'google_tag_manager_initial'], + [8, 'gpt_initial'], + [9, 'lockr_initial'], + [10, 'osano_initial'], + [11, 'permutive_initial'], + [12, 'sourcepoint_initial'], + [13, 'prebid_initial'], + [14, 'testlight_initial'], + ]); + expect(MAX_FIRST_DISPLAY_SLICES).toBe(14); + expect( + selectFirstDisplayCatalog({ + eligibleBatch: true, + integrations: ['aps', 'gpt', 'prebid'], + apsParticipates: true, + renderOwnerParticipates: true, + prebidParticipates: true, + }).map(({ id }) => id) + ).toEqual([ + 'first_display', + 'render_owner_initial', + 'aps_initial', + 'gpt_initial', + 'prebid_initial', + ]); + expect( + selectFirstDisplayCatalog({ + eligibleBatch: true, + integrations: ['gpt'], + renderOwnerParticipates: true, + }).map(({ id }) => id) + ).toEqual(['first_display', 'render_owner_initial', 'gpt_initial']); + expect( + selectFirstDisplayCatalog({ + eligibleBatch: true, + integrations: ['gpt'], + renderOwnerParticipates: false, + }).map(({ id }) => id) + ).toEqual(['first_display', 'gpt_initial']); + expect( + selectFirstDisplayCatalog({ + eligibleBatch: true, + integrations: ['gpt'], + apsParticipates: true, + }).map(({ id }) => id) + ).toEqual(['first_display', 'gpt_initial']); + expect(selectFirstDisplayCatalog({ eligibleBatch: false, integrations: [] })).toEqual([]); + expect(() => + selectFirstDisplayCatalog({ eligibleBatch: true, integrations: ['unknown'] }) + ).toThrow(/unknown/i); + expect( + FIRST_DISPLAY_CATALOG.every( + ({ allowedImports, inputs, outputs, obligation }) => + allowedImports.length > 0 && + inputs.length > 0 && + outputs.length > 0 && + obligation.length > 0 + ) + ).toBe(true); + }); + it('pins the exact twenty rows, phases, triggers, products, predicates, and order', () => { + expect( + RELEASE_CATALOG.map(({ id, product, phase, trigger, include, config }) => [ + id, + product, + phase, + trigger, + include, + config, + ]) + ).toEqual(EXPECTED); + expect(RELEASE_CATALOG.map(({ order }) => order)).toEqual( + Array.from({ length: 20 }, (_, index) => index + 1) + ); + }); + + it('pins the exact capability graph and named scopes', () => { + expect(RELEASE_CATALOG.map(({ id, provides, consumes }) => [id, provides, consumes])).toEqual([ + [ + 'render_runtime', + [ + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + 'trace.presentation.v1', + 'direct.v1', + ], + ['runtime.v1'], + ], + ['aps', ['aps.v1'], ['runtime.v1', 'slots.v1', 'render.v1', 'messages.v1', 'trace.v1']], + ['creative', [], ['runtime.v1']], + ['datadome', [], ['runtime.v1']], + ['didomi', [], ['runtime.v1']], + ['google_tag_manager', [], ['runtime.v1']], + [ + 'gpt', + ['gpt.v1', 'gpt.events.v1', 'pbs_cache.baseline.v1'], + [ + 'runtime.v1', + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + 'aps.v1?aps', + ], + ], + ['gpt_diagnostics', ['gpt_diag.v1'], ['runtime.v1', 'gpt.events.v1']], + ['lockr', [], ['runtime.v1']], + ['osano_consent', ['osano_consent.v1'], ['runtime.v1']], + ['permutive_context', ['permutive_context.v1'], ['runtime.v1']], + ['sourcepoint_consent', ['sourcepoint_consent.v1'], ['runtime.v1']], + [ + 'prebid', + ['prebid.v1'], + ['runtime.v1', 'slots.v1', 'render.v1', 'messages.v1', 'aps.v1?aps'], + ], + ['testlight', [], ['runtime.v1']], + [ + 'diagnostics_presentation', + [], + ['runtime.v1', 'trace.presentation.v1', 'gpt_diag.v1?gpt_diagnostics_active'], + ], + [ + 'gpt_later', + [], + ['runtime.v1', 'slots.v1', 'auction.v1', 'render.v1', 'gpt.v1', 'trace.v1'], + ], + ['osano_lifecycle', [], ['runtime.v1', 'osano_consent.v1']], + ['permutive_lifecycle', [], ['runtime.v1', 'permutive_context.v1']], + ['prebid_later', [], ['runtime.v1', 'slots.v1', 'gpt.v1', 'prebid.v1']], + ['sourcepoint_lifecycle', [], ['runtime.v1', 'sourcepoint_consent.v1']], + ]); + expect(RELEASE_CATALOG.every(({ obligation }) => obligation.length > 0)).toBe(true); + }); + + it('derives capacity and budget vectors without an internal diagnostics subscriber cap', () => { + expect(MAX_TAKEOVER_MODULES).toBe(14); + expect(MAX_MANIFEST_MODULES).toBe(20); + expect('MAX_INTERNAL_DIAGNOSTICS_SUBSCRIPTIONS' in releaseCatalog).toBe(false); + expect(MINIMAL_TAKEOVER_IDS).toEqual(['core', 'render_runtime']); + expect(REFERENCE_TAKEOVER_IDS).toEqual([ + 'core', + 'render_runtime', + 'creative', + 'gpt', + 'prebid', + 'datadome', + ]); + expect(() => validateReleaseCatalog(RELEASE_CATALOG.slice(0, 13))).not.toThrow(); + expect(() => validateReleaseCatalog(RELEASE_CATALOG.slice(0, 14))).not.toThrow(); + expect(() => validateReleaseCatalog(RELEASE_CATALOG.slice(0, 15))).not.toThrow(); + expect(() => validateReleaseCatalog(RELEASE_CATALOG.slice(0, 19))).not.toThrow(); + expect(() => validateReleaseCatalog(RELEASE_CATALOG.slice(0, 20))).not.toThrow(); + const fifteenTakeover = [ + ...RELEASE_CATALOG.slice(0, 14), + { + ...RELEASE_CATALOG[14]!, + phase: 'takeover' as const, + trigger: null, + }, + ]; + expect(() => validateReleaseCatalog(fifteenTakeover)).toThrow( + /takeover capacity|phase override/i + ); + expect(() => validateReleaseCatalog([...RELEASE_CATALOG, RELEASE_CATALOG[0]!])).toThrow(); + }); + + it('selects rows only through deny-unknown server-owned predicates', () => { + expect(selectReleaseCatalog({ integrations: [] }).map(({ id }) => id)).toEqual([ + 'render_runtime', + ]); + expect( + selectReleaseCatalog({ + integrations: ['aps', 'gpt', 'prebid'], + creative: { enabled: true, clickGuard: false, renderGuard: true }, + gptDiagnosticsActive: true, + renderTraceOverlay: true, + }).map(({ id }) => id) + ).toEqual([ + 'render_runtime', + 'aps', + 'creative', + 'gpt', + 'gpt_diagnostics', + 'prebid', + 'diagnostics_presentation', + 'gpt_later', + 'prebid_later', + ]); + expect( + selectReleaseCatalog({ + integrations: [], + gptDiagnosticsActive: true, + renderTraceOverlay: false, + }).map(({ id }) => id) + ).toEqual(['render_runtime', 'gpt_diagnostics', 'diagnostics_presentation']); + expect( + selectReleaseCatalog({ + integrations: [], + gptDiagnosticsActive: false, + renderTraceOverlay: true, + }).map(({ id }) => id) + ).toEqual(['render_runtime', 'diagnostics_presentation']); + expect( + selectReleaseCatalog({ + integrations: [], + gptDiagnosticsActive: false, + renderTraceOverlay: false, + }).map(({ id }) => id) + ).toEqual(['render_runtime']); + expect(() => selectReleaseCatalog({ integrations: ['unknown'] })).toThrow(/unknown/i); + }); + + it('rejects duplicate providers, undeclared edges, cycles, deferred providers, and bad order', () => { + const clone = (): ReleaseCatalogEntry[] => RELEASE_CATALOG.map((entry) => ({ ...entry })); + + const duplicateProvider = clone(); + duplicateProvider[2] = { ...duplicateProvider[2]!, provides: ['aps.v1'] }; + expect(() => validateReleaseCatalog(duplicateProvider)).toThrow(/provider/i); + + const unknownEdge = clone(); + unknownEdge[2] = { ...unknownEdge[2]!, consumes: ['missing.v1'] }; + expect(() => validateReleaseCatalog(unknownEdge)).toThrow(/capability/i); + + const deferredProvider = clone(); + deferredProvider[14] = { ...deferredProvider[14]!, provides: ['later.v1'] }; + expect(() => validateReleaseCatalog(deferredProvider)).toThrow(/deferred provider/i); + + const cycle = clone(); + cycle[0] = { ...cycle[0]!, consumes: ['aps.v1'] }; + expect(() => validateReleaseCatalog(cycle)).toThrow(/order|cycle/i); + + const wrongOrder = clone(); + [wrongOrder[0], wrongOrder[1]] = [wrongOrder[1]!, wrongOrder[0]!]; + expect(() => validateReleaseCatalog(wrongOrder)).toThrow(/order/i); + + const phaseOverride = clone(); + phaseOverride[13] = { + ...phaseOverride[13]!, + phase: 'deferred', + trigger: 'first_display_or_idle', + }; + expect(() => validateReleaseCatalog(phaseOverride)).toThrow(/phase override/i); + + const configOverride = clone(); + configOverride[15] = { ...configOverride[15]!, config: 'prebid' }; + expect(() => validateReleaseCatalog(configOverride)).toThrow(/config|catalog/i); + + const invalidConditionalEdge = clone(); + invalidConditionalEdge[12] = { + ...invalidConditionalEdge[12]!, + consumes: ['runtime.v1', 'aps.v1?publisher_choice'], + }; + expect(() => validateReleaseCatalog(invalidConditionalEdge)).toThrow(/conditional/i); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts new file mode 100644 index 000000000..37704329b --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -0,0 +1,2865 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import { + AdUnitRegistrationError, + RequestAdsInputError, + TsjsUnavailableError, + type AdUnitRegistrationErrorCode, +} from '../../src/kernel/fallback'; +import { createRuntime as createRuntimeOwner, type RuntimeOptions } from '../../src/kernel/runtime'; +import { snapshotTsjsBootV1 } from '../../src/core/contracts/boot'; +import { createDiagnosticsPresentationIntegrationRegistration } from '../../src/integrations/gpt_diagnostics/presentation'; +import type { + IntegrationPrepareContext, + PreparedIntegration, +} from '../../src/kernel/integration_registry'; +import { createLifecycleIntegrationRegistration } from '../../src/kernel/lifecycle_module'; +import { failedFirstDisplayTakeover } from '../first_display/helpers/compact_takeover'; + +const RELEASE = 'a'.repeat(64); +const TRUSTED_RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + +function takeoverRegistration( + candidate: Readonly<{ + abi: 1; + id: string; + phase: 'takeover'; + releaseId: string; + prepare: ( + context: IntegrationPrepareContext + ) => PreparedIntegration | PromiseLike; + }> +) { + return Object.freeze({ + abi: candidate.abi, + id: candidate.id, + phase: candidate.phase, + releaseId: candidate.releaseId, + prepareSync: (context: IntegrationPrepareContext) => + candidate.prepare(context) as PreparedIntegration, + prepare: candidate.prepare, + }); +} + +function installTestRuntimeScript(runtimeDocument: Document, takeover = false): void { + if (runtimeDocument.currentScript) return; + const script = runtimeDocument.createElement('script'); + script.id = takeover ? 'trustedserver-js-runtime' : 'trustedserver-js'; + script.src = new URL(TRUSTED_RUNTIME_SRC, runtimeDocument.location.origin).href; + runtimeDocument.head.insertBefore(script, null); + Object.defineProperty(runtimeDocument, 'currentScript', { + configurable: true, + value: script, + }); +} + +function createRuntime(options: RuntimeOptions) { + installTestRuntimeScript(options.document ?? document, options.coordinateTakeover !== undefined); + let acceptedOptions = options; + try { + if ( + typeof options.boot === 'object' && + options.boot !== null && + !Array.isArray(options.boot) && + Object.getPrototypeOf(options.boot) === Object.prototype + ) { + const descriptors = Object.getOwnPropertyDescriptors(options.boot); + if (Object.values(descriptors).every((descriptor) => 'value' in descriptor)) { + const fields = Object.fromEntries( + Object.entries(descriptors).map(([key, descriptor]) => [key, descriptor.value]) + ); + const carrier = Object.prototype.hasOwnProperty.call(fields, 'integrations') + ? fields['integrations'] + : defaultIntegrationConfigs(options.manifest); + const candidate = Object.prototype.hasOwnProperty.call(fields, 'abi') + ? options.boot + : { + abi: 1, + releaseId: RELEASE, + manifest: options.manifest, + auctionProjection: fields['auctionProjection'], + integrations: carrier, + creative: fields['creative'], + diagnostics: fields['diagnostics'], + }; + const snapshot = snapshotTsjsBootV1(candidate, RELEASE); + if (snapshot) { + const retained = + Object.prototype.hasOwnProperty.call(fields, 'abi') && Object.isFrozen(options.boot); + const acceptedBoot = retained ? options.boot : snapshot; + acceptedOptions = { + ...options, + manifest: retained ? fields['manifest'] : snapshot.manifest, + boot: acceptedBoot, + }; + } + } + } + } catch { + // Hostile values remain untouched so production boundary tests can reject them. + } + return createRuntimeOwner(acceptedOptions); +} + +const CONFIG_ORDER = Object.freeze([ + 'aps', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'lockr', + 'osano', + 'permutive', + 'prebid', + 'sourcepoint', + 'testlight', +]); + +function configProduct(id: string): string | undefined { + if (id === 'gpt' || id === 'gpt_later') return 'gpt'; + if (id === 'osano_consent' || id === 'osano_lifecycle') return 'osano'; + if (id === 'permutive_context' || id === 'permutive_lifecycle') return 'permutive'; + if (id === 'prebid' || id === 'prebid_later') return 'prebid'; + if (id === 'sourcepoint_consent' || id === 'sourcepoint_lifecycle') return 'sourcepoint'; + return CONFIG_ORDER.includes(id) ? id : undefined; +} + +function defaultConfig(id: string): Readonly> { + if (id === 'didomi') return { proxyPath: '/integrations/didomi/sdk.js' }; + if (id === 'gpt') return { gamAttributionEnabled: false, pageBidsEnabled: true }; + if (id === 'prebid') { + return { accountId: 'test', timeout: 1_000, debug: false, bidders: [] }; + } + if (id === 'sourcepoint') return { rewriteSdk: false }; + return {}; +} + +function defaultIntegrationConfigs(candidateManifest: unknown): Readonly> { + const entries = + typeof candidateManifest === 'object' && + candidateManifest !== null && + Array.isArray((candidateManifest as { integrations?: unknown }).integrations) + ? (candidateManifest as { integrations: Array<{ id?: unknown }> }).integrations + : []; + const selected = new Set( + entries.flatMap(({ id }) => (typeof id === 'string' ? [configProduct(id)] : [])) + ); + return { + version: 1, + entries: CONFIG_ORDER.filter((id) => selected.has(id)).map((id) => ({ + id, + config: defaultConfig(id), + })), + }; +} + +function boot(results: readonly object[] = []) { + return { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'boot', results }, + slots: results.map((result) => { + const slot = (result as { readonly slot?: unknown }).slot; + return { + slot, + gamUnitPath: `/123/${String(slot)}`, + divId: String(slot), + formats: [[300, 250]], + targeting: {}, + }; + }), + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }; +} + +function takeoverHandoff() { + const projectionDigest = 'b'.repeat(64); + const compact = failedFirstDisplayTakeover(RELEASE); + return { + capture: compact.capture, + boot: compact.boot, + handoff: { + version: 1, + releaseId: RELEASE, + generation: 1, + projectionDigest, + integrationConfigDigest: 'c'.repeat(64), + slices: ['first_display'], + slots: [ + { + id: 'slot-1', + aliases: [], + domId: 'div-1', + gamPath: '/123/slot-1', + formats: [[300, 250]], + owner: 'trusted_server', + outcome: 'failed', + targeting: [], + targetingOwnership: [], + committedArtifact: 'none', + gptToken: null, + }, + ], + attempts: [ + { + id: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + slotId: 'slot-1', + ordinal: 1, + state: 'failed', + reason: 'internal_error', + }, + ], + tombstones: [], + artifacts: [], + parserState: [], + gptDiagnostics: { facts: [], overflowCount: 0, dropCount: 0 }, + timing: { bidsScriptMs: 0, firstDisplayMs: null, terminalMs: 0, paintMs: 0 }, + highWater: { + navigationAttemptPrefix: 'AAECAwQFBgc', + nextNavigationAttemptOrdinal: 2, + nextAttemptOrdinal: 2, + nextSlotRegistrationOrdinal: 2, + reservationClockEpochMs: 0, + nextReservationOrdinal: 1, + nextTicketOrdinal: 1, + }, + cycles: [], + trace: { + nextSequence: 1, + nextGlobalSlotOrdinal: 2, + slots: [{ slotId: 'slot-1', impressions: 0, bindings: [] }], + }, + mutationRevision: 0, + }, + outline: { + version: 1, + releaseId: RELEASE, + generation: 1, + projectionDigest, + integrationConfigDigest: 'c'.repeat(64), + slices: ['first_display'], + slotCount: 1, + outcomeCount: 1, + capabilities: [], + objectKinds: [], + }, + } as const; +} + +function manifest(ids: readonly string[]) { + const deferredIds = new Set([ + 'diagnostics_presentation', + 'gpt_later', + 'osano_lifecycle', + 'permutive_lifecycle', + 'prebid_later', + 'sourcepoint_lifecycle', + ]); + return { + version: 1, + releaseId: RELEASE, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => + deferredIds.has(id) + ? { + id, + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + src: `/static/tsjs=tsjs-${id}.min.js?v=${'d'.repeat(64)}`, + } + : { id, phase: 'takeover' as const } + ), + }; +} + +type ReflectionTrap = 'getPrototypeOf' | 'ownKeys' | 'getOwnPropertyDescriptor'; + +function hostileRecord(trap: ReflectionTrap, target: object = {}): object { + const fail = () => { + throw new Error(`hostile ${trap}`); + }; + const handler: ProxyHandler = {}; + if (trap === 'getPrototypeOf') handler.getPrototypeOf = fail; + if (trap === 'ownKeys') handler.ownKeys = fail; + if (trap === 'getOwnPropertyDescriptor') handler.getOwnPropertyDescriptor = fail; + return new Proxy(target, handler); +} + +function thrownBy(callback: () => unknown): unknown { + try { + callback(); + } catch (error) { + return error; + } + throw new Error('Expected callback to throw'); +} + +describe('Runtime bootstrap owner', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + document.head.replaceChildren(); + Object.defineProperty(document, 'currentScript', { configurable: true, value: null }); + }); + + it('exports the exact programmatic registration error taxonomy', () => { + type ExpectedCode = + | 'invalid_units' + | 'invalid_unit' + | 'invalid_code' + | 'duplicate_code' + | 'slot_collision' + | 'invalid_media_types' + | 'invalid_dimensions' + | 'dimensions_out_of_range' + | 'invalid_bids' + | 'invalid_bidder' + | 'invalid_params' + | 'request_body_too_large' + | 'registry_capacity'; + + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it.each([ + { + boundary: 'no document', + arrange: () => { + vi.stubGlobal('document', undefined); + return undefined; + }, + }, + { + boundary: 'no takeover tag', + arrange: () => document, + }, + { + boundary: 'wrong realm and owner document', + arrange: () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const foreignDocument = frame.contentDocument; + if (!foreignDocument) throw new Error('should expose an iframe document'); + const script = foreignDocument.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL(TRUSTED_RUNTIME_SRC, window.location.origin).href; + foreignDocument.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'wrong id', + arrange: () => { + const script = document.createElement('script'); + script.id = 'publisher-script'; + script.src = new URL(TRUSTED_RUNTIME_SRC, window.location.origin).href; + document.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'disconnected tag', + arrange: () => { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL(TRUSTED_RUNTIME_SRC, window.location.origin).href; + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'duplicate tag', + arrange: () => { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL(TRUSTED_RUNTIME_SRC, window.location.origin).href; + const duplicate = script.cloneNode() as HTMLScriptElement; + document.head.append(script, duplicate); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'cross-origin source', + arrange: () => { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = `https://attacker.example${TRUSTED_RUNTIME_SRC}`; + document.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'fragment source', + arrange: () => { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = `${new URL(TRUSTED_RUNTIME_SRC, window.location.origin).href}#publisher`; + document.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'wrong route', + arrange: () => { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL( + `/static/tsjs=tsjs-publisher.min.js?v=${'c'.repeat(64)}`, + window.location.origin + ).href; + document.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'malformed artifact hash', + arrange: () => { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL( + `/static/tsjs=tsjs-unified.min.js?v=${'C'.repeat(64)}`, + window.location.origin + ).href; + document.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + ])('rejects caller-supplied takeover source at the $boundary boundary', ({ arrange }) => { + const runtimeDocument = arrange(); + const queued = vi.fn(); + const target = { boot: boot(), que: [queued] }; + const bootDescriptor = Object.getOwnPropertyDescriptor(target, 'boot'); + const queueDescriptor = Object.getOwnPropertyDescriptor(target, 'que'); + const options: RuntimeOptions & Record = { + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([] as string[]), + boot: target.boot, + ...(runtimeDocument ? { document: runtimeDocument } : {}), + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }; + options['trustedRuntimeSrc'] = TRUSTED_RUNTIME_SRC; + const runtime = createRuntimeOwner(options); + + expect(runtime.start()).toBe(false); + expect(runtime.state).toBe('unclaimed'); + expect(Object.getOwnPropertyDescriptor(target, 'boot')).toEqual(bootDescriptor); + expect(Object.getOwnPropertyDescriptor(target, 'que')).toEqual(queueDescriptor); + expect(target).not.toHaveProperty('_registerIntegration'); + expect(target).not.toHaveProperty('_internal'); + expect(queued).not.toHaveBeenCalled(); + }); + + it('commits one kernel after core/integration activation and afterCommit before queue drain', async () => { + const order: string[] = []; + const target = { que: [() => order.push('queued')], config: { publisher: true } }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + activateCore: () => order.push('core'), + kernel: { + addAdUnits: () => ({ registered: [] }), + diagnostics: Object.freeze({}), + requestAds: async () => ({ slots: [] }), + }, + }); + + expect(runtime.state).toBe('unclaimed'); + expect(runtime.start()).toBe(true); + expect(runtime.state).toBe('installing'); + expect(target.config).toEqual({ publisher: true }); + expect( + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId: RELEASE, + prepare: () => ({ + activate: ({ afterCommit }: { afterCommit(callback: () => void): void }) => { + order.push('integration'); + afterCommit(() => order.push('after-commit')); + }, + }), + }) + ) + ).toBe(true); + + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(runtime.state).toBe('kernel'); + expect(order).toEqual(['core', 'integration', 'after-commit', 'queued']); + expect(target).toMatchObject({ version: '1.0.0', releaseId: RELEASE }); + expect(Object.isFrozen(target.que)).toBe(true); + expect(Object.getOwnPropertyDescriptor(target, '_internal')).toMatchObject({ + enumerable: false, + writable: false, + configurable: false, + }); + expect( + (target as { _registerIntegration?: (value: unknown) => boolean })._registerIntegration?.({ + id: 'late', + }) + ).toBe(false); + }); + + it('publishes the exact closure-supplied immutable boot snapshot without rereading the target', async () => { + const target: Record = {}; + const acceptedBoot = snapshotTsjsBootV1( + { + abi: 1, + releaseId: RELEASE, + manifest: manifest(['test_module']), + ...boot(), + integrations: { version: 1, entries: [] }, + }, + RELEASE + ); + expect(acceptedBoot).toBeDefined(); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: acceptedBoot!.manifest, + knownIntegrationIds: Object.freeze(['test_module']), + catalog: Object.freeze([ + Object.freeze({ + id: 'test_module', + phase: 'takeover' as const, + trigger: null, + config: null, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }), + ]), + boot: acceptedBoot, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + + expect(runtime.start()).toBe(true); + target.boot = Object.freeze({ publisherReplacement: true }); + expect( + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'test_module', + phase: 'takeover', + releaseId: RELEASE, + prepare: ({ config }: { config: unknown }) => { + expect(config).toBeUndefined(); + return Object.freeze({ activate: () => undefined }); + }, + }) + ) + ).toBe(true); + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(target.boot).toBe(acceptedBoot); + }); + + it('publishes direct.v1 through stable public closures only after provider activation', async () => { + const target: Record = {}; + const addAdUnits = vi.fn((candidate: unknown) => Object.freeze({ candidate })); + const requestAds = vi.fn(async (_candidate?: unknown) => + Object.freeze({ slots: Object.freeze([]) }) + ); + let active = false; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['render_runtime']), + knownIntegrationIds: Object.freeze(['render_runtime']), + catalog: Object.freeze([ + Object.freeze({ + id: 'render_runtime', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze(['direct.v1']), + }), + ]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'render_runtime', + phase: 'takeover', + releaseId: RELEASE, + prepare: ({ interfaces }: { interfaces: Readonly> }) => { + expect(Reflect.ownKeys(interfaces)).toEqual(['runtime.v1']); + return Object.freeze({ + activate: ({ onDispose }: { onDispose(callback: () => void): void }) => { + active = true; + onDispose(() => { + active = false; + }); + }, + interfaces: Object.freeze({ + 'direct.v1': Object.freeze({ + addAdUnits: (candidate: unknown) => { + if (!active) throw new Error('inactive'); + return addAdUnits(candidate); + }, + requestAds: async (candidate?: unknown) => { + if (!active) throw new Error('inactive'); + return requestAds(candidate); + }, + diagnostics: Object.freeze({ owner: 'render_runtime' }), + }), + }), + }); + }, + }) + ) + ).toBe(true); + + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const api = target as { + addAdUnits: (candidate: unknown) => unknown; + requestAds: (candidate?: unknown) => Promise; + diagnostics: unknown; + }; + expect(api.addAdUnits('unit')).toEqual({ candidate: 'unit' }); + await expect(api.requestAds()).resolves.toEqual({ slots: [] }); + expect(api.diagnostics).toEqual({ owner: 'render_runtime' }); + runtime.dispose(); + expect(() => api.addAdUnits('late')).toThrow('inactive'); + }); + + it('publishes the staged takeover GPT diagnostics API without waiting for presentation', async () => { + const target: Record = {}; + const renderTrace = Object.freeze({ current: vi.fn(), history: vi.fn(), subscribe: vi.fn() }); + const gpt = Object.freeze({ + snapshot: vi.fn(() => Object.freeze({ slots: Object.freeze([]) })), + export: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + show: vi.fn(), + hide: vi.fn(), + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['render_runtime', 'gpt_diagnostics', 'diagnostics_presentation']), + knownIntegrationIds: Object.freeze([ + 'render_runtime', + 'gpt_diagnostics', + 'diagnostics_presentation', + ]), + catalog: Object.freeze([ + Object.freeze({ + id: 'render_runtime', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze(['direct.v1']), + }), + Object.freeze({ + id: 'gpt_diagnostics', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze(['gpt_diag.v1']), + }), + Object.freeze({ + id: 'diagnostics_presentation', + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + consumes: Object.freeze(['runtime.v1', 'gpt_diag.v1']), + provides: Object.freeze([]), + }), + ]), + boot: { + ...boot(), + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'render_runtime', + phase: 'takeover', + releaseId: RELEASE, + prepare: () => + Object.freeze({ + activate: () => undefined, + interfaces: Object.freeze({ + 'direct.v1': Object.freeze({ + addAdUnits: vi.fn(), + requestAds: vi.fn(), + diagnostics: Object.freeze({ renderTrace }), + }), + }), + }), + }) + ) + ).toBe(true); + expect( + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'gpt_diagnostics', + phase: 'takeover', + releaseId: RELEASE, + prepare: () => + Object.freeze({ + activate: () => undefined, + interfaces: Object.freeze({ + 'gpt_diag.v1': Object.freeze({ api: gpt, attachPresentation: vi.fn() }), + }), + }), + }) + ) + ).toBe(true); + + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const diagnostics = target['diagnostics'] as Readonly>; + expect(Object.isFrozen(diagnostics)).toBe(true); + expect(Reflect.ownKeys(diagnostics)).toEqual(['renderTrace', 'gpt']); + expect(diagnostics['renderTrace']).toBe(renderTrace); + expect(diagnostics['gpt']).toBe(gpt); + }); + + it('binds creative and GPT diagnostics from the private validated boot snapshot', async () => { + const target: Record = {}; + const prepared = new Map(); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['creative', 'gpt_diagnostics', 'diagnostics_presentation']), + knownIntegrationIds: Object.freeze([ + 'creative', + 'gpt_diagnostics', + 'diagnostics_presentation', + ]), + catalog: Object.freeze([ + Object.freeze({ + id: 'creative', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze([]), + }), + Object.freeze({ + id: 'gpt_diagnostics', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze(['gpt_diag.v1']), + }), + Object.freeze({ + id: 'diagnostics_presentation', + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + consumes: Object.freeze(['runtime.v1', 'gpt_diag.v1']), + provides: Object.freeze([]), + }), + ]), + boot: { + ...boot(), + creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + getBindings: () => + Object.freeze({ + config: Object.freeze({ publisherControlled: true }), + interfaces: Object.freeze({}), + }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + for (const id of ['creative', 'gpt_diagnostics']) { + expect( + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id, + phase: 'takeover', + releaseId: RELEASE, + prepare: ({ config }: { config: unknown }) => { + prepared.set(id, config); + return id === 'gpt_diagnostics' + ? Object.freeze({ + activate: () => undefined, + interfaces: Object.freeze({ + 'gpt_diag.v1': Object.freeze({ + api: Object.freeze({ + snapshot: vi.fn(), + export: vi.fn(), + subscribe: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + }), + attachPresentation: vi.fn(), + }), + }), + }) + : Object.freeze({ activate: () => undefined }); + }, + }) + ) + ).toBe(true); + } + + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(prepared.get('creative')).toEqual({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }); + expect(prepared.get('gpt_diagnostics')).toEqual({ active: true }); + expect(Object.isFrozen(prepared.get('creative'))).toBe(true); + expect(Object.isFrozen(prepared.get('gpt_diagnostics'))).toBe(true); + }); + + it('keeps the authenticated registrar live and starts deferred loading only after the gate', async () => { + vi.useFakeTimers(); + const frames: FrameRequestCallback[] = []; + const idle: Array<() => void> = []; + const runtimeHash = 'c'.repeat(64); + const deferredHash = 'd'.repeat(64); + const runtimeScript = document.createElement('script'); + runtimeScript.id = 'trustedserver-js'; + runtimeScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${runtimeHash}`; + document.head.insertBefore(runtimeScript, null); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: runtimeScript, + }); + const target: Record = {}; + const deferredPrepare = vi.fn(() => Object.freeze({ activate: () => undefined })); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + document, + manifest: { + version: 1, + releaseId: RELEASE, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${runtimeHash}`, + integrations: [ + { id: 'render_runtime', phase: 'takeover' }, + { + id: 'gpt_later', + phase: 'deferred', + trigger: 'first_display_or_idle', + src: `/static/tsjs=tsjs-gpt_later.min.js?v=${deferredHash}`, + }, + ], + }, + knownIntegrationIds: Object.freeze(['render_runtime', 'gpt_later']), + catalog: Object.freeze([ + Object.freeze({ + id: 'render_runtime', + phase: 'takeover' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }), + Object.freeze({ + id: 'gpt_later', + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }), + ]), + boot: boot(), + getBindings: () => Object.freeze({ config: undefined, interfaces: Object.freeze({}) }), + phaseScheduler: { + cancelAnimationFrame: vi.fn(), + cancelIdleCallback: vi.fn(), + clearTimeout, + requestAnimationFrame: (callback) => { + frames.push(callback); + return frames.length; + }, + requestIdleCallback: (callback) => { + idle.push(callback); + return idle.length; + }, + setTimeout, + }, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'render_runtime', + phase: 'takeover', + releaseId: RELEASE, + prepare: () => Object.freeze({ activate: () => undefined }), + }) + ) + ).toBe(true); + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(Object.getOwnPropertyDescriptor(target, '_registerIntegration')).toMatchObject({ + configurable: false, + enumerable: true, + writable: false, + }); + expect(document.head.querySelectorAll('script')).toHaveLength(1); + + expect(runtime.protectFirstDisplayAttemptBatch([Promise.resolve()])).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + frames.shift()?.(1); + frames.shift()?.(2); + idle.shift()?.(); + await Promise.resolve(); + await Promise.resolve(); + const deferredScript = [...document.head.querySelectorAll('script')].find( + (script) => script !== runtimeScript + ); + expect(deferredScript?.src).toContain('tsjs-gpt_later.min.js'); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: deferredScript, + }); + const register = target['_registerIntegration']; + expect(typeof register).toBe('function'); + expect( + Reflect.apply(register as (...args: unknown[]) => unknown, target, [ + { + abi: 1, + id: 'gpt_later', + phase: 'deferred', + releaseId: RELEASE, + prepare: deferredPrepare, + }, + ]) + ).toBe(true); + deferredScript?.dispatchEvent(new Event('load')); + await vi.waitFor(() => expect(deferredPrepare).toHaveBeenCalledOnce()); + }); + + it('loads overlay-only presentation, GPT later, and Prebid later as separate authenticated artifacts', async () => { + vi.useFakeTimers(); + const runtimeHash = 'c'.repeat(64); + const deferredHash = 'd'.repeat(64); + const runtimeScript = document.createElement('script'); + runtimeScript.id = 'trustedserver-js'; + runtimeScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${runtimeHash}`; + document.head.insertBefore(runtimeScript, null); + let executingScript: HTMLScriptElement | null = runtimeScript; + vi.spyOn(document, 'currentScript', 'get').mockImplementation(() => executingScript); + const frames: FrameRequestCallback[] = []; + const idle: Array<() => void> = []; + const target: Record = {}; + const traceAttach = vi.fn(() => vi.fn()); + const traceDiagnostics = Object.freeze({ + current: vi.fn(() => Object.freeze({})), + history: vi.fn(() => Object.freeze([])), + subscribe: vi.fn(() => vi.fn()), + }); + const traceDataCapability = Object.freeze({ diagnostics: traceDiagnostics }); + const tracePresentationCapability = Object.freeze({ attachPresentation: traceAttach }); + const gptLaterRelease = vi.fn(); + const prebidLaterRelease = vi.fn(); + const gptConfig = { gamAttributionEnabled: true, pageBidsEnabled: true }; + const prebidConfig = { accountId: 'publisher' }; + const gptLater = Object.freeze({ + activate: vi.fn(() => gptLaterRelease), + start: vi.fn(), + }); + const prebidLater = Object.freeze({ + activate: vi.fn(() => prebidLaterRelease), + start: vi.fn(), + }); + const deferredIds = Object.freeze(['diagnostics_presentation', 'gpt_later', 'prebid_later']); + const manifestEntries = deferredIds.map((id) => + Object.freeze({ + id, + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + src: `/static/tsjs=tsjs-${id}.min.js?v=${deferredHash}`, + }) + ); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + document, + manifest: { + version: 1, + releaseId: RELEASE, + firstDisplay: null, + runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${runtimeHash}`, + integrations: [{ id: 'trace_provider', phase: 'takeover' }, ...manifestEntries], + }, + knownIntegrationIds: Object.freeze([ + 'trace_provider', + 'optional_gpt_diag_provider', + ...deferredIds, + ]), + catalog: Object.freeze([ + Object.freeze({ + id: 'trace_provider', + phase: 'takeover' as const, + trigger: null, + config: null, + consumes: Object.freeze([]), + provides: Object.freeze(['trace.v1', 'trace.presentation.v1']), + }), + Object.freeze({ + id: 'optional_gpt_diag_provider', + phase: 'takeover' as const, + trigger: null, + config: null, + consumes: Object.freeze([]), + provides: Object.freeze(['gpt_diag.v1']), + }), + Object.freeze({ + id: 'diagnostics_presentation', + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + config: null, + consumes: Object.freeze([ + 'runtime.v1', + 'trace.presentation.v1', + 'gpt_diag.v1?gpt_diagnostics_active', + ]), + provides: Object.freeze([]), + }), + ...deferredIds.slice(1).map((id) => + Object.freeze({ + id, + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + config: id === 'gpt_later' ? ('gpt' as const) : ('prebid' as const), + consumes: Object.freeze([]), + provides: Object.freeze([]), + }) + ), + ]), + boot: { + ...boot(), + integrations: { + version: 1, + entries: [ + { id: 'gpt', config: gptConfig }, + { id: 'prebid', config: prebidConfig }, + ], + }, + diagnostics: { version: 1, renderTraceOverlay: true, gpt: { active: false } }, + }, + getBindings: (id) => + Object.freeze({ + config: id === 'gpt_later' || id === 'prebid_later' ? Object.freeze({}) : undefined, + interfaces: Object.freeze( + id === 'gpt_later' + ? { gpt_later: gptLater } + : id === 'prebid_later' + ? { prebid_later: prebidLater } + : {} + ), + }), + phaseScheduler: { + cancelAnimationFrame: vi.fn(), + cancelIdleCallback: vi.fn(), + clearTimeout, + requestAnimationFrame: (callback) => { + frames.push(callback); + return frames.length; + }, + requestIdleCallback: (callback) => { + idle.push(callback); + return idle.length; + }, + setTimeout, + }, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'trace_provider', + phase: 'takeover', + releaseId: RELEASE, + prepare: () => + Object.freeze({ + activate: () => undefined, + interfaces: Object.freeze({ + 'trace.v1': traceDataCapability, + 'trace.presentation.v1': tracePresentationCapability, + }), + }), + }) + ) + ).toBe(true); + expect(Reflect.ownKeys(traceDataCapability)).toEqual(['diagnostics']); + expect(traceDataCapability).not.toHaveProperty('attachPresentation'); + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(document.head.querySelectorAll('script')).toHaveLength(1); + expect(traceAttach).not.toHaveBeenCalled(); + expect(gptLater.activate).not.toHaveBeenCalled(); + expect(prebidLater.activate).not.toHaveBeenCalled(); + + const loadedSources: string[] = []; + const originalHeadAppend = document.head.append.bind(document.head); + vi.spyOn(document.head, 'append').mockImplementation((...nodes) => { + originalHeadAppend(...nodes); + for (const node of nodes) { + if (!(node instanceof HTMLScriptElement) || node === runtimeScript) continue; + const entry = manifestEntries.find(({ src }) => node.src.endsWith(src)); + if (!entry) throw new Error('Unexpected deferred artifact source'); + executingScript = node; + loadedSources.push(node.src); + const registration = + entry.id === 'diagnostics_presentation' + ? createDiagnosticsPresentationIntegrationRegistration(RELEASE) + : createLifecycleIntegrationRegistration(entry.id, RELEASE); + expect(runtime.registerIntegration(registration)).toBe(true); + node.onload?.(new Event('load')); + executingScript = runtimeScript; + } + }); + + expect(runtime.protectFirstDisplayAttemptBatch([Promise.resolve()])).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + frames.shift()?.(1); + frames.shift()?.(2); + idle.shift()?.(); + await vi.waitFor(() => { + expect(traceAttach).toHaveBeenCalledOnce(); + expect(gptLater.start).toHaveBeenCalledOnce(); + expect(prebidLater.start).toHaveBeenCalledOnce(); + }); + expect(loadedSources).toEqual( + manifestEntries.map(({ src }) => new URL(src, window.location.origin).href) + ); + expect(new Set(loadedSources)).toHaveLength(3); + expect(loadedSources.every((source) => !source.includes('tsjs-unified'))).toBe(true); + expect(gptLater.activate).toHaveBeenCalledOnce(); + expect(prebidLater.activate).toHaveBeenCalledOnce(); + const acceptedCarrier = ( + target['boot'] as { + integrations: { entries: readonly { id: string; config: unknown }[] }; + } + ).integrations; + const acceptedGpt = acceptedCarrier.entries.find(({ id }) => id === 'gpt')?.config; + const acceptedPrebid = acceptedCarrier.entries.find(({ id }) => id === 'prebid')?.config; + expect(gptLater.activate).toHaveBeenCalledWith(acceptedGpt); + expect(gptLater.start).toHaveBeenCalledWith(acceptedGpt); + expect(prebidLater.activate).toHaveBeenCalledWith(acceptedPrebid); + expect(prebidLater.start).toHaveBeenCalledWith(acceptedPrebid); + expect(acceptedGpt).not.toBe(gptConfig); + expect(acceptedPrebid).not.toBe(prebidConfig); + + runtime.dispose(); + expect(gptLaterRelease).toHaveBeenCalledOnce(); + expect(prebidLaterRelease).toHaveBeenCalledOnce(); + }); + + it('resolves the frozen diagnostics namespace only after core and module activation', async () => { + const target: Record = {}; + const diagnostics = Object.freeze({ renderTrace: Object.freeze({}) }); + let activated = false; + const getDiagnosticsForPublish = vi.fn(() => { + expect(activated).toBe(true); + return diagnostics; + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + activateCore: () => { + activated = true; + }, + getDiagnosticsForPublish, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({ premature: true }), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(getDiagnosticsForPublish).toHaveBeenCalledOnce(); + expect(target['diagnostics']).toBe(diagnostics); + }); + + it('prepares inert owner interfaces before module preparation and activates afterward', async () => { + const order: string[] = []; + let prepared = false; + const runtime = createRuntime({ + target: { que: [() => order.push('drain')] }, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + prepareOwner: ({ boot: acceptedBoot, onDispose }) => { + expect(Object.isFrozen(acceptedBoot)).toBe(true); + prepared = true; + order.push('owner:prepare'); + onDispose(() => order.push('owner:dispose')); + }, + getBindings: () => { + expect(prepared).toBe(true); + order.push('bindings'); + return { config: Object.freeze({}), interfaces: Object.freeze({}) }; + }, + activateOwner: () => order.push('owner:activate'), + activateCore: () => order.push('core:activate'), + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId: RELEASE, + prepare: ({ onDispose }: { onDispose(callback: () => void): void }) => { + order.push('module:prepare'); + onDispose(() => order.push('module:dispose')); + return { + activate: ({ afterCommit }: { afterCommit(callback: () => void): void }) => { + order.push('module:activate'); + afterCommit(() => order.push('after-commit')); + }, + }; + }, + }) + ) + ).toBe(true); + + const result = await runtime.install(); + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'owner:prepare', + 'bindings', + 'module:prepare', + 'owner:activate', + 'core:activate', + 'module:activate', + 'after-commit', + 'drain', + ]); + + if (result.state === 'kernel') result.dispose(); + expect(order.slice(-2)).toEqual(['module:dispose', 'owner:dispose']); + }); + + it('keeps persistent activation and publication inside the supplied takeover call stack', async () => { + const order: string[] = []; + const runtime = createRuntime({ + target: { que: [() => order.push('drain')] }, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + prepareOwner: () => order.push('prepare'), + activateOwner: () => order.push('activate'), + coordinateTakeover: (prepared) => { + order.push('takeover:begin'); + const candidate = takeoverHandoff(); + const handoff = prepared.validateHandoff( + candidate.capture, + candidate.outline, + candidate.boot + ); + expect(handoff).toBeDefined(); + prepared.activate( + Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: handoff!, + identities: Object.freeze([]), + }) + ); + order.push('takeover:activated'); + prepared.commit(); + order.push('takeover:committed'); + }, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + const result = await runtime.install(); + expect(result, JSON.stringify(order)).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'prepare', + 'takeover:begin', + 'activate', + 'takeover:activated', + 'drain', + 'takeover:committed', + ]); + }); + + it.each(['prepare', 'activate'] as const)( + 'returns a takeover %s failure to bootstrap without publishing a second fallback owner', + async (checkpoint) => { + const target = { que: [] as unknown[] }; + const onInstallComplete = vi.fn(); + const coordinateTakeover = vi.fn((prepared) => { + const candidate = takeoverHandoff(); + const handoff = prepared.validateHandoff( + candidate.capture, + candidate.outline, + candidate.boot + ); + expect(handoff).toBeDefined(); + prepared.activate( + Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: handoff!, + identities: Object.freeze([]), + }) + ); + prepared.commit(); + }); + const fail = () => { + throw new Error(checkpoint); + }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + ...(checkpoint === 'prepare' ? { prepareOwner: fail } : { activateOwner: fail }), + coordinateTakeover, + onInstallComplete, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + + expect(runtime.state).toBe('failed'); + expect(onInstallComplete).toHaveBeenCalledOnce(); + expect(onInstallComplete).toHaveBeenCalledWith({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(target).not.toHaveProperty('version'); + expect(target).not.toHaveProperty('_internal'); + expect(target).not.toHaveProperty('requestAds'); + if (checkpoint === 'prepare') expect(coordinateTakeover).not.toHaveBeenCalled(); + else expect(coordinateTakeover).toHaveBeenCalledOnce(); + } + ); + + it('stops activation when owner activation disposes the installing runtime', async () => { + const activateCore = vi.fn(); + const activateModule = vi.fn(); + const disposeOwner = vi.fn(); + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + activateOwner: ({ onDispose }) => { + onDispose(disposeOwner); + runtime.dispose(); + }, + activateCore, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId: RELEASE, + prepare: () => ({ activate: activateModule }), + }) + ) + ).toBe(true); + + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activateCore).not.toHaveBeenCalled(); + expect(activateModule).not.toHaveBeenCalled(); + expect(disposeOwner).toHaveBeenCalledOnce(); + expect(runtime.state).toBe('fallback'); + expect(target).toMatchObject({ + _internal: { state: 'fallback', releaseId: RELEASE, reason: 'bundle_partial' }, + }); + }); + + it('runs queued work at the exact activation, commit, afterCommit, and FIFO drain boundaries', async () => { + const order: string[] = []; + let commitPushInstalled = false; + const backing: { que?: unknown[]; version?: string } = { + que: [ + function (this: unknown) { + expect(this).toBe(target); + order.push('preload-start'); + target.que?.push(() => order.push('preload-nested')); + order.push('preload-end'); + }, + ], + }; + const target = new Proxy(backing, { + defineProperty(object, key, descriptor) { + if (key === 'version' && !commitPushInstalled) { + commitPushInstalled = true; + object.que?.push(() => order.push('commit-enqueued')); + } + return Reflect.defineProperty(object, key, descriptor); + }, + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + activateCore: () => { + order.push('core-activation'); + target.que?.push(() => order.push('core-enqueued')); + }, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId: RELEASE, + prepare: () => ({ + activate: ({ afterCommit }: { afterCommit(callback: () => void): void }) => { + order.push('module-activation'); + target.que?.push(() => order.push('module-enqueued')); + afterCommit(() => { + order.push('after-commit-start'); + target.que?.push(() => order.push('after-commit-enqueued')); + order.push('after-commit-end'); + }); + }, + }), + }) + ) + ).toBe(true); + + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + expect(order).toEqual([ + 'core-activation', + 'module-activation', + 'commit-enqueued', + 'after-commit-start', + 'after-commit-enqueued', + 'after-commit-end', + 'preload-start', + 'preload-nested', + 'preload-end', + 'core-enqueued', + 'module-enqueued', + ]); + }); + + it.each([ + ['invalid manifest', { version: 2 }, 'abi_mismatch'], + ['missing bundle', manifest(['gpt']), 'bundle_partial'], + ] as const)('commits terminal fallback for %s', async (_name, candidateManifest, reason) => { + vi.useFakeTimers(); + const queued = vi.fn(); + const activateCore = vi.fn(); + const target = { que: [queued], boot: boot() }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: candidateManifest, + knownIntegrationIds: Object.freeze(['gpt']), + boot: target.boot, + activateCore, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + runtime.start(); + const installed = runtime.install(); + if (reason === 'bundle_partial') await vi.advanceTimersByTimeAsync(10_000); + await expect(installed).resolves.toEqual({ state: 'fallback', reason }); + + expect(runtime.state).toBe('fallback'); + expect(activateCore).not.toHaveBeenCalled(); + expect(queued).toHaveBeenCalledOnce(); + expect(target).toMatchObject({ version: '1.0.0', releaseId: RELEASE }); + expect((target as { _internal?: unknown })._internal).toEqual({ + state: 'fallback', + releaseId: RELEASE, + reason, + initialDisplayCommitted: false, + }); + await expect( + (target as unknown as { requestAds(options?: unknown): Promise }).requestAds() + ).resolves.toEqual({ slots: [] }); + expect( + (target as unknown as { _registerIntegration(value: unknown): boolean })._registerIntegration( + { + id: 'gpt', + releaseId: RELEASE, + prepare: vi.fn(), + } + ) + ).toBe(false); + }); + + it('publishes the captured exact takeover source when the manifest field is missing', async () => { + const runtimeSrc = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; + const runtimeScript = document.createElement('script'); + runtimeScript.id = 'trustedserver-js'; + runtimeScript.src = new URL(runtimeSrc, window.location.origin).href; + document.head.insertBefore(runtimeScript, null); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: runtimeScript, + }); + const candidateManifest = { + version: 1, + releaseId: RELEASE, + integrations: [], + }; + const target = { + boot: { + ...boot(), + manifest: candidateManifest, + }, + }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + document, + manifest: candidateManifest, + knownIntegrationIds: Object.freeze([] as string[]), + boot: target.boot, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect((target as { boot: { manifest: unknown } }).boot.manifest).toEqual({ + version: 1, + releaseId: RELEASE, + firstDisplay: null, + runtimeSrc, + integrations: [], + }); + }); + + it('publishes the captured exact takeover source when the manifest field is malformed', async () => { + const runtimeSrc = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; + const runtimeScript = document.createElement('script'); + runtimeScript.id = 'trustedserver-js'; + runtimeScript.src = new URL(runtimeSrc, window.location.origin).href; + document.head.insertBefore(runtimeScript, null); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: runtimeScript, + }); + const candidateManifest = { + version: 1, + releaseId: RELEASE, + firstDisplay: null, + runtimeSrc: `${runtimeSrc}&publisher=1`, + integrations: [], + }; + const target = { + boot: { + ...boot(), + manifest: candidateManifest, + }, + }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + document, + manifest: candidateManifest, + knownIntegrationIds: Object.freeze([] as string[]), + boot: target.boot, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect((target as { boot: { manifest: unknown } }).boot.manifest).toEqual({ + version: 1, + releaseId: RELEASE, + firstDisplay: null, + runtimeSrc, + integrations: [], + }); + }); + + it('leaves the namespace unclaimed when no trusted takeover source exists', () => { + const target = { + boot: boot(), + que: [vi.fn()], + }; + const bootDescriptor = Object.getOwnPropertyDescriptor(target, 'boot'); + const queueDescriptor = Object.getOwnPropertyDescriptor(target, 'que'); + const runtime = createRuntimeOwner({ + target, + releaseId: RELEASE, + manifest: { version: 1, releaseId: RELEASE, integrations: [] }, + knownIntegrationIds: Object.freeze([] as string[]), + boot: target.boot, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(false); + expect(runtime.state).toBe('unclaimed'); + expect(Object.getOwnPropertyDescriptor(target, 'boot')).toEqual(bootDescriptor); + expect(Object.getOwnPropertyDescriptor(target, 'que')).toEqual(queueDescriptor); + expect(target).not.toHaveProperty('_registerIntegration'); + expect(target).not.toHaveProperty('_internal'); + }); + + it('publishes an exact terminal namespace with no publisher-owned fields', async () => { + const target = { + que: [] as unknown[], + diagnostics: { legacy: true }, + adInit: vi.fn(), + renderAdUnit: vi.fn(), + setConfig: vi.fn(), + publisher: { retained: true }, + }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + + expect(Object.prototype.hasOwnProperty.call(target, 'diagnostics')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(target, 'adInit')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(target, 'renderAdUnit')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(target, 'setConfig')).toBe(false); + expect(target).not.toHaveProperty('publisher'); + }); + + it('allows exactly one bootstrap owner for a namespace', () => { + const target = {}; + const options = { + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([] as string[]), + boot: boot(), + kernel: { + addAdUnits: () => ({ registered: [] }), + diagnostics: Object.freeze({}), + requestAds: async () => ({ slots: [] }), + }, + }; + const first = createRuntime(options); + const second = createRuntime(options); + + expect(first.start()).toBe(true); + expect(second.start()).toBe(false); + expect(first.generation).not.toBe(second.generation); + }); + + it('rejects a detached async no-agent preparation before activation', async () => { + const target = {}; + const staleCoreActivation = vi.fn(); + const staleModuleActivation = vi.fn(); + const staleDisposal = vi.fn(); + let resolveStalePreparation: (() => void) | undefined; + const stalePreparation = new Promise((resolve) => { + resolveStalePreparation = resolve; + }); + const first = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + activateCore: staleCoreActivation, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + const second = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(first.start()).toBe(true); + expect( + first.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId: RELEASE, + prepare: ({ onDispose }: { onDispose(callback: () => void): void }) => { + onDispose(staleDisposal); + return stalePreparation.then(() => ({ activate: staleModuleActivation })); + }, + }) + ) + ).toBe(true); + const staleInstall = first.install(); + expect(first.state).toBe('fallback'); + expect(Reflect.deleteProperty(target, '_registerIntegration')).toBe(false); + expect(second.start()).toBe(false); + resolveStalePreparation?.(); + await expect(staleInstall).resolves.toEqual({ state: 'fallback', reason: 'bundle_partial' }); + + expect(staleCoreActivation).not.toHaveBeenCalled(); + expect(staleModuleActivation).not.toHaveBeenCalled(); + expect(staleDisposal).toHaveBeenCalledOnce(); + expect(first.state).toBe('fallback'); + expect(second.state).toBe('unclaimed'); + expect((target as { _internal?: unknown })._internal).toEqual({ + state: 'fallback', + releaseId: RELEASE, + reason: 'bundle_partial', + initialDisplayCommitted: false, + }); + }); + + it('rejects registration when candidate reflection replaces the owner handshake', async () => { + const target = {}; + const staleCoreActivation = vi.fn(); + const staleModuleActivation = vi.fn(); + const options = { + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }; + const first = createRuntime({ ...options, activateCore: staleCoreActivation }); + const second = createRuntime(options); + + expect(first.start()).toBe(true); + const registration = new Proxy( + { + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId: RELEASE, + prepareSync: () => ({ activate: staleModuleActivation }), + prepare: () => ({ activate: staleModuleActivation }), + }, + { + ownKeys(candidate) { + expect(Reflect.deleteProperty(target, '_registerIntegration')).toBe(true); + return Reflect.ownKeys(candidate); + }, + } + ); + + expect(first.registerIntegration(registration)).toBe(false); + expect(second.start()).toBe(true); + expect( + second.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId: RELEASE, + prepare: () => ({ activate: vi.fn() }), + }) + ) + ).toBe(true); + await expect(second.install()).resolves.toMatchObject({ state: 'kernel' }); + await expect(first.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + + expect(staleCoreActivation).not.toHaveBeenCalled(); + expect(staleModuleActivation).not.toHaveBeenCalled(); + expect(first.state).toBe('failed'); + expect(second.state).toBe('kernel'); + }); + + it('allows exactly one bootstrap owner across independently evaluated core modules', async () => { + const target = {}; + const options = { + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([] as string[]), + boot: boot(), + kernel: { + addAdUnits: () => ({ registered: [] }), + diagnostics: Object.freeze({}), + requestAds: async () => ({ slots: [] }), + }, + }; + const firstModule = await import('../../src/kernel/runtime'); + vi.resetModules(); + const secondModule = await import('../../src/kernel/runtime'); + installTestRuntimeScript(document); + const first = firstModule.createRuntime(options); + const second = secondModule.createRuntime(options); + + expect(first.start()).toBe(true); + expect(second.start()).toBe(false); + expect(first.generation).not.toBe(second.generation); + }); + + it('refuses a conflicting terminal namespace before constructing an installing generation', () => { + const target: { que: unknown[]; version?: string } = { que: [] }; + Object.defineProperty(target, 'version', { + configurable: false, + enumerable: true, + value: 'publisher', + writable: false, + }); + const queueDescriptor = Object.getOwnPropertyDescriptor(target, 'que'); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([] as string[]), + boot: boot(), + kernel: { + addAdUnits: () => ({ registered: [] }), + diagnostics: Object.freeze({}), + requestAds: async () => ({ slots: [] }), + }, + }); + + expect(runtime.start()).toBe(false); + expect(runtime.state).toBe('unclaimed'); + expect(Object.getOwnPropertyDescriptor(target, 'que')).toEqual(queueDescriptor); + expect(Reflect.ownKeys(target)).toEqual(['que', 'version']); + }); + + it.each([ + [ + 'wrong release', + { + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId: 'b'.repeat(64), + prepareSync: vi.fn(), + prepare: vi.fn(), + }, + ], + [ + 'unknown id', + { + abi: 1, + id: 'aps', + phase: 'takeover', + releaseId: RELEASE, + prepareSync: vi.fn(), + prepare: vi.fn(), + }, + ], + ])( + 'classifies %s registration as abi_mismatch without invoking module code', + async (_name, registration) => { + const runtime = createRuntime({ + target: {}, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + runtime.start(); + + expect(runtime.registerIntegration(registration)).toBe(false); + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect(registration.prepare).not.toHaveBeenCalled(); + } + ); + + it('classifies duplicate registration as abi_mismatch', async () => { + const prepare = vi.fn(() => ({ activate: vi.fn() })); + const registration = takeoverRegistration({ + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId: RELEASE, + prepare, + }); + const runtime = createRuntime({ + target: {}, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + runtime.start(); + expect(runtime.registerIntegration(registration)).toBe(true); + expect(runtime.registerIntegration(registration)).toBe(false); + + await expect(runtime.install()).resolves.toEqual({ state: 'fallback', reason: 'abi_mismatch' }); + expect(prepare).not.toHaveBeenCalled(); + }); + + it.each(['prepare_throw', 'prepare_reject', 'activate_throw'] as const)( + 'unwinds %s as bundle_partial', + async (checkpoint) => { + const disposed = vi.fn(); + const prepare = + checkpoint === 'prepare_throw' + ? () => { + throw new Error('prepare'); + } + : checkpoint === 'prepare_reject' + ? async ({ onDispose }: { onDispose(callback: () => void): void }) => { + onDispose(disposed); + throw new Error('prepare'); + } + : ({ onDispose }: { onDispose(callback: () => void): void }) => { + onDispose(disposed); + return { + activate: () => { + throw new Error('activate'); + }, + }; + }; + const runtime = createRuntime({ + target: {}, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId: RELEASE, + prepare, + }) + ); + + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + if (checkpoint !== 'prepare_throw') expect(disposed).toHaveBeenCalledOnce(); + } + ); + + it('shares the ten-second watchdog with a hung preparation and ignores its late continuation', async () => { + vi.useFakeTimers(); + let finish: ((value: { activate(): void }) => void) | undefined; + const lateActivate = vi.fn(); + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId: RELEASE, + prepare: () => + new Promise<{ activate(): void }>((resolve) => { + finish = resolve; + }), + }) + ); + const installed = runtime.install(); + + await vi.advanceTimersByTimeAsync(10_000); + await expect(installed).resolves.toEqual({ state: 'fallback', reason: 'bundle_partial' }); + finish?.({ activate: lateActivate }); + await Promise.resolve(); + expect(lateActivate).not.toHaveBeenCalled(); + expect(runtime.state).toBe('fallback'); + }); + + it('isolates afterCommit failure after kernel publication', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + runtime.registerIntegration( + takeoverRegistration({ + abi: 1, + id: 'gpt', + phase: 'takeover', + releaseId: RELEASE, + prepare: () => ({ + activate: ({ afterCommit }: { afterCommit(callback: () => void): void }) => + afterCommit(() => { + throw new Error('post commit'); + }), + }), + }) + ); + + await expect(runtime.install()).resolves.toMatchObject({ + state: 'kernel', + runtimeFailures: [{ id: 'gpt', phase: 'after_commit' }], + }); + expect(runtime.state).toBe('kernel'); + }); + + it('validates fallback calls against the exact empty safe projection', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot([{ slot: 'known', outcome: 'no_bid' }]), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const api = target as unknown as { + addAdUnits(units: unknown): unknown; + requestAds(options?: unknown): Promise; + boot: unknown; + }; + + await expect(api.requestAds()).resolves.toEqual({ slots: [] }); + await expect(api.requestAds({ slots: ['known', 'unknown'] })).resolves.toEqual({ + slots: [ + { slot: 'known', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + { slot: 'unknown', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + ], + }); + const controller = new AbortController(); + controller.abort(); + await expect(api.requestAds({ slots: ['known'], signal: controller.signal })).resolves.toEqual({ + slots: [{ slot: 'known', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + await expect(api.requestAds({ slots: [] })).rejects.toBeInstanceOf(RequestAdsInputError); + expect(() => api.addAdUnits({ code: '', mediaTypes: {} })).toThrow(AdUnitRegistrationError); + expect(() => + api.addAdUnits({ + code: 'programmatic', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }) + ).toThrow(TsjsUnavailableError); + expect(Object.isFrozen(api.boot)).toBe(true); + }); + + it('substitutes the exact safe auction projection when boot data is hostile', async () => { + const getter = vi.fn(() => ({ version: 1 })); + const hostile = {}; + Object.defineProperty(hostile, 'auctionProjection', { enumerable: true, get: getter }); + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: hostile, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + + expect(getter).not.toHaveBeenCalled(); + expect( + (target as unknown as { boot: { auctionProjection: unknown } }).boot.auctionProjection + ).toEqual({ + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], + bids: [], + }); + }); + + it('never retains projected slot membership in fallback boot', async () => { + const target = { boot: boot([{ slot: 'initial', outcome: 'no_bid' }]) }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: target.boot, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + target.boot = boot([{ slot: 'mutated', outcome: 'no_bid' }]); + + await runtime.install(); + const api = target as unknown as { + boot: { auctionProjection: { auction: { results: readonly { slot: string }[] } } }; + requestAds(options: unknown): Promise; + }; + expect(api.boot.auctionProjection.auction.results).toEqual([]); + await expect(api.requestAds({ slots: ['initial', 'mutated'] })).resolves.toEqual({ + slots: [ + { slot: 'initial', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + { slot: 'mutated', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + ], + }); + }); + + it.each(['getPrototypeOf', 'ownKeys', 'getOwnPropertyDescriptor'] as const)( + 'maps a hostile request options %s trap to invalid_options', + async (trap) => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const requestAds = (target as unknown as { requestAds(value: unknown): Promise }) + .requestAds; + const optionsTarget = trap === 'getOwnPropertyDescriptor' ? { slots: ['known'] } : {}; + + await expect(requestAds(hostileRecord(trap, optionsTarget))).rejects.toMatchObject({ + code: 'invalid_options', + }); + } + ); + + it.each(['getPrototypeOf', 'ownKeys', 'getOwnPropertyDescriptor'] as const)( + 'maps a hostile addAdUnits unit %s trap to invalid_unit', + async (trap) => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + const unit = hostileRecord(trap, { + code: 'hostile', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }); + + expect(() => addAdUnits(unit)).toThrow( + expect.objectContaining({ code: 'invalid_unit', unitIndex: 0 }) + ); + } + ); + + it('maps hostile outer addAdUnits Array reflection to invalid_units', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + const units = new Proxy([], { + ownKeys() { + throw new Error('hostile outer Array'); + }, + }); + + const error = thrownBy(() => addAdUnits(units)); + expect(error).toMatchObject({ code: 'invalid_units' }); + expect(Object.prototype.hasOwnProperty.call(error, 'unitIndex')).toBe(false); + }); + + it('maps a revoked outer addAdUnits Array proxy to invalid_units', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const { proxy, revoke } = Proxy.revocable([], {}); + revoke(); + + const error = thrownBy(() => + (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits(proxy) + ); + expect(error).toMatchObject({ code: 'invalid_units' }); + expect(Object.prototype.hasOwnProperty.call(error, 'unitIndex')).toBe(false); + }); + + it.each(['getPrototypeOf', 'ownKeys', 'getOwnPropertyDescriptor'] as const)( + 'substitutes exact safe boot for a hostile boot %s trap', + async (trap) => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: hostileRecord(trap, trap === 'getOwnPropertyDescriptor' ? boot() : {}), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect( + (target as unknown as { boot: { auctionProjection: unknown } }).boot.auctionProjection + ).toEqual({ + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], + bids: [], + }); + } + ); + + it('substitutes exact safe boot when nested boot contract proxies throw', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: { + cachePolicy: hostileRecord('ownKeys'), + auctionProjection: hostileRecord('getPrototypeOf'), + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect( + (target as unknown as { boot: { auctionProjection: unknown } }).boot.auctionProjection + ).toEqual({ + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], + bids: [], + }); + }); + + it('rejects a full boot whose server manifest disagrees with the accepted bundle manifest', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: { abi: 1, releaseId: RELEASE, manifest: manifest(['gpt']), ...boot() }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + + await expect(runtime.install()).resolves.toEqual({ state: 'fallback', reason: 'abi_mismatch' }); + }); + + it('binds validation and fallback publication to the embedded bundle release', async () => { + const serverRelease = 'b'.repeat(64); + const serverManifest = { version: 1, releaseId: serverRelease, integrations: [] }; + const target = {}; + const runtime = createRuntime({ + target, + releaseId: serverRelease, + manifest: serverManifest, + knownIntegrationIds: Object.freeze([]), + boot: { + abi: 1, + releaseId: serverRelease, + manifest: serverManifest, + ...boot(), + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + + await expect(runtime.install()).resolves.toEqual({ state: 'fallback', reason: 'abi_mismatch' }); + expect(target).toMatchObject({ + releaseId: RELEASE, + boot: { releaseId: RELEASE, manifest: { releaseId: RELEASE } }, + _internal: { state: 'fallback', releaseId: RELEASE, reason: 'abi_mismatch' }, + }); + }); + + it('does not invoke hostile Array iterators at fallback input boundaries', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot([{ slot: 'known', outcome: 'no_bid' }]), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const iterator = vi.fn(); + const slots = ['known']; + Object.defineProperty(slots, Symbol.iterator, { value: iterator }); + + await expect( + (target as unknown as { requestAds(value: unknown): Promise }).requestAds({ slots }) + ).rejects.toMatchObject({ code: 'invalid_slots' }); + expect(iterator).not.toHaveBeenCalled(); + }); + + it('uses exact addAdUnits dimension and bidder validation before refusing valid input', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + + expect(() => + addAdUnits({ code: 'zero', mediaTypes: { banner: { sizes: [[0, 250]] } } }) + ).toThrow(expect.objectContaining({ code: 'invalid_dimensions' })); + expect(() => + addAdUnits({ code: 'large', mediaTypes: { banner: { sizes: [[4097, 250]] } } }) + ).toThrow(expect.objectContaining({ code: 'dimensions_out_of_range' })); + expect(() => + addAdUnits({ + code: 'bad-bidder', + mediaTypes: { banner: { sizes: [[1, 1]] } }, + bids: [{ bidder: 'x'.repeat(65) }], + }) + ).toThrow(expect.objectContaining({ code: 'invalid_bidder' })); + expect(() => + addAdUnits({ + code: 'valid', + mediaTypes: { banner: { sizes: [[1, 4096]] } }, + bids: [{ bidder: 'aps', params: { placement: 'one' } }], + }) + ).toThrow(TsjsUnavailableError); + }); + + it.each([ + ['high', '\ud800'], + ['low', '\udc00'], + ] as const)( + 'rejects a lone %s UTF-16 surrogate in a programmatic slot code', + async (_kind, code) => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + + expect(() => addAdUnits({ code, mediaTypes: { banner: { sizes: [[300, 250]] } } })).toThrow( + expect.objectContaining({ code: 'invalid_code', unitIndex: 0 }) + ); + } + ); + + it('does not retain server slot collisions or capacity in fallback validation', async () => { + const makeFallback = async (slots: readonly string[]) => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(slots.map((slot) => ({ slot, outcome: 'no_bid' }))), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + return (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + }; + const collision = await makeFallback(['server']); + expect(() => + collision({ code: 'server', mediaTypes: { banner: { sizes: [[300, 250]] } } }) + ).toThrow(TsjsUnavailableError); + + const full = await makeFallback(Array.from({ length: 256 }, (_, index) => `slot-${index}`)); + expect(() => + full({ code: 'overflow', mediaTypes: { banner: { sizes: [[300, 250]] } } }) + ).toThrow(TsjsUnavailableError); + }); + + it('reports aggregate request overflow before combined registry capacity', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot( + Array.from({ length: 256 }, (_, index) => ({ + slot: `server-${index}`, + outcome: 'no_bid', + })) + ), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + + expect(() => + addAdUnits({ + code: 'programmatic-overflow', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'aps', params: { payload: 'x'.repeat(256 * 1024) } }], + }) + ).toThrow(expect.objectContaining({ code: 'request_body_too_large' })); + }); + + it('accepts contract-valid large collections and deep params before refusing availability', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + const sizes = Array.from({ length: 257 }, () => [1, 1]); + const bids = Array.from({ length: 257 }, () => ({ bidder: 'aps' })); + const paramsArray = Array.from({ length: 4097 }, () => 0); + let deepParams: object = { leaf: true }; + for (let depth = 0; depth < 128; depth += 1) deepParams = { child: deepParams }; + + for (const unit of [ + { code: 'many-sizes', mediaTypes: { banner: { sizes } } }, + { code: 'many-bids', mediaTypes: { banner: { sizes: [[1, 1]] } }, bids }, + { + code: 'large-params-array', + mediaTypes: { banner: { sizes: [[1, 1]] } }, + bids: [{ bidder: 'aps', params: { values: paramsArray } }], + }, + { + code: 'deep-params', + mediaTypes: { banner: { sizes: [[1, 1]] } }, + bids: [{ bidder: 'aps', params: deepParams }], + }, + ]) { + expect(() => addAdUnits(unit)).toThrow(TsjsUnavailableError); + } + }); + + it('classifies an empty banner size list as invalid_media_types', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + + expect(() => + (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits({ + code: 'empty-sizes', + mediaTypes: { banner: { sizes: [] } }, + }) + ).toThrow(expect.objectContaining({ code: 'invalid_media_types', unitIndex: 0 })); + }); + + it('bounds an exponentially expanded shared params DAG without revisiting nodes', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + const descriptorReads: number[] = []; + let shared: object = { value: 'leaf' }; + for (let depth = 0; depth < 24; depth += 1) { + const node = { left: shared, right: shared }; + const nodeIndex = descriptorReads.length; + descriptorReads.push(0); + shared = new Proxy(node, { + getOwnPropertyDescriptor(object, key) { + descriptorReads[nodeIndex] = (descriptorReads[nodeIndex] ?? 0) + 1; + if ((descriptorReads[nodeIndex] ?? 0) > Reflect.ownKeys(object).length) { + throw new Error('shared DAG node was expanded more than once'); + } + return Reflect.getOwnPropertyDescriptor(object, key); + }, + }); + } + + expect(() => + addAdUnits({ + code: 'shared-dag', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'aps', params: shared }], + }) + ).toThrow(expect.objectContaining({ code: 'request_body_too_large' })); + expect(descriptorReads.every((reads) => reads <= 2)).toBe(true); + }); + + it('measures addAdUnits input without invoking inherited toJSON hooks', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const hook = vi.fn(() => { + throw new Error('publisher toJSON'); + }); + Object.defineProperty(Object.prototype, 'toJSON', { configurable: true, value: hook }); + Object.defineProperty(Array.prototype, 'toJSON', { configurable: true, value: hook }); + try { + expect(() => + (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits({ + code: 'valid', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'aps', params: { placement: 'one' } }], + }) + ).toThrow(TsjsUnavailableError); + expect(hook).not.toHaveBeenCalled(); + } finally { + Reflect.deleteProperty(Object.prototype, 'toJSON'); + Reflect.deleteProperty(Array.prototype, 'toJSON'); + } + }); + + it('publishes an immutable exact logger facade', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const publicLog = (target as unknown as { log: object }).log; + + expect(Object.isFrozen(publicLog)).toBe(true); + expect(Object.keys(publicLog)).toEqual([ + 'setLevel', + 'getLevel', + 'error', + 'warn', + 'info', + 'debug', + ]); + expect(Reflect.set(publicLog, 'warn', vi.fn())).toBe(false); + }); + + it('returns false when queue descriptor reflection becomes hostile after preflight', () => { + let queueDescriptorReads = 0; + const backing = {}; + const target = new Proxy(backing, { + getOwnPropertyDescriptor(object, key) { + if (key === 'que') { + queueDescriptorReads += 1; + if (queueDescriptorReads === 2) throw new Error('hostile second queue reflection'); + } + return Reflect.getOwnPropertyDescriptor(object, key); + }, + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + let started: boolean | undefined; + + expect(() => { + started = runtime.start(); + }).not.toThrow(); + expect(started).toBe(false); + expect(runtime.state).toBe('unclaimed'); + expect(queueDescriptorReads).toBe(2); + expect(Object.prototype.hasOwnProperty.call(backing, '_registerIntegration')).toBe(false); + }); + + it('returns false when a claim mutation and its rollback restoration both throw', () => { + const ingress: unknown[] = []; + const backing = { que: ingress, boot: boot() }; + let queueDefinitionCalls = 0; + const target = new Proxy(backing, { + defineProperty(object, key, descriptor) { + if (key === 'que') { + queueDefinitionCalls += 1; + if (queueDefinitionCalls === 1) { + Reflect.defineProperty(object, key, descriptor); + throw new Error('hostile claim definition'); + } + if (queueDefinitionCalls === 2) throw new Error('hostile rollback definition'); + } + return Reflect.defineProperty(object, key, descriptor); + }, + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: backing.boot, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + let started: boolean | undefined; + + expect(() => { + started = runtime.start(); + }).not.toThrow(); + expect(started).toBe(false); + expect(runtime.state).toBe('unclaimed'); + expect(queueDefinitionCalls).toBe(2); + expect(Object.prototype.hasOwnProperty.call(backing, '_registerIntegration')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(backing, 'version')).toBe(false); + expect(Object.getOwnPropertyDescriptor(backing, 'que')).toMatchObject({ + configurable: true, + enumerable: true, + value: ingress, + writable: false, + }); + }); + + it('rolls back a failed start claim without leaving a partial owner', () => { + let fail = true; + const backing = {}; + const target = new Proxy(backing, { + defineProperty(object, key, descriptor) { + if (fail) { + fail = false; + throw new Error('transient define failure'); + } + return Reflect.defineProperty(object, key, descriptor); + }, + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + + expect(runtime.start()).toBe(false); + expect(runtime.state).toBe('unclaimed'); + expect(Object.prototype.hasOwnProperty.call(target, '_registerIntegration')).toBe(false); + expect( + createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }).start() + ).toBe(true); + }); + + it('captures the monotonic start before queue normalization work', async () => { + let time = 0; + const backing = {}; + const target = new Proxy(backing, { + defineProperty(object, key, descriptor) { + time = 10_000; + return Reflect.defineProperty(object, key, descriptor); + }, + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + now: () => time, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts new file mode 100644 index 000000000..5f305a647 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts @@ -0,0 +1,442 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { + createRuntimeSession, + type NavigationIdentityIssuerFactory, +} from '../../src/kernel/sessions'; + +function identityFactory(seed = 1): NavigationIdentityIssuerFactory { + let navigation = seed; + return () => { + const value = navigation; + navigation += 1; + return createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(value); + return target; + }, + }); + }; +} + +function frozenProjection(id: string): Readonly { + return Object.freeze({ + version: 1, + auction: Object.freeze({ version: 1, auctionId: id, results: Object.freeze([]) }), + bids: Object.freeze([]), + }); +} + +describe('runtime and navigation sessions', () => { + it('reports every navigation generation exactly once at its disposal boundary', () => { + const onNavigationDispose = vi.fn(); + const runtime = createRuntimeSession({ + createIdentityIssuer: identityFactory(), + onNavigationDispose, + }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + + const replacement = runtime.replaceNavigation(); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + expect(onNavigationDispose).toHaveBeenCalledExactlyOnceWith(initial.value.generation); + + runtime.dispose(); + runtime.dispose(); + expect(onNavigationDispose).toHaveBeenCalledTimes(2); + expect(onNavigationDispose).toHaveBeenLastCalledWith(replacement.value.generation); + }); + + it('owns one current navigation and replaces it atomically before reverse disposal', () => { + const order: string[] = []; + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + + expect(initial.ok).toBe(true); + if (!initial.ok) throw new Error('Expected initial navigation'); + expect(runtime.currentNavigation).toBe(initial.value); + initial.value.onDispose('first', () => order.push('first')); + initial.value.onDispose('second', () => { + expect(runtime.currentNavigation).not.toBe(initial.value); + order.push('second'); + }); + + const replacement = runtime.replaceNavigation(); + + expect(replacement.ok).toBe(true); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + expect(runtime.currentNavigation).toBe(replacement.value); + expect(initial.value.disposed).toBe(true); + expect(replacement.value.currentAuctionProjection).toBeUndefined(); + expect(order).toEqual(['second', 'first']); + expect(runtime.snapshotInventoryForTest()).toMatchObject({ + currentNavigationGeneration: replacement.value.generation, + disposedNavigations: 1, + navigationCount: 1, + }); + }); + + it('makes late old-generation callbacks inert and allows the same DOM alias on a new route', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + const mutation = vi.fn(); + const oldCallback = initial.value.capture(mutation); + + expect(initial.value.claimAlias('shared-dom-id')).toBe(true); + expect(oldCallback('before')).toBe(true); + const replacement = runtime.replaceNavigation(); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + + expect(replacement.value.claimAlias('shared-dom-id')).toBe(true); + expect(oldCallback('late')).toBe(false); + expect(mutation).toHaveBeenCalledExactlyOnceWith('before'); + expect(initial.value.snapshotInventoryForTest().aliases).toBe(0); + expect(replacement.value.snapshotInventoryForTest().aliases).toBe(1); + }); + + it('does not publish the replacement while old-navigation disposers are running', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + let disposerSawCurrent = true; + let aliasMutation: boolean | undefined; + initial.value.onDispose('reentrant-alias', () => { + disposerSawCurrent = runtime.currentNavigation !== undefined; + aliasMutation = runtime.currentNavigation?.claimAlias('must-not-cross-generation'); + }); + + const replacement = runtime.replaceNavigation(); + + expect(replacement).toMatchObject({ ok: true }); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + expect(disposerSawCurrent).toBe(false); + expect(aliasMutation).toBeUndefined(); + expect(runtime.currentNavigation).toBe(replacement.value); + expect(replacement.value.disposed).toBe(false); + expect(replacement.value.snapshotInventoryForTest().aliases).toBe(0); + }); + + it('blocks nested replacement from an old-navigation disposer', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + let nestedReplacement: ReturnType | undefined; + initial.value.onDispose('nested-replacement', () => { + nestedReplacement = runtime.replaceNavigation(); + }); + + const replacement = runtime.replaceNavigation(); + + expect(nestedReplacement).toEqual({ + ok: false, + reason: 'navigation_transition_in_progress', + }); + expect(replacement).toMatchObject({ ok: true }); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + expect(runtime.currentNavigation).toBe(replacement.value); + expect(replacement.value.disposed).toBe(false); + + const successive = runtime.replaceNavigation(); + expect(successive).toMatchObject({ ok: true }); + if (!successive.ok) throw new Error('Expected successive replacement'); + expect(runtime.currentNavigation).toBe(successive.value); + expect(successive.value.disposed).toBe(false); + }); + + it('publishes no replacement if runtime disposal occurs during old-navigation unwind', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + initial.value.onDispose('runtime', () => runtime.dispose()); + + expect(runtime.replaceNavigation()).toEqual({ + ok: false, + reason: 'runtime_disposed', + }); + expect(runtime.currentNavigation).toBeUndefined(); + expect(runtime.disposed).toBe(true); + }); + + it('publishes no initial navigation if identity setup disposes the runtime', () => { + const issueIdentity = identityFactory(); + const runtime = createRuntimeSession({ + createIdentityIssuer: () => { + runtime.dispose(); + return issueIdentity(); + }, + }); + + expect(runtime.startInitialNavigation(frozenProjection('initial'))).toEqual({ + ok: false, + reason: 'runtime_disposed', + }); + expect(runtime.currentNavigation).toBeUndefined(); + expect(runtime.disposed).toBe(true); + }); + + it('blocks nested initial-navigation creation from identity setup', () => { + const issueIdentity = identityFactory(); + let nested: ReturnType['startInitialNavigation']>; + let firstCall = true; + const runtime = createRuntimeSession({ + createIdentityIssuer: () => { + if (firstCall) { + firstCall = false; + nested = runtime.startInitialNavigation(frozenProjection('nested')); + } + return issueIdentity(); + }, + }); + + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + + expect(nested!).toEqual({ + ok: false, + reason: 'navigation_transition_in_progress', + }); + expect(initial).toMatchObject({ ok: true }); + if (!initial.ok) throw new Error('Expected initial navigation'); + expect(runtime.currentNavigation).toBe(initial.value); + expect(initial.value.disposed).toBe(false); + }); + + it('cleans timers, listeners, and ports exactly once across double disposal', () => { + const cleanup = { + timer: vi.fn(), + listener: vi.fn(), + port: vi.fn(), + }; + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const navigation = runtime.startInitialNavigation(frozenProjection('initial')); + if (!navigation.ok) throw new Error('Expected initial navigation'); + + navigation.value.onDispose('timer', cleanup.timer); + navigation.value.onDispose('listener', cleanup.listener); + navigation.value.onDispose('port', cleanup.port); + navigation.value.dispose(); + navigation.value.dispose(); + + expect(cleanup.port).toHaveBeenCalledOnce(); + expect(cleanup.listener).toHaveBeenCalledOnce(); + expect(cleanup.timer).toHaveBeenCalledOnce(); + expect(navigation.value.snapshotInventoryForTest()).toMatchObject({ + disposed: true, + activeDisposers: 0, + disposedByKind: { listener: 1, port: 1, timer: 1 }, + }); + }); + + it('clears an exactly current navigation after direct child disposal', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + + initial.value.dispose(); + + expect(runtime.currentNavigation).toBeUndefined(); + expect(runtime.snapshotInventoryForTest()).toMatchObject({ + currentNavigationGeneration: undefined, + disposedNavigations: 1, + navigationCount: 0, + }); + const replacement = runtime.replaceNavigation(); + expect(replacement).toMatchObject({ ok: true }); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + expect(runtime.currentNavigation).toBe(replacement.value); + expect(replacement.value.disposed).toBe(false); + }); + + it('blocks replacement before remaining direct-disposal callbacks can mutate a new route', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + let replacement: ReturnType | undefined; + let disposerSawCurrent = true; + let aliasMutation: boolean | undefined; + initial.value.onDispose('old-mutator', () => { + disposerSawCurrent = runtime.currentNavigation !== undefined; + aliasMutation = runtime.currentNavigation?.claimAlias('must-not-cross-generation'); + }); + initial.value.onDispose('replacement', () => { + replacement = runtime.replaceNavigation(); + }); + + initial.value.dispose(); + + expect(replacement).toEqual({ + ok: false, + reason: 'navigation_transition_in_progress', + }); + expect(disposerSawCurrent).toBe(false); + expect(aliasMutation).toBeUndefined(); + expect(runtime.currentNavigation).toBeUndefined(); + expect(runtime.snapshotInventoryForTest()).toMatchObject({ + currentNavigationGeneration: undefined, + disposedNavigations: 1, + navigationCount: 0, + }); + + const successive = runtime.replaceNavigation(); + expect(successive).toMatchObject({ ok: true }); + if (!successive.ok) throw new Error('Expected successive navigation'); + expect(runtime.currentNavigation).toBe(successive.value); + expect(successive.value.snapshotInventoryForTest().aliases).toBe(0); + }); + + it('owns auction batches and render attempts in nested child scopes', () => { + const order: string[] = []; + const staleMutation = vi.fn(); + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory(7) }); + const navigation = runtime.startInitialNavigation(frozenProjection('initial')); + if (!navigation.ok) throw new Error('Expected initial navigation'); + const batch = navigation.value.createAuctionBatch('batch-one'); + const overlappingBatch = navigation.value.createAuctionBatch('batch-two'); + + expect(batch).toBeDefined(); + if (!batch) throw new Error('Expected auction batch'); + if (!overlappingBatch) throw new Error('Expected overlapping auction batch'); + const attempt = batch.createRenderAttempt('slot-one'); + const secondAttempt = batch.createRenderAttempt('slot-two'); + expect(attempt).toMatchObject({ ok: true }); + if (!attempt.ok) throw new Error('Expected render attempt'); + expect(secondAttempt).toMatchObject({ ok: true }); + if (!secondAttempt.ok) throw new Error('Expected second render attempt'); + expect(overlappingBatch.createRenderAttempt('slot-one')).toEqual({ + ok: false, + reason: 'attempt_exists', + }); + expect(attempt.value.id).toMatch(/^a1_[A-Za-z0-9_-]{22}$/); + expect(attempt.value.navigationGeneration).toBe(navigation.value.generation); + expect(attempt.value.navigationGeneration).not.toBe(attempt.value.generation); + batch.onDispose('batch', () => order.push('batch')); + batch.onDispose('late-callback', navigation.value.capture(staleMutation)); + attempt.value.onDispose('attempt-first', () => order.push('attempt-first')); + attempt.value.onDispose('attempt-second', () => order.push('attempt-second')); + expect(navigation.value.snapshotInventoryForTest()).toMatchObject({ + attempts: 2, + batches: 2, + retainedAttemptScopes: 2, + retainedBatchScopes: 2, + }); + + secondAttempt.value.dispose(); + overlappingBatch.dispose(); + expect(navigation.value.snapshotInventoryForTest()).toMatchObject({ + attempts: 1, + batches: 1, + retainedAttemptScopes: 1, + retainedBatchScopes: 1, + }); + + navigation.value.dispose(); + + expect(staleMutation).not.toHaveBeenCalled(); + expect(order).toEqual(['attempt-second', 'attempt-first', 'batch']); + expect(batch.disposed).toBe(true); + expect(attempt.value.disposed).toBe(true); + expect(navigation.value.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + batches: 0, + }); + }); + + it('prepares, commits, and rolls back one immutable winner-context admission', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const navigation = runtime.startInitialNavigation(); + if (!navigation.ok) throw new Error('Expected navigation'); + const batch = navigation.value.createAuctionBatch('winner-context'); + if (!batch) throw new Error('Expected auction batch'); + const attempt = batch.createRenderAttempt('fictional-slot'); + if (!attempt.ok) throw new Error('Expected render attempt'); + const accepted = Object.freeze({ selectedCpm: 1.25 }); + + expect(attempt.value.winnerContext).toBeUndefined(); + const first = attempt.value.prepareWinnerContext(accepted); + expect(first).toBeDefined(); + expect(attempt.value.winnerContext).toBeUndefined(); + expect(first?.commit()).toBe(true); + expect(attempt.value.winnerContext).toBe(accepted); + expect(first?.rollback()).toBe(true); + expect(attempt.value.winnerContext).toBeUndefined(); + + const committed = attempt.value.prepareWinnerContext(accepted); + expect(committed?.commit()).toBe(true); + expect(attempt.value.winnerContext).toBe(accepted); + expect(attempt.value.prepareWinnerContext(accepted)?.commit()).toBe(true); + expect( + attempt.value.prepareWinnerContext(Object.freeze({ selectedCpm: 1.25 })) + ).toBeUndefined(); + + attempt.value.dispose(); + expect(attempt.value.prepareWinnerContext(Object.freeze({ selectedCpm: 2 }))).toBeUndefined(); + expect(attempt.value.winnerContext).toBe(accepted); + }); + + it('refuses identity failure before replacing or creating route work', () => { + const firstIssuer = identityFactory(); + const createIdentityIssuer = vi + .fn() + .mockImplementationOnce(firstIssuer) + .mockReturnValue({ ok: false, reason: 'identity_generation_failed' }); + const runtime = createRuntimeSession({ createIdentityIssuer }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + const disposer = vi.fn(); + initial.value.onDispose('route', disposer); + + const replacement = runtime.replaceNavigation(); + + expect(replacement).toEqual({ ok: false, reason: 'identity_generation_failed' }); + expect(runtime.currentNavigation).toBe(initial.value); + expect(initial.value.disposed).toBe(false); + expect(disposer).not.toHaveBeenCalled(); + expect(runtime.snapshotInventoryForTest()).toMatchObject({ + disposedNavigations: 0, + navigationCount: 1, + }); + }); + + it('owns aliases, intents, targeting, batches, attempts, and one immutable projection', () => { + const projection = frozenProjection('initial'); + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const navigation = runtime.startInitialNavigation(projection); + if (!navigation.ok) throw new Error('Expected initial navigation'); + + expect(navigation.value.claimAlias('slot-alias')).toBe(true); + expect(navigation.value.claimAlias('slot-alias')).toBe(false); + expect(navigation.value.claimIntent('slot-one')).toBe(true); + expect(navigation.value.claimTargeting('slot-one')).toBe(true); + expect(navigation.value.currentAuctionProjection).toBe(projection); + expect(Object.isFrozen(navigation.value.currentAuctionProjection)).toBe(true); + expect(navigation.value.snapshotInventoryForTest()).toMatchObject({ + aliases: 1, + attempts: 0, + batches: 0, + intents: 1, + targetingOwners: 1, + }); + }); + + it('owns injected interfaces and runtime disposers without exposing mutable inventory', () => { + const order: string[] = []; + const interfaces = Object.freeze({ messaging: Object.freeze({ active: true }) }); + const runtime = createRuntimeSession({ + createIdentityIssuer: identityFactory(), + interfaces, + }); + runtime.onDispose('adapter', () => order.push('adapter')); + runtime.onDispose('service', () => order.push('service')); + + expect(runtime.interfaces).toBe(interfaces); + expect(Object.isFrozen(runtime.interfaces)).toBe(true); + runtime.dispose(); + runtime.dispose(); + + expect(order).toEqual(['service', 'adapter']); + const inventory = runtime.snapshotInventoryForTest(); + expect(Object.isFrozen(inventory)).toBe(true); + expect(inventory).toMatchObject({ disposed: true, activeDisposers: 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 6f4be0caa..27e9353e8 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 @@ -18,6 +18,7 @@ import { JSDOM } from 'jsdom'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { main } from '../build-prebid-external.mjs'; +import { createBrowserPrebidAdapter } from '../src/adapters/prebid'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const libDir = path.resolve(__dirname, '..'); @@ -26,6 +27,19 @@ let outputDirectory; let bundleCode; let shimCode; let prebidVersion; +let artifactManifest; + +function cloneAndDeepFreezeInWindow(pageWindow, value) { + const cloned = pageWindow.JSON.parse(JSON.stringify(value)); + const freeze = (entry) => { + if (entry && typeof entry === 'object') { + for (const key of pageWindow.Object.getOwnPropertyNames(entry)) freeze(entry[key]); + pageWindow.Object.freeze(entry); + } + return entry; + }; + return freeze(cloned); +} beforeAll(async () => { outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-artifacts-')); @@ -38,9 +52,11 @@ beforeAll(async () => { '--out', outputDirectory, ]); - const manifest = JSON.parse(fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8')); - bundleCode = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); - prebidVersion = manifest.prebidVersion; + artifactManifest = JSON.parse( + fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8') + ); + bundleCode = fs.readFileSync(path.join(outputDirectory, artifactManifest.filename), 'utf8'); + prebidVersion = artifactManifest.prebidVersion; const { build } = await import('vite'); await build({ @@ -58,7 +74,6 @@ beforeAll(async () => { format: 'iife', dir: outputDirectory, entryFileNames: 'tsjs-prebid.js', - inlineDynamicImports: true, extend: false, name: 'tsjs_prebid', }, @@ -86,142 +101,371 @@ describe('tsjs-prebid shim artifact', () => { // A value-import of Prebid or a private rendering helper would multiply // the shim size; retain a margin above the normal compact shim output. expect(bundleCode.length).toBeGreaterThan(200_000); - expect(shimCode.length).toBeLessThan(32_000); - expect(shimCode).toContain('markWinningBidAsUsed'); + expect(shimCode.length).toBeLessThan(30_000); + expect(shimCode).toContain('registerTrustedServerBidder'); }); }); describe('external bundle + served shim evaluated together', () => { - it('populates the public API, installs the shim exactly once, and routes an /auction request', async () => { + it('reuses an exact artifact without replaying factories and keeps one watchdog per wrapper', () => { const dom = new JSDOM('', { url: 'https://pub.example.com/article', runScripts: 'outside-only', - pretendToBeVisual: true, }); const pageWindow = dom.window; - - // Stub the network before any artifact runs: Prebid's ajax module - // captures window.fetch at evaluation time and builds Request objects. - // jsdom ships none of the fetch API, so lend it Node's — with relative - // URLs resolved against the page, as a browser Request would. - const fetchSpy = vi.fn( - async () => - new Response('{}', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ); - pageWindow.fetch = fetchSpy; - pageWindow.Request = class PageRequest extends Request { - constructor(resource, init) { - super( - typeof resource === 'string' - ? new URL(resource, 'https://pub.example.com').href - : resource, - init - ); + const watchdogs = []; + const originalSetTimeout = pageWindow.setTimeout.bind(pageWindow); + pageWindow.setTimeout = (callback, delay, ...arguments_) => { + if (delay === 5_000 && String(callback).includes('__tsWatchdogFired')) { + watchdogs.push(callback); + return 1; } + return originalSetTimeout(callback, delay, ...arguments_); }; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; pageWindow.Headers = Headers; pageWindow.Response = Response; pageWindow.AbortController = AbortController; - if (!('isSecureContext' in pageWindow)) { - pageWindow.isSecureContext = true; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + + pageWindow.eval(bundleCode); + const firstBinding = pageWindow.pbjs; + const firstRequestBids = firstBinding.requestBids; + const firstStamp = firstBinding.__trustedServerArtifactV1; + pageWindow.eval(bundleCode); + + expect(pageWindow.pbjs).toBe(firstBinding); + expect(pageWindow.pbjs.requestBids).toBe(firstRequestBids); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(firstStamp); + expect(watchdogs).toHaveLength(2); + + const processQueue = vi.fn(firstBinding.processQueue.bind(firstBinding)); + firstBinding.processQueue = processQueue; + for (const watchdog of watchdogs) { + watchdog(); + watchdog(); } + expect(processQueue).toHaveBeenCalledTimes(2); + dom.window.close(); + }); - // Mirror the server's head-injected state, which always precedes the - // bundle script in document order. + it('reuses separately constructed identical artifacts without reporting a conflict', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + const watchdogs = []; + const originalSetTimeout = pageWindow.setTimeout.bind(pageWindow); + pageWindow.setTimeout = (callback, delay, ...arguments_) => { + if (delay === 5_000 && String(callback).includes('__tsWatchdogFired')) { + watchdogs.push(callback); + return 1; + } + return originalSetTimeout(callback, delay, ...arguments_); + }; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); - pageWindow.__tsjs_prebid = { - clientSideBidders: [], - serverSideBidders: ['appnexus'], + const warn = vi.fn(); + pageWindow.console.warn = warn; + const firstBytes = Buffer.from(bundleCode, 'utf8'); + const duplicateBytes = Buffer.from(bundleCode, 'utf8'); + expect(firstBytes).not.toBe(duplicateBytes); + expect(firstBytes.equals(duplicateBytes)).toBe(true); + + pageWindow.eval(firstBytes.toString('utf8')); + const firstBinding = pageWindow.pbjs; + const firstRequestBids = firstBinding.requestBids; + const firstRegisterBidAdapter = firstBinding.registerBidAdapter; + const firstStamp = firstBinding.__trustedServerArtifactV1; + pageWindow.eval(duplicateBytes.toString('utf8')); + + expect(pageWindow.pbjs).toBe(firstBinding); + expect(pageWindow.pbjs.requestBids).toBe(firstRequestBids); + expect(pageWindow.pbjs.registerBidAdapter).toBe(firstRegisterBidAdapter); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(firstStamp); + expect(warn).not.toHaveBeenCalled(); + expect(watchdogs).toHaveLength(2); + + const processQueue = vi.fn(firstBinding.processQueue.bind(firstBinding)); + firstBinding.processQueue = processQueue; + for (const watchdog of watchdogs) { + watchdog(); + watchdog(); + } + expect(processQueue).toHaveBeenCalledTimes(2); + dom.window.close(); + }); + + it('refuses a different valid artifact without disturbing the working binding', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + const conflictingStamp = { + abi: artifactManifest.abi, + artifactReleaseId: 'f'.repeat(64), + prebidVersion: artifactManifest.prebidVersion, + moduleStems: artifactManifest.moduleStems, + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, }; + pageWindow.eval( + `window.__conflictingRequestBids=function conflictingRequestBids(){};window.pbjs={que:[],cmd:[]};["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids","setTargetingForGPTAsync"].forEach(function(name){window.pbjs[name]=name==="requestBids"?window.__conflictingRequestBids:function(){};});` + ); + pageWindow.__conflictingStamp = cloneAndDeepFreezeInWindow(pageWindow, conflictingStamp); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__conflictingStamp, + enumerable: false, + writable: false, + configurable: false, + }); + const binding = pageWindow.pbjs; + const warn = vi.fn(); + pageWindow.console.warn = warn; - pageWindow.eval(bundleCode); + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(pageWindow.pbjs).toBe(binding); + expect(pageWindow.pbjs.requestBids).toBe(pageWindow.__conflictingRequestBids); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__conflictingStamp); + expect(warn).toHaveBeenCalledTimes(1); + dom.window.close(); + }); + it('does not mistake an exact stamp on a Prebid stub for an initialized duplicate', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.__exactStamp = cloneAndDeepFreezeInWindow(pageWindow, { + abi: artifactManifest.abi, + artifactReleaseId: artifactManifest.artifactReleaseId, + prebidVersion: artifactManifest.prebidVersion, + moduleStems: artifactManifest.moduleStems, + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + }); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__exactStamp, + enumerable: false, + writable: false, + configurable: false, + }); + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); expect(typeof pageWindow.pbjs.requestBids).toBe('function'); - expect(typeof pageWindow.pbjs.registerBidAdapter).toBe('function'); - expect(pageWindow.__tsjs_prebid_bundle.adapters).toEqual(['adf']); - expect([...pageWindow.__tsjs_prebid_bundle.bidderCodes]).toEqual([ - 'adf', - 'adform', - 'adformOpenRTB', - ]); - expect([...pageWindow.__tsjs_prebid_bundle.userIdModules]).toEqual(['sharedIdSystem']); - - // Count trustedServer registrations across repeated shim evaluations. - const originalRegisterBidAdapter = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); - const registerSpy = vi.fn(originalRegisterBidAdapter); - pageWindow.pbjs.registerBidAdapter = registerSpy; - - pageWindow.eval(shimCode); - const wrappedRequestBids = pageWindow.pbjs.requestBids; - - // A second evaluation (double script inclusion, or a legacy bundle that - // still carries a baked-in shim running after this one) must be a no-op. - pageWindow.eval(shimCode); - - const trustedServerRegistrations = registerSpy.mock.calls.filter( - ([, bidderCode]) => bidderCode === 'trustedServer' + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__exactStamp); + dom.window.close(); + }); + + it('accepts an exact 128-byte non-ASCII artifact name on a real stamped binding', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + const boundaryName = 'é'.repeat(64); + const boundaryStamp = { + abi: artifactManifest.abi, + artifactReleaseId: 'e'.repeat(64), + prebidVersion: artifactManifest.prebidVersion, + moduleStems: [...artifactManifest.moduleStems, boundaryName].sort(), + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + }; + pageWindow.eval( + `window.__fakeRequestBids=function fakeRequestBids(){};window.pbjs={que:[],cmd:[]};["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids","setTargetingForGPTAsync"].forEach(function(name){window.pbjs[name]=name==="requestBids"?window.__fakeRequestBids:function(){};});` ); - expect(trustedServerRegistrations).toHaveLength(1); - expect(pageWindow.pbjs.requestBids).toBe(wrappedRequestBids); - expect(pageWindow.__tsjsPrebidShimInstalled).toBe(true); - - // Drive one real auction through the wrapped requestBids and assert the - // transformed request reaches /auction. - const slot = pageWindow.document.createElement('div'); - slot.id = 'ad-slot-1'; - pageWindow.document.body.appendChild(slot); - - pageWindow.pbjs.requestBids({ - adUnits: [ - { - code: 'ad-slot-1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - bids: [ - { - bidder: 'trustedServer', - params: { - bidderParams: { - appnexus: { placementId: 1 }, - pbsProviderId: { placementId: 2 }, - returnedSeatAlias: { placementId: 3 }, - }, - }, - }, - ], - }, - ], - timeout: 1000, + pageWindow.__boundaryStamp = cloneAndDeepFreezeInWindow(pageWindow, boundaryStamp); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__boundaryStamp, + enumerable: false, + writable: false, + configurable: false, }); - const requestUrl = (resource) => - typeof resource === 'string' ? resource : String(resource?.url ?? resource); + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(pageWindow.pbjs.requestBids).toBe(pageWindow.__fakeRequestBids); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__boundaryStamp); + dom.window.close(); + }); - await vi.waitFor( - () => { - expect( - fetchSpy.mock.calls.some(([resource]) => requestUrl(resource).includes('/auction')) - ).toBe(true); - }, - { timeout: 10_000 } + it('does not accept a UTF-8-overlong artifact name on a real stamped binding', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + const overlongName = `${'é'.repeat(64)}a`; + const malformedStamp = { + abi: artifactManifest.abi, + artifactReleaseId: 'f'.repeat(64), + prebidVersion: artifactManifest.prebidVersion, + moduleStems: [...artifactManifest.moduleStems, overlongName].sort(), + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + }; + pageWindow.eval( + `window.__fakeRequestBids=function fakeRequestBids(){};window.pbjs={que:[],cmd:[]};["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids","setTargetingForGPTAsync"].forEach(function(name){window.pbjs[name]=name==="requestBids"?window.__fakeRequestBids:function(){};});` ); + pageWindow.__malformedStamp = cloneAndDeepFreezeInWindow(pageWindow, malformedStamp); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__malformedStamp, + enumerable: false, + writable: false, + configurable: false, + }); - const [resource, init] = fetchSpy.mock.calls.find(([target]) => - requestUrl(target).includes('/auction') - ); - const body = init?.body ?? (typeof resource === 'object' ? await resource.text() : undefined); - const method = init?.method ?? resource?.method; - expect(method).toBe('POST'); - const payload = JSON.parse(body); - const adUnit = payload.adUnits[0]; - expect(adUnit.code).toBe('ad-slot-1'); - // Stored trustedServer params retain only authoritative server-side route - // codes; provider IDs and returned aliases cannot reach /auction. - const trustedServerBid = adUnit.bids.find((bid) => bid.bidder === 'trustedServer'); - expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 1 } }); + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(typeof pageWindow.pbjs.requestBids).toBe('function'); + expect(pageWindow.pbjs.requestBids).not.toBe(pageWindow.__fakeRequestBids); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__malformedStamp); + dom.window.close(); + }); + it('keeps publisher Prebid usable when a hostile stamp cannot be replaced', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + const hostileStamp = Object.freeze({ abi: 99 }); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: hostileStamp, + enumerable: true, + writable: false, + configurable: false, + }); + const warn = vi.fn(); + pageWindow.console.warn = warn; + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(typeof pageWindow.pbjs.requestBids).toBe('function'); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(hostileStamp); + expect(warn).toHaveBeenCalledTimes(1); + dom.window.close(); + }); + + it('admits one exact TS bid through the real 10.26.0 response callback', async () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + pretendToBeVisual: true, + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.eval(bundleCode); + + const adapter = createBrowserPrebidAdapter(pageWindow); + let resolveAuction; + const auctionReady = new Promise((resolve) => { + resolveAuction = resolve; + }); + let resolveBidsBack; + const bidsBack = new Promise((resolve) => { + resolveBidsBack = resolve; + }); + const operation = adapter.run((prebid) => { + prebid.registerTrustedServerBidder(resolveAuction); + return prebid.requestBids({ + adUnits: [ + { + code: 'slot-one', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'trustedServer', params: {} }], + }, + ], + timeout: 1_000, + bidsBackHandler: resolveBidsBack, + }); + }); + await operation.result; + const auction = await auctionReady; + expect(Object.isFrozen(auction)).toBe(true); + expect(auction.bids).toHaveLength(1); + + const request = auction.bids[0]; + const reservationId = `r1_${'z'.repeat(22)}`; + const prepared = Object.freeze({ + auctionId: auction.auctionId, + adUnitCode: request.adUnitCode, + bid: Object.freeze({ + requestId: request.requestId, + adId: reservationId, + cpm: 1.25, + width: 300, + height: 250, + ad: '', + ttl: 300, + creativeId: 'creative-one', + netRevenue: true, + currency: 'USD', + bidderCode: 'trustedServer', + meta: Object.freeze({ + advertiserDomains: Object.freeze([]), + tsAuctionId: auction.auctionId, + tsBidId: 'server-bid-one', + }), + }), + }); + + const beforeAdmission = pageWindow.pbjs.getBidResponsesForAdUnitCode('slot-one'); + expect(Array.isArray(beforeAdmission)).toBe(true); + expect(Array.isArray(beforeAdmission.bids)).toBe(true); + expect(beforeAdmission.bids).toHaveLength(0); + expect(adapter.admitTrustedBid(prepared)).toBe('admitted'); + const stored = pageWindow.pbjs.getBidResponsesForAdUnitCode('slot-one').bids; + const admitted = stored.filter((bid) => bid.adId === reservationId); + expect(admitted).toHaveLength(1); + expect(admitted[0]).toMatchObject({ + adId: reservationId, + adUnitCode: 'slot-one', + auctionId: auction.auctionId, + requestId: request.requestId, + adserverTargeting: { hb_adid: reservationId }, + }); + auction.complete(); + await bidsBack; + adapter.dispose(); dom.window.close(); }, 60_000); }); diff --git a/crates/trusted-server-js/lib/test/services/auction_batch.test.ts b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts new file mode 100644 index 000000000..768faffde --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts @@ -0,0 +1,660 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { parseTrustedServerAuctionResponseV1 } from '../../src/core/auction'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { + createRuntimeSession, + type NavigationSession, + type RenderAttemptScope, +} from '../../src/kernel/sessions'; +import { + createAuctionBatchService, + type AuctionBatchFetcher, + type AuctionBatchServiceOptions, +} from '../../src/services/auction_batch'; +import type { + RenderAttempt, + RenderCancellationReason, + RenderFailureReason, + RenderOutcome, +} from '../../src/services/render'; + +function navigation(): NavigationSession { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(7); + return target; + }, + }), + }); + const result = runtime.startInitialNavigation(); + if (!result.ok) throw new Error(result.reason); + return result.value; +} + +interface AttemptHarness { + readonly attempt: RenderAttempt; + readonly outcomes: readonly RenderOutcome[]; +} + +function attemptHarness(owner: RenderAttemptScope): AttemptHarness { + const outcomes: RenderOutcome[] = []; + const observers: Array<(outcome: RenderOutcome) => void> = []; + let outcome: RenderOutcome | undefined; + const settle = (next: RenderOutcome): boolean => { + if (outcome) return false; + outcome = Object.freeze(next); + outcomes.push(outcome); + owner.dispose(); + observers.splice(0).forEach((observer) => observer(outcome!)); + return true; + }; + owner.onDispose('test-render-lifecycle', () => { + if (!outcome) settle({ outcome: 'cancelled', reason: 'navigation_disposed' }); + }); + const attempt = { + id: owner.id, + slot: owner.slot, + generation: owner.generation, + navigationGeneration: owner.navigationGeneration, + parentAttemptId: undefined, + renderSource: undefined, + winnerContext: undefined, + admitDirectWinner: vi.fn(() => true), + admitClaimedWinner: vi.fn(() => false), + beginGamClaim: vi.fn(() => false), + ownerClaimed: vi.fn(() => false), + ownerRegistered: vi.fn(() => false), + beginDirect: vi.fn(() => false), + beginApsDocument: vi.fn(() => false), + beginAdm: vi.fn(() => false), + apsDocumentAccepted: vi.fn(() => false), + accept: () => settle({ outcome: 'accepted' }), + noBid: () => settle({ outcome: 'no_bid' }), + fail: (reason: RenderFailureReason) => settle({ outcome: 'failed', reason }), + cancel: (reason: RenderCancellationReason) => settle({ outcome: 'cancelled', reason }), + onSettled: (observer: (terminal: RenderOutcome) => void) => { + if (outcome) observer(outcome); + else observers.push(observer); + return true; + }, + snapshot: () => ({ + history: Object.freeze(outcome ? ['created', outcome.outcome] : ['created']), + outcome, + state: outcome?.outcome ?? ('created' as const), + }), + } as RenderAttempt; + return { attempt, outcomes }; +} + +function candidateId(index: number): string { + return index.toString(36).padStart(12, 'A'); +} + +function reservationId(index: number): string { + return `r1_${index.toString(36).padStart(22, 'A')}`; +} + +type Decision = + | { slot: string; outcome: 'winner'; candidateId: string } + | { slot: string; outcome: 'no_bid' } + | { slot: string; outcome: 'failed'; reason: 'provider_timeout' }; + +function response(decisions: readonly Decision[]): unknown { + const winners = decisions.filter( + (decision): decision is Extract => + decision.outcome === 'winner' + ); + return { + id: 'auction-1', + cur: 'USD', + seatbid: + winners.length === 0 + ? [] + : [ + { + seat: 'prebid', + bid: winners.map((winner, index) => { + const source = { + type: 'adm', + version: 1, + adm: `
${winner.slot}
`, + width: 300, + height: 250, + }; + return { + id: reservationId(index), + impid: winner.slot, + price: index + 1, + adm: source.adm, + w: source.width, + h: source.height, + ext: { + trusted_server: { + candidate_id: winner.candidateId, + slot_id: winner.slot, + render_source: source, + }, + }, + }; + }), + }, + ], + ext: { + trusted_server: { + slot_results: { version: 1, auctionId: 'auction-1', results: decisions }, + }, + }, + }; +} + +function successfulFetcher(body: unknown): AuctionBatchFetcher { + return vi.fn(async () => ({ ok: true, json: async () => body })); +} + +function createService(options: Omit) { + return createAuctionBatchService({ + ...options, + parseResponse: parseTrustedServerAuctionResponseV1, + }); +} + +function abortablePendingFetcher(): { + readonly fetcher: AuctionBatchFetcher; + readonly signals: AbortSignal[]; +} { + const signals: AbortSignal[] = []; + const fetcher: AuctionBatchFetcher = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + const signal = init.signal; + if (!signal) throw new Error('Expected a fetch signal'); + signals.push(signal); + signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { + once: true, + }); + }) + ); + return { fetcher, signals }; +} + +describe('auction batch service', () => { + it('rejects a response whose decisions reverse immutable request order', async () => { + const attempts = new Map(); + const fetcher = successfulFetcher( + response([ + { slot: 'slot-a', outcome: 'no_bid' }, + { slot: 'slot-b', outcome: 'winner', candidateId: candidateId(0) }, + ]) + ); + const service = createService({ + createAttempt: (owner) => { + const harness = attemptHarness(owner); + attempts.set(owner.slot, harness); + return { ok: true, value: harness.attempt }; + }, + fetcher, + renderWinner: (attempt) => attempt.accept(), + }); + + const batch = service.create({ + navigation: navigation(), + requestBody: '{"adUnits":[]}', + slots: Object.freeze(['slot-b', 'slot-a']), + timeoutMs: 10_000, + }); + + await expect(batch.result).resolves.toEqual({ + slots: [ + { slot: 'slot-b', path: 'primary', outcome: 'failed', reason: 'invalid_response' }, + { slot: 'slot-a', path: 'primary', outcome: 'failed', reason: 'invalid_response' }, + ], + }); + expect(fetcher).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledWith( + '/auction', + expect.objectContaining({ + method: 'POST', + body: '{"adUnits":[]}', + signal: expect.any(AbortSignal), + }) + ); + expect(attempts.size).toBe(2); + expect(attempts.get('slot-b')?.attempt.admitDirectWinner).not.toHaveBeenCalled(); + expect(Object.isFrozen(await batch.result)).toBe(true); + expect(Object.isFrozen((await batch.result).slots)).toBe(true); + }); + + it('fails only live children on the shared response deadline and aborts the fetch', async () => { + vi.useFakeTimers(); + try { + const pending = abortablePendingFetcher(); + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: pending.fetcher, + renderWinner: () => false, + }); + const batch = service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a', 'slot-b']), + timeoutMs: 100, + }); + + await vi.advanceTimersByTimeAsync(99); + expect(pending.signals[0]?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + await expect(batch.result).resolves.toEqual({ + slots: [ + { slot: 'slot-a', path: 'primary', outcome: 'failed', reason: 'auction_timeout' }, + { slot: 'slot-b', path: 'primary', outcome: 'failed', reason: 'auction_timeout' }, + ], + }); + expect(pending.signals[0]?.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels issued children without fetching for an already-aborted caller', async () => { + const fetcher = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); + const createAttempt = vi.fn((owner: RenderAttemptScope) => ({ + ok: true as const, + value: attemptHarness(owner).attempt, + })); + const service = createService({ + createAttempt, + fetcher, + renderWinner: () => false, + }); + const caller = new AbortController(); + caller.abort(); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + signal: caller.signal, + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }).result + ).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + expect(createAttempt).toHaveBeenCalledOnce(); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('supersedes only overlapping children and retains the old fetch until all old children settle', async () => { + const firstFetch = abortablePendingFetcher(); + const secondFetch = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); + const fetchers = [firstFetch.fetcher, secondFetch] as const; + let fetchIndex = 0; + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: (input, init) => fetchers[fetchIndex++]!(input, init), + renderWinner: () => false, + }); + const firstAbort = new AbortController(); + const owner = navigation(); + const first = service.create({ + navigation: owner, + requestBody: '{}', + signal: firstAbort.signal, + slots: Object.freeze(['slot-a', 'slot-b']), + timeoutMs: 10_000, + }); + const second = service.create({ + navigation: owner, + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + + expect(firstFetch.signals[0]?.aborted).toBe(false); + await expect(second.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'no_bid' }], + }); + firstAbort.abort(); + await expect(first.result).resolves.toEqual({ + slots: [ + { slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'superseded' }, + { slot: 'slot-b', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }, + ], + }); + expect(firstFetch.signals[0]?.aborted).toBe(true); + }); + + it.each([ + { + name: 'network rejection', + fetcher: vi.fn(async () => Promise.reject(new Error('offline'))), + reason: 'network_error', + }, + { + name: 'non-success response', + fetcher: vi.fn(async () => ({ ok: false, json: async () => ({}) })), + reason: 'http_error', + }, + { + name: 'invalid JSON body', + fetcher: vi.fn(async () => ({ + ok: true, + json: async () => Promise.reject(new SyntaxError('invalid JSON')), + })), + reason: 'invalid_response', + }, + { + name: 'missing slot decision', + fetcher: successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])), + reason: 'invalid_response', + }, + { + name: 'extra slot decision', + fetcher: successfulFetcher( + response([ + { slot: 'slot-a', outcome: 'no_bid' }, + { slot: 'slot-b', outcome: 'no_bid' }, + { slot: 'slot-extra', outcome: 'no_bid' }, + ]) + ), + reason: 'invalid_response', + }, + ] as const)('preserves $name as $reason for every live child', async ({ fetcher, reason }) => { + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher, + renderWinner: () => false, + }); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a', 'slot-b']), + timeoutMs: 10_000, + }).result + ).resolves.toEqual({ + slots: [ + { slot: 'slot-a', path: 'primary', outcome: 'failed', reason }, + { slot: 'slot-b', path: 'primary', outcome: 'failed', reason }, + ], + }); + }); + + it('passes through an exact server failure without inferring no-bid', async () => { + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: successfulFetcher( + response([{ slot: 'slot-a', outcome: 'failed', reason: 'provider_timeout' }]) + ), + renderWinner: () => false, + }); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }).result + ).resolves.toEqual({ + slots: [ + { + slot: 'slot-a', + path: 'primary', + outcome: 'failed', + reason: 'provider_timeout', + }, + ], + }); + }); + + it('ends the shared deadline after parse while retaining caller cancellation during render', async () => { + vi.useFakeTimers(); + try { + let fetchSignal: AbortSignal | undefined; + const settled = vi.fn(); + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: vi.fn(async (_input, init) => { + fetchSignal = init.signal; + return { + ok: true, + json: async () => + response([{ slot: 'slot-a', outcome: 'winner', candidateId: candidateId(0) }]), + }; + }), + renderWinner: () => true, + }); + const caller = new AbortController(); + const batch = service.create({ + navigation: navigation(), + requestBody: '{}', + signal: caller.signal, + slots: Object.freeze(['slot-a']), + timeoutMs: 100, + }); + void batch.result.then(settled); + + await vi.advanceTimersByTimeAsync(1_000); + expect(settled).not.toHaveBeenCalled(); + expect(fetchSignal?.aborted).toBe(false); + + caller.abort(); + await expect(batch.result).resolves.toEqual({ + slots: [ + { slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }, + ], + }); + expect(fetchSignal?.aborted).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels every child and the shared fetch when navigation disposes', async () => { + const pending = abortablePendingFetcher(); + const owner = navigation(); + const service = createService({ + createAttempt: (attemptOwner) => ({ + ok: true, + value: attemptHarness(attemptOwner).attempt, + }), + fetcher: pending.fetcher, + renderWinner: () => false, + }); + const batch = service.create({ + navigation: owner, + requestBody: '{}', + slots: Object.freeze(['slot-a', 'slot-b']), + timeoutMs: 10_000, + }); + + owner.dispose(); + + await expect(batch.result).resolves.toEqual({ + slots: [ + { + slot: 'slot-a', + path: 'primary', + outcome: 'cancelled', + reason: 'navigation_disposed', + }, + { + slot: 'slot-b', + path: 'primary', + outcome: 'cancelled', + reason: 'navigation_disposed', + }, + ], + }); + expect(pending.signals[0]?.aborted).toBe(true); + }); + + it('aborts the old shared fetch when its only child is superseded', async () => { + const firstFetch = abortablePendingFetcher(); + const owner = navigation(); + const fetchers = [ + firstFetch.fetcher, + successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])), + ] as const; + let fetchIndex = 0; + const service = createService({ + createAttempt: (attemptOwner) => ({ + ok: true, + value: attemptHarness(attemptOwner).attempt, + }), + fetcher: (input, init) => fetchers[fetchIndex++]!(input, init), + renderWinner: () => false, + }); + const first = service.create({ + navigation: owner, + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + const second = service.create({ + navigation: owner, + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + + await expect(first.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'superseded' }], + }); + expect(firstFetch.signals[0]?.aborted).toBe(true); + await expect(second.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'no_bid' }], + }); + }); + + it('fails closed without fetching when deadline setup settles reentrantly', async () => { + const fetcher = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); + const clear = vi.fn(); + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher, + renderWinner: () => false, + scheduler: { + clear, + set: (callback) => { + callback(); + return Object.freeze({ handle: true }); + }, + }, + }); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 100, + }).result + ).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'failed', reason: 'auction_timeout' }], + }); + expect(fetcher).not.toHaveBeenCalled(); + expect(clear).toHaveBeenCalled(); + }); + + it('settles and skips transport when an attempt refuses settlement observation', async () => { + const fetcher = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); + const service = createService({ + createAttempt: (owner) => { + const attempt = attemptHarness(owner).attempt; + return { + ok: true, + value: { + ...attempt, + fail: vi.fn(() => false), + onSettled: vi.fn(() => false), + } as RenderAttempt, + }; + }, + fetcher, + renderWinner: () => false, + }); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 100, + }).result + ).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'failed', reason: 'internal_error' }], + }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('contains an attempt that claims cancellation without notifying its observer', async () => { + const pending = abortablePendingFetcher(); + const service = createService({ + createAttempt: (owner) => { + const attempt = attemptHarness(owner).attempt; + return { + ok: true, + value: { + ...attempt, + cancel: vi.fn(() => true), + } as RenderAttempt, + }; + }, + fetcher: pending.fetcher, + renderWinner: () => false, + }); + const batch = service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + + batch.cancel(); + + await expect(batch.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + expect(pending.signals[0]?.aborted).toBe(true); + }); + + it('observes a branded caller signal without consulting shadowed instance hooks', async () => { + const pending = abortablePendingFetcher(); + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: pending.fetcher, + renderWinner: () => false, + }); + const caller = new AbortController(); + const publisherHook = vi.fn(() => { + throw new Error('publisher signal hook'); + }); + Object.defineProperties(caller.signal, { + aborted: { configurable: true, get: publisherHook }, + addEventListener: { configurable: true, get: publisherHook }, + removeEventListener: { configurable: true, get: publisherHook }, + }); + + const batch = service.create({ + navigation: navigation(), + requestBody: '{}', + signal: caller.signal, + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + caller.abort(); + + await expect(batch.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + expect(publisherHook).not.toHaveBeenCalled(); + expect(pending.signals[0]?.aborted).toBe(true); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/context.test.ts b/crates/trusted-server-js/lib/test/services/context.test.ts new file mode 100644 index 000000000..c2a4725dd --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/context.test.ts @@ -0,0 +1,892 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createAuctionContextRegistry, + type ContextContributorOwner, +} from '../../src/services/context'; + +const MAX_CONTEXT_JSON_BYTES = 256 * 1024; +const MAX_CONTEXT_ENCODED_KEY_BYTES = MAX_CONTEXT_JSON_BYTES - 7; +const MAX_CONTEXT_STRUCTURE_ENTRIES = Math.floor((MAX_CONTEXT_JSON_BYTES - 1) / 2); + +function owner(): ContextContributorOwner & { readonly dispose: () => void } { + const generation = Object.freeze({}); + const disposers: (() => void)[] = []; + let current = true; + return Object.freeze({ + generation, + isCurrent: () => current, + onDispose: (_kind: string, callback: () => void) => { + if (!current) callback(); + else disposers.push(callback); + }, + dispose: () => { + if (!current) return; + current = false; + for (let index = disposers.length - 1; index >= 0; index -= 1) { + disposers[index]?.(); + } + disposers.length = 0; + }, + }); +} + +describe('AuctionContextRegistry', () => { + it('snapshots in manifest order with later-key precedence and recursive freezing', () => { + const runtimeOwner = owner(); + const firstOwner = owner(); + const secondOwner = owner(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['first', 'second']), + runtimeOwner, + }); + + expect( + registry.register('second', () => ({ shared: 'second', nested: { value: 2 } }), secondOwner) + ).toBe(true); + expect(registry.register('first', () => ({ first: true, shared: 'first' }), firstOwner)).toBe( + true + ); + + const snapshot = registry.snapshot(); + + expect(snapshot).toEqual({ first: true, shared: 'second', nested: { value: 2 } }); + expect(Object.keys(snapshot)).toEqual(['first', 'shared', 'nested']); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.nested)).toBe(true); + }); + + it('isolates a throwing contributor and does not retain any of its partial values', () => { + const runtimeOwner = owner(); + const failure = vi.fn(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['good-first', 'hostile', 'good-last']), + runtimeOwner, + onContributorFailure: failure, + }); + const partial = { leaked: 'must-not-escape' }; + Object.defineProperty(partial, 'throwing', { + enumerable: true, + get() { + throw new Error('hostile getter'); + }, + }); + registry.register('good-first', () => ({ retained: 'first' }), owner()); + registry.register('hostile', () => partial, owner()); + registry.register('good-last', () => ({ retained: 'last' }), owner()); + + const snapshot = registry.snapshot(); + + expect(snapshot).toEqual({ retained: 'last' }); + expect(snapshot).not.toHaveProperty('leaked'); + expect(failure.mock.calls).toEqual([ + [{ integrationId: 'hostile', reason: 'contributor_failed' }], + ]); + expect(Object.isFrozen(failure.mock.calls[0]?.[0])).toBe(true); + }); + + it('removes an owner-scoped contributor before the next batch snapshot', () => { + const runtimeOwner = owner(); + const contributorOwner = owner(); + const contributor = vi.fn(() => ({ active: true })); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner, + }); + expect(registry.register('integration', contributor, contributorOwner)).toBe(true); + expect(registry.snapshot()).toEqual({ active: true }); + + contributorOwner.dispose(); + + expect(registry.snapshot()).toEqual({}); + expect(contributor).toHaveBeenCalledOnce(); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: [], + }); + }); + + it('rejects unknown, duplicate, and stale-owner registrations', () => { + const runtimeOwner = owner(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['known']), + runtimeOwner, + }); + const active = owner(); + const stale = owner(); + stale.dispose(); + + expect(registry.register('unknown', () => ({}), active)).toBe(false); + expect(registry.register('known', () => ({ first: true }), active)).toBe(true); + expect(registry.register('known', () => ({ duplicate: true }), owner())).toBe(false); + active.dispose(); + expect(registry.register('known', () => ({ stale: true }), stale)).toBe(false); + }); + + it('fails closed when the runtime-owner generation getter throws during construction', () => { + const hostileRuntimeOwner = new Proxy(owner(), { + get(target, key, receiver) { + if (key === 'generation') throw new Error('hostile generation getter'); + return Reflect.get(target, key, receiver); + }, + }); + let registry: ReturnType | undefined; + + expect(() => { + registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: hostileRuntimeOwner, + }); + }).not.toThrow(); + + const snapshot = registry?.snapshot(); + expect(snapshot).toEqual({}); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(registry?.register('integration', () => ({ leaked: true }), owner())).toBe(false); + expect(registry?.snapshotInventoryForTest()).toEqual({ + disposed: true, + registrations: [], + }); + }); + + it('fails closed when the runtime-owner generation getter throws during a later snapshot', () => { + const runtimeOwner = owner(); + let throwOnGenerationRead = false; + const hostileRuntimeOwner = new Proxy(runtimeOwner, { + get(target, key, receiver) { + if (key === 'generation' && throwOnGenerationRead) { + throw new Error('hostile generation getter'); + } + return Reflect.get(target, key, receiver); + }, + }); + const contributor = vi.fn(() => ({ leaked: true })); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: hostileRuntimeOwner, + }); + expect(registry.register('integration', contributor, owner())).toBe(true); + + throwOnGenerationRead = true; + let snapshot: Readonly> | undefined; + expect(() => { + snapshot = registry.snapshot(); + }).not.toThrow(); + + expect(snapshot).toEqual({}); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(contributor).not.toHaveBeenCalled(); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: true, + registrations: [], + }); + }); + + it('contains a throwing contributor-owner generation getter without retention', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const hostileOwner = new Proxy(owner(), { + get(target, key, receiver) { + if (key === 'generation') throw new Error('hostile generation getter'); + return Reflect.get(target, key, receiver); + }, + }); + + expect(() => + registry.register('integration', () => ({ leaked: true }), hostileOwner) + ).not.toThrow(); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: [], + }); + expect(registry.snapshot()).toEqual({}); + }); + + it('reads contributor-owner generation once at each registration checkpoint', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const generation = Object.freeze({}); + const readGeneration = vi.fn(() => generation); + const contributorOwner = { + get generation() { + return readGeneration(); + }, + isCurrent: () => true, + onDispose: vi.fn(), + }; + + expect(registry.register('integration', () => ({ retained: true }), contributorOwner)).toBe( + true + ); + expect(readGeneration).toHaveBeenCalledTimes(2); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: ['integration'], + }); + }); + + it.each([ + ['finalization', false], + ['throw rollback', true], + ] as const)( + 'does not delete a reentrant replacement record during outer %s', + (_name, throwAfterReplacement) => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const replacementOwner = owner(); + let replacementRegistered: boolean | undefined; + const outerOwner: ContextContributorOwner = { + generation: Object.freeze({}), + isCurrent: () => true, + onDispose: (_kind, cleanup) => { + cleanup(); + replacementRegistered = registry.register( + 'integration', + () => ({ replacement: true }), + replacementOwner + ); + if (throwAfterReplacement) throw new Error('outer onDispose failed'); + }, + }; + + expect(registry.register('integration', () => ({ outer: true }), outerOwner)).toBe(false); + expect(replacementRegistered).toBe(true); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: ['integration'], + }); + expect(registry.snapshot()).toEqual({ replacement: true }); + } + ); + + it('reports a registration as displaced when final owner reflection installs a replacement', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const generation = Object.freeze({}); + const replacementOwner = owner(); + let generationReads = 0; + let cleanup: (() => void) | undefined; + let replacementRegistered: boolean | undefined; + const reentrantOwner: ContextContributorOwner = { + get generation() { + generationReads += 1; + if (generationReads === 2) { + cleanup?.(); + replacementRegistered = registry.register( + 'integration', + () => ({ replacement: true }), + replacementOwner + ); + } + return generation; + }, + isCurrent: () => true, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }; + + expect(registry.register('integration', () => ({ displaced: true }), reentrantOwner)).toBe( + false + ); + expect(replacementRegistered).toBe(true); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: ['integration'], + }); + expect(registry.snapshot()).toEqual({ replacement: true }); + }); + + it('rolls back a registration whose owner generation changes during onDispose', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const firstGeneration = Object.freeze({}); + const secondGeneration = Object.freeze({}); + let generation = firstGeneration; + let rotateGeneration = true; + const readGeneration = vi.fn(() => generation); + const changingOwner: ContextContributorOwner = { + get generation() { + return readGeneration(); + }, + isCurrent: () => true, + onDispose: () => { + if (!rotateGeneration) return; + rotateGeneration = false; + generation = secondGeneration; + }, + }; + + expect(registry.register('integration', () => ({ stale: true }), changingOwner)).toBe(false); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: [], + }); + expect(registry.register('integration', () => ({ current: true }), changingOwner)).toBe(true); + expect(readGeneration).toHaveBeenCalledTimes(4); + expect(registry.snapshot()).toEqual({ current: true }); + }); + + it('rejects a reflected contributor-owner generation that is not an object', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const invalidOwner = { + generation: null as unknown as object, + isCurrent: () => true, + onDispose: vi.fn(), + }; + + expect(registry.register('integration', () => ({ leaked: true }), invalidOwner)).toBe(false); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: [], + }); + }); + + it.each(['isCurrent', 'onDispose'] as const)( + 'contains a throwing contributor-owner %s trap without retention', + (method) => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const contributorOwner = owner(); + const hostileOwner = new Proxy(contributorOwner, { + get(target, key, receiver) { + if (key === method) throw new Error(`hostile ${method} trap`); + return Reflect.get(target, key, receiver); + }, + }); + + expect(() => + registry.register('integration', () => ({ leaked: true }), hostileOwner) + ).not.toThrow(); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: [], + }); + } + ); + + it('takes one fresh contributor snapshot per batch call without retaining prior values', () => { + const runtimeOwner = owner(); + const mutable = { value: 1 }; + const contributor = vi.fn(() => ({ nested: mutable })); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner, + }); + registry.register('integration', contributor, owner()); + + const first = registry.snapshot(); + mutable.value = 2; + const second = registry.snapshot(); + + expect(first).toEqual({ nested: { value: 1 } }); + expect(second).toEqual({ nested: { value: 2 } }); + expect(first).not.toBe(second); + expect(first.nested).not.toBe(second.nested); + expect(contributor).toHaveBeenCalledTimes(2); + }); + + it('does not invoke a record displaced during its owner-currentness reflection', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const generation = Object.freeze({}); + const replacementOwner = owner(); + const displacedContributor = vi.fn(() => ({ displaced: true })); + const replacementContributor = vi.fn(() => ({ replacement: true })); + let generationReads = 0; + let cleanup: (() => void) | undefined; + let replacementRegistered: boolean | undefined; + const reentrantOwner: ContextContributorOwner = { + get generation() { + generationReads += 1; + if (generationReads === 3) { + cleanup?.(); + replacementRegistered = registry.register( + 'integration', + replacementContributor, + replacementOwner + ); + } + return generation; + }, + isCurrent: () => true, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }; + expect(registry.register('integration', displacedContributor, reentrantOwner)).toBe(true); + + expect(registry.snapshot()).toEqual({}); + expect(replacementRegistered).toBe(true); + expect(displacedContributor).not.toHaveBeenCalled(); + expect(replacementContributor).not.toHaveBeenCalled(); + expect(registry.snapshot()).toEqual({ replacement: true }); + expect(replacementContributor).toHaveBeenCalledOnce(); + }); + + it('does not merge a record displaced during contributor execution', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const generation = Object.freeze({}); + const replacementOwner = owner(); + const replacementContributor = vi.fn(() => ({ replacement: true })); + let cleanup: (() => void) | undefined; + let replacementRegistered: boolean | undefined; + const reentrantOwner: ContextContributorOwner = { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }; + const displacedContributor = vi.fn(() => { + cleanup?.(); + replacementRegistered = registry.register( + 'integration', + replacementContributor, + replacementOwner + ); + return { displaced: true }; + }); + expect(registry.register('integration', displacedContributor, reentrantOwner)).toBe(true); + + expect(registry.snapshot()).toEqual({}); + expect(replacementRegistered).toBe(true); + expect(displacedContributor).toHaveBeenCalledOnce(); + expect(replacementContributor).not.toHaveBeenCalled(); + expect(registry.snapshot()).toEqual({ replacement: true }); + expect(replacementContributor).toHaveBeenCalledOnce(); + }); + + it('does not merge a record displaced during runtime-currentness reflection', () => { + const runtimeGeneration = Object.freeze({}); + let replaceOnRuntimeReflection = false; + let contributorCleanup: (() => void) | undefined; + let replacementRegistered: boolean | undefined; + const replacementOwner = owner(); + const replacementContributor = vi.fn(() => ({ replacement: true })); + const runtimeOwner: ContextContributorOwner = { + get generation() { + if (replaceOnRuntimeReflection) { + replaceOnRuntimeReflection = false; + contributorCleanup?.(); + replacementRegistered = registry.register( + 'integration', + replacementContributor, + replacementOwner + ); + } + return runtimeGeneration; + }, + isCurrent: () => true, + onDispose: vi.fn(), + }; + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner, + }); + const displacedContributor = vi.fn(() => { + replaceOnRuntimeReflection = true; + return { displaced: true }; + }); + expect( + registry.register('integration', displacedContributor, { + generation: Object.freeze({}), + isCurrent: () => true, + onDispose: (_kind, callback) => { + contributorCleanup = callback; + }, + }) + ).toBe(true); + + expect(registry.snapshot()).toEqual({}); + expect(replacementRegistered).toBe(true); + expect(displacedContributor).toHaveBeenCalledOnce(); + expect(replacementContributor).not.toHaveBeenCalled(); + expect(registry.snapshot()).toEqual({ replacement: true }); + expect(replacementContributor).toHaveBeenCalledOnce(); + }); + + it('fails closed when final runtime reflection displaces an already accepted record', () => { + const runtimeGeneration = Object.freeze({}); + const contributorGeneration = Object.freeze({}); + const replacementOwner = owner(); + const staleContributor = vi.fn(() => ({ stale: true })); + const replacementContributor = vi.fn(() => ({ replacement: true })); + let contributorGenerationReads = 0; + let contributorCleanup: (() => void) | undefined; + let reflectReplacement = false; + let replacementRegistered: boolean | undefined; + const runtimeOwner: ContextContributorOwner = { + get generation() { + if (reflectReplacement) { + reflectReplacement = false; + contributorCleanup?.(); + replacementRegistered = registry.register( + 'integration', + replacementContributor, + replacementOwner + ); + } + return runtimeGeneration; + }, + isCurrent: () => true, + onDispose: vi.fn(), + }; + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner, + }); + expect( + registry.register('integration', staleContributor, { + get generation() { + contributorGenerationReads += 1; + if (contributorGenerationReads === 4) reflectReplacement = true; + return contributorGeneration; + }, + isCurrent: () => true, + onDispose: (_kind, callback) => { + contributorCleanup = callback; + }, + }) + ).toBe(true); + + const firstSnapshot = registry.snapshot(); + expect(firstSnapshot).toEqual({}); + expect(Object.isFrozen(firstSnapshot)).toBe(true); + expect(replacementRegistered).toBe(true); + expect(staleContributor).toHaveBeenCalledOnce(); + expect(replacementContributor).not.toHaveBeenCalled(); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: ['integration'], + }); + expect(registry.snapshot()).toEqual({ replacement: true }); + expect(replacementContributor).toHaveBeenCalledOnce(); + }); + + it('fails closed when final runtime reflection disposes the registry after acceptance', () => { + const runtimeGeneration = Object.freeze({}); + const contributorGeneration = Object.freeze({}); + let contributorGenerationReads = 0; + let reflectDisposal = false; + const runtimeOwner: ContextContributorOwner = { + get generation() { + if (reflectDisposal) { + reflectDisposal = false; + registry.dispose(); + } + return runtimeGeneration; + }, + isCurrent: () => true, + onDispose: vi.fn(), + }; + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner, + }); + expect( + registry.register('integration', () => ({ stale: true }), { + get generation() { + contributorGenerationReads += 1; + if (contributorGenerationReads === 4) reflectDisposal = true; + return contributorGeneration; + }, + isCurrent: () => true, + onDispose: vi.fn(), + }) + ).toBe(true); + + const snapshot = registry.snapshot(); + expect(snapshot).toEqual({}); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: true, + registrations: [], + }); + }); + + it('does not classify primitive clone records through Object.prototype pollution', () => { + const priorDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, 'source'); + try { + Object.defineProperty(Object.prototype, 'source', { + configurable: true, + enumerable: false, + value: 'polluted', + writable: true, + }); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + registry.register( + 'integration', + () => ({ string: 'value', number: 7, boolean: true, nullable: null }), + owner() + ); + + expect(registry.snapshot()).toEqual({ + string: 'value', + number: 7, + boolean: true, + nullable: null, + }); + } finally { + if (priorDescriptor) Object.defineProperty(Object.prototype, 'source', priorDescriptor); + else Reflect.deleteProperty(Object.prototype, 'source'); + } + }); + + it('makes stale callbacks and logger failures inert after runtime disposal', () => { + const runtimeOwner = owner(); + const contributor = vi.fn(() => { + throw new Error('contributor failed'); + }); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner, + onContributorFailure: () => { + throw new Error('logger failed'); + }, + }); + registry.register('integration', contributor, owner()); + + expect(() => registry.snapshot()).not.toThrow(); + runtimeOwner.dispose(); + expect(registry.snapshot()).toEqual({}); + expect(contributor).toHaveBeenCalledOnce(); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: true, + registrations: [], + }); + }); + + it('discards the whole batch snapshot if a contributor disposes the runtime', () => { + const runtimeOwner = owner(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['first', 'disposing']), + runtimeOwner, + }); + registry.register('first', () => ({ stale: 'must-not-escape' }), owner()); + registry.register( + 'disposing', + () => { + runtimeOwner.dispose(); + return { late: 'must-not-escape' }; + }, + owner() + ); + + expect(registry.snapshot()).toEqual({}); + }); + + it.each([ + ['just below', MAX_CONTEXT_JSON_BYTES - 1, true], + ['at', MAX_CONTEXT_JSON_BYTES, true], + ['above', MAX_CONTEXT_JSON_BYTES + 1, false], + ] as const)( + 'applies the shared JSON byte budget %s the body ceiling', + (_name, bytes, accepted) => { + const failure = vi.fn(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + onContributorFailure: failure, + }); + const payload = 'x'.repeat(bytes - 14); + registry.register('integration', () => ({ payload }), owner()); + + const snapshot = registry.snapshot(); + + if (accepted) { + expect(new TextEncoder().encode(JSON.stringify(snapshot)).byteLength).toBe(bytes); + expect(snapshot).toEqual({ payload }); + expect(failure).not.toHaveBeenCalled(); + } else { + expect(snapshot).toEqual({}); + expect(failure.mock.calls).toEqual([ + [{ integrationId: 'integration', reason: 'contributor_failed' }], + ]); + } + } + ); + + it('accounts for multibyte and escaped JSON strings at the exact byte ceiling', () => { + const payloadBytes = MAX_CONTEXT_JSON_BYTES - 14; + const emojiCount = Math.floor((payloadBytes - 4) / 4); + const payload = `${'😀'.repeat(emojiCount)}xx"\n`; + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + registry.register('integration', () => ({ payload }), owner()); + + const snapshot = registry.snapshot(); + + expect(new TextEncoder().encode(JSON.stringify(snapshot)).byteLength).toBe( + MAX_CONTEXT_JSON_BYTES + ); + expect(snapshot).toEqual({ payload }); + }); + + it('shares the byte budget across contributors and rejects an overflowing merge atomically', () => { + const failure = vi.fn(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['first', 'overflowing']), + runtimeOwner: owner(), + onContributorFailure: failure, + }); + const payload = 'x'.repeat(MAX_CONTEXT_JSON_BYTES - 14); + registry.register('first', () => ({ payload }), owner()); + registry.register('overflowing', () => ({ late: true }), owner()); + + const snapshot = registry.snapshot(); + + expect(new TextEncoder().encode(JSON.stringify(snapshot)).byteLength).toBe( + MAX_CONTEXT_JSON_BYTES + ); + expect(snapshot).toEqual({ payload }); + expect(failure.mock.calls).toEqual([ + [{ integrationId: 'overflowing', reason: 'contributor_failed' }], + ]); + }); + + it('subtracts replaced predecessor bytes before admitting a later contributor', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['first', 'replacement']), + runtimeOwner: owner(), + }); + registry.register( + 'first', + () => ({ shared: 'x'.repeat(MAX_CONTEXT_JSON_BYTES - 13) }), + owner() + ); + registry.register('replacement', () => ({ shared: 'small', later: true }), owner()); + + expect(registry.snapshot()).toEqual({ shared: 'small', later: true }); + }); + + it('retains no replacement values when one prospective contributor exceeds the budget', () => { + const failure = vi.fn(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['first', 'overflowing']), + runtimeOwner: owner(), + onContributorFailure: failure, + }); + registry.register('first', () => ({ shared: 'original' }), owner()); + registry.register( + 'overflowing', + () => ({ shared: 'must-not-replace', excess: 'x'.repeat(MAX_CONTEXT_JSON_BYTES) }), + owner() + ); + + expect(registry.snapshot()).toEqual({ shared: 'original' }); + expect(failure.mock.calls).toEqual([ + [{ integrationId: 'overflowing', reason: 'contributor_failed' }], + ]); + }); + + it('clones and freezes a deeply nested contribution without a recursion cap', () => { + const depth = 12_000; + let deep: Record = { terminal: true }; + for (let index = 0; index < depth; index += 1) deep = { next: deep }; + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['deep']), + runtimeOwner: owner(), + }); + registry.register('deep', () => ({ deep }), owner()); + + const snapshot = registry.snapshot(); + + let cursor = snapshot.deep; + for (let index = 0; index < depth; index += 1) { + expect(Object.isFrozen(cursor)).toBe(true); + cursor = (cursor as { readonly next: unknown }).next; + } + expect(cursor).toEqual({ terminal: true }); + }); + + it('rejects an oversized encoded key before retaining contributor values', () => { + const failure = vi.fn(); + const hugeKey = 'k'.repeat(MAX_CONTEXT_ENCODED_KEY_BYTES + 1); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['huge-key']), + runtimeOwner: owner(), + onContributorFailure: failure, + }); + registry.register('huge-key', () => ({ [hugeKey]: 'must-not-escape' }), owner()); + + expect(registry.snapshot()).toEqual({}); + expect(failure.mock.calls).toEqual([ + [{ integrationId: 'huge-key', reason: 'contributor_failed' }], + ]); + }); + + it('rejects a huge iterative structure and continues with the next contributor', () => { + let huge: unknown[] = []; + const depth = Math.ceil(MAX_CONTEXT_STRUCTURE_ENTRIES / 2) + 1; + for (let index = 0; index < depth; index += 1) huge = [huge]; + const failure = vi.fn(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['huge', 'later']), + runtimeOwner: owner(), + onContributorFailure: failure, + }); + registry.register('huge', () => ({ huge }), owner()); + registry.register('later', () => ({ retained: true }), owner()); + + expect(registry.snapshot()).toEqual({ retained: true }); + expect(failure.mock.calls).toEqual([[{ integrationId: 'huge', reason: 'contributor_failed' }]]); + }); + + it('rejects a cyclic graph atomically and continues in manifest order', () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + const failure = vi.fn(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['cyclic', 'later']), + runtimeOwner: owner(), + onContributorFailure: failure, + }); + registry.register('cyclic', () => ({ leaked: true, cyclic }), owner()); + registry.register('later', () => ({ retained: true }), owner()); + + expect(registry.snapshot()).toEqual({ retained: true }); + expect(failure.mock.calls).toEqual([ + [{ integrationId: 'cyclic', reason: 'contributor_failed' }], + ]); + }); + + it.each([ + ['just below', 19, true], + ['at', 20, true], + ['above', 21, false], + ] as const)('%s the canonical manifest capacity accepts=%s', (_name, count, accepted) => { + const ids = Object.freeze(Array.from({ length: count }, (_, index) => `integration-${index}`)); + const construct = (): void => { + createAuctionContextRegistry({ manifestIntegrationIds: ids, runtimeOwner: owner() }); + }; + + if (accepted) expect(construct).not.toThrow(); + else expect(construct).toThrow(TypeError); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/projections.test.ts b/crates/trusted-server-js/lib/test/services/projections.test.ts new file mode 100644 index 000000000..601e52ffc --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/projections.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { parseBrowserAuctionProjectionV1 } from '../../src/core/contracts/auction_projection'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { createRuntimeSession, type NavigationSession } from '../../src/kernel/sessions'; +import { + createPageBidsController, + prepareInitialAuctionProjection, + type PreparedProjectionSlots, + type ProjectionSlotRegistration, + type ProjectionSlotRegistry, +} from '../../src/services/projections'; + +function runtimeSession() { + let prefix = 0; + return createRuntimeSession({ + createIdentityIssuer: () => { + prefix += 1; + return createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(prefix); + return target; + }, + }); + }, + }); +} + +function projection(slots: readonly string[], auctionId = 'page-bids') { + return { + version: 1, + auction: { + version: 1, + auctionId, + results: slots.map((slot) => ({ slot, outcome: 'no_bid' as const })), + }, + slots: slots.map((slot) => ({ + slot, + gamUnitPath: `/123/${slot}`, + divId: `div-${slot}`, + formats: [[300, 250]], + targeting: {}, + })), + bids: [], + }; +} + +class SlotLedger implements ProjectionSlotRegistry { + public readonly slots = new Set(); + public prepareCalls = 0; + public commitHook: (() => void) | undefined; + + public constructor(programmaticCount = 0) { + for (let index = 0; index < programmaticCount; index += 1) { + this.slots.add(`programmatic-${index}`); + } + } + + public prepareProjectionSlots( + ownerGeneration: object, + slots: readonly ProjectionSlotRegistration[], + maximumActiveSlots: number + ): PreparedProjectionSlots | undefined { + this.prepareCalls += 1; + if ( + this.slots.size + slots.length > maximumActiveSlots || + slots.some((slot) => this.slots.has(slot.registeredSlotId)) + ) { + return undefined; + } + let committed = false; + return Object.freeze({ + ownerGeneration, + commit: () => { + this.commitHook?.(); + for (const slot of slots) this.slots.add(slot.registeredSlotId); + committed = true; + return true; + }, + rollback: () => { + if (!committed) return; + for (const slot of slots) this.slots.delete(slot.registeredSlotId); + committed = false; + }, + }); + } +} + +function controller(navigation: NavigationSession, registry: ProjectionSlotRegistry) { + return createPageBidsController({ + navigation, + parseProjection: parseBrowserAuctionProjectionV1, + slotRegistry: registry, + }); +} + +describe('initial auction projection', () => { + it('deep-copies and recursively freezes boot input without mutating it', () => { + const bootProjection = projection(['server-slot'], 'initial'); + + const prepared = prepareInitialAuctionProjection( + bootProjection, + parseBrowserAuctionProjectionV1 + ); + + expect(prepared).toEqual(bootProjection); + expect(prepared).not.toBe(bootProjection); + expect(Object.isFrozen(prepared)).toBe(true); + expect(Object.isFrozen((prepared as typeof bootProjection).auction)).toBe(true); + expect(Object.isFrozen((prepared as typeof bootProjection).auction.results)).toBe(true); + expect(Object.isFrozen(bootProjection)).toBe(false); + bootProjection.auction.auctionId = 'publisher-mutated'; + expect((prepared as typeof bootProjection).auction.auctionId).toBe('initial'); + }); +}); + +describe('SPA page-bids projection controller', () => { + it('prepares exact placement aliases in the same transaction as projected slot ids', () => { + const runtime = runtimeSession(); + const initial = runtime.startInitialNavigation( + prepareInitialAuctionProjection(projection([], 'initial'), parseBrowserAuctionProjectionV1) + ); + if (!initial.ok) throw new Error(initial.reason); + const replacement = runtime.replaceNavigation(); + if (!replacement.ok) throw new Error(replacement.reason); + const prepareProjectionSlots = vi.fn(() => ({ + ownerGeneration: replacement.value.generation, + commit: () => true, + rollback: vi.fn(), + })); + + expect( + controller(replacement.value, { prepareProjectionSlots }).commit(projection(['server-slot'])) + ).toEqual({ status: 'committed' }); + expect(prepareProjectionSlots).toHaveBeenCalledExactlyOnceWith( + replacement.value.generation, + [ + { + registeredSlotId: 'server-slot', + domAliases: ['div-server-slot'], + }, + ], + 256 + ); + runtime.dispose(); + }); + + it('atomically reserves slots and commits one immutable current-generation projection', () => { + const runtime = runtimeSession(); + const navigation = runtime.startInitialNavigation( + prepareInitialAuctionProjection(projection([], 'initial'), parseBrowserAuctionProjectionV1) + ); + if (!navigation.ok) throw new Error('Expected initial navigation'); + const spa = runtime.replaceNavigation(); + if (!spa.ok) throw new Error('Expected SPA navigation'); + const registry = new SlotLedger(254); + const input = projection(['server-one', 'server-two']); + + expect(controller(spa.value, registry).commit(input)).toEqual({ status: 'committed' }); + expect([...registry.slots].slice(-2)).toEqual(['server-one', 'server-two']); + expect(spa.value.currentAuctionProjection).toEqual(input); + expect(spa.value.currentAuctionProjection).not.toBe(input); + expect(Object.isFrozen(spa.value.currentAuctionProjection)).toBe(true); + expect( + Object.isFrozen((spa.value.currentAuctionProjection as typeof input).auction.results[0]) + ).toBe(true); + input.auction.auctionId = 'publisher-mutated'; + expect((spa.value.currentAuctionProjection as typeof input).auction.auctionId).toBe( + 'page-bids' + ); + }); + + it('rejects a duplicate response without preparing or changing committed state', () => { + const runtime = runtimeSession(); + const spa = runtime.startInitialNavigation(); + if (!spa.ok) throw new Error('Expected navigation'); + const registry = new SlotLedger(); + const pageBids = controller(spa.value, registry); + + expect(pageBids.commit(projection(['first']))).toEqual({ status: 'committed' }); + expect(pageBids.commit(projection(['second']))).toEqual({ + status: 'rejected', + reason: 'duplicate', + }); + expect(registry.prepareCalls).toBe(1); + expect([...registry.slots]).toEqual(['first']); + expect( + (spa.value.currentAuctionProjection as ReturnType).auction.results + ).toEqual([{ slot: 'first', outcome: 'no_bid' }]); + }); + + it('makes a late old-generation response inert after navigation replacement', () => { + const runtime = runtimeSession(); + const initial = runtime.startInitialNavigation(); + if (!initial.ok) throw new Error('Expected navigation'); + const registry = new SlotLedger(); + const pageBids = controller(initial.value, registry); + const replacement = runtime.replaceNavigation(); + if (!replacement.ok) throw new Error('Expected replacement'); + + expect(pageBids.commit(projection(['stale']))).toEqual({ + status: 'rejected', + reason: 'stale', + }); + expect(registry.prepareCalls).toBe(0); + expect(registry.slots.size).toBe(0); + expect(replacement.value.currentAuctionProjection).toBeUndefined(); + }); + + it('rejects malformed input without retaining or reserving it', () => { + const runtime = runtimeSession(); + const navigation = runtime.startInitialNavigation(); + if (!navigation.ok) throw new Error('Expected navigation'); + const registry = new SlotLedger(); + const malformed = { ...projection(['slot']), extra: true }; + + expect(controller(navigation.value, registry).commit(malformed)).toEqual({ + status: 'rejected', + reason: 'malformed', + }); + expect(registry.prepareCalls).toBe(0); + expect(registry.slots.size).toBe(0); + expect(navigation.value.currentAuctionProjection).toBeUndefined(); + }); + + it.each([ + [255, 1, 'committed'], + [255, 2, 'capacity'], + [256, 1, 'capacity'], + ] as const)( + 'enforces the shared 256 cap with %i programmatic plus %i projected slots', + (programmatic, projected, expected) => { + const runtime = runtimeSession(); + const navigation = runtime.startInitialNavigation(); + if (!navigation.ok) throw new Error('Expected navigation'); + const registry = new SlotLedger(programmatic); + const slots = Array.from({ length: projected }, (_, index) => `server-${index}`); + + const result = controller(navigation.value, registry).commit(projection(slots)); + + expect(result).toEqual( + expected === 'committed' + ? { status: 'committed' } + : { status: 'rejected', reason: 'capacity' } + ); + expect(registry.slots.size).toBe(expected === 'committed' ? 256 : programmatic); + expect(navigation.value.currentAuctionProjection === undefined).toBe( + expected !== 'committed' + ); + } + ); + + it('rolls back prepared slots if ownership changes during the synchronous commit', () => { + const runtime = runtimeSession(); + const navigation = runtime.startInitialNavigation(); + if (!navigation.ok) throw new Error('Expected navigation'); + const registry = new SlotLedger(); + registry.commitHook = () => { + runtime.replaceNavigation(); + }; + + expect(controller(navigation.value, registry).commit(projection(['raced']))).toEqual({ + status: 'rejected', + reason: 'stale', + }); + expect(registry.slots.size).toBe(0); + expect(runtime.currentNavigation?.currentAuctionProjection).toBeUndefined(); + }); + + it('does not retain prior-navigation projection after a malformed SPA response', () => { + const runtime = runtimeSession(); + const initialProjection = prepareInitialAuctionProjection( + projection(['old-slot'], 'initial'), + parseBrowserAuctionProjectionV1 + ); + const initial = runtime.startInitialNavigation(initialProjection); + if (!initial.ok) throw new Error('Expected initial navigation'); + const spa = runtime.replaceNavigation(); + if (!spa.ok) throw new Error('Expected SPA navigation'); + + expect(controller(spa.value, new SlotLedger()).commit({ invalid: true })).toEqual({ + status: 'rejected', + reason: 'malformed', + }); + expect(initial.value.currentAuctionProjection).toBeUndefined(); + expect(spa.value.currentAuctionProjection).toBeUndefined(); + }); + + it('isolates a throwing parser and a throwing reservation commit', () => { + const runtime = runtimeSession(); + const first = runtime.startInitialNavigation(); + if (!first.ok) throw new Error('Expected navigation'); + const parser = vi.fn(() => { + throw new Error('hostile parser'); + }); + expect( + createPageBidsController({ + navigation: first.value, + parseProjection: parser, + slotRegistry: new SlotLedger(), + }).commit(projection(['slot'])) + ).toEqual({ status: 'rejected', reason: 'malformed' }); + + const second = runtime.replaceNavigation(); + if (!second.ok) throw new Error('Expected replacement'); + const rollback = vi.fn(); + const throwingRegistry: ProjectionSlotRegistry = { + prepareProjectionSlots: () => ({ + ownerGeneration: second.value.generation, + commit: () => { + throw new Error('commit failed'); + }, + rollback, + }), + }; + expect(controller(second.value, throwingRegistry).commit(projection(['slot']))).toEqual({ + status: 'rejected', + reason: 'capacity', + }); + expect(rollback).toHaveBeenCalledOnce(); + expect(second.value.currentAuctionProjection).toBeUndefined(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts new file mode 100644 index 000000000..03669b80b --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -0,0 +1,3186 @@ +import { describe, expect, it, vi } from 'vitest'; + +import apsEnvelope from '../fixtures/aps-renderer-v1.json'; +import { createBrowserMessagingAdapter } from '../../src/adapters/messaging'; +import { renderPucApsAttempt } from '../../src/integrations/aps/render'; +import { + createPucBridge, + PUC_DYNAMIC_OWNER, + type PucBridgeOptions, +} from '../../src/services/puc_bridge'; +import { + bindCommittedArtifactGuard, + createArtifactHostPositionLeaseRegistry, + type RenderFailureReason, + type RenderOutcome, +} from '../../src/services/render'; +import type { + ReservationClaimResult, + ReservationRecognition, + ReservationRenderSource, +} from '../../src/services/reservations'; + +const RESERVATION_ID = 'r1_abcdefghijklmnopqrstuv'; +const LIFECYCLE_TICKET = 't1_abcdefghijklmnopqrstuv'; + +function createPort() { + return { + addEventListener: vi.fn(), + close: vi.fn(), + postMessage: vi.fn(), + removeEventListener: vi.fn(), + start: vi.fn(), + }; +} + +function exactRequest(adId = RESERVATION_ID): string { + return JSON.stringify({ + message: 'Prebid Request', + adId, + adServerDomain: 'ads.example.com', + }); +} + +function exactOwnerRegistration(adId: string, lifecycleTicket = LIFECYCLE_TICKET): string { + return JSON.stringify({ + message: 'TS Render Owner Register', + adId, + version: 1, + lifecycleTicket, + }); +} + +interface HarnessOptions { + readonly claim?: PucBridgeOptions['reservations']['claim']; + readonly messageChannel?: new () => { readonly port1: unknown; readonly port2: unknown }; + readonly mintLifecycleTicket?: PucBridgeOptions['mintLifecycleTicket']; + readonly now?: PucBridgeOptions['now']; + readonly publisherOrigin?: string; + readonly resizeCollapsedShell?: PucBridgeOptions['resizeCollapsedShell']; + readonly mountAps?: PucBridgeOptions['mountAps']; + readonly scheduler?: PucBridgeOptions['scheduler']; + readonly slots?: PucBridgeOptions['slots']; +} + +function createHarness( + recognize: (reservationId: unknown) => ReservationRecognition, + options: HarnessOptions = {} +) { + const listeners: Array<(event: MessageEvent) => void> = []; + const target = { + addEventListener: vi.fn( + (_type: 'message', next: (event: MessageEvent) => void, _capture: true) => { + listeners.push(next); + } + ), + removeEventListener: vi.fn( + (_type: 'message', removed: (event: MessageEvent) => void, _capture: true) => { + const index = listeners.indexOf(removed); + if (index >= 0) listeners.splice(index, 1); + } + ), + ...(options.messageChannel ? { MessageChannel: options.messageChannel } : {}), + }; + const bridgeOptions: PucBridgeOptions = { + messaging: createBrowserMessagingAdapter(target, { + ...(options.publisherOrigin ? { expectedPublisherOrigin: options.publisherOrigin } : {}), + validateApsRenderer: () => true, + }), + mintLifecycleTicket: + options.mintLifecycleTicket ?? + (() => Object.freeze({ ok: true as const, value: LIFECYCLE_TICKET })), + reservations: { + claim: options.claim ?? (() => ({ recognized: false }) satisfies ReservationClaimResult), + recognize, + }, + ...(options.now ? { now: options.now } : {}), + ...(options.resizeCollapsedShell ? { resizeCollapsedShell: options.resizeCollapsedShell } : {}), + ...(options.mountAps ? { mountAps: options.mountAps } : {}), + ...(options.scheduler ? { scheduler: options.scheduler } : {}), + ...(options.slots ? { slots: options.slots } : {}), + }; + const bridge = createPucBridge(bridgeOptions); + const dispatch = (event: Record): void => { + if (listeners.length === 0) { + throw new Error('Expected the capture listener to be installed synchronously'); + } + for (const listener of [...listeners]) listener(event as unknown as MessageEvent); + }; + return { bridge, dispatch, listeners, target }; +} + +function createGamAttempt(kind: 'aps' | 'adm' = 'aps', index = 0) { + const suffix = index.toString(36).padStart(22, '0').slice(-22); + const id = `a1_${suffix}`; + const reservationId = `r1_${suffix}`; + const navigationGeneration = Object.freeze({ navigation: index }); + const generation = Object.freeze({ attempt: index }); + const winnerContext = Object.freeze({ selectedCpm: 1.25 }); + let state = 'created'; + let outcome: RenderOutcome | undefined; + let renderSource: ReservationRenderSource | undefined; + const settlementObservers: Array<(outcome: RenderOutcome) => void> = []; + const apsBid = apsEnvelope.seatbid[0]!.bid[0]!; + const owner = Object.freeze({ + id, + slot: `slot-${index}`, + navigationGeneration, + generation, + winnerContext, + isCurrent: vi.fn(() => outcome === undefined), + prepareWinnerContext: vi.fn(), + }); + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: id, + slot: owner.slot, + navigationGeneration, + dispose: vi.fn(), + }); + const attempt = Object.freeze({ + id, + slot: owner.slot, + generation, + navigationGeneration, + get renderSource() { + return renderSource; + }, + beginGamClaim: vi.fn(() => { + if (state !== 'created' || outcome !== undefined) return false; + state = 'waiting_for_gam_and_claim'; + return true; + }), + admitClaimedWinner: vi.fn(() => { + if (state !== 'waiting_for_gam_and_claim' || outcome !== undefined) return false; + renderSource = Object.freeze( + kind === 'aps' + ? { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: apsBid.id, + tagType: apsBid.ext.tagtype, + creativeUrl: apsBid.ext.creativeurl, + width: apsBid.w, + height: apsBid.h, + aaxResponse: btoa(JSON.stringify(apsEnvelope)), + } + : { + type: 'adm', + version: 1, + adm: '
fictional creative
', + width: 300, + height: 250, + } + ) as ReservationRenderSource; + return true; + }), + ownerClaimed: vi.fn(() => { + if (!renderSource || state !== 'waiting_for_gam_and_claim' || outcome !== undefined) { + return false; + } + state = 'waiting_for_owner'; + return true; + }), + ownerRegistered: vi.fn(() => { + if (state !== 'waiting_for_owner' || outcome !== undefined) return false; + state = 'waiting_for_insertion'; + return true; + }), + beginApsDocument: vi.fn(() => { + if (state !== 'waiting_for_insertion' || outcome !== undefined) return false; + state = 'waiting_for_document'; + return true; + }), + beginAdm: vi.fn(() => { + if (state !== 'waiting_for_insertion' || outcome !== undefined) return false; + state = 'waiting_for_adm'; + return true; + }), + apsDocumentAccepted: vi.fn(() => { + if (state !== 'waiting_for_document' || outcome !== undefined) return false; + state = 'waiting_for_aps_completion'; + return true; + }), + accept: vi.fn(() => { + if ( + (state !== 'waiting_for_aps_completion' && state !== 'waiting_for_adm') || + outcome !== undefined + ) { + return false; + } + outcome = Object.freeze({ outcome: 'accepted' }); + state = 'accepted'; + for (const observer of settlementObservers) observer(outcome); + return true; + }), + cancel: vi.fn((reason: 'caller_aborted' | 'superseded' | 'navigation_disposed') => { + if (outcome !== undefined) return false; + outcome = Object.freeze({ outcome: 'cancelled' as const, reason }); + state = 'cancelled'; + for (const observer of settlementObservers) observer(outcome); + return true; + }), + fail: vi.fn((reason: RenderFailureReason) => { + if (outcome !== undefined) return false; + outcome = Object.freeze({ outcome: 'failed', reason }); + state = 'failed'; + for (const observer of settlementObservers) observer(outcome); + return true; + }), + onSettled: vi.fn((callback: (terminal: RenderOutcome) => void) => { + if (outcome !== undefined) return false; + settlementObservers.push(callback); + return true; + }), + snapshot: vi.fn(() => Object.freeze({ state, outcome, history: Object.freeze([state]) })), + }); + return { artifact, attempt, owner, reservationId }; +} + +function dispatchPortMessage( + port: ReturnType, + data: unknown, + ports: readonly unknown[] = [] +): void { + const listener = port.addEventListener.mock.calls.find((call) => call[0] === 'message')?.[1] as + ((event: { data: unknown; ports: readonly unknown[] }) => void) | undefined; + if (!listener) throw new Error('Expected the retained port listener to be installed'); + listener({ data, ports }); +} + +function createClock() { + let now = 0; + let nextHandle = 0; + const tasks = new Map void; deadline: number }>(); + const scheduler = { + set: vi.fn((callback: () => void, milliseconds: number): number => { + nextHandle += 1; + tasks.set(nextHandle, { callback, deadline: now + milliseconds }); + return nextHandle; + }), + clear: vi.fn((handle: unknown): void => { + if (typeof handle === 'number') tasks.delete(handle); + }), + }; + const advance = (milliseconds: number): void => { + now += milliseconds; + for (const [handle, task] of [...tasks]) { + if (task.deadline <= now) { + tasks.delete(handle); + task.callback(); + } + } + }; + return { advance, now: () => now, scheduler }; +} + +function issueReadyTicket( + harness: ReturnType, + gam: ReturnType, + source: object +): void { + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [createPort()], + source, + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); +} + +describe('Universal Creative bridge dispatcher', () => { + it('adopts first-display lifecycle-ticket tombstones and expires them in the local epoch', () => { + let now = 100; + const tasks: Array<() => void> = []; + const harness = createHarness(() => ({ recognized: false }), { + now: () => now, + scheduler: { + set: (callback) => { + tasks.push(callback); + return callback; + }, + clear: (handle) => { + const index = tasks.indexOf(handle as () => void); + if (index >= 0) tasks.splice(index, 1); + }, + }, + }); + + expect( + harness.bridge.adoptFirstDisplayTickets({ + clockEpochMs: 40, + nextTicketOrdinal: 4, + tombstones: [{ expiresAtMs: 140, ticket: LIFECYCLE_TICKET }], + }) + ).toBe(true); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + const responsePort = createPort(); + const stopImmediatePropagation = vi.fn(); + harness.dispatch({ + data: exactOwnerRegistration(RESERVATION_ID), + ports: [responsePort], + source: Object.freeze({}), + stopImmediatePropagation, + }); + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(responsePort.postMessage).toHaveBeenCalledOnce(); + expect(responsePort.close).toHaveBeenCalledOnce(); + + now = 200; + tasks.shift()?.(); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(0); + expect( + harness.bridge.adoptFirstDisplayTickets({ + clockEpochMs: 200, + nextTicketOrdinal: 5, + tombstones: [], + }) + ).toBe(false); + }); + + it('keeps APS out of the PUC realm while preserving ADM iframe ordering', () => { + const admStart = PUC_DYNAMIC_OWNER.indexOf('const insertAdm'); + const controlStart = PUC_DYNAMIC_OWNER.indexOf('const receiveControl'); + const admOwner = PUC_DYNAMIC_OWNER.slice(admStart, controlStart); + + expect(new TextEncoder().encode(PUC_DYNAMIC_OWNER).byteLength).toBeLessThanOrEqual(64 * 1_024); + expect(admStart).toBeGreaterThanOrEqual(0); + expect(controlStart).toBeGreaterThan(admStart); + expect(admOwner.indexOf('next.onload =')).toBeLessThan( + admOwner.indexOf('next.srcdoc = intendedSource;') + ); + expect(admOwner.indexOf('next.onerror =')).toBeLessThan( + admOwner.indexOf('next.srcdoc = intendedSource;') + ); + expect(PUC_DYNAMIC_OWNER).not.toContain('const insertAps'); + expect(PUC_DYNAMIC_OWNER).not.toContain('TS APS Start'); + expect(PUC_DYNAMIC_OWNER).not.toContain('rendererUrl'); + expect(PUC_DYNAMIC_OWNER).not.toContain('aaxResponse'); + expect(PUC_DYNAMIC_OWNER).toContain('TS APS Top Mount Started'); + expect(PUC_DYNAMIC_OWNER).not.toMatch( + /\b(?:fetch|XMLHttpRequest|WebSocket|EventSource|Worker|SharedWorker|Blob)\b/ + ); + expect(PUC_DYNAMIC_OWNER).not.toContain('import('); + expect(PUC_DYNAMIC_OWNER).not.toContain('createObjectURL'); + expect(PUC_DYNAMIC_OWNER).not.toMatch(/createElement\(["'](?:script|link)["']\)/); + }); + + it('binds ADM load and final acceptance to the exact inserted navigation', () => { + const admStart = PUC_DYNAMIC_OWNER.indexOf('const insertAdm'); + const controlStart = PUC_DYNAMIC_OWNER.indexOf('const receiveControl'); + const admOwner = PUC_DYNAMIC_OWNER.slice(admStart, controlStart); + + expect(admOwner).toContain('next.parentNode === creativeWindow.document.body'); + expect(admOwner).toContain('next.srcdoc === intendedSource'); + expect(admOwner).toContain('next.getAttribute("src") === null'); + expect(PUC_DYNAMIC_OWNER).toContain('ownerFrameCurrent?.() === true'); + }); + + it.each([ + 'duplicate registration key', + 'accessor-backed registration port', + 'accessor-backed registration ports collection', + 'usable registration port before an accessor', + ])('rejects a %s without binding its owner channel', async (caseName) => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(_listener: ((event: unknown) => void) | null) {}, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + let portAccessorCalls = 0; + let rendered: Promise | undefined; + let observedRejection: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + observedRejection = rendered.then( + () => undefined, + (error: unknown) => error + ); + const ports: unknown[] = [controlPort]; + if (caseName === 'accessor-backed registration port') { + Object.defineProperty(ports, '0', { + configurable: true, + enumerable: true, + get: () => { + portAccessorCalls += 1; + return controlPort; + }, + }); + } + if (caseName === 'usable registration port before an accessor') { + ports[1] = undefined; + Object.defineProperty(ports, '1', { + configurable: true, + enumerable: true, + get: () => { + portAccessorCalls += 1; + return createPort(); + }, + }); + } + const registrationEvent: Record = { + data: + caseName === 'duplicate registration key' + ? `{"message":"TS Render Owner Registered","adId":"${RESERVATION_ID}","version":1,"lifecycleTicket":"${LIFECYCLE_TICKET}","lifecycleTicket":"${LIFECYCLE_TICKET}"}` + : JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports, + }; + if (caseName === 'accessor-backed registration ports collection') { + Object.defineProperty(registrationEvent, 'ports', { + configurable: true, + enumerable: true, + get: () => { + portAccessorCalls += 1; + return ports; + }, + }); + } + registrationCallback?.(registrationEvent); + + expect(controlPort.start).not.toHaveBeenCalled(); + expect(portAccessorCalls).toBe(0); + if ( + caseName === 'duplicate registration key' || + caseName === 'usable registration port before an accessor' + ) { + expect(controlPort.close).toHaveBeenCalledOnce(); + } + await expect(observedRejection).resolves.toEqual( + expect.objectContaining({ message: 'TS render owner registration refused' }) + ); + } finally { + await vi.runAllTimersAsync(); + await observedRejection; + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('closes usable control-message ports without reading a later accessor', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + const usable = createPort(); + let accessorCalls = 0; + let observedRejection: Promise | undefined; + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + observedRejection = rendered.then( + () => undefined, + (error: unknown) => error + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + const ports: unknown[] = [usable, undefined]; + Object.defineProperty(ports, '1', { + configurable: true, + enumerable: true, + get: () => { + accessorCalls += 1; + return createPort(); + }, + }); + + controlListener?.({ + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
must not render
', + width: 300, + height: 250, + }, + }, + ports, + }); + + expect(accessorCalls).toBe(0); + expect(usable.close).toHaveBeenCalledOnce(); + expect(controlPort.close).toHaveBeenCalledOnce(); + expect(document.body.querySelector('iframe')).toBeNull(); + await expect(observedRejection).resolves.toEqual( + expect.objectContaining({ message: 'TS render owner control refused' }) + ); + } finally { + await vi.runAllTimersAsync(); + await observedRejection; + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('runs the checked-in PUC owner through helper registration and final ADM settlement', async () => { + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + const stopListening = vi.fn(); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + type: string, + payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return stopListening; + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + + try { + const ownerData = window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>; + const rendered = dynamicWindow.render!(ownerData, { sendMessage }, window); + expect(sendMessage).toHaveBeenCalledWith( + 'TS Render Owner Register', + { version: 1, lifecycleTicket: LIFECYCLE_TICKET }, + expect.any(Function) + ); + registrationCallback?.( + new MessageEvent('message', { + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort as unknown as MessagePort], + }) + ); + expect(stopListening).toHaveBeenCalledOnce(); + expect(controlPort.start).toHaveBeenCalledOnce(); + + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
remote creative
', + width: 300, + height: 250, + }, + }, + ports: [], + }) + ); + const frame = document.body.querySelector('iframe'); + expect(frame).not.toBeNull(); + expect(frame?.srcdoc).toContain('
remote creative
'); + expect(frame?.getAttribute('sandbox')).toBe( + 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation' + ); + expect(controlPort.postMessage).toHaveBeenCalledWith({ + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + + frame?.dispatchEvent(new Event('load')); + expect(controlPort.postMessage).toHaveBeenCalledWith({ + message: 'TS ADM Loaded', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }) + ); + + await expect(rendered).resolves.toBeUndefined(); + expect(frame?.isConnected).toBe(true); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('contains synchronous helper registration and control-port settlement reentrancy', async () => { + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + const stopListening = vi.fn(); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + start: vi.fn(() => { + controlListener?.({ + data: { + message: 'TS APS Top Mount Started', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }, + ports: [], + }); + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }); + }), + }; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + callback({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + return stopListening; + } + ); + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + + await expect(rendered).resolves.toBeUndefined(); + expect(controlPort.start).toHaveBeenCalledOnce(); + expect(controlPort.close).toHaveBeenCalledOnce(); + expect(stopListening).toHaveBeenCalledOnce(); + } finally { + delete dynamicWindow.render; + } + }); + + it('rejects accepted ADM settlement after the owner iframe navigation changes', async () => { + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.( + new MessageEvent('message', { + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort as unknown as MessagePort], + }) + ); + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
intended creative
', + width: 300, + height: 250, + }, + }, + ports: [], + }) + ); + const frame = document.body.querySelector('iframe'); + expect(frame).not.toBeNull(); + if (!frame) throw new Error('Expected owner iframe'); + frame.srcdoc = '
replaced creative
'; + frame.dispatchEvent(new Event('load')); + expect(controlPort.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ message: 'TS ADM Loaded' }) + ); + + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }) + ); + + await expect(rendered).rejects.toThrow('TS render owner control refused'); + expect(frame.isConnected).toBe(false); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('settles and closes the owner channel when every terminal DOM cleanup hook throws', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + const hostileOwnerWindow = Object.create(window) as Window; + const clearTimeout = vi.fn(() => { + throw new Error('clear timeout failed'); + }); + Object.defineProperties(hostileOwnerWindow, { + clearTimeout: { configurable: true, value: clearTimeout }, + document: { configurable: true, value: document }, + setTimeout: { configurable: true, value: window.setTimeout.bind(window) }, + }); + let registrationCallback: ((event: unknown) => void) | undefined; + const stopListening = vi.fn(); + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return stopListening; + } + ); + let controlListener: ((event: unknown) => void) | undefined; + let throwOnHandlerClear = false; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + if (listener === null && throwOnHandlerClear) throw new Error('message clear failed'); + controlListener = listener ?? undefined; + }, + set onmessageerror(listener: ((event: unknown) => void) | null) { + if (listener === null && throwOnHandlerClear) { + throw new Error('messageerror clear failed'); + } + }, + }; + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + hostileOwnerWindow + ); + const observed = rendered.then( + () => 'resolved', + () => 'rejected' + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
cleanup test
', + width: 300, + height: 250, + }, + }, + ports: [], + }); + const frame = document.body.querySelector('iframe'); + if (!frame) throw new Error('Expected the owner frame'); + const loadHandler = frame.onload; + const errorHandler = frame.onerror; + Object.defineProperties(frame, { + onerror: { + configurable: true, + get: () => errorHandler, + set: (value: unknown) => { + if (value === null) throw new Error('frame error-handler clear failed'); + }, + }, + onload: { + configurable: true, + get: () => loadHandler, + set: (value: unknown) => { + if (value === null) throw new Error('frame load-handler clear failed'); + }, + }, + remove: { + configurable: true, + value: vi.fn(() => { + throw new Error('frame removal failed'); + }), + }, + }); + throwOnHandlerClear = true; + + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'failed', + }, + ports: [], + }); + + await Promise.resolve(); + expect(await Promise.race([observed, Promise.resolve('pending')])).toBe('rejected'); + expect(clearTimeout).toHaveBeenCalled(); + expect(stopListening).toHaveBeenCalledOnce(); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('keeps APS DOM ownership out of the PUC frame after a data-free top-mount signal', async () => { + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS APS Top Mount Started', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }, + ports: [], + }); + + expect(document.body.querySelector('iframe')).toBeNull(); + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }); + await expect(rendered).resolves.toBeUndefined(); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('refuses the removed data-bearing APS start and closes every transferred port', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + const documentPort = createPort(); + let rendered: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS APS Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', + envelope: { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer: { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + }, + }, + }, + ports: [documentPort], + }); + + await expect(rendered).rejects.toThrow('TS render owner control refused'); + expect(documentPort.close).toHaveBeenCalledOnce(); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + await vi.runAllTimersAsync(); + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('fails closed immediately when the PUC helper does not return its disposer', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let rendered: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage: vi.fn(() => undefined) }, + window + ); + const immediate = rendered.then( + () => 'resolved', + () => 'rejected' + ); + + await Promise.resolve(); + expect(await Promise.race([immediate, Promise.resolve('pending')])).toBe('rejected'); + } finally { + await vi.runAllTimersAsync(); + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('rejects registration at exactly three seconds, disposes the helper, and closes a late port', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + const stopListening = vi.fn(); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return stopListening; + } + ); + let rendered: Promise | undefined; + let settlement = 'pending'; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + void rendered.then( + () => { + settlement = 'resolved'; + }, + () => { + settlement = 'rejected'; + } + ); + + await vi.advanceTimersByTimeAsync(2_999); + expect(settlement).toBe('pending'); + expect(stopListening).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(settlement).toBe('rejected'); + expect(stopListening).toHaveBeenCalledOnce(); + + const latePort = createPort(); + registrationCallback?.({ data: '{}', ports: [latePort] }); + expect(latePort.close).toHaveBeenCalledOnce(); + expect(stopListening).toHaveBeenCalledOnce(); + } finally { + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('removes uncommitted owner DOM at the exact twenty-second watchdog boundary', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + let rendered: Promise | undefined; + let settlement = 'pending'; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + void rendered.then( + () => { + settlement = 'resolved'; + }, + () => { + settlement = 'rejected'; + } + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
uncommitted creative
', + width: 300, + height: 250, + }, + }, + ports: [], + }); + const frame = document.body.querySelector('iframe'); + expect(frame?.isConnected).toBe(true); + + await vi.advanceTimersByTimeAsync(19_999); + expect(settlement).toBe('pending'); + expect(frame?.isConnected).toBe(true); + expect(controlPort.close).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(settlement).toBe('rejected'); + expect(frame?.isConnected).toBe(false); + expect(controlPort.close).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it.each([ + { caseName: 'unexpected field', message: { unexpected: true }, ports: [] }, + { caseName: 'transferred port', message: {}, ports: [createPort()] }, + { caseName: 'wrong message', message: { message: 'TS APS Start' }, ports: [] }, + ])('refuses a malformed top-mount signal with an $caseName', async ({ message, ports }) => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + let rendered: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS APS Top Mount Started', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + ...message, + }, + ports, + }); + await expect(rendered).rejects.toThrow('TS render owner control refused'); + expect(document.body.querySelector('iframe')).toBeNull(); + for (const port of ports) expect(port.close).toHaveBeenCalledOnce(); + } finally { + await vi.runAllTimersAsync(); + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('installs one capture listener synchronously and removes only that listener on disposal', () => { + const harness = createHarness(() => ({ recognized: false })); + + expect(harness.target.addEventListener).toHaveBeenCalledOnce(); + expect(harness.target.addEventListener.mock.calls[0]?.[0]).toBe('message'); + expect(harness.target.addEventListener.mock.calls[0]?.[2]).toBe(true); + expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + attempts: 0, + disposed: false, + liveTickets: 0, + pendingClaims: 0, + ticketTombstones: 0, + }); + + harness.bridge.dispose(); + harness.bridge.dispose(); + expect(harness.target.removeEventListener).toHaveBeenCalledOnce(); + expect(harness.target.removeEventListener.mock.calls[0]?.[0]).toBe('message'); + expect(harness.target.removeEventListener.mock.calls[0]?.[2]).toBe(true); + expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + attempts: 0, + disposed: true, + liveTickets: 0, + pendingClaims: 0, + ticketTombstones: 0, + }); + }); + + it('leaves native Prebid identifiers untouched before port or source inspection', () => { + const recognize = vi.fn((): ReservationRecognition => ({ recognized: false })); + const harness = createHarness(recognize); + const stopImmediatePropagation = vi.fn(); + const ports = vi.fn(() => { + throw new Error('native ports must not be read'); + }); + const source = vi.fn(() => { + throw new Error('native source must not be read'); + }); + + harness.dispatch({ + data: exactRequest('native-prebid-id'), + stopImmediatePropagation, + get ports() { + return ports(); + }, + get source() { + return source(); + }, + }); + + expect(recognize).toHaveBeenCalledWith('native-prebid-id'); + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + expect(ports).not.toHaveBeenCalled(); + expect(source).not.toHaveBeenCalled(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + }); + + it.each([ + ['extended object', { message: 'Prebid Request', adId: RESERVATION_ID, extra: true }], + [ + 'extended JSON', + JSON.stringify({ message: 'Prebid Request', adId: RESERVATION_ID, extra: true }), + ], + ])('suppresses and generically refuses a recognized %s before exact parsing', (_label, data) => { + const order: string[] = []; + const harness = createHarness((reservationId) => { + order.push(`lookup:${String(reservationId)}`); + return { recognized: true, state: 'renderable', expiresAt: 1_000 }; + }); + const port = createPort(); + + harness.dispatch({ + data, + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(() => order.push('stop')), + }); + + expect(order).toEqual([`lookup:${RESERVATION_ID}`, 'stop']); + expect(port.postMessage).toHaveBeenCalledOnce(); + expect(String(port.postMessage.mock.calls[0]?.[0])).toBe( + JSON.stringify({ + message: 'Prebid Response', + adId: RESERVATION_ID, + rendererVersion: '4', + tsOwner: { version: 1, status: 'refused' }, + }) + ); + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'Prebid Response', + adId: RESERVATION_ID, + rendererVersion: '4', + tsOwner: { version: 1, status: 'refused' }, + }); + expect(port.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(port.close).toHaveBeenCalledOnce(); + }); + + it('suppresses recognized requests with the wrong port count, refuses on the first, and closes every port', () => { + const harness = createHarness(() => ({ + recognized: true, + state: 'renderable', + expiresAt: 1_000, + })); + const first = createPort(); + const second = createPort(); + const third = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: exactRequest(), + ports: [first, second, third], + source: Object.freeze({}), + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(first.postMessage).toHaveBeenCalledOnce(); + expect(JSON.parse(String(first.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'Prebid Response', + adId: RESERVATION_ID, + rendererVersion: '4', + tsOwner: { version: 1, status: 'refused' }, + }); + expect(first.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(second.postMessage).not.toHaveBeenCalled(); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).toHaveBeenCalledOnce(); + expect(third.close).toHaveBeenCalledOnce(); + + const malformed = { close: vi.fn() }; + const laterUsable = createPort(); + harness.dispatch({ + data: exactRequest(), + ports: [malformed, laterUsable], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + expect(malformed.close).toHaveBeenCalledOnce(); + expect(laterUsable.postMessage).toHaveBeenCalledOnce(); + expect(laterUsable.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + }); + + it('buffers only the first exact live claim and generically refuses a duplicate', () => { + const gam = createGamAttempt('aps'); + const harness = createHarness(() => ({ + recognized: true, + state: 'renderable', + expiresAt: 1_000, + })); + const first = createPort(); + const duplicate = createPort(); + const source = Object.freeze({ frame: 'authoritative' }); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + + harness.dispatch({ + data: exactRequest(), + ports: [first], + source, + stopImmediatePropagation: vi.fn(), + }); + + expect(first.postMessage).not.toHaveBeenCalled(); + expect(first.close).not.toHaveBeenCalled(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(1); + + harness.dispatch({ + data: exactRequest(), + ports: [duplicate], + source: Object.freeze({ frame: 'duplicate' }), + stopImmediatePropagation: vi.fn(), + }); + + expect(duplicate.postMessage).toHaveBeenCalledOnce(); + expect(duplicate.close).toHaveBeenCalledOnce(); + expect(first.postMessage).not.toHaveBeenCalled(); + expect(first.close).not.toHaveBeenCalled(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(1); + + harness.bridge.dispose(); + expect(first.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + }); + + it.each(['caller_aborted', 'superseded', 'navigation_disposed'] as const)( + 'contains a claim-first attempt cancelled as %s', + (reason) => { + const gam = createGamAttempt('aps'); + const harness = createHarness(() => ({ + recognized: true, + state: 'renderable', + expiresAt: 10_000, + })); + const port = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({ frame: 'authoritative' }), + stopImmediatePropagation: vi.fn(), + }); + + expect(gam.attempt.cancel(reason)).toBe(true); + + expect(port.postMessage).not.toHaveBeenCalled(); + expect(port.close).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + pendingClaims: 0, + }); + } + ); + + it.each(['gam_empty', 'gpt_request_timeout', 'gpt_completion_timeout'] as const)( + 'contains a GAM-first attempt failed as %s and clears its claim deadline', + (reason) => { + const clock = createClock(); + const gam = createGamAttempt('aps'); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { now: clock.now, scheduler: clock.scheduler } + ); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + + expect(gam.attempt.fail(reason)).toBe(true); + + expect(clock.scheduler.clear).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + pendingClaims: 0, + }); + } + ); + + it('tombstones a ready ticket when the owning attempt settles before registration', () => { + const clock = createClock(); + const gam = createGamAttempt('adm'); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: clock.now, + scheduler: clock.scheduler, + } + ); + issueReadyTicket(harness, gam, Object.freeze({ frame: 'authoritative' })); + + expect(gam.attempt.cancel('superseded')).toBe(true); + + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + ticketTombstones: 1, + }); + }); + + it.each(['consumed', 'disposed', 'awaiting_prebid_selection'] as const)( + 'suppresses and refuses a recognized non-renderable %s reservation', + (state) => { + const harness = createHarness(() => ({ recognized: true, state, expiresAt: 1_000 })); + const port = createPort(); + + harness.dispatch({ + data: exactRequest(), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + + expect(port.postMessage).toHaveBeenCalledOnce(); + expect(port.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + } + ); + + it('joins an early claim with nonempty GAM and exposes only owner kind and ticket', () => { + const gam = createGamAttempt('aps'); + const source = Object.freeze({ frame: 'authoritative' }); + const claim = vi.fn(({ pucSource }: { pucSource: unknown }): ReservationClaimResult => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + })); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + const port = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + + harness.dispatch({ + data: exactRequest(), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }); + expect(claim).not.toHaveBeenCalled(); + expect(port.postMessage).not.toHaveBeenCalled(); + + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + + expect(claim).toHaveBeenCalledWith({ + attempt: gam.owner, + navigationGeneration: gam.owner.navigationGeneration, + pucSource: source, + reservationId: RESERVATION_ID, + slot: gam.owner.slot, + }); + expect(gam.attempt.admitClaimedWinner).toHaveBeenCalledOnce(); + expect(gam.attempt.ownerClaimed).toHaveBeenCalledOnce(); + expect(port.postMessage).toHaveBeenCalledOnce(); + const rawResponse = String(port.postMessage.mock.calls[0]?.[0]); + const response = JSON.parse(rawResponse); + expect( + new TextEncoder().encode(String(port.postMessage.mock.calls[0]?.[0])).byteLength + ).toBeLessThanOrEqual(72 * 1_024); + expect(response).toEqual({ + message: 'Prebid Response', + adId: RESERVATION_ID, + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }); + expect(rawResponse).toBe( + JSON.stringify({ + message: 'Prebid Response', + adId: RESERVATION_ID, + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ); + expect(response).not.toHaveProperty('source'); + expect(response).not.toHaveProperty('renderSource'); + expect(response).not.toHaveProperty('winnerContext'); + expect(port.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(port.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + attempts: 1, + disposed: false, + liveTickets: 1, + pendingClaims: 0, + ticketTombstones: 0, + }); + + expect(gam.attempt.fail('internal_error')).toBe(true); + expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + attempts: 0, + disposed: false, + liveTickets: 0, + pendingClaims: 0, + ticketTombstones: 1, + }); + }); + + it('requests one guarded shell resize only after the current ready response posts', () => { + const gam = createGamAttempt('aps', 81); + const source = Object.freeze({ frame: 'authoritative' }); + const resizeCollapsedShell = vi.fn(() => true); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + resizeCollapsedShell, + } + ); + const port = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + + expect(resizeCollapsedShell).toHaveBeenCalledExactlyOnceWith({ + source, + width: 300, + height: 250, + }); + expect(port.postMessage.mock.invocationCallOrder[0]).toBeLessThan( + resizeCollapsedShell.mock.invocationCallOrder[0]! + ); + }); + + it('does not resize after a failed post or a navigation cancellation during the post', () => { + const resizeCollapsedShell = vi.fn(() => true); + for (const cancelDuringPost of [false, true]) { + const gam = createGamAttempt('adm', cancelDuringPost ? 83 : 82); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + resizeCollapsedShell, + } + ); + const port = createPort(); + port.postMessage.mockImplementation(() => { + if (cancelDuringPost) gam.attempt.cancel('navigation_disposed'); + else throw new Error('post failed'); + }); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({ frame: cancelDuringPost ? 'cancelled' : 'failed' }), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + } + + expect(resizeCollapsedShell).not.toHaveBeenCalled(); + }); + + it('starts the exact three-second claim deadline only after nonempty GAM', () => { + const clock = createClock(); + const gam = createGamAttempt('adm'); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { now: clock.now, scheduler: clock.scheduler } + ); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + + expect(clock.scheduler.set).toHaveBeenCalledWith(expect.any(Function), 3_000); + clock.advance(2_999); + expect(gam.attempt.fail).not.toHaveBeenCalled(); + clock.advance(1); + expect(gam.attempt.fail).toHaveBeenCalledWith('bridge_claim_timeout'); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().attempts).toBe(0); + }); + + it('clears a GAM-first claim deadline when the exact request completes the join', () => { + const clock = createClock(); + const gam = createGamAttempt('adm'); + const claim = vi.fn(({ pucSource }: { pucSource: unknown }): ReservationClaimResult => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + })); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: clock.now, + scheduler: clock.scheduler, + } + ); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + const staleClaimDeadline = clock.scheduler.set.mock.calls[0]?.[0]; + if (typeof staleClaimDeadline !== 'function') { + throw new Error('Expected the GAM-first claim deadline callback'); + } + const port = createPort(); + harness.dispatch({ + data: exactRequest(), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + + expect(port.postMessage).toHaveBeenCalledOnce(); + expect(gam.attempt.renderSource).toMatchObject({ type: 'adm', version: 1 }); + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.kind).toBe('adm'); + expect(clock.scheduler.clear).toHaveBeenCalledOnce(); + staleClaimDeadline(); + expect(gam.attempt.fail).not.toHaveBeenCalledWith('bridge_claim_timeout'); + clock.advance(2_999); + expect(gam.attempt.fail).not.toHaveBeenCalled(); + clock.advance(1); + expect(gam.attempt.fail).toHaveBeenCalledWith('owner_registration_timeout'); + expect(gam.attempt.fail).not.toHaveBeenCalledWith('bridge_claim_timeout'); + }); + + it('checks all eight ticket draws against live and tombstoned entries', () => { + const first = createGamAttempt('aps', 1); + const second = createGamAttempt('aps', 2); + let draws = 0; + const claim = vi.fn(({ pucSource }: { pucSource: unknown }): ReservationClaimResult => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + })); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim, + mintLifecycleTicket: () => { + draws += 1; + return Object.freeze({ ok: true as const, value: LIFECYCLE_TICKET }); + }, + } + ); + for (const gam of [first, second]) { + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + const port = createPort(); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({ index: draws }), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + if (gam === first) { + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.status).toBe( + 'ready' + ); + expect(gam.attempt.fail('internal_error')).toBe(true); + } else { + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.status).toBe( + 'refused' + ); + expect(gam.attempt.fail).toHaveBeenCalledWith('identity_generation_failed'); + } + } + expect(draws).toBe(9); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + }); + + it('retains ticket tombstones through 2,999 ms and prunes them at 3,000 ms', () => { + const clock = createClock(); + const gam = createGamAttempt('aps', 7); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: clock.now, + scheduler: clock.scheduler, + } + ); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [createPort()], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(gam.attempt.fail('internal_error')).toBe(true); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + + clock.advance(2_999); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + clock.advance(1); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(0); + }); + + it('starts the fixed ticket TTL only after posting the ready outer response', () => { + const clock = createClock(); + const gam = createGamAttempt('aps', 71); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: clock.now, + scheduler: clock.scheduler, + } + ); + const port = createPort(); + port.postMessage.mockImplementation(() => clock.advance(1_000)); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + + clock.advance(2_000); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + expect(gam.attempt.fail).not.toHaveBeenCalled(); + clock.advance(999); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + clock.advance(1); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(0); + expect(gam.attempt.fail).toHaveBeenCalledWith('owner_registration_timeout'); + }); + + it('keeps a reused ticket live when a cleared expiry callback from its prior issue arrives late', () => { + let now = 0; + const callbacks: Array<() => void> = []; + const scheduler = { + set: vi.fn((callback: () => void): number => { + callbacks[callbacks.length] = callback; + return callbacks.length; + }), + clear: vi.fn(), + }; + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: () => now, + scheduler, + } + ); + const first = createGamAttempt('aps', 72); + issueReadyTicket(harness, first, Object.freeze({ frame: 'first' })); + const firstExpiry = callbacks[0]; + if (!firstExpiry) throw new Error('Expected the first ticket expiry callback'); + + now = 3_000; + firstExpiry(); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(0); + + const second = createGamAttempt('aps', 73); + issueReadyTicket(harness, second, Object.freeze({ frame: 'second' })); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + + now = 6_000; + firstExpiry(); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + expect(second.attempt.fail).not.toHaveBeenCalled(); + + const secondExpiry = callbacks[1]; + if (!secondExpiry) throw new Error('Expected the reused ticket expiry callback'); + secondExpiry(); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(0); + expect(second.attempt.fail).toHaveBeenCalledWith('owner_registration_timeout'); + }); + + it('fails and tombstones a ticket when the ready outer response cannot be posted', () => { + const gam = createGamAttempt('adm', 8); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + const port = createPort(); + port.postMessage.mockImplementation(() => { + throw new Error('outer response transport failed'); + }); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + + expect(gam.attempt.fail).toHaveBeenCalledWith('internal_error'); + expect(port.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + pendingClaims: 0, + ticketTombstones: 1, + }); + }); + + it('shares ticket capacity 320 across live entries without eviction', () => { + const clock = createClock(); + let draw = 0; + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => { + const suffix = draw.toString(36).padStart(22, '0').slice(-22); + draw += 1; + return Object.freeze({ ok: true as const, value: `t1_${suffix}` }); + }, + now: clock.now, + scheduler: clock.scheduler, + } + ); + + for (let index = 0; index < 320; index += 1) { + const gam = createGamAttempt('aps', 100 + index); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + const port = createPort(); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({ index }), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.status).toBe('ready'); + } + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 320, + liveTickets: 320, + ticketTombstones: 0, + }); + + const overflow = createGamAttempt('aps', 999); + const overflowPort = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: overflow.artifact, + attempt: overflow.attempt, + owner: overflow.owner, + reservationId: overflow.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(overflow.reservationId), + ports: [overflowPort], + source: Object.freeze({ overflow: true }), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: overflow.artifact, + attempt: overflow.attempt, + owner: overflow.owner, + reservationId: overflow.reservationId, + }) + ).toBe(true); + expect(overflow.attempt.fail).toHaveBeenCalledWith('capability_registry_full'); + expect(JSON.parse(String(overflowPort.postMessage.mock.calls[0]?.[0])).tsOwner.status).toBe( + 'refused' + ); + expect(draw).toBe(320); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 320, + liveTickets: 320, + ticketTombstones: 0, + }); + harness.bridge.dispose(); + }); + + it('ignores an unknown owner ticket before suppression, source, or port inspection', () => { + const harness = createHarness(() => ({ recognized: false })); + const stopImmediatePropagation = vi.fn(); + const ports = vi.fn(() => { + throw new Error('unknown ticket ports must not be read'); + }); + const source = vi.fn(() => { + throw new Error('unknown ticket source must not be read'); + }); + + harness.dispatch({ + data: exactOwnerRegistration(RESERVATION_ID, 't1_0000000000000000000000'), + stopImmediatePropagation, + get ports() { + return ports(); + }, + get source() { + return source(); + }, + }); + + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + expect(ports).not.toHaveBeenCalled(); + expect(source).not.toHaveBeenCalled(); + }); + + it('suppresses a known owner ticket before failing closed on a regressed clock', () => { + let now = 100; + const gam = createGamAttempt('adm', 1_009); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: () => now, + } + ); + issueReadyTicket(harness, gam, pucSource); + now = 99; + const responsePort = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [responsePort], + source: pucSource, + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(JSON.parse(String(responsePort.postMessage.mock.calls[0]?.[0]))).toMatchObject({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + }); + expect(responsePort.close).toHaveBeenCalledOnce(); + expect(gam.attempt.fail).toHaveBeenCalledWith('internal_error'); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + }); + + it('consumes one exact owner registration and retains only the kernel control endpoint', () => { + const gam = createGamAttempt('adm', 1_001); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const retained = createPort(); + const transferred = createPort(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = retained; + readonly port2 = transferred; + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const responsePort = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [responsePort], + source: pucSource, + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(gam.attempt.ownerRegistered).toHaveBeenCalledOnce(); + expect(JSON.parse(String(responsePort.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'TS Render Owner Registered', + adId: gam.reservationId, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(responsePort.postMessage.mock.calls[0]?.[1]).toEqual([transferred]); + expect(responsePort.close).toHaveBeenCalledOnce(); + expect(transferred.close).not.toHaveBeenCalled(); + expect(retained.close).not.toHaveBeenCalled(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 1, + liveTickets: 0, + ticketTombstones: 1, + }); + + expect(gam.attempt.fail('internal_error')).toBe(true); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(retained.close).toHaveBeenCalledOnce(); + expect(transferred.close).not.toHaveBeenCalled(); + }); + + it('closes both channel endpoints when owner-channel construction settles reentrantly', () => { + const gam = createGamAttempt('adm', 1_010); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const retained = createPort(); + const transferred = createPort(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = retained; + readonly port2 = transferred; + + constructor() { + gam.attempt.fail('internal_error'); + } + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const responsePort = createPort(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [responsePort], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(retained.close).toHaveBeenCalledOnce(); + expect(transferred.close).toHaveBeenCalledOnce(); + expect(responsePort.close).toHaveBeenCalledOnce(); + expect(JSON.parse(String(responsePort.postMessage.mock.calls[0]?.[0]))).toMatchObject({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + }); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().attempts).toBe(0); + }); + + it('sends exact ADM start and settles only after owner insertion and intended load', () => { + const gam = createGamAttempt('adm', 1_011); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = controlRetained; + readonly port2 = controlTransferred; + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const responsePort = createPort(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [responsePort], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(controlRetained.postMessage).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage.mock.calls[0]).toEqual([ + { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
fictional creative
', + width: 300, + height: 250, + }, + }, + [], + ]); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + + dispatchPortMessage(controlRetained, { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(gam.attempt.beginAdm).toHaveBeenCalledWith(gam.artifact); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + + dispatchPortMessage(controlRetained, { + message: 'TS ADM Loaded', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(gam.attempt.accept).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); + expect(controlRetained.postMessage).toHaveBeenCalledTimes(2); + expect(controlRetained.postMessage.mock.calls[1]).toEqual([ + { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + [], + ]); + expect(controlRetained.close).toHaveBeenCalledOnce(); + }); + + it('fails closed and contains every port when an owner control message transfers one', () => { + const gam = createGamAttempt('adm', 1_014); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = controlRetained; + readonly port2 = controlTransferred; + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + const unexpected = createPort(); + + dispatchPortMessage( + controlRetained, + { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }, + [unexpected] + ); + + expect(unexpected.close).toHaveBeenCalledOnce(); + expect(gam.attempt.beginAdm).not.toHaveBeenCalled(); + expect(gam.attempt.fail).toHaveBeenCalledWith('internal_error'); + expect(controlRetained.close).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + }); + + it('mounts APS in the exact top-page slot and sends only the data-free owner signal', () => { + document.body.innerHTML = + '
gam
'; + const gam = createGamAttempt('aps', 1_012); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const bootstrapNonce = `b1_${'b'.repeat(22)}`; + const rendererNonce = `n1_${'n'.repeat(22)}`; + const nonceRegistry = (nonce: string) => + Object.freeze({ + bindSource: vi.fn(() => true), + consume: vi.fn(() => true), + dispose: vi.fn(), + issue: vi.fn(() => Object.freeze({ ok: true as const, nonce })), + snapshotForTest: vi.fn(() => + Object.freeze({ bindings: 1, disposed: false, liveNonces: 1 }) + ), + }); + const bootstraps = nonceRegistry(bootstrapNonce); + const renderers = nonceRegistry(rendererNonce); + const topSlot = document.getElementById('top-slot')!; + const isCurrent = vi.fn(() => true); + const resolveApsMountBinding = vi.fn(() => + Object.freeze({ + bindArtifact: () => + Object.freeze({ + commit: () => true, + finalize: () => undefined, + isCurrent: () => true, + previousArtifact: undefined, + release: () => undefined, + rollback: () => undefined, + }), + bindingEpoch: Object.freeze({ binding: 1 }), + cycle: Object.freeze({ isRetired: () => false }), + element: topSlot, + physicalSlot: Object.freeze({ slot: 'physical' }), + isCurrent, + }) + ); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = controlRetained; + readonly port2 = controlTransferred; + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + mountAps: (input) => + renderPucApsAttempt({ + ...input, + bindArtifactGuard: bindCommittedArtifactGuard, + bootstrapNonces: bootstraps, + hostPositions: createArtifactHostPositionLeaseRegistry(), + nonces: renderers, + publisherOrigin: window.location.origin, + }), + slots: { resolveApsMountBinding }, + } + ); + + try { + issueReadyTicket(harness, gam, pucSource); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(resolveApsMountBinding).toHaveBeenCalledWith( + gam.owner.navigationGeneration, + gam.owner.slot, + gam.attempt.id + ); + expect(gam.attempt.fail.mock.calls).toEqual([]); + expect(bootstraps.issue).toHaveBeenCalledOnce(); + expect(renderers.issue).toHaveBeenCalledOnce(); + expect(gam.attempt.beginApsDocument).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); + expect(document.body.querySelector('#publisher-child iframe')).toBeNull(); + expect(document.body.querySelectorAll('#top-slot iframe')).toHaveLength(1); + const frame = topSlot.querySelector('iframe')!; + expect(frame.style.position).toBe('absolute'); + expect(frame.style.visibility).toBe('hidden'); + expect(topSlot.querySelector('span')?.textContent).toBe('gam'); + expect(controlRetained.postMessage).toHaveBeenCalledExactlyOnceWith( + { + message: 'TS APS Top Mount Started', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }, + [] + ); + + const source = frame.contentWindow!; + harness.dispatch({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + origin: 'null', + ports: [], + source, + }); + const rendererPort = createPort(); + harness.dispatch({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, + }), + origin: 'null', + ports: [rendererPort], + source, + }); + dispatchPortMessage(rendererPort, { + message: 'TS APS Document Accepted', + version: 1, + nonce: rendererNonce, + }); + dispatchPortMessage(rendererPort, { + message: 'TS APS Render Completed', + version: 1, + nonce: rendererNonce, + }); + + expect(frame.style.visibility).toBe('visible'); + expect(gam.attempt.accept).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage.mock.calls[1]).toEqual([ + { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + [], + ]); + expect(controlRetained.close).toHaveBeenCalledOnce(); + } finally { + bootstraps.dispose(); + renderers.dispose(); + harness.bridge.dispose(); + document.body.innerHTML = ''; + } + }); + it('suppresses, refuses, and invalidates a live ticket used from the wrong source', () => { + const gam = createGamAttempt('adm', 1_002); + const pucSource = Object.freeze({ frame: 'authoritative' }); + let channels = 0; + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + constructor() { + channels += 1; + } + + readonly port1 = createPort(); + readonly port2 = createPort(); + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const wrongSourcePort = createPort(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [wrongSourcePort], + source: Object.freeze({ frame: 'wrong' }), + stopImmediatePropagation: vi.fn(), + }); + + expect(JSON.parse(String(wrongSourcePort.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + version: 1, + }); + expect(wrongSourcePort.close).toHaveBeenCalledOnce(); + expect(channels).toBe(0); + expect(gam.attempt.fail).toHaveBeenCalledWith('bridge_id_mismatch'); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + ticketTombstones: 1, + }); + + const replayPort = createPort(); + const stopReplay = vi.fn(); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [replayPort], + source: pucSource, + stopImmediatePropagation: stopReplay, + }); + expect(stopReplay).toHaveBeenCalledOnce(); + expect(JSON.parse(String(replayPort.postMessage.mock.calls[0]?.[0]))).toMatchObject({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + }); + expect(replayPort.close).toHaveBeenCalledOnce(); + expect(channels).toBe(0); + }); + + it('invalidates a live owner ticket on an extended shape or wrong port count', () => { + const gam = createGamAttempt('aps', 1_003); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const first = createPort(); + const second = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: JSON.stringify({ + message: 'TS Render Owner Register', + adId: gam.reservationId, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + extra: true, + }), + ports: [first, second], + source: pucSource, + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(JSON.parse(String(first.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + version: 1, + }); + expect(first.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).toHaveBeenCalledOnce(); + expect(gam.attempt.fail).toHaveBeenCalledWith('bridge_id_mismatch'); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + }); + + it('tombstones a posted ticket when its expiry scheduler cannot arm', () => { + let now = 0; + const gam = createGamAttempt('aps', 81); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: () => now, + scheduler: { + clear: vi.fn(), + set: vi.fn(() => undefined), + }, + } + ); + const port = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(port.postMessage).toHaveBeenCalledOnce(); + expect(gam.attempt.fail).toHaveBeenCalledWith('internal_error'); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + liveTickets: 0, + ticketTombstones: 1, + }); + + now = 3_000; + const latePort = createPort(); + const stopImmediatePropagation = vi.fn(); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId, LIFECYCLE_TICKET), + ports: [latePort], + source: Object.freeze({}), + stopImmediatePropagation, + }); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(0); + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(JSON.parse(String(latePort.postMessage.mock.calls[0]?.[0]))).toMatchObject({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + }); + expect(latePort.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts new file mode 100644 index 000000000..6db23c88b --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -0,0 +1,3758 @@ +import { describe, expect, it, vi } from 'vitest'; + +import apsEnvelope from '../fixtures/aps-renderer-v1.json'; +import { createBrowserMessagingAdapter } from '../../src/adapters/messaging'; +import { prepareAdmIframe } from '../../src/core/render'; +import { resizeCollapsedPucShell } from '../../src/core/puc_shell'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { createRuntimeSession } from '../../src/kernel/sessions'; +import type { RenderAttemptScope, WinnerContext } from '../../src/kernel/sessions'; +import { + APS_PERMANENT_SANDBOX, + APS_RENDERER_SANDBOX, + APS_RENDERER_V2_PATH, + renderDirectApsAttempt, + renderPucApsAttempt, + resolveApsRendererV2Url, +} from '../../src/integrations/aps/render'; +import { + bindCommittedArtifactGuard, + bindCommittedArtifactRetirement, + createArtifactHostPositionLeaseRegistry, + createCommittedArtifactStore, + createBootstrapNonceRegistry, + createRenderAttempt, + createRendererNonceRegistry, + createSlotOperation, + renderDirectAdmAttempt, + type CommittedRenderArtifact, + type DirectAdmIframeConstructor, + type DirectAdmIframeHandle, + type RenderAttempt, + type RenderAttemptDiagnosticsObservation, + type RenderAttemptSnapshot, + type RenderAttemptState, + type SlotOperation, + type SlotOperationOptions, +} from '../../src/services/render'; +import { + createReservationService, + type ReservationClaimResult, + type ReservationRenderSource, + type ReservationService, +} from '../../src/services/reservations'; + +const ATTEMPT_ONE = 'a1_0000000000000000000000'; +const ATTEMPT_TWO = 'a1_0000000000000000000001'; + +function indexedAttemptId(index: number): string { + return `a1_${index.toString().padStart(22, '0')}`; +} + +function indexedRendererNonce(index: number): string { + return `n1_${index.toString().padStart(22, '0')}`; +} + +function indexedBootstrapNonce(index: number): string { + return `b1_${index.toString().padStart(22, '0')}`; +} + +const ADM_SOURCE = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
fictional creative
', + width: 300, + height: 250, +}); + +const APS_SOURCE = Object.freeze({ + type: 'aps' as const, + version: 1 as const, + accountId: 'fictional-account', + bidId: 'fictional-bid', + tagType: 'iframe' as const, + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'e30=', +}); + +const DIRECT_APS_BID = apsEnvelope.seatbid[0]!.bid[0]!; +const DIRECT_APS_SOURCE = Object.freeze({ + type: 'aps' as const, + version: 1 as const, + accountId: 'fictional-account', + bidId: DIRECT_APS_BID.id, + creativeId: 'fictional-creative', + tagType: DIRECT_APS_BID.ext.tagtype as 'iframe', + creativeUrl: DIRECT_APS_BID.ext.creativeurl, + width: DIRECT_APS_BID.w, + height: DIRECT_APS_BID.h, + aaxResponse: btoa(JSON.stringify(apsEnvelope)), +}); + +const WINNER_CONTEXT = Object.freeze({ selectedCpm: 1 }); + +describe('collapsed PUC shell resize', () => { + function collapsedShell(): { + readonly frame: HTMLIFrameElement; + readonly wrapper: HTMLDivElement; + } { + const wrapper = document.createElement('div'); + const frame = document.createElement('iframe'); + wrapper.style.width = '1px'; + wrapper.style.height = '1px'; + frame.setAttribute('width', '1'); + frame.setAttribute('height', '1'); + frame.style.width = '1px'; + frame.style.height = '1px'; + wrapper.appendChild(frame); + document.body.appendChild(wrapper); + return { frame, wrapper }; + } + + it('resizes only the exact connected source iframe and its collapsed immediate wrapper once', () => { + const selected = collapsedShell(); + const sibling = collapsedShell(); + + try { + expect( + resizeCollapsedPucShell({ + source: selected.frame.contentWindow!, + width: 300, + height: 250, + }) + ).toBe(true); + expect(selected.frame.style.width).toBe('300px'); + expect(selected.frame.style.height).toBe('250px'); + expect(selected.wrapper.style.width).toBe('300px'); + expect(selected.wrapper.style.height).toBe('250px'); + expect(sibling.frame.style.width).toBe('1px'); + expect(sibling.wrapper.style.width).toBe('1px'); + + expect( + resizeCollapsedPucShell({ + source: selected.frame.contentWindow!, + width: 300, + height: 250, + }) + ).toBe(false); + } finally { + selected.wrapper.remove(); + sibling.wrapper.remove(); + } + }); + + it('rejects invalid dimensions and non-ordinary, expanded, detached, or replaced shells atomically', () => { + const cases: Array<(shell: ReturnType) => void> = [ + ({ wrapper }) => { + wrapper.style.width = '2px'; + }, + ({ frame }) => { + frame.style.position = 'fixed'; + }, + ({ wrapper }) => { + wrapper.style.position = 'sticky'; + }, + ({ wrapper }) => { + wrapper.setAttribute('data-anchor-status', 'displayed'); + }, + ({ frame }) => { + frame.remove(); + }, + ]; + + for (const mutate of cases) { + const shell = collapsedShell(); + const source = shell.frame.contentWindow!; + mutate(shell); + try { + expect(resizeCollapsedPucShell({ source, width: 300, height: 250 })).toBe(false); + expect(shell.wrapper.style.height).toBe('1px'); + expect(shell.frame.style.height).toBe('1px'); + } finally { + shell.wrapper.remove(); + } + } + + const invalid = collapsedShell(); + try { + expect( + resizeCollapsedPucShell({ + source: invalid.frame.contentWindow!, + width: Number.NaN, + height: 250, + }) + ).toBe(false); + expect(invalid.frame.style.width).toBe('1px'); + expect(invalid.wrapper.style.width).toBe('1px'); + } finally { + invalid.wrapper.remove(); + } + }); + + it('rejects a collapsed ordinary wrapper nested inside an anchor shell', () => { + const shell = collapsedShell(); + const anchor = document.createElement('a'); + shell.wrapper.replaceWith(anchor); + anchor.appendChild(shell.wrapper); + + try { + expect( + resizeCollapsedPucShell({ + source: shell.frame.contentWindow!, + width: 300, + height: 250, + }) + ).toBe(false); + expect(shell.frame.style.width).toBe('1px'); + expect(shell.wrapper.style.width).toBe('1px'); + } finally { + anchor.remove(); + } + }); +}); + +function prepareRenderSource(candidate: unknown) { + if (candidate === ADM_SOURCE) return ADM_SOURCE; + if (candidate === APS_SOURCE) return APS_SOURCE; + if (candidate === DIRECT_APS_SOURCE) return DIRECT_APS_SOURCE; + return undefined; +} + +const RESERVATION_ID = 'r1_0000000000000000000000'; +const attemptReservations = new WeakMap(); +const matrixClaims = new WeakMap(); + +function reservations(): ReservationService { + return createReservationService({ now: () => 0, prepareRenderSource }); +} + +type TestOwner = RenderAttemptScope & { + admitClaimedContext(context: WinnerContext): void; + disposeFromNavigation(): void; +}; + +function owner( + id = ATTEMPT_ONE, + slot = 'fictional-slot', + navigationGeneration = Object.freeze({}) +): TestOwner { + let current = true; + let disposed = false; + let winnerContext: WinnerContext | undefined; + const callbacks: Array<() => void> = []; + const controller = new AbortController(); + const scope = { + id, + slot, + generation: Object.freeze({}), + navigationGeneration, + interfaces: Object.freeze({}), + get disposed() { + return disposed; + }, + get signal() { + return controller.signal; + }, + get winnerContext() { + return winnerContext; + }, + capture: + (callback: (...arguments_: Arguments) => unknown) => + (...arguments_: Arguments): boolean => { + if (!scope.isCurrent()) return false; + callback(...arguments_); + return true; + }, + isCurrent: () => current && !disposed, + prepareWinnerContext: (context: WinnerContext) => { + if (!scope.isCurrent()) return undefined; + const previous = winnerContext; + if (previous !== undefined && previous !== context) return undefined; + let committed = false; + return Object.freeze({ + commit: () => { + if (committed) return winnerContext === context; + if (!scope.isCurrent() || winnerContext !== previous) return false; + winnerContext = context; + committed = true; + return true; + }, + rollback: () => { + if (committed && previous === undefined && winnerContext === context) { + winnerContext = undefined; + } + committed = false; + return winnerContext === previous; + }, + }); + }, + onDispose: (_kind: string, callback: () => void) => { + callbacks.push(callback); + }, + dispose: () => { + if (disposed) return; + disposed = true; + controller.abort(); + for (let index = callbacks.length - 1; index >= 0; index -= 1) callbacks[index]?.(); + }, + disposeFromNavigation: () => { + current = false; + scope.dispose(); + }, + admitClaimedContext: (context: WinnerContext) => { + winnerContext = context; + }, + } satisfies TestOwner; + return scope; +} + +function artifact( + render: Pick, + kind: CommittedRenderArtifact['kind'] = 'direct_iframe' +): CommittedRenderArtifact & { dispose: ReturnType } { + return Object.freeze({ + kind, + attemptId: render.id, + slot: render.slot, + navigationGeneration: render.navigationGeneration, + dispose: vi.fn(), + }); +} + +function apsArtifactBinding() { + return Object.freeze({ + commit: vi.fn(() => true), + finalize: vi.fn(), + isCurrent: vi.fn(() => true), + previousArtifact: undefined, + release: vi.fn(), + rollback: vi.fn(), + }); +} + +function attempt( + scope = owner(), + options: Partial[0]> = {} +): RenderAttempt { + const reservationService = options.reservations ?? reservations(); + const result = createRenderAttempt({ + artifacts: options.artifacts ?? createCommittedArtifactStore(), + owner: scope, + prepareRenderSource: options.prepareRenderSource ?? prepareRenderSource, + reservations: reservationService, + ...(options.parentAttemptId === undefined ? {} : { parentAttemptId: options.parentAttemptId }), + ...(options.publishDiagnostics === undefined + ? {} + : { publishDiagnostics: options.publishDiagnostics }), + ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), + }); + expect(result).toMatchObject({ ok: true }); + if (!result.ok) throw new Error('should create an attempt'); + attemptReservations.set(result.value, reservationService); + return result.value; +} + +function rendererPort() { + return Object.freeze({ close: vi.fn() }); +} + +function browserMessagePort() { + const listeners = new Set<(event: unknown) => void>(); + const messageErrorListeners = new Set<(event: unknown) => void>(); + return { + addEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + (type === 'messageerror' ? messageErrorListeners : listeners).add(listener); + }), + close: vi.fn(), + emit(data: unknown): void { + for (const listener of listeners) listener({ data }); + }, + emitError(): void { + for (const listener of messageErrorListeners) listener({}); + }, + postMessage: vi.fn(), + removeEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + (type === 'messageerror' ? messageErrorListeners : listeners).delete(listener); + }), + start: vi.fn(), + }; +} + +describe('APS role-separated nonce registries', () => { + it('owns independent 256-entry bootstrap and renderer capacities', () => { + let bootstrapDraw = 0; + let rendererDraw = 0; + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => + Object.freeze({ ok: true as const, value: indexedBootstrapNonce(bootstrapDraw++) }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => + Object.freeze({ ok: true as const, value: indexedRendererNonce(rendererDraw++) }), + }); + for (let index = 0; index < 256; index += 1) { + const render = attempt(owner(indexedAttemptId(index), `slot-${index}`)); + expect(bootstraps.issue({ attempt: render })).toMatchObject({ ok: true }); + expect(renderers.issue({ attempt: render })).toMatchObject({ ok: true }); + } + const overflow = attempt(owner(indexedAttemptId(999), 'slot-overflow')); + expect(bootstraps.issue({ attempt: overflow })).toEqual({ + ok: false, + reason: 'capability_registry_full', + }); + expect(renderers.issue({ attempt: overflow })).toEqual({ + ok: false, + reason: 'capability_registry_full', + }); + expect(bootstraps.snapshotForTest()).toMatchObject({ bindings: 256, liveNonces: 256 }); + expect(renderers.snapshotForTest()).toMatchObject({ bindings: 256, liveNonces: 256 }); + }); + + it('rejects crossed roles and binds a deferred source/port exactly once', () => { + const render = attempt(); + const bootstrapNonce = indexedBootstrapNonce(1); + const port = rendererPort(); + const source = Object.freeze({ outer: true }); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: bootstrapNonce }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: bootstrapNonce }), + }); + + expect(bootstraps.issue({ attempt: render })).toEqual({ ok: true, nonce: bootstrapNonce }); + expect(renderers.issue({ attempt: render })).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + const expectation = Object.freeze({ + nonce: bootstrapNonce, + attempt: render, + generation: render.generation, + source, + port, + }); + expect(bootstraps.bindSource(expectation)).toBe(true); + expect(bootstraps.bindSource(expectation)).toBe(false); + expect(bootstraps.consume(expectation)).toBe(true); + expect(bootstraps.consume(expectation)).toBe(false); + expect(port.close).not.toHaveBeenCalled(); + expect(render.fail('internal_error')).toBe(true); + expect(port.close).toHaveBeenCalledOnce(); + }); + + it('exhausts bootstrap collisions after exactly eight draws', () => { + const collision = indexedBootstrapNonce(1); + const mintNonce = vi.fn(() => Object.freeze({ ok: true as const, value: collision })); + const registry = createBootstrapNonceRegistry({ mintNonce }); + expect(registry.issue({ attempt: attempt() })).toEqual({ ok: true, nonce: collision }); + expect(registry.issue({ attempt: attempt(owner(indexedAttemptId(2), 'slot-2')) })).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(mintNonce).toHaveBeenCalledTimes(9); + }); +}); + +describe('renderer nonce registry', () => { + it('admits exactly 256 active bindings and refuses the 257th without drawing', () => { + let draw = 0; + const mintNonce = vi.fn(() => + Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }) + ); + const registry = createRendererNonceRegistry({ mintNonce }); + + for (let index = 0; index < 257; index += 1) { + const render = attempt(owner(indexedAttemptId(index), `slot-${index}`)); + const issued = registry.issue({ + attempt: render, + source: Object.freeze({ index }), + port: rendererPort(), + }); + if (index < 256) { + expect(issued).toEqual({ ok: true, nonce: indexedRendererNonce(index) }); + expect(registry.snapshotForTest()).toMatchObject({ + bindings: index + 1, + liveNonces: index + 1, + }); + } else { + expect(issued).toEqual({ ok: false, reason: 'capability_registry_full' }); + } + } + expect(mintNonce).toHaveBeenCalledTimes(256); + }); + + it('uses eight total collision draws and contains identity-source failure', () => { + const nonce = indexedRendererNonce(7); + const collisionMint = vi.fn(() => Object.freeze({ ok: true as const, value: nonce })); + const registry = createRendererNonceRegistry({ mintNonce: collisionMint }); + const first = attempt(owner(indexedAttemptId(1), 'slot-1')); + const second = attempt(owner(indexedAttemptId(2), 'slot-2')); + expect( + registry.issue({ attempt: first, source: Object.freeze({}), port: rendererPort() }) + ).toEqual({ ok: true, nonce }); + expect( + registry.issue({ attempt: second, source: Object.freeze({}), port: rendererPort() }) + ).toEqual({ ok: false, reason: 'identity_generation_failed' }); + expect(collisionMint).toHaveBeenCalledTimes(9); + + const failedMint = vi.fn(() => + Object.freeze({ ok: false as const, reason: 'identity_generation_failed' as const }) + ); + const failedRegistry = createRendererNonceRegistry({ mintNonce: failedMint }); + expect( + failedRegistry.issue({ + attempt: attempt(owner(indexedAttemptId(3), 'slot-3')), + source: Object.freeze({}), + port: rendererPort(), + }) + ).toEqual({ ok: false, reason: 'identity_generation_failed' }); + expect(failedMint).toHaveBeenCalledOnce(); + }); + + it.each([ + ['undefined', () => undefined], + ['null', () => null], + ['primitive', () => 1], + [ + 'accessor', + () => + Object.freeze( + Object.defineProperties( + {}, + { + ok: { + enumerable: true, + get: () => { + throw new Error('sensitive issuer result'); + }, + }, + value: { enumerable: true, value: indexedRendererNonce(1) }, + } + ) + ), + ], + [ + 'proxy', + () => + new Proxy(Object.freeze({ ok: true, value: indexedRendererNonce(1) }), { + ownKeys: () => { + throw new Error('sensitive issuer proxy'); + }, + }), + ], + [ + 'malformed success', + () => Object.freeze({ ok: true, value: indexedRendererNonce(1), unexpected: true }), + ], + ['malformed failure', () => Object.freeze({ ok: false, reason: 'different_failure' })], + ])('fails closed for a hostile %s issuer result', (_label, hostileResult) => { + const registry = createRendererNonceRegistry({ + mintNonce: hostileResult as never, + }); + let result: unknown; + expect(() => { + result = registry.issue({ + attempt: attempt(owner(indexedAttemptId(9), 'slot-9')), + source: Object.freeze({}), + port: rendererPort(), + }); + }).not.toThrow(); + expect(result).toEqual({ ok: false, reason: 'identity_generation_failed' }); + }); + + it('consumes once only for the exact nonce, source, port, attempt, and generation', () => { + const nonce = indexedRendererNonce(1); + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const render = attempt(owner(indexedAttemptId(1), 'slot-1')); + const other = attempt(owner(indexedAttemptId(2), 'slot-2')); + const source = Object.freeze({}); + const port = rendererPort(); + expect(registry.issue({ attempt: render, source, port })).toEqual({ ok: true, nonce }); + + expect( + registry.consume({ + nonce: indexedRendererNonce(2), + attempt: render, + generation: render.generation, + source, + port, + }) + ).toBe(false); + expect( + registry.consume({ + nonce, + attempt: other, + generation: render.generation, + source, + port, + }) + ).toBe(false); + expect( + registry.consume({ + nonce, + attempt: render, + generation: Object.freeze({}), + source, + port, + }) + ).toBe(false); + expect( + registry.consume({ + nonce, + attempt: render, + generation: render.generation, + source: Object.freeze({}), + port, + }) + ).toBe(false); + expect( + registry.consume({ + nonce, + attempt: render, + generation: render.generation, + source, + port: rendererPort(), + }) + ).toBe(false); + const exact = { nonce, attempt: render, generation: render.generation, source, port }; + expect(registry.consume(exact)).toBe(true); + expect(registry.consume(exact)).toBe(false); + expect(registry.snapshotForTest()).toMatchObject({ bindings: 1, liveNonces: 0 }); + expect(port.close).not.toHaveBeenCalled(); + }); + + it('issues before insertion and binds exactly one later renderer source before consumption', () => { + const nonce = indexedRendererNonce(1); + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const render = attempt(owner(indexedAttemptId(1), 'slot-1')); + const other = attempt(owner(indexedAttemptId(2), 'slot-2')); + const port = rendererPort(); + const source = Object.freeze({ window: true }); + const wrongSource = Object.freeze({ window: false }); + expect(registry.issue({ attempt: render, port })).toEqual({ ok: true, nonce }); + const exact = Object.freeze({ + nonce, + attempt: render, + generation: render.generation, + source, + port, + }); + + expect(registry.consume(exact)).toBe(false); + expect(registry.bindSource(Object.freeze({ ...exact, nonce: indexedRendererNonce(2) }))).toBe( + false + ); + expect(registry.bindSource(Object.freeze({ ...exact, attempt: other }))).toBe(false); + expect(registry.bindSource(Object.freeze({ ...exact, generation: Object.freeze({}) }))).toBe( + false + ); + expect(registry.bindSource(Object.freeze({ ...exact, source: wrongSource }))).toBe(true); + expect(registry.bindSource(exact)).toBe(false); + expect(registry.consume(exact)).toBe(false); + expect(registry.consume(Object.freeze({ ...exact, source: wrongSource }))).toBe(true); + expect(registry.consume(Object.freeze({ ...exact, source: wrongSource }))).toBe(false); + }); + + it('cannot bind a deferred renderer source after attempt or registry disposal', () => { + let draw = 0; + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + const settled = attempt(owner(indexedAttemptId(1), 'slot-1')); + const settledPort = rendererPort(); + const settledIssue = registry.issue({ attempt: settled, port: settledPort }); + if (!settledIssue.ok) throw new Error('Expected deferred binding'); + expect(settled.fail('internal_error')).toBe(true); + expect( + registry.bindSource( + Object.freeze({ + nonce: settledIssue.nonce, + attempt: settled, + generation: settled.generation, + source: Object.freeze({}), + port: settledPort, + }) + ) + ).toBe(false); + expect(settledPort.close).toHaveBeenCalledOnce(); + + const disposed = attempt(owner(indexedAttemptId(2), 'slot-2')); + const disposedPort = rendererPort(); + const disposedIssue = registry.issue({ attempt: disposed, port: disposedPort }); + if (!disposedIssue.ok) throw new Error('Expected deferred binding'); + registry.dispose(); + expect( + registry.bindSource( + Object.freeze({ + nonce: disposedIssue.nonce, + attempt: disposed, + generation: disposed.generation, + source: Object.freeze({}), + port: disposedPort, + }) + ) + ).toBe(false); + expect(disposedPort.close).toHaveBeenCalledOnce(); + }); + + it('lets exactly one nested deferred source bind win before a hostile outer replay', () => { + const nonce = indexedRendererNonce(1); + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const render = attempt(); + const port = rendererPort(); + const nestedSource = Object.freeze({ nested: true }); + const outerSource = Object.freeze({ outer: true }); + expect(registry.issue({ attempt: render, port })).toEqual({ ok: true, nonce }); + const nestedExpectation = Object.freeze({ + nonce, + attempt: render, + generation: render.generation, + source: nestedSource, + port, + }); + const outerExpectation = Object.freeze({ + nonce, + attempt: render, + generation: render.generation, + source: outerSource, + port, + }); + let nested: boolean | undefined; + let reentered = false; + const replay = new Proxy(outerExpectation, { + ownKeys: (target) => { + if (!reentered) { + reentered = true; + nested = registry.bindSource(nestedExpectation); + } + return Reflect.ownKeys(target); + }, + }); + + expect(registry.bindSource(replay)).toBe(false); + expect(nested).toBe(true); + expect(registry.consume(outerExpectation)).toBe(false); + expect(registry.consume(nestedExpectation)).toBe(true); + }); + + it('rejects cross-attempt retained-port reuse without taking failed-issue ownership', () => { + let draw = 0; + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + const port = rendererPort(); + const first = attempt(owner(indexedAttemptId(1), 'slot-1')); + const second = attempt(owner(indexedAttemptId(2), 'slot-2')); + expect(registry.issue({ attempt: first, source: Object.freeze({}), port })).toMatchObject({ + ok: true, + }); + expect(registry.issue({ attempt: second, source: Object.freeze({}), port })).toEqual({ + ok: false, + reason: 'invalid_attempt', + }); + expect(port.close).not.toHaveBeenCalled(); + expect(second.fail('internal_error')).toBe(true); + expect(port.close).not.toHaveBeenCalled(); + expect(first.fail('internal_error')).toBe(true); + expect(port.close).toHaveBeenCalledOnce(); + expect( + registry.issue({ + attempt: attempt(owner(indexedAttemptId(3), 'slot-3')), + source: Object.freeze({}), + port, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + registry.dispose(); + expect(port.close).toHaveBeenCalledOnce(); + }); + + it('retires a transferred port before close can reenter issuance', () => { + let draw = 0; + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + const first = attempt(owner(indexedAttemptId(1), 'slot-1')); + const second = attempt(owner(indexedAttemptId(2), 'slot-2')); + let nested: unknown; + const port = Object.freeze({ + close: vi.fn(() => { + nested = registry.issue({ attempt: second, source: Object.freeze({}), port }); + }), + }); + expect(registry.issue({ attempt: first, source: Object.freeze({}), port })).toMatchObject({ + ok: true, + }); + expect(first.fail('internal_error')).toBe(true); + expect(nested).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(port.close).toHaveBeenCalledOnce(); + }); + + it('makes branded settlement registration and revalidation intrinsic under prototype mutation', () => { + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + const render = attempt(owner(indexedAttemptId(1), 'slot-1')); + let closes = 0; + const port = Object.freeze({ + close: () => { + closes += 1; + }, + }); + const nativePush = Array.prototype.push; + const nativeSlice = Array.prototype.slice; + let poisonCalls = 0; + const push = vi.spyOn(Array.prototype, 'push').mockImplementation(function ( + this: unknown[], + ...values + ) { + poisonCalls += 1; + Reflect.apply(nativePush, this, values); + throw new Error('hostile observer registration'); + }); + let sliceCalls = 0; + const slice = vi.spyOn(Array.prototype, 'slice').mockImplementation(function ( + this: unknown[], + start?: number, + end?: number + ) { + sliceCalls += 1; + const result = Reflect.apply(nativeSlice, this, [start, end]); + if (sliceCalls >= 4) throw new Error('hostile post-registration snapshot'); + return result; + }); + let issued: unknown; + try { + issued = registry.issue({ attempt: render, source: Object.freeze({}), port }); + } finally { + slice.mockRestore(); + push.mockRestore(); + } + expect(poisonCalls).toBe(0); + expect(sliceCalls).toBe(0); + expect(issued).toEqual({ ok: true, nonce: indexedRendererNonce(1) }); + expect(closes).toBe(0); + expect(render.fail('internal_error')).toBe(true); + expect(closes).toBe(1); + }); + + it('drains terminal observers intrinsically before prototype splice can throw', () => { + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + const render = attempt(owner(indexedAttemptId(1), 'slot-1')); + let closes = 0; + const port = Object.freeze({ + close: () => { + closes += 1; + }, + }); + expect(registry.issue({ attempt: render, source: Object.freeze({}), port })).toMatchObject({ + ok: true, + }); + const nativeSplice = Array.prototype.splice; + let spliceCalls = 0; + const splice = vi.spyOn(Array.prototype, 'splice').mockImplementation(function ( + this: unknown[], + start: number, + deleteCount?: number + ) { + spliceCalls += 1; + Reflect.apply(nativeSplice, this, [start, deleteCount]); + throw new Error('hostile terminal observer drain'); + }); + let iteratorCalls = 0; + const iterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + writable: true, + value: () => { + iteratorCalls += 1; + throw new Error('hostile terminal observer iteration'); + }, + }); + let settled: boolean | undefined; + let thrown: unknown; + try { + settled = render.fail('internal_error'); + } catch (error) { + thrown = error; + } finally { + if (iterator) Object.defineProperty(Array.prototype, Symbol.iterator, iterator); + splice.mockRestore(); + } + expect(thrown).toBeUndefined(); + expect(settled).toBe(true); + expect(spliceCalls).toBe(0); + expect(iteratorCalls).toBe(0); + expect(closes).toBe(1); + }); + + it('binds pending and live issuance to the exact issued attempt generation', () => { + let draw = 0; + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + const sharedOwner = owner(indexedAttemptId(1), 'slot-1'); + const first = attempt(sharedOwner); + const second = attempt(sharedOwner); + const secondPort = rendererPort(); + expect( + registry.issue({ attempt: first, source: Object.freeze({}), port: rendererPort() }) + ).toMatchObject({ ok: true }); + expect( + registry.issue({ attempt: second, source: Object.freeze({}), port: secondPort }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(secondPort.close).not.toHaveBeenCalled(); + + const nestedOwner = owner(indexedAttemptId(2), 'slot-2'); + const outer = attempt(nestedOwner); + const inner = attempt(nestedOwner); + const innerPort = rendererPort(); + let nested: unknown; + let recurse = true; + const reentrantRegistry = createRendererNonceRegistry({ + mintNonce: () => { + if (recurse) { + recurse = false; + nested = reentrantRegistry.issue({ + attempt: inner, + source: Object.freeze({}), + port: innerPort, + }); + } + return Object.freeze({ ok: true as const, value: indexedRendererNonce(9) }); + }, + }); + expect( + reentrantRegistry.issue({ + attempt: outer, + source: Object.freeze({}), + port: rendererPort(), + }) + ).toMatchObject({ ok: true }); + expect(nested).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(innerPort.close).not.toHaveBeenCalled(); + }); + + it('cannot publish after the issuer reentrantly disposes the registry', () => { + const port = rendererPort(); + const registry = createRendererNonceRegistry({ + mintNonce: () => { + registry.dispose(); + return Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }); + }, + }); + const issued = registry.issue({ + attempt: attempt(owner(indexedAttemptId(1), 'slot-1')), + source: Object.freeze({}), + port, + }); + expect(issued).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(issued).not.toHaveProperty('nonce'); + expect(port.close).not.toHaveBeenCalled(); + expect(registry.snapshotForTest()).toEqual({ + bindings: 0, + disposed: true, + liveNonces: 0, + }); + }); + + it('reserves attempt and capacity before invoking a reentrant issuer', () => { + let draw = 0; + let reenter: (() => void) | undefined; + const registry = createRendererNonceRegistry({ + mintNonce: () => { + const callback = reenter; + reenter = undefined; + callback?.(); + return Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }); + }, + }); + + for (let index = 0; index < 255; index += 1) { + expect( + registry.issue({ + attempt: attempt(owner(indexedAttemptId(index), `slot-${index}`)), + source: Object.freeze({}), + port: rendererPort(), + }) + ).toMatchObject({ ok: true }); + } + const outerAttempt = attempt(owner(indexedAttemptId(255), 'slot-255')); + const innerAttempt = attempt(owner(indexedAttemptId(256), 'slot-256')); + let nestedCapacity: unknown; + reenter = () => { + nestedCapacity = registry.issue({ + attempt: innerAttempt, + source: Object.freeze({}), + port: rendererPort(), + }); + }; + expect( + registry.issue({ + attempt: outerAttempt, + source: Object.freeze({}), + port: rendererPort(), + }) + ).toMatchObject({ ok: true }); + expect(nestedCapacity).toEqual({ ok: false, reason: 'capability_registry_full' }); + expect(registry.snapshotForTest()).toMatchObject({ bindings: 256, liveNonces: 256 }); + + const sameAttempt = attempt(owner(indexedAttemptId(999), 'slot-999')); + const sameInput = { + attempt: sameAttempt, + source: Object.freeze({}), + port: rendererPort(), + }; + let nestedSameAttempt: unknown; + let recurse = true; + const sameAttemptRegistry = createRendererNonceRegistry({ + mintNonce: () => { + if (recurse) { + recurse = false; + nestedSameAttempt = sameAttemptRegistry.issue(sameInput); + } + return Object.freeze({ ok: true as const, value: indexedRendererNonce(998) }); + }, + }); + expect(sameAttemptRegistry.issue(sameInput)).toMatchObject({ ok: true }); + expect(nestedSameAttempt).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(sameAttemptRegistry.snapshotForTest()).toMatchObject({ bindings: 1, liveNonces: 1 }); + }); + + it('lets exactly one nested exact consume win before a hostile outer replay', () => { + const nonce = indexedRendererNonce(1); + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const render = attempt(owner(indexedAttemptId(1), 'slot-1')); + const source = Object.freeze({}); + const port = rendererPort(); + expect(registry.issue({ attempt: render, source, port })).toEqual({ ok: true, nonce }); + const exact = Object.freeze({ + nonce, + attempt: render, + generation: render.generation, + source, + port, + }); + let nested: boolean | undefined; + let reentered = false; + const replay = new Proxy(exact, { + ownKeys: (target) => { + if (!reentered) { + reentered = true; + nested = registry.consume(exact); + } + return Reflect.ownKeys(target); + }, + }); + + expect(registry.consume(replay)).toBe(false); + expect(nested).toBe(true); + expect(registry.consume(exact)).toBe(false); + }); + + it('closes and removes attempt-owned bindings on settlement with no nonce history', () => { + const nonce = indexedRendererNonce(1); + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const first = attempt(owner(indexedAttemptId(1), 'slot-1')); + const firstPort = rendererPort(); + const firstSource = Object.freeze({}); + expect(registry.issue({ attempt: first, source: firstSource, port: firstPort })).toEqual({ + ok: true, + nonce, + }); + expect( + registry.consume({ + nonce, + attempt: first, + generation: first.generation, + source: firstSource, + port: firstPort, + }) + ).toBe(true); + expect( + registry.issue({ attempt: first, source: Object.freeze({}), port: rendererPort() }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(first.fail('internal_error')).toBe(true); + expect(first.fail('internal_error')).toBe(false); + expect(firstPort.close).toHaveBeenCalledOnce(); + expect(registry.snapshotForTest()).toEqual({ + bindings: 0, + disposed: false, + liveNonces: 0, + }); + + const second = attempt(owner(indexedAttemptId(2), 'slot-2')); + expect( + registry.issue({ attempt: second, source: Object.freeze({}), port: rendererPort() }) + ).toEqual({ ok: true, nonce }); + expect( + registry.issue({ attempt: second, source: Object.freeze({}), port: rendererPort() }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + }); + + it('disposes live and consumed runtime bindings exactly once and remains terminal', () => { + let draw = 0; + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + const live = attempt(owner(indexedAttemptId(1), 'slot-1')); + const consumed = attempt(owner(indexedAttemptId(2), 'slot-2')); + const liveSource = Object.freeze({ live: true }); + const consumedSource = Object.freeze({ consumed: true }); + let liveCloses = 0; + let consumedCloses = 0; + const livePort = Object.freeze({ close: () => (liveCloses += 1) }); + const consumedPort = Object.freeze({ close: () => (consumedCloses += 1) }); + const liveIssue = registry.issue({ attempt: live, source: liveSource, port: livePort }); + const consumedIssue = registry.issue({ + attempt: consumed, + source: consumedSource, + port: consumedPort, + }); + if (!liveIssue.ok || !consumedIssue.ok) throw new Error('Expected nonce bindings'); + const consumedExpectation = Object.freeze({ + nonce: consumedIssue.nonce, + attempt: consumed, + generation: consumed.generation, + source: consumedSource, + port: consumedPort, + }); + expect(registry.consume(consumedExpectation)).toBe(true); + + const iterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + let iteratorCalls = 0; + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + writable: true, + value: () => { + iteratorCalls += 1; + throw new Error('hostile registry disposal iteration'); + }, + }); + let disposeError: unknown; + try { + registry.dispose(); + } catch (error) { + disposeError = error; + } finally { + if (iterator) Object.defineProperty(Array.prototype, Symbol.iterator, iterator); + } + expect(disposeError).toBeUndefined(); + expect(iteratorCalls).toBe(0); + expect(liveCloses).toBe(1); + expect(consumedCloses).toBe(1); + expect(registry.snapshotForTest()).toEqual({ + bindings: 0, + disposed: true, + liveNonces: 0, + }); + registry.dispose(); + expect(live.fail('internal_error')).toBe(true); + expect(consumed.fail('internal_error')).toBe(true); + expect(liveCloses).toBe(1); + expect(consumedCloses).toBe(1); + expect(registry.consume(consumedExpectation)).toBe(false); + + const rejectedPort = rendererPort(); + expect( + registry.issue({ + attempt: attempt(owner(indexedAttemptId(3), 'slot-3')), + source: Object.freeze({}), + port: rejectedPort, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(rejectedPort.close).not.toHaveBeenCalled(); + }); +}); + +describe('direct APS attempt rendering', () => { + it('mounts only the bootstrap frame before completing the exact three-phase handoff', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
placeholder
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const bootstrapNonce = indexedBootstrapNonce(1); + const rendererNonce = indexedRendererNonce(1); + let captureListener: ((event: MessageEvent) => void) | undefined; + const target = { + addEventListener: vi.fn( + (_type: 'message', listener: (event: MessageEvent) => void, _capture: true) => { + captureListener = listener; + } + ), + removeEventListener: vi.fn(), + }; + const messaging = createBrowserMessagingAdapter(target); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: bootstrapNonce }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: rendererNonce }), + }); + const rendererPort = browserMessagePort(); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, + bootstrapNonces: bootstraps, + container: document.getElementById('fictional-slot')!, + messaging, + nonces: renderers, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = document.querySelector('#fictional-slot iframe')!; + const source = frame.contentWindow!; + const postMessage = vi.spyOn(source, 'postMessage'); + expect(frame.getAttribute('src')).toBe( + `${window.location.origin}${APS_RENDERER_V2_PATH}#${bootstrapNonce}` + ); + expect(frame.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); + expect(document.querySelectorAll('#fictional-slot iframe')).toHaveLength(1); + expect(captureListener).toBeTypeOf('function'); + expect(rendererPort.postMessage).not.toHaveBeenCalled(); + + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + origin: 'null', + ports: [], + source, + } as unknown as MessageEvent); + expect(frame.getAttribute('sandbox')).toBe(APS_PERMANENT_SANDBOX); + expect(postMessage).toHaveBeenCalledOnce(); + const navigation = postMessage.mock.calls[0]?.[0]; + expect(navigation).toBeTypeOf('string'); + const parsedNavigation = JSON.parse(navigation as string) as Record; + expect(parsedNavigation).toEqual({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce, + creativeOrigin: 'https://creative.example', + tagType: 'iframe', + }); + expect(navigation).toBe(JSON.stringify(parsedNavigation)); + expect(postMessage.mock.calls[0]?.slice(1)).toEqual(['*', []]); + + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, + }), + origin: 'null', + ports: [rendererPort], + source, + } as unknown as MessageEvent); + expect(rendererPort.postMessage).toHaveBeenCalledOnce(); + expect(rendererPort.postMessage.mock.calls[0]?.[0]).toEqual({ + version: 1, + nonce: rendererNonce, + publisherOrigin: window.location.origin, + renderer: DIRECT_APS_SOURCE, + }); + rendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: rendererNonce, + }); + expect(render.snapshot().state).toBe('waiting_for_aps_completion'); + rendererPort.emit({ + message: 'TS APS Runner Loaded', + version: 1, + nonce: rendererNonce, + }); + expect(render.snapshot().outcome).toBeUndefined(); + rendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: rendererNonce, + }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(document.querySelector('#fictional-slot span')).toBeNull(); + expect(frame.isConnected).toBe(true); + } finally { + bootstraps.dispose(); + renderers.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + function directApsHarness( + renderOptions: Partial[0]> = {} + ) { + document.body.innerHTML = '
placeholder
'; + const render = attempt(owner(), renderOptions); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const bootstrapNonce = indexedBootstrapNonce(2); + const rendererNonce = indexedRendererNonce(2); + let captureListener: ((event: MessageEvent) => void) | undefined; + const target = { + addEventListener: vi.fn( + (_type: 'message', listener: (event: MessageEvent) => void, _capture: true) => { + captureListener = listener; + } + ), + removeEventListener: vi.fn(), + }; + const messaging = createBrowserMessagingAdapter(target); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: bootstrapNonce }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: rendererNonce }), + }); + const rawRendererPort = browserMessagePort(); + const container = document.getElementById('fictional-slot')!; + const mounted = renderDirectApsAttempt({ + attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, + bootstrapNonces: bootstraps, + container, + messaging, + nonces: renderers, + publisherOrigin: window.location.origin, + }); + const frame = container.querySelector('iframe'); + const sourceWindow = frame?.contentWindow; + + const emitGlobal = ( + data: string, + ports: readonly unknown[] = [], + source: unknown = sourceWindow, + origin = 'null' + ): void => { + captureListener?.({ data, origin, ports, source } as unknown as MessageEvent); + }; + const bootstrapReady = ( + ports: readonly unknown[] = [], + source: unknown = sourceWindow, + origin = 'null' + ): void => { + emitGlobal( + JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + ports, + source, + origin + ); + }; + const containerReady = ( + ports: readonly unknown[] = [rawRendererPort], + source: unknown = sourceWindow, + origin = 'null' + ): void => { + emitGlobal( + JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, + }), + ports, + source, + origin + ); + }; + const cleanup = (): void => { + if (render.snapshot().outcome === undefined) render.cancel('caller_aborted'); + bootstraps.dispose(); + renderers.dispose(); + document.body.innerHTML = ''; + }; + return { + bootstrapNonce, + bootstrapReady, + bootstraps, + captureListener, + cleanup, + container, + containerReady, + emitGlobal, + frame, + messaging, + mounted, + rawRendererPort, + render, + rendererNonce, + renderers, + sourceWindow, + target, + }; + } + + function completeDirectApsHandshake(harness: ReturnType): void { + harness.bootstrapReady(); + harness.containerReady(); + } + + it('rejects noncanonical, wrong-source, wrong-origin, and wrong-port bootstrap readiness', () => { + vi.useFakeTimers(); + const ignored = directApsHarness(); + try { + expect(ignored.mounted).toBe(true); + ignored.emitGlobal( + JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce: ignored.bootstrapNonce, + }), + [], + Object.freeze({}) + ); + ignored.emitGlobal( + JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce: ignored.bootstrapNonce, + }), + [], + ignored.sourceWindow, + window.location.origin + ); + ignored.emitGlobal( + ' {"message":"TS APS Bootstrap Ready","version":1,"bootstrapNonce":"' + + ignored.bootstrapNonce + + '"}', + [] + ); + expect(ignored.frame?.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); + expect(ignored.render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_document', + }); + } finally { + ignored.cleanup(); + } + + const rejected = directApsHarness(); + const unexpectedPort = browserMessagePort(); + try { + rejected.bootstrapReady([unexpectedPort]); + expect(unexpectedPort.close).toHaveBeenCalledOnce(); + expect(rejected.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(rejected.container.querySelector('iframe')).toBeNull(); + } finally { + rejected.cleanup(); + vi.useRealTimers(); + } + }); + + it('shares one three-second deadline through bootstrap, navigation, channel, and acceptance', () => { + vi.useFakeTimers(); + const noBootstrap = directApsHarness(); + try { + expect(noBootstrap.mounted).toBe(true); + vi.advanceTimersByTime(2_999); + expect(noBootstrap.render.snapshot().outcome).toBeUndefined(); + vi.advanceTimersByTime(1); + expect(noBootstrap.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(noBootstrap.container.querySelector('iframe')).toBeNull(); + } finally { + noBootstrap.cleanup(); + } + + const noAcceptance = directApsHarness(); + try { + completeDirectApsHandshake(noAcceptance); + vi.advanceTimersByTime(2_999); + expect(noAcceptance.render.snapshot().outcome).toBeUndefined(); + vi.advanceTimersByTime(1); + expect(noAcceptance.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(noAcceptance.rawRendererPort.close).toHaveBeenCalledOnce(); + } finally { + noAcceptance.cleanup(); + vi.useRealTimers(); + } + }); + + it('starts the sole ten-second completion deadline at document acceptance', () => { + vi.useFakeTimers(); + const harness = directApsHarness(); + try { + completeDirectApsHandshake(harness); + harness.rawRendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: harness.rendererNonce, + }); + harness.rawRendererPort.emit({ + message: 'TS APS Runner Loaded', + version: 1, + nonce: harness.rendererNonce, + }); + vi.advanceTimersByTime(9_999); + expect(harness.render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_aps_completion', + }); + vi.advanceTimersByTime(1); + expect(harness.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'runner_failed', + }); + expect(harness.container.querySelector('iframe')).toBeNull(); + } finally { + harness.cleanup(); + vi.useRealTimers(); + } + }); + + it.each([ + ['descriptor_invalid', 'winner_not_renderable'], + ['runner_no_load', 'runner_no_load'], + ['runner_failed', 'runner_failed'], + ] as const)('maps renderer failure %s to %s', (wireReason, expectedReason) => { + vi.useFakeTimers(); + const harness = directApsHarness(); + try { + completeDirectApsHandshake(harness); + harness.rawRendererPort.emit({ + message: 'TS APS Render Failed', + version: 1, + nonce: harness.rendererNonce, + reason: wireReason, + }); + expect(harness.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: expectedReason, + }); + expect(harness.container.querySelector('iframe')).toBeNull(); + } finally { + harness.cleanup(); + vi.useRealTimers(); + } + }); + + it('makes frame mutation, port errors, cancellation, and late replays terminal and inert', () => { + vi.useFakeTimers(); + const mutated = directApsHarness(); + try { + mutated.frame?.setAttribute( + 'src', + window.location.origin + APS_RENDERER_V2_PATH + '#b1_9999999999999999999999' + ); + mutated.bootstrapReady(); + expect(mutated.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(mutated.container.querySelector('iframe')).toBeNull(); + } finally { + mutated.cleanup(); + } + + const errored = directApsHarness(); + try { + completeDirectApsHandshake(errored); + errored.rawRendererPort.emitError(); + expect(errored.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(errored.rawRendererPort.close).toHaveBeenCalledOnce(); + } finally { + errored.cleanup(); + } + + const cancelled = directApsHarness(); + try { + completeDirectApsHandshake(cancelled); + expect(cancelled.render.cancel('caller_aborted')).toBe(true); + expect(cancelled.container.querySelector('iframe')).toBeNull(); + expect(cancelled.rawRendererPort.close).toHaveBeenCalledOnce(); + cancelled.rawRendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: cancelled.rendererNonce, + }); + cancelled.rawRendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: cancelled.rendererNonce, + }); + expect(cancelled.render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + expect(cancelled.target.removeEventListener).toHaveBeenCalledOnce(); + } finally { + cancelled.cleanup(); + vi.useRealTimers(); + } + }); + + it('removes only insertion-time predecessors after completion', () => { + vi.useFakeTimers(); + const harness = directApsHarness(); + const duringRender = document.createElement('div'); + duringRender.id = 'during-render'; + let reentrant: HTMLDivElement | undefined; + expect( + harness.render.onSettled((outcome) => { + if (outcome.outcome !== 'accepted') return; + reentrant = document.createElement('div'); + reentrant.id = 'reentrant'; + harness.container.appendChild(reentrant); + }) + ).toBe(true); + try { + completeDirectApsHandshake(harness); + harness.rawRendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: harness.rendererNonce, + }); + harness.container.appendChild(duringRender); + harness.rawRendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: harness.rendererNonce, + }); + expect(harness.render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(harness.container.querySelector('span')).toBeNull(); + expect(duringRender.parentNode).toBe(harness.container); + expect(reentrant?.parentNode).toBe(harness.container); + expect(harness.frame?.parentNode).toBe(harness.container); + } finally { + harness.cleanup(); + vi.useRealTimers(); + } + }); + + it('rejects invalid descriptors before nonce allocation, listener installation, or DOM mutation', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const target = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + const bootstraps = createBootstrapNonceRegistry(); + const renderers = createRendererNonceRegistry(); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectApsAttempt({ + attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, + bootstrapNonces: bootstraps, + container, + messaging: createBrowserMessagingAdapter(target), + nonces: renderers, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + expect(target.addEventListener).not.toHaveBeenCalled(); + expect(bootstraps.snapshotForTest().bindings).toBe(0); + expect(renderers.snapshotForTest().bindings).toBe(0); + expect(container.children).toHaveLength(0); + bootstraps.dispose(); + renderers.dispose(); + document.body.innerHTML = ''; + }); + + it('allows HTTPS and loopback HTTP renderer origins but rejects production HTTP', () => { + expect(resolveApsRendererV2Url('https://publisher.example')).toBe( + 'https://publisher.example/integrations/aps/renderer/v2' + ); + expect(resolveApsRendererV2Url('http://localhost:8080')).toBe( + 'http://localhost:8080/integrations/aps/renderer/v2' + ); + expect(resolveApsRendererV2Url('http://127.0.0.1:8080')).toBe( + 'http://127.0.0.1:8080/integrations/aps/renderer/v2' + ); + expect(resolveApsRendererV2Url('http://[::1]:8080')).toBe( + 'http://[::1]:8080/integrations/aps/renderer/v2' + ); + expect(resolveApsRendererV2Url('http://publisher.example')).toBeUndefined(); + }); + + it('keeps a PUC APS overlay hidden until completion and preserves the slot host', () => { + document.body.innerHTML = '
GAM content
'; + const scope = owner(); + const artifactStore = createCommittedArtifactStore(); + const hostPositions = createArtifactHostPositionLeaseRegistry(); + const render = attempt(scope, { artifacts: artifactStore }); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, DIRECT_APS_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + const baseArtifact = artifact(scope, 'puc'); + const bootstrapNonce = indexedBootstrapNonce(41); + const rendererNonce = indexedRendererNonce(41); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: bootstrapNonce }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: rendererNonce }), + }); + let captureListener: ((event: MessageEvent) => void) | undefined; + const messaging = createBrowserMessagingAdapter({ + addEventListener: ( + _type: 'message', + listener: (event: MessageEvent) => void, + _capture: true + ) => { + captureListener = listener; + }, + removeEventListener: vi.fn(), + }); + const container = document.getElementById('fictional-slot')!; + const rendererPort = browserMessagePort(); + const isBindingCurrent = vi.fn(() => true); + const onArtifactTransferred = vi.fn(); + const artifactBinding = apsArtifactBinding(); + + try { + expect( + renderPucApsAttempt({ + attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, + baseArtifact, + bindArtifact: () => artifactBinding, + bootstrapNonces: bootstraps, + container, + hostPositions, + isBindingCurrent, + messaging, + nonces: renderers, + onArtifactTransferred, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe')!; + const source = frame.contentWindow!; + expect(onArtifactTransferred).toHaveBeenCalledOnce(); + expect(container.querySelector('span')?.textContent).toBe('GAM content'); + expect(container.style.position).toBe('relative'); + expect(frame.style.position).toBe('absolute'); + expect(frame.style.visibility).toBe('hidden'); + + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + origin: 'null', + ports: [], + source, + } as unknown as MessageEvent); + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, + }), + origin: 'null', + ports: [rendererPort], + source, + } as unknown as MessageEvent); + rendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: rendererNonce, + }); + rendererPort.emit({ + message: 'TS APS Runner Loaded', + version: 1, + nonce: rendererNonce, + }); + expect(frame.style.visibility).toBe('hidden'); + rendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: rendererNonce, + }); + + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(artifactBinding.commit).toHaveBeenCalledOnce(); + expect(artifactBinding.finalize).toHaveBeenCalledOnce(); + expect(artifactBinding.release).not.toHaveBeenCalled(); + expect(artifactBinding.rollback).not.toHaveBeenCalled(); + expect(frame.style.visibility).toBe('visible'); + expect(container.querySelector('span')?.textContent).toBe('GAM content'); + expect(baseArtifact.dispose).not.toHaveBeenCalled(); + + container.style.setProperty('position', 'absolute', 'important'); + expect(artifactStore.sweep()).toBe(1); + expect(frame.isConnected).toBe(false); + expect(container.querySelector('span')?.textContent).toBe('GAM content'); + expect(container.style.getPropertyValue('position')).toBe('absolute'); + expect(container.style.getPropertyPriority('position')).toBe('important'); + expect(baseArtifact.dispose).toHaveBeenCalledOnce(); + expect(artifactBinding.release).toHaveBeenCalledOnce(); + } finally { + bootstraps.dispose(); + renderers.dispose(); + document.body.innerHTML = ''; + } + }); + + it('transfers one host-position lease across consecutive accepted PUC overlays', () => { + document.body.innerHTML = '
publisher
'; + const generation = Object.freeze({}); + const artifactStore = createCommittedArtifactStore(); + const hostPositions = createArtifactHostPositionLeaseRegistry(); + const container = document.getElementById('fictional-slot')!; + let physicalArtifact: CommittedRenderArtifact | undefined; + const resources: Array<{ dispose: () => void }> = []; + + const bindArtifact = (candidate: CommittedRenderArtifact) => { + const previousArtifact = physicalArtifact; + let phase: 'prepared' | 'committed' | 'finalized' | 'released' | 'rolled_back' = 'prepared'; + return Object.freeze({ + commit: (): boolean => { + if (phase !== 'prepared' || physicalArtifact !== previousArtifact) return false; + physicalArtifact = candidate; + phase = 'committed'; + return true; + }, + finalize: (): void => { + if (phase === 'committed') phase = 'finalized'; + }, + isCurrent: (): boolean => + (phase === 'committed' || phase === 'finalized') && physicalArtifact === candidate, + previousArtifact, + release: (): void => { + if (phase === 'released' || phase === 'rolled_back') return; + if (physicalArtifact === candidate) { + physicalArtifact = phase === 'committed' ? previousArtifact : undefined; + } + phase = 'released'; + }, + rollback: (): void => { + if (phase === 'committed' && physicalArtifact === candidate) { + physicalArtifact = previousArtifact; + } + phase = 'rolled_back'; + }, + }); + }; + + const mountAndComplete = (index: number): CommittedRenderArtifact => { + const scope = owner(indexedAttemptId(index), 'fictional-slot', generation); + const render = attempt(scope, { artifacts: artifactStore }); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, DIRECT_APS_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedBootstrapNonce(index) }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(index) }), + }); + resources.push(bootstraps, renderers); + let captureListener: ((event: MessageEvent) => void) | undefined; + const rendererPort = browserMessagePort(); + expect( + renderPucApsAttempt({ + attempt: render, + baseArtifact: artifact(scope, 'puc'), + bindArtifact, + bindArtifactGuard: bindCommittedArtifactGuard, + bootstrapNonces: bootstraps, + container, + hostPositions, + isBindingCurrent: () => true, + messaging: createBrowserMessagingAdapter({ + addEventListener: ( + _type: 'message', + listener: (event: MessageEvent) => void, + _capture: true + ) => { + captureListener = listener; + }, + removeEventListener: vi.fn(), + }), + nonces: renderers, + onArtifactTransferred: vi.fn(), + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frames = [...container.querySelectorAll('iframe')]; + const frame = frames[frames.length - 1]!; + const source = frame.contentWindow!; + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce: indexedBootstrapNonce(index), + }), + origin: 'null', + ports: [], + source, + } as unknown as MessageEvent); + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce: indexedBootstrapNonce(index), + rendererNonce: indexedRendererNonce(index), + }), + origin: 'null', + ports: [rendererPort], + source, + } as unknown as MessageEvent); + rendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: indexedRendererNonce(index), + }); + rendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: indexedRendererNonce(index), + }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + const committed = artifactStore.current('fictional-slot'); + expect(committed).toBe(physicalArtifact); + if (!committed) throw new Error('should promote the APS overlay'); + return committed; + }; + + try { + const first = mountAndComplete(51); + expect(container.style.position).toBe('relative'); + const second = mountAndComplete(52); + expect(second).not.toBe(first); + expect(first.dispose).toBeTypeOf('function'); + expect(container.style.position).toBe('relative'); + expect(container.querySelectorAll('iframe')).toHaveLength(1); + expect(container.querySelector('span')?.textContent).toBe('publisher'); + + expect(artifactStore.release(second)).toBe(true); + expect(container.style.position).toBe(''); + expect(container.querySelectorAll('iframe')).toHaveLength(0); + expect(container.querySelector('span')?.textContent).toBe('publisher'); + } finally { + for (const resource of resources) resource.dispose(); + artifactStore.dispose(); + document.body.innerHTML = ''; + } + }); + + it('removes only the failed PUC overlay and compare-restores its host position', () => { + document.body.innerHTML = '
publisher
'; + const scope = owner(); + const render = attempt(scope); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, DIRECT_APS_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + const baseArtifact = artifact(scope, 'puc'); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedBootstrapNonce(42) }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(42) }), + }); + const container = document.getElementById('fictional-slot')!; + const artifactBinding = apsArtifactBinding(); + const hostPositions = createArtifactHostPositionLeaseRegistry(); + + try { + expect( + renderPucApsAttempt({ + attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, + baseArtifact, + bindArtifact: () => artifactBinding, + bootstrapNonces: bootstraps, + container, + hostPositions, + isBindingCurrent: () => true, + messaging: createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + nonces: renderers, + onArtifactTransferred: vi.fn(), + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(container.style.position).toBe('relative'); + expect(render.fail('runner_failed')).toBe(true); + expect(container.querySelector('iframe')).toBeNull(); + expect(container.querySelector('span')?.textContent).toBe('publisher'); + expect(container.style.position).toBe(''); + expect(baseArtifact.dispose).toHaveBeenCalledOnce(); + expect(artifactBinding.commit).not.toHaveBeenCalled(); + expect(artifactBinding.finalize).not.toHaveBeenCalled(); + expect(artifactBinding.release).toHaveBeenCalledOnce(); + } finally { + bootstraps.dispose(); + renderers.dispose(); + document.body.innerHTML = ''; + } + }); +}); + +function claimed( + render: RenderAttempt, + scope: TestOwner, + source: ReservationRenderSource +): Extract { + const service = attemptReservations.get(render); + if (!service) throw new Error('should own a reservation service'); + const registered = service.registerRender({ + reservationId: RESERVATION_ID, + slot: scope.slot, + navigation: { + generation: scope.navigationGeneration, + isCurrent: scope.isCurrent, + onDispose: scope.onDispose, + }, + attemptId: scope.id, + renderSource: source, + winnerContext: WINNER_CONTEXT, + }); + if (!registered.ok) throw new Error('should register a render reservation'); + const result = service.claim({ + reservationId: RESERVATION_ID, + slot: scope.slot, + navigationGeneration: scope.navigationGeneration, + attempt: scope, + pucSource: Object.freeze({}), + }); + if (!result.recognized || !result.claimed) throw new Error('should claim a reservation'); + return result; +} + +function slotOperation(options: SlotOperationOptions): SlotOperation { + const result = createSlotOperation(options); + expect(result).toMatchObject({ ok: true }); + if (!result.ok) throw new Error('should create a slot operation'); + return result.value; +} + +describe('direct ADM attempt rendering', () => { + it('accepts the exact intended srcdoc and promotes its iframe artifact', () => { + document.body.innerHTML = '
placeholder
'; + const artifacts = createCommittedArtifactStore(); + const render = attempt(owner(), { artifacts }); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'waiting_for_adm' }); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + expect(frame?.srcdoc).toContain('fictional creative'); + expect(frame?.hasAttribute('src')).toBe(false); + expect(container.querySelector('span')).not.toBeNull(); + + frame?.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(container.querySelector('span')).toBeNull(); + expect(container.querySelector('iframe')).toBe(frame); + expect(artifacts.current('fictional-slot')).toMatchObject({ + attemptId: render.id, + kind: 'direct_iframe', + }); + + artifacts.dispose(); + expect(frame?.isConnected).toBe(false); + document.body.innerHTML = ''; + }); + + it('commits predecessors despite settlement-time iterator poisoning', () => { + document.body.innerHTML = '
placeholder
'; + const container = document.getElementById('fictional-slot')!; + const predecessor = container.querySelector('span'); + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const nativeIterator = Array.prototype[Symbol.iterator]; + let ownedIteratorCalls = 0; + expect(iteratorDescriptor).toBeDefined(); + expect( + render.onSettled(() => { + Object.defineProperty(Array.prototype, Symbol.iterator, { + ...iteratorDescriptor, + value: function (this: unknown[]) { + const first = this[0]; + const isAttributeTuple = + this.length === 2 && + typeof first === 'string' && + (first === 'sandbox' || + first === 'referrerpolicy' || + first === 'width' || + first === 'height' || + first === 'scrolling' || + first === 'frameborder' || + first === 'marginwidth' || + first === 'marginheight' || + first === 'title' || + first === 'aria-label' || + first === 'style'); + const isAttributeList = + this.length === 11 && Array.isArray(first) && first[0] === 'sandbox'; + const isPredecessorSnapshot = this.length === 1 && first === predecessor; + if (isAttributeTuple || isAttributeList || isPredecessorSnapshot) { + ownedIteratorCalls += 1; + throw new Error('hostile owned-array iterator'); + } + return Reflect.apply(nativeIterator, this, []); + }, + }); + }) + ).toBe(true); + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + + try { + frame?.dispatchEvent(new Event('load')); + } finally { + if (iteratorDescriptor) { + Object.defineProperty(Array.prototype, Symbol.iterator, iteratorDescriptor); + } + } + + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(ownedIteratorCalls).toBe(0); + expect(predecessor?.isConnected).toBe(false); + expect(frame?.isConnected).toBe(true); + document.body.innerHTML = ''; + }); + + it.each(['property', 'append', 'current', 'activate'] as const)( + 'contains a throwing ADM handle %s phase and disposes its exact frame', + (phase) => { + document.body.innerHTML = '
'; + const container = document.getElementById('fictional-slot')!; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + let underlying: DirectAdmIframeHandle | undefined; + const prepareIframe: DirectAdmIframeConstructor = (options) => { + underlying = prepareAdmIframe(options); + if (!underlying) return undefined; + if (phase === 'property') { + return new Proxy(underlying, { + get(target, property, receiver) { + if (property === 'append') throw new Error('hostile append property'); + return Reflect.get(target, property, receiver); + }, + }); + } + return Object.freeze({ + frame: underlying.frame, + append: () => { + const appended = underlying?.append() === true; + if (phase === 'append') throw new Error('hostile append'); + return appended; + }, + activate: () => { + const activated = underlying?.activate() === true; + if (phase === 'activate') throw new Error('hostile activate'); + return activated; + }, + commit: () => underlying?.commit() === true, + current: () => { + if (phase === 'current') throw new Error('hostile current'); + return underlying?.current() === true; + }, + dispose: () => underlying?.dispose(), + }); + }; + + expect(() => + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe, + publisherOrigin: window.location.origin, + }) + ).not.toThrow(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'adm_document_no_load', + }); + expect(container.querySelector('iframe')).toBeNull(); + expect(underlying?.append()).toBe(false); + document.body.innerHTML = ''; + } + ); + + it('rejects a non-publisher creative origin before inserting a frame', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: 'https://not-the-publisher.example', + }) + ).toBe(false); + expect(container.querySelector('iframe')).toBeNull(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + document.body.innerHTML = ''; + }); + + it('anchors the five-second deadline after inserting a complete srcdoc frame', () => { + document.body.innerHTML = '
'; + const render = attempt(owner(), { + scheduler: Object.freeze({ + clear: vi.fn(), + set: (callback: () => void) => { + callback(); + return Object.freeze({}); + }, + }), + }); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + const observer = new MutationObserver(() => undefined); + observer.observe(container, { childList: true }); + + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'adm_document_no_load', + }); + const mutations = observer.takeRecords(); + const inserted = mutations + .flatMap((mutation) => [...mutation.addedNodes]) + .find((node): node is HTMLIFrameElement => node instanceof HTMLIFrameElement); + expect(inserted?.srcdoc).toContain('fictional creative'); + expect(inserted?.hasAttribute('src')).toBe(false); + expect(mutations.some((mutation) => mutation.removedNodes.length === 1)).toBe(true); + expect(container.querySelector('iframe')).toBeNull(); + observer.disconnect(); + document.body.innerHTML = ''; + }); + + it.each(['error', 'removed', 'replaced-srcdoc'] as const)( + 'fails and removes an unaccepted frame when it is %s', + (failure) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + if (!frame) throw new Error('should insert an ADM frame'); + + if (failure === 'error') frame.dispatchEvent(new Event('error')); + if (failure === 'removed') { + frame.remove(); + frame.dispatchEvent(new Event('load')); + } + if (failure === 'replaced-srcdoc') { + frame.srcdoc = 'publisher replacement'; + frame.dispatchEvent(new Event('load')); + } + + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'adm_document_no_load', + }); + expect(frame.isConnected).toBe(false); + document.body.innerHTML = ''; + } + ); + + it.each([ + ['sandbox', (frame: HTMLIFrameElement) => frame.setAttribute('sandbox', 'allow-scripts')], + [ + 'referrer policy', + (frame: HTMLIFrameElement) => frame.setAttribute('referrerpolicy', 'unsafe-url'), + ], + ['dimensions', (frame: HTMLIFrameElement) => frame.setAttribute('width', '301')], + ['layout style', (frame: HTMLIFrameElement) => frame.style.setProperty('width', '301px')], + ] as const)( + 'refuses acceptance after publisher mutation of the exact %s contract', + (_field, mutate) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + if (!frame) throw new Error('should insert an ADM frame'); + + mutate(frame); + frame.dispatchEvent(new Event('load')); + + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'adm_document_no_load', + }); + expect(frame.isConnected).toBe(false); + document.body.innerHTML = ''; + } + ); + + it('removes on cancellation and makes every late frame event inert', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + + expect(render.cancel('caller_aborted')).toBe(true); + expect(frame?.isConnected).toBe(false); + frame?.dispatchEvent(new Event('load')); + frame?.dispatchEvent(new Event('error')); + expect(render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + document.body.innerHTML = ''; + }); + + it('rejects an admitted but malformed frozen ADM source before DOM mutation', () => { + const malformed = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '', + width: 0, + height: 250, + }); + document.body.innerHTML = '
'; + const render = attempt(owner(), { + prepareRenderSource: (candidate) => (candidate === malformed ? malformed : undefined), + }); + expect(render.admitDirectWinner(malformed, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(container.querySelector('iframe')).toBeNull(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + document.body.innerHTML = ''; + }); +}); + +describe('RenderAttempt state machine', () => { + it('implements the exact PUC APS state table and makes invalid/replay transitions inert', () => { + const scope = owner(); + const candidate = artifact(scope, 'aps_mount'); + const render = attempt(scope); + const observed: RenderAttemptState[] = []; + + expect(render.beginGamClaim()).toBe(true); + expect(render.beginDirect()).toBe(false); + expect(render.admitClaimedWinner(claimed(render, scope, APS_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + expect(render.beginApsDocument(candidate)).toBe(true); + expect(render.beginAdm(candidate)).toBe(false); + expect(render.apsDocumentAccepted()).toBe(true); + expect(render.accept()).toBe(true); + expect(render.accept()).toBe(false); + expect(render.fail('runner_failed')).toBe(false); + expect(candidate.dispose).not.toHaveBeenCalled(); + + for (const state of render.snapshot().history) observed.push(state); + expect(observed).toEqual([ + 'created', + 'waiting_for_gam_and_claim', + 'waiting_for_owner', + 'waiting_for_insertion', + 'waiting_for_document', + 'waiting_for_aps_completion', + 'accepted', + ]); + expect(render.snapshot()).toMatchObject({ + state: 'accepted', + outcome: { outcome: 'accepted' }, + }); + expect(scope.disposed).toBe(true); + }); + + it('implements direct and owner ADM paths without permitting APS-only transitions', () => { + const directOwner = owner(); + const directArtifact = artifact(directOwner); + const direct = attempt(directOwner); + expect(direct.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(direct.beginDirect()).toBe(true); + expect(direct.beginAdm(directArtifact)).toBe(true); + expect(direct.apsDocumentAccepted()).toBe(false); + expect(direct.accept()).toBe(true); + + const pucOwner = owner(ATTEMPT_TWO); + const pucArtifact = artifact(pucOwner, 'puc'); + const puc = attempt(pucOwner); + expect(puc.beginGamClaim()).toBe(true); + expect(puc.admitClaimedWinner(claimed(puc, pucOwner, ADM_SOURCE))).toBe(true); + expect(puc.ownerClaimed()).toBe(true); + expect(puc.ownerRegistered()).toBe(true); + expect(puc.beginAdm(pucArtifact)).toBe(true); + expect(puc.accept()).toBe(true); + expect(puc.snapshot().history).toEqual([ + 'created', + 'waiting_for_gam_and_claim', + 'waiting_for_owner', + 'waiting_for_insertion', + 'waiting_for_adm', + 'accepted', + ]); + }); + + it('rejects source and artifact combinations from a different render path', () => { + const directApsOwner = owner(); + const directAps = attempt(directApsOwner); + expect(directAps.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(directAps.beginDirect()).toBe(true); + expect(directAps.beginAdm(artifact(directApsOwner))).toBe(false); + expect(directAps.beginApsDocument(artifact(directApsOwner, 'puc'))).toBe(false); + expect(directAps.beginApsDocument(artifact(directApsOwner, 'aps_mount'))).toBe(true); + + const directAdmOwner = owner(ATTEMPT_TWO); + const directAdm = attempt(directAdmOwner); + expect(directAdm.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(directAdm.beginDirect()).toBe(true); + expect(directAdm.beginApsDocument(artifact(directAdmOwner))).toBe(false); + expect(directAdm.beginAdm(artifact(directAdmOwner, 'puc'))).toBe(false); + expect(directAdm.beginAdm(artifact(directAdmOwner))).toBe(true); + + const pucApsOwner = owner('a1_0000000000000000000002'); + const pucAps = attempt(pucApsOwner); + expect(pucAps.beginGamClaim()).toBe(true); + expect(pucAps.admitClaimedWinner(claimed(pucAps, pucApsOwner, APS_SOURCE))).toBe(true); + expect(pucAps.ownerClaimed()).toBe(true); + expect(pucAps.ownerRegistered()).toBe(true); + expect(pucAps.beginAdm(artifact(pucApsOwner, 'puc'))).toBe(false); + expect(pucAps.beginApsDocument(artifact(pucApsOwner))).toBe(false); + expect(pucAps.beginApsDocument(artifact(pucApsOwner, 'aps_mount'))).toBe(true); + }); + + it('admits a claimed winner only through the exact one-shot source/context claim', () => { + const scope = owner(); + const render = attempt(scope); + expect(render.beginGamClaim()).toBe(true); + const exactClaim = claimed(render, scope, APS_SOURCE); + const exactContext = scope.winnerContext; + if (!exactContext) throw new Error('should admit the exact reservation context'); + + const mismatchedOwner = owner(ATTEMPT_TWO); + const mismatched = attempt(mismatchedOwner); + expect(mismatched.beginGamClaim()).toBe(true); + mismatchedOwner.admitClaimedContext(exactContext); + expect(mismatched.admitClaimedWinner(exactClaim)).toBe(false); + expect(mismatched.renderSource).toBeUndefined(); + mismatched.cancel('caller_aborted'); + + expect(render.admitClaimedWinner(Object.freeze({}))).toBe(false); + expect(render.admitClaimedWinner(exactClaim)).toBe(true); + expect(render.renderSource).toEqual(APS_SOURCE); + expect(render.winnerContext).toBe(exactContext); + expect(render.admitClaimedWinner(exactClaim)).toBe(false); + }); + + it('enforces every valid, invalid, and replay transition in the state table', () => { + type Transition = + | 'admit_direct' + | 'admit_claimed' + | 'begin_gam_claim' + | 'owner_claimed' + | 'owner_registered' + | 'begin_direct' + | 'begin_aps_document' + | 'begin_adm' + | 'aps_document_accepted' + | 'accept' + | 'no_bid' + | 'gam_empty' + | 'fail' + | 'cancel'; + type ScenarioName = + | 'created' + | 'created_direct' + | 'waiting_for_gam_and_claim' + | 'waiting_for_gam_and_claim_admitted' + | 'waiting_for_owner' + | 'waiting_for_insertion_aps' + | 'waiting_for_insertion_adm' + | 'rendering_direct_aps' + | 'rendering_direct_adm' + | 'waiting_for_document' + | 'waiting_for_aps_completion' + | 'waiting_for_adm' + | 'accepted' + | 'no_bid' + | 'failed' + | 'cancelled'; + + const transitions: readonly Transition[] = [ + 'admit_direct', + 'admit_claimed', + 'begin_gam_claim', + 'owner_claimed', + 'owner_registered', + 'begin_direct', + 'begin_aps_document', + 'begin_adm', + 'aps_document_accepted', + 'accept', + 'no_bid', + 'gam_empty', + 'fail', + 'cancel', + ]; + const valid = new Map>([ + ['created', new Set(['admit_direct', 'begin_gam_claim', 'no_bid', 'fail', 'cancel'])], + ['created_direct', new Set(['begin_direct', 'fail', 'cancel'])], + ['waiting_for_gam_and_claim', new Set(['admit_claimed', 'gam_empty', 'fail', 'cancel'])], + [ + 'waiting_for_gam_and_claim_admitted', + new Set(['owner_claimed', 'gam_empty', 'fail', 'cancel']), + ], + ['waiting_for_owner', new Set(['owner_registered', 'fail', 'cancel'])], + ['waiting_for_insertion_aps', new Set(['begin_aps_document', 'fail', 'cancel'])], + ['waiting_for_insertion_adm', new Set(['begin_adm', 'fail', 'cancel'])], + ['rendering_direct_aps', new Set(['begin_aps_document', 'fail', 'cancel'])], + ['rendering_direct_adm', new Set(['begin_adm', 'fail', 'cancel'])], + ['waiting_for_document', new Set(['aps_document_accepted', 'fail', 'cancel'])], + ['waiting_for_aps_completion', new Set(['accept', 'fail', 'cancel'])], + ['waiting_for_adm', new Set(['accept', 'fail', 'cancel'])], + ['accepted', new Set()], + ['no_bid', new Set()], + ['failed', new Set()], + ['cancelled', new Set()], + ]); + + const build = (name: ScenarioName): RenderAttempt => { + const scope = owner(); + const render = attempt(scope); + const claim = (source: typeof APS_SOURCE | typeof ADM_SOURCE): void => { + render.beginGamClaim(); + render.admitClaimedWinner(claimed(render, scope, source)); + }; + switch (name) { + case 'created': + break; + case 'created_direct': + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + break; + case 'waiting_for_gam_and_claim': + render.beginGamClaim(); + matrixClaims.set(render, claimed(render, scope, APS_SOURCE)); + break; + case 'waiting_for_gam_and_claim_admitted': + claim(APS_SOURCE); + break; + case 'waiting_for_owner': + claim(APS_SOURCE); + render.ownerClaimed(); + break; + case 'waiting_for_insertion_aps': + claim(APS_SOURCE); + render.ownerClaimed(); + render.ownerRegistered(); + break; + case 'waiting_for_insertion_adm': + claim(ADM_SOURCE); + render.ownerClaimed(); + render.ownerRegistered(); + break; + case 'rendering_direct_aps': + render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + break; + case 'rendering_direct_adm': + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + break; + case 'waiting_for_document': + render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginApsDocument(artifact(scope, 'aps_mount')); + break; + case 'waiting_for_aps_completion': + render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginApsDocument(artifact(scope, 'aps_mount')); + render.apsDocumentAccepted(); + break; + case 'waiting_for_adm': + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginAdm(artifact(scope)); + break; + case 'accepted': + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginAdm(artifact(scope)); + render.accept(); + break; + case 'no_bid': + render.noBid(); + break; + case 'failed': + render.fail('internal_error'); + break; + case 'cancelled': + render.cancel('caller_aborted'); + break; + } + return render; + }; + + const invoke = (render: RenderAttempt, transition: Transition): boolean => { + switch (transition) { + case 'admit_direct': + return render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + case 'admit_claimed': + return render.admitClaimedWinner(matrixClaims.get(render) ?? Object.freeze({})); + case 'begin_gam_claim': + return render.beginGamClaim(); + case 'owner_claimed': + return render.ownerClaimed(); + case 'owner_registered': + return render.ownerRegistered(); + case 'begin_direct': + return render.beginDirect(); + case 'begin_aps_document': + return render.beginApsDocument(artifact(render, 'aps_mount')); + case 'begin_adm': + return render.beginAdm( + artifact( + render, + render.snapshot().state === 'waiting_for_insertion' ? 'puc' : 'direct_iframe' + ) + ); + case 'aps_document_accepted': + return render.apsDocumentAccepted(); + case 'accept': + return render.accept(); + case 'no_bid': + return render.noBid(); + case 'gam_empty': + return render.fail('gam_empty'); + case 'fail': + return render.fail('internal_error'); + case 'cancel': + return render.cancel('caller_aborted'); + } + }; + + for (const [scenario, expectedTransitions] of valid) { + for (const transition of transitions) { + const render = build(scenario); + const expected = expectedTransitions.has(transition); + expect(invoke(render, transition), `${scenario} -> ${transition}`).toBe(expected); + if (expected) { + expect(invoke(render, transition), `${scenario} -> ${transition} replay`).toBe(false); + } + if (!render.snapshot().outcome) render.cancel('caller_aborted'); + } + } + }); + + it('owns the exact admitted source and winner context for a direct APS path', () => { + const scope = owner(); + const render = attempt(scope); + expect(render.beginDirect()).toBe(false); + expect(render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(render.renderSource).toBe(APS_SOURCE); + expect(render.winnerContext).toBe(WINNER_CONTEXT); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(false); + + const candidate = artifact(scope, 'aps_mount'); + expect(render.beginDirect()).toBe(true); + expect(render.beginApsDocument(candidate)).toBe(true); + expect(render.apsDocumentAccepted()).toBe(true); + expect(render.accept()).toBe(true); + expect(render.renderSource).toBeUndefined(); + expect(render.winnerContext).toBeUndefined(); + }); + + it('allows no_bid only for the exact parsed decision before rendering starts', () => { + const noBid = attempt(); + expect(noBid.noBid()).toBe(true); + expect(noBid.snapshot()).toMatchObject({ state: 'no_bid', outcome: { outcome: 'no_bid' } }); + expect(noBid.beginDirect()).toBe(false); + + const rendering = attempt(owner(ATTEMPT_TWO)); + expect(rendering.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(rendering.beginDirect()).toBe(true); + expect(rendering.noBid()).toBe(false); + expect(rendering.fail('invalid_response')).toBe(true); + }); + + it('races state-owned timeout, success, failure, abort, and navigation disposal through one latch', () => { + vi.useFakeTimers(); + try { + const timedOwner = owner(); + const timedArtifact = artifact(timedOwner); + const timed = attempt(timedOwner, { + owner: timedOwner, + artifacts: createCommittedArtifactStore(), + }); + expect(timed.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(timed.beginDirect()).toBe(true); + expect(timed.beginAdm(timedArtifact)).toBe(true); + vi.advanceTimersByTime(5_000); + expect(timed.snapshot()).toMatchObject({ + outcome: { outcome: 'failed', reason: 'adm_document_no_load' }, + }); + expect(timedArtifact.dispose).toHaveBeenCalledOnce(); + expect(timed.accept()).toBe(false); + expect(timed.cancel('caller_aborted')).toBe(false); + + const aborted = attempt(owner(ATTEMPT_TWO)); + expect(aborted.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(aborted.beginDirect()).toBe(true); + expect(aborted.cancel('caller_aborted')).toBe(true); + expect(aborted.fail('internal_error')).toBe(false); + + const navigationOwner = owner('a1_0000000000000000000002'); + const navigationAttempt = attempt(navigationOwner); + expect(navigationAttempt.beginGamClaim()).toBe(true); + navigationOwner.disposeFromNavigation(); + expect(navigationAttempt.snapshot()).toMatchObject({ + outcome: { outcome: 'cancelled', reason: 'navigation_disposed' }, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('uses fixed transition-owned deadline timings and failure mappings', () => { + vi.useFakeTimers(); + try { + const registrationOwner = owner(); + const registration = attempt(registrationOwner); + registration.beginGamClaim(); + registration.admitClaimedWinner(claimed(registration, registrationOwner, APS_SOURCE)); + registration.ownerClaimed(); + vi.advanceTimersByTime(2_999); + expect(registration.snapshot().state).toBe('waiting_for_owner'); + vi.advanceTimersByTime(1); + expect(registration.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'owner_registration_timeout', + }); + + const insertionOwner = owner(ATTEMPT_TWO); + const insertion = attempt(insertionOwner); + insertion.beginGamClaim(); + insertion.admitClaimedWinner(claimed(insertion, insertionOwner, APS_SOURCE)); + insertion.ownerClaimed(); + insertion.ownerRegistered(); + vi.advanceTimersByTime(1_000); + expect(insertion.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'owner_insertion_timeout', + }); + + const documentOwner = owner('a1_0000000000000000000002'); + const documentAttempt = attempt(documentOwner); + documentAttempt.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + documentAttempt.beginDirect(); + documentAttempt.beginApsDocument(artifact(documentOwner, 'aps_mount')); + vi.advanceTimersByTime(3_000); + expect(documentAttempt.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + + const completionOwner = owner('a1_0000000000000000000003'); + const completion = attempt(completionOwner); + completion.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + completion.beginDirect(); + completion.beginApsDocument(artifact(completionOwner, 'aps_mount')); + completion.apsDocumentAccepted(); + vi.advanceTimersByTime(10_000); + expect(completion.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'runner_failed', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('reserves transition and terminal latches before hostile scheduler and artifact cleanup', () => { + const transitionReference: { current?: RenderAttempt } = {}; + let clearReenters = false; + const scheduler = { + set: vi.fn(() => Object.freeze({})), + clear: vi.fn(() => { + if (clearReenters) transitionReference.current?.cancel('caller_aborted'); + }), + }; + const transitionOwner = owner(); + const transitionAttempt = attempt(transitionOwner, { scheduler }); + transitionReference.current = transitionAttempt; + transitionAttempt.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + transitionAttempt.beginDirect(); + transitionAttempt.beginApsDocument(artifact(transitionOwner, 'aps_mount')); + clearReenters = true; + + expect(transitionAttempt.apsDocumentAccepted()).toBe(true); + expect(transitionAttempt.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + expect(transitionAttempt.snapshot().history.slice(-2)).toEqual([ + 'waiting_for_aps_completion', + 'cancelled', + ]); + + const disposalReference: { current?: RenderAttempt } = {}; + const disposalOwner = owner(ATTEMPT_TWO); + const hostileArtifact = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: disposalOwner.id, + slot: disposalOwner.slot, + navigationGeneration: disposalOwner.navigationGeneration, + dispose: vi.fn(() => disposalReference.current?.cancel('superseded')), + }); + const disposalAttempt = attempt(disposalOwner); + disposalReference.current = disposalAttempt; + disposalAttempt.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + disposalAttempt.beginDirect(); + disposalAttempt.beginAdm(hostileArtifact); + + expect(disposalAttempt.fail('internal_error')).toBe(true); + expect(disposalAttempt.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'internal_error', + }); + expect(disposalAttempt.snapshot().history.filter((state) => state === 'failed')).toHaveLength( + 1 + ); + expect(disposalAttempt.snapshot().history).not.toContain('cancelled'); + }); + + it('does not promote after deadline cleanup reentrantly settles the attempt', () => { + const artifacts = createCommittedArtifactStore(); + const reference: { current?: RenderAttempt } = {}; + let cancelOnClear = false; + const scheduler = { + set: vi.fn(() => Object.freeze({})), + clear: vi.fn(() => { + if (cancelOnClear) reference.current?.cancel('caller_aborted'); + }), + }; + const scope = owner(); + const candidate = artifact(scope); + const render = attempt(scope, { artifacts, scheduler }); + reference.current = render; + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginAdm(candidate); + cancelOnClear = true; + + expect(render.accept()).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + expect(candidate.dispose).toHaveBeenCalledOnce(); + expect(artifacts.current(scope.slot)).toBeUndefined(); + }); + + it('rejects malformed or stale attempt ownership before registering work', () => { + const malformed = owner('bad-attempt'); + expect( + createRenderAttempt({ + owner: malformed, + artifacts: createCommittedArtifactStore(), + reservations: reservations(), + prepareRenderSource, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + + const stale = owner(); + stale.disposeFromNavigation(); + expect( + createRenderAttempt({ + owner: stale, + artifacts: createCommittedArtifactStore(), + reservations: reservations(), + prepareRenderSource, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + }); + + it('transactionally disposes owners when lifecycle registration cannot commit', () => { + for (const mode of ['throw', 'callback', 'identity'] as const) { + const scope = owner(); + const originalDispose = scope.dispose; + const dispose = vi.fn(() => originalDispose()); + Object.defineProperty(scope, 'dispose', { configurable: true, value: dispose }); + Object.defineProperty(scope, 'onDispose', { + configurable: true, + value: (_kind: string, callback: () => void) => { + if (mode === 'callback') callback(); + if (mode === 'identity') { + Object.defineProperty(scope, 'id', { configurable: true, value: ATTEMPT_TWO }); + } + if (mode === 'throw') throw new Error('registration failed'); + }, + }); + + expect( + createRenderAttempt({ + owner: scope, + artifacts: createCommittedArtifactStore(), + reservations: reservations(), + prepareRenderSource, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + expect(dispose, mode).toHaveBeenCalledOnce(); + } + }); + + it('releases real session indexes after every post-issuance construction rejection', () => { + let issuedByte = 0; + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(issuedByte); + issuedByte += 1; + return target; + }, + }), + }); + const navigation = runtime.startInitialNavigation(); + if (!navigation.ok) throw new Error('should start a navigation'); + const batch = navigation.value.createAuctionBatch('batch-render-construction'); + if (!batch) throw new Error('should create an auction batch'); + const slot = 'fictional-slot'; + + const unbrandedOwner = batch.createRenderAttempt(slot); + if (!unbrandedOwner.ok) throw new Error('should issue the first owner'); + expect( + createRenderAttempt({ + owner: unbrandedOwner.value, + artifacts: { ...createCommittedArtifactStore() }, + reservations: reservations(), + prepareRenderSource, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + + const invalidSchedulerOwner = batch.createRenderAttempt(slot); + expect(invalidSchedulerOwner).toMatchObject({ ok: true }); + if (!invalidSchedulerOwner.ok) throw new Error('should retry after provenance rejection'); + expect( + createRenderAttempt({ + owner: invalidSchedulerOwner.value, + artifacts: createCommittedArtifactStore(), + reservations: reservations(), + prepareRenderSource, + scheduler: { set: undefined as never, clear: () => undefined }, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + + const unbrandedReservationsOwner = batch.createRenderAttempt(slot); + expect(unbrandedReservationsOwner).toMatchObject({ ok: true }); + if (!unbrandedReservationsOwner.ok) { + throw new Error('should retry after scheduler rejection'); + } + expect( + createRenderAttempt({ + owner: unbrandedReservationsOwner.value, + artifacts: createCommittedArtifactStore(), + prepareRenderSource, + reservations: { + ...reservations(), + consumeClaim: () => + Object.freeze({ + renderSource: ADM_SOURCE, + winnerContext: WINNER_CONTEXT, + }), + }, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + + expect(batch.createRenderAttempt(slot)).toMatchObject({ ok: true }); + runtime.dispose(); + }); + + it('runtime-rejects invalid terminal reasons instead of publishing malformed outcomes', () => { + const render = attempt(); + expect(render.fail('invented_failure' as never)).toBe(false); + expect(render.cancel('invented_cancellation' as never)).toBe(false); + expect(render.snapshot()).toMatchObject({ state: 'created', outcome: undefined }); + expect(render.fail('internal_error')).toBe(true); + }); +}); + +describe('committed artifact ownership', () => { + it('retires a lost guarded artifact synchronously and exactly once', () => { + const store = createCommittedArtifactStore(); + const candidate = artifact(owner()); + const retireAssociation = vi.fn(); + let current = true; + expect(bindCommittedArtifactGuard(candidate, () => current)).toBe(true); + expect(bindCommittedArtifactRetirement(candidate, retireAssociation)).toBe(true); + expect(bindCommittedArtifactRetirement(candidate, vi.fn())).toBe(false); + expect(store.promote(candidate)).toBe(true); + + expect(store.sweep()).toBe(0); + current = false; + expect(store.sweep()).toBe(1); + expect(store.current(candidate.slot)).toBeUndefined(); + expect(retireAssociation).toHaveBeenCalledOnce(); + expect(candidate.dispose).toHaveBeenCalledOnce(); + expect(store.sweep()).toBe(0); + expect(candidate.dispose).toHaveBeenCalledOnce(); + expect(retireAssociation).toHaveBeenCalledOnce(); + }); + + it('promotes before attempt disposal, preserves accepted DOM, and disposes the prior artifact before replacement', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const firstOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const firstArtifact = artifact(firstOwner); + const first = attempt(firstOwner, { owner: firstOwner, artifacts: store }); + first.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + first.beginDirect(); + first.beginAdm(firstArtifact); + expect(first.accept()).toBe(true); + expect(firstOwner.disposed).toBe(true); + expect(firstArtifact.dispose).not.toHaveBeenCalled(); + expect(store.current('fictional-slot')).toBe(firstArtifact); + + const secondOwner = owner(ATTEMPT_TWO, 'fictional-slot', generation); + const secondArtifact = artifact(secondOwner); + const second = attempt(secondOwner, { owner: secondOwner, artifacts: store }); + second.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + second.beginDirect(); + second.beginAdm(secondArtifact); + expect(second.accept()).toBe(true); + expect(firstArtifact.dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBe(secondArtifact); + expect(secondArtifact.dispose).not.toHaveBeenCalled(); + + store.disposeNavigation(generation); + expect(secondArtifact.dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBeUndefined(); + }); + + it('disposes only uncommitted artifacts on failure or cancellation', () => { + for (const [index, settle] of (['failed', 'cancelled'] as const).entries()) { + const scope = owner(`a1_000000000000000000000${index}`); + const candidate = artifact(scope); + const render = attempt(scope); + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginAdm(candidate); + if (settle === 'failed') expect(render.fail('adm_document_no_load')).toBe(true); + else expect(render.cancel('superseded')).toBe(true); + expect(candidate.dispose).toHaveBeenCalledOnce(); + } + }); + + it('does not publish a replacement when prior-artifact disposal reentrantly cancels it', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const secondOwner = owner(ATTEMPT_TWO, 'fictional-slot', generation); + const firstOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const firstArtifact = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: firstOwner.id, + slot: 'fictional-slot', + navigationGeneration: generation, + dispose: vi.fn(() => secondOwner.disposeFromNavigation()), + }); + const first = attempt(firstOwner, { owner: firstOwner, artifacts: store }); + first.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + first.beginDirect(); + first.beginAdm(firstArtifact); + first.accept(); + + const secondArtifact = artifact(secondOwner); + const second = attempt(secondOwner, { owner: secondOwner, artifacts: store }); + second.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + second.beginDirect(); + second.beginAdm(secondArtifact); + + expect(second.accept()).toBe(false); + expect(second.snapshot()).toMatchObject({ + outcome: { outcome: 'cancelled', reason: 'navigation_disposed' }, + }); + expect(firstArtifact.dispose).toHaveBeenCalledOnce(); + expect(secondArtifact.dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBeUndefined(); + }); + + it('requires an immutable exact-attempt artifact without invoking accessors', () => { + const scope = owner(); + const render = attempt(scope); + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + const wrongAttempt = Object.freeze({ + ...artifact(scope), + attemptId: ATTEMPT_TWO, + }); + expect(render.beginAdm(wrongAttempt)).toBe(false); + + const getter = vi.fn(() => 'direct_iframe'); + const hostile = Object.freeze( + Object.defineProperties( + {}, + { + attemptId: { enumerable: true, value: scope.id }, + dispose: { enumerable: true, value: vi.fn() }, + kind: { enumerable: true, get: getter }, + navigationGeneration: { enumerable: true, value: scope.navigationGeneration }, + slot: { enumerable: true, value: scope.slot }, + } + ) + ); + expect(render.beginAdm(hostile as CommittedRenderArtifact)).toBe(false); + expect(getter).not.toHaveBeenCalled(); + }); + + it('defers reentrant navigation disposal and never publishes into a disposed generation', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const firstOwner = owner(ATTEMPT_ONE, 'slot-one', generation); + const secondOwner = owner(ATTEMPT_TWO, 'slot-two', generation); + const replacementOwner = owner('a1_0000000000000000000002', 'slot-one', generation); + const first = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: firstOwner.id, + slot: firstOwner.slot, + navigationGeneration: generation, + dispose: vi.fn(() => store.disposeNavigation(generation)), + }); + const second = artifact(secondOwner); + const replacement = artifact(replacementOwner); + expect(store.promote(first)).toBe(true); + expect(store.promote(second)).toBe(true); + + expect(store.promote(replacement)).toBe(false); + expect(first.dispose).toHaveBeenCalledOnce(); + expect(second.dispose).toHaveBeenCalledOnce(); + expect(store.current('slot-one')).toBeUndefined(); + expect(store.current('slot-two')).toBeUndefined(); + }); + + it('never retries a throwing artifact disposer', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const firstOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const replacementOwner = owner(ATTEMPT_TWO, 'fictional-slot', generation); + const dispose = vi.fn(() => { + throw new Error('partial artifact disposal'); + }); + const first = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: firstOwner.id, + slot: firstOwner.slot, + navigationGeneration: generation, + dispose, + }); + expect(store.promote(first)).toBe(true); + expect(store.promote(artifact(replacementOwner))).toBe(false); + expect(store.current('fictional-slot')).toBe(first); + + store.disposeNavigation(generation); + expect(dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBeUndefined(); + }); + + it('fails closed and contains an asynchronous artifact disposer', async () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const firstOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const replacementOwner = owner(ATTEMPT_TWO, 'fictional-slot', generation); + const dispose = vi.fn(async () => { + throw new Error('asynchronous artifact disposal is unsupported'); + }); + const first = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: firstOwner.id, + slot: firstOwner.slot, + navigationGeneration: generation, + dispose, + }); + expect(store.promote(first)).toBe(true); + + expect(store.promote(artifact(replacementOwner))).toBe(false); + await Promise.resolve(); + + expect(dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBe(first); + }); + + it.each(['fulfilled_promise', 'fulfilling_thenable'] as const)( + 'contains an asynchronous %s disposer without publishing a replacement', + async (mode) => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const firstOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const replacementOwner = owner(ATTEMPT_TWO, 'fictional-slot', generation); + const dispose = vi.fn(() => + mode === 'fulfilled_promise' + ? Promise.resolve() + : { + then: (fulfilled: () => void) => { + queueMicrotask(() => fulfilled()); + }, + } + ); + const first = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: firstOwner.id, + slot: firstOwner.slot, + navigationGeneration: generation, + dispose, + }); + expect(store.promote(first)).toBe(true); + + expect(store.promote(artifact(replacementOwner))).toBe(false); + await Promise.resolve(); + await Promise.resolve(); + + expect(dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBe(first); + } + ); + + it('never republishes an artifact after its disposal has started', () => { + const store = createCommittedArtifactStore(); + const candidate = artifact(owner()); + expect(store.promote(candidate)).toBe(true); + expect(store.release(candidate)).toBe(true); + expect(candidate.dispose).toHaveBeenCalledOnce(); + + expect(store.promote(candidate)).toBe(false); + expect(store.current(candidate.slot)).toBeUndefined(); + expect(candidate.dispose).toHaveBeenCalledOnce(); + }); + + it('preserves the prior artifact when promotion currentness is already false', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const current = artifact(owner(ATTEMPT_ONE, 'fictional-slot', generation)); + const candidate = artifact(owner(ATTEMPT_TWO, 'fictional-slot', generation)); + expect(store.promote(current)).toBe(true); + + expect(store.promote(candidate, () => false)).toBe(false); + expect(current.dispose).not.toHaveBeenCalled(); + expect(candidate.dispose).not.toHaveBeenCalled(); + expect(store.current('fictional-slot')).toBe(current); + }); + + it('does not publish a candidate whose exact association is lost during prior disposal', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + let associationCurrent = true; + const currentOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const current = Object.freeze({ + kind: 'aps_mount' as const, + attemptId: currentOwner.id, + slot: currentOwner.slot, + navigationGeneration: generation, + dispose: vi.fn(() => { + associationCurrent = false; + }), + }); + const candidate = artifact(owner(ATTEMPT_TWO, 'fictional-slot', generation), 'aps_mount'); + expect(bindCommittedArtifactGuard(candidate, () => associationCurrent)).toBe(true); + expect(store.promote(current)).toBe(true); + + expect(store.promote(candidate)).toBe(false); + expect(current.dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBeUndefined(); + expect(candidate.dispose).not.toHaveBeenCalled(); + }); + + it('never publishes after its navigation generation or whole store is disposed', () => { + const generation = Object.freeze({}); + const navigationStore = createCommittedArtifactStore(); + const navigationArtifact = artifact(owner(ATTEMPT_ONE, 'fictional-slot', generation)); + navigationStore.disposeNavigation(generation); + expect(navigationStore.promote(navigationArtifact)).toBe(false); + expect(navigationStore.current('fictional-slot')).toBeUndefined(); + + const runtimeStore = createCommittedArtifactStore(); + const runtimeArtifact = artifact(owner(ATTEMPT_TWO, 'fictional-slot', generation)); + expect( + runtimeStore.promote(runtimeArtifact, () => { + runtimeStore.dispose(); + return true; + }) + ).toBe(false); + expect(runtimeStore.current('fictional-slot')).toBeUndefined(); + }); + + it('contains collection prototype tampering at every artifact-store boundary', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const first = artifact(owner(ATTEMPT_ONE, 'fictional-slot', generation)); + const replacement = artifact(owner(ATTEMPT_TWO, 'fictional-slot', generation)); + const originalMapGet = Map.prototype.get; + const originalSetAdd = Set.prototype.add; + const originalWeakMapHas = WeakMap.prototype.has; + + let promoted: boolean | undefined; + Map.prototype.get = () => { + throw new Error('tampered Map.get'); + }; + try { + promoted = store.promote(first); + } finally { + Map.prototype.get = originalMapGet; + } + expect(promoted).toBe(true); + + let released: boolean | undefined; + WeakMap.prototype.has = () => { + throw new Error('tampered WeakMap.has'); + }; + try { + released = store.release(first); + } finally { + WeakMap.prototype.has = originalWeakMapHas; + } + expect(released).toBe(true); + expect(first.dispose).toHaveBeenCalledOnce(); + + expect(store.promote(replacement)).toBe(true); + Set.prototype.add = () => { + throw new Error('tampered Set.add'); + }; + try { + store.disposeNavigation(generation); + } finally { + Set.prototype.add = originalSetAdd; + } + expect(replacement.dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBeUndefined(); + }); + + it('keeps store bookkeeping valid when a disposer tampers with collection prototypes', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const originalMapGet = Map.prototype.get; + const dispose = vi.fn(() => { + Map.prototype.get = () => { + throw new Error('tampered Map.get'); + }; + }); + const current = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: ATTEMPT_ONE, + slot: 'fictional-slot', + navigationGeneration: generation, + dispose, + }); + const replacement = artifact(owner(ATTEMPT_TWO, 'fictional-slot', generation)); + expect(store.promote(current)).toBe(true); + let promoted: boolean | undefined; + try { + promoted = store.promote(replacement); + } finally { + Map.prototype.get = originalMapGet; + } + + expect(promoted).toBe(true); + expect(dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBe(replacement); + }); +}); + +describe('RenderAttempt diagnostics producer', () => { + it('publishes one frozen terminal observation only after accepted artifact state commits', () => { + const artifacts = createCommittedArtifactStore(); + const attemptReference: { current?: RenderAttempt } = {}; + const snapshots: RenderAttemptSnapshot[] = []; + const publishDiagnostics = vi.fn((observation: RenderAttemptDiagnosticsObservation) => { + expect(Object.isFrozen(observation)).toBe(true); + expect(Object.isFrozen(observation.outcome)).toBe(true); + snapshots.push(attemptReference.current!.snapshot()); + throw new Error('fictional diagnostics failure'); + }); + const renderAttempt = attempt(owner(), { artifacts, publishDiagnostics }); + attemptReference.current = renderAttempt; + expect(renderAttempt.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(renderAttempt.beginDirect()).toBe(true); + const committed = artifact(renderAttempt); + expect(renderAttempt.beginAdm(committed)).toBe(true); + + expect(renderAttempt.accept()).toBe(true); + + expect(artifacts.current(renderAttempt.slot)).toBe(committed); + expect(snapshots).toEqual([ + expect.objectContaining({ state: 'accepted', outcome: { outcome: 'accepted' } }), + ]); + expect(publishDiagnostics).toHaveBeenCalledOnce(); + expect(publishDiagnostics).toHaveBeenCalledWith({ + kind: 'render_attempt', + attemptId: renderAttempt.id, + slotId: renderAttempt.slot, + path: 'auction', + rendered: true, + injected: true, + servedFrom: 'inline', + state: 'accepted', + outcome: { outcome: 'accepted' }, + }); + }); + + it('publishes source-owned APS trace identity without exposing the creative payload', () => { + const publishDiagnostics = vi.fn(); + const renderAttempt = attempt(owner(), { publishDiagnostics }); + expect(renderAttempt.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(renderAttempt.beginDirect()).toBe(true); + const committed = artifact(renderAttempt, 'aps_mount'); + expect(renderAttempt.beginApsDocument(committed)).toBe(true); + expect(renderAttempt.apsDocumentAccepted()).toBe(true); + + expect(renderAttempt.accept()).toBe(true); + + expect(publishDiagnostics).toHaveBeenCalledWith( + expect.objectContaining({ + bidId: DIRECT_APS_SOURCE.bidId, + creativeId: DIRECT_APS_SOURCE.creativeId, + injected: true, + rendered: true, + }) + ); + const observation = publishDiagnostics.mock.calls[0]?.[0] as Record; + expect(observation).not.toHaveProperty('aaxResponse'); + expect(observation).not.toHaveProperty('creativeUrl'); + }); + + it('publishes terminal failure after the lifecycle state commit and never republishes', () => { + const attemptReference: { current?: RenderAttempt } = {}; + const observedStates: RenderAttemptState[] = []; + const publishDiagnostics = vi.fn(() => { + observedStates.push(attemptReference.current!.snapshot().state); + return false; + }); + const renderAttempt = attempt(owner(), { publishDiagnostics }); + attemptReference.current = renderAttempt; + + expect(renderAttempt.fail('runner_failed')).toBe(true); + expect(renderAttempt.cancel('superseded')).toBe(false); + + expect(observedStates).toEqual(['failed']); + expect(publishDiagnostics).toHaveBeenCalledOnce(); + }); +}); + +describe('SlotOperation result isolation', () => { + it('rejects an unbranded structural primary before observing or starting fallback', () => { + const createFallback = vi.fn(); + const forged = { + id: ATTEMPT_ONE, + slot: 'fictional-slot', + navigationGeneration: Object.freeze({}), + onSettled: vi.fn(), + snapshot: vi.fn(), + } as unknown as RenderAttempt; + + expect(createSlotOperation({ primary: forged, createFallback })).toEqual({ + ok: false, + reason: 'invalid_attempt', + }); + expect(forged.onSettled).not.toHaveBeenCalled(); + expect(createFallback).not.toHaveBeenCalled(); + }); + + it('retains immutable primary gam_empty and settles from one distinct fallback child', () => { + const primary = attempt(); + let fallback: RenderAttempt | undefined; + const operation = slotOperation({ + primary, + createFallback: (parentAttemptId) => { + const childOwner = owner(ATTEMPT_TWO, primary.slot, primary.navigationGeneration); + const result = createRenderAttempt({ + owner: childOwner, + artifacts: createCommittedArtifactStore(), + reservations: reservations(), + prepareRenderSource, + parentAttemptId, + }); + if (result.ok) fallback = result.value; + return result; + }, + }); + + primary.beginGamClaim(); + expect(primary.fail('gam_empty')).toBe(true); + expect(fallback).toBeDefined(); + expect(fallback?.parentAttemptId).toBe(primary.id); + expect(fallback?.id).not.toBe(primary.id); + expect(fallback?.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + fallback?.beginDirect(); + const fallbackArtifact = artifact(fallback!); + fallback?.beginAdm(fallbackArtifact); + expect(fallback?.accept()).toBe(true); + + expect(operation.snapshot()).toEqual({ + settled: true, + result: { + path: 'fallback', + outcome: { outcome: 'accepted' }, + primaryAttemptId: ATTEMPT_ONE, + primary: { outcome: 'failed', reason: 'gam_empty' }, + fallbackAttemptId: ATTEMPT_TWO, + fallback: { outcome: 'accepted' }, + }, + }); + expect(Object.isFrozen(operation.snapshot().result)).toBe(true); + }); + + it('does not start fallback for ineligible primary results or settle twice', () => { + const primary = attempt(); + const createFallback = vi.fn(); + const operation = slotOperation({ primary, createFallback }); + primary.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + primary.beginDirect(); + primary.fail('runner_failed'); + + expect(createFallback).not.toHaveBeenCalled(); + expect(operation.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'primary', + outcome: { outcome: 'failed', reason: 'runner_failed' }, + }, + }); + expect(primary.cancel('superseded')).toBe(false); + }); + + it('cannot forge fallback with gam_empty outside an attributable GAM state', () => { + const primary = attempt(); + const createFallback = vi.fn(); + const operation = slotOperation({ primary, createFallback }); + + expect(primary.fail('gam_empty')).toBe(false); + expect(createFallback).not.toHaveBeenCalled(); + expect(operation.snapshot()).toEqual({ settled: false }); + expect(primary.cancel('caller_aborted')).toBe(true); + }); + + it('rejects a fallback child from another navigation generation', () => { + const primary = attempt(); + let child: RenderAttempt | undefined; + const operation = slotOperation({ + primary, + createFallback: (parentAttemptId) => { + const result = createRenderAttempt({ + owner: owner(ATTEMPT_TWO), + artifacts: createCommittedArtifactStore(), + reservations: reservations(), + prepareRenderSource, + parentAttemptId, + }); + if (result.ok) child = result.value; + return result; + }, + }); + primary.beginGamClaim(); + primary.fail('gam_empty'); + + expect(child?.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'superseded', + }); + expect(operation.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'fallback', + outcome: { outcome: 'failed', reason: 'internal_error' }, + }, + }); + }); + + it('fails closed when fallback identity issuance fails', () => { + const primary = attempt(); + const operation = slotOperation({ + primary, + createFallback: () => Object.freeze({ ok: false, reason: 'identity_generation_failed' }), + }); + primary.beginGamClaim(); + primary.fail('gam_empty'); + + expect(operation.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'fallback', + primary: { outcome: 'failed', reason: 'gam_empty' }, + outcome: { outcome: 'failed', reason: 'identity_generation_failed' }, + }, + }); + }); + + it('contains hostile fallback result getters and child subscription failures', () => { + const getterPrimary = attempt(); + const getterOperation = slotOperation({ + primary: getterPrimary, + createFallback: () => + Object.defineProperty({}, 'ok', { + get: () => { + throw new Error('hostile result getter'); + }, + }) as never, + }); + getterPrimary.beginGamClaim(); + getterPrimary.fail('gam_empty'); + expect(getterOperation.snapshot()).toMatchObject({ + settled: true, + result: { outcome: { outcome: 'failed', reason: 'internal_error' } }, + }); + + const subscriptionPrimary = attempt(owner(ATTEMPT_ONE, 'fictional-slot', Object.freeze({}))); + const hostileChild = { + id: ATTEMPT_TWO, + slot: subscriptionPrimary.slot, + parentAttemptId: subscriptionPrimary.id, + navigationGeneration: subscriptionPrimary.navigationGeneration, + cancel: vi.fn(() => true), + onSettled: () => { + throw new Error('hostile child subscription'); + }, + } as unknown as RenderAttempt; + const subscriptionOperation = slotOperation({ + primary: subscriptionPrimary, + createFallback: () => Object.freeze({ ok: true, value: hostileChild }), + }); + subscriptionPrimary.beginGamClaim(); + subscriptionPrimary.fail('gam_empty'); + expect(subscriptionOperation.snapshot()).toMatchObject({ + settled: true, + result: { outcome: { outcome: 'failed', reason: 'internal_error' } }, + }); + expect(hostileChild.cancel).toHaveBeenCalledOnce(); + }); + + it('rejects a fallback result accessor without rereading or cancelling another value', () => { + const primary = attempt(); + const first = { cancel: vi.fn() }; + const second = { cancel: vi.fn() }; + let reads = 0; + const result = Object.freeze( + Object.defineProperties( + {}, + { + ok: { enumerable: true, value: true }, + value: { + enumerable: true, + get: () => { + reads += 1; + return reads === 1 ? first : second; + }, + }, + } + ) + ); + const operation = slotOperation({ primary, createFallback: () => result as never }); + primary.beginGamClaim(); + primary.fail('gam_empty'); + + expect(operation.snapshot()).toMatchObject({ + settled: true, + result: { outcome: { outcome: 'failed', reason: 'internal_error' } }, + }); + expect(reads).toBe(0); + expect(first.cancel).not.toHaveBeenCalled(); + expect(second.cancel).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts new file mode 100644 index 000000000..af90dbbb0 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -0,0 +1,2279 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { parseBidRenderSourceV1 } from '../../src/core/contracts/auction_projection'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { + createRuntimeSession, + type NavigationSession, + type RenderAttemptScope, + type WinnerContext, +} from '../../src/kernel/sessions'; +import { + PREBID_ADMISSION_LEASE_MS, + RENDER_RESERVATION_LIFETIME_MS, + createReservationService, + isRendererReservationId, + type ReservationOwner, + type ReservationRenderSource, +} from '../../src/services/reservations'; + +function reservationId(index = 0): string { + return `r1_${index.toString(36).padStart(22, '0')}`; +} + +function runtimeNavigation(): { + readonly navigation: NavigationSession; + readonly runtime: ReturnType; +} { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(7); + return target; + }, + }), + }); + const navigation = runtime.startInitialNavigation(); + if (!navigation.ok) throw new Error('Expected a navigation'); + return { navigation: navigation.value, runtime }; +} + +function renderAttempt(navigation: NavigationSession, slot = 'fictional-slot'): RenderAttemptScope { + const batch = navigation.createAuctionBatch(`batch-${slot}`); + if (!batch) throw new Error('Expected an auction batch'); + const attempt = batch.createRenderAttempt(slot); + if (!attempt.ok) throw new Error('Expected a render attempt'); + return attempt.value; +} + +function admSource(markup = '
fictional creative
') { + return { type: 'adm', version: 1, adm: markup, width: 300, height: 250 } as const; +} + +function apsSource() { + const creativeUrl = 'https://creative.example/render'; + const envelope = { + seatbid: [ + { + bid: [ + { + id: 'upstream-bid', + w: 300, + h: 250, + price: 1.25, + ext: { creativeurl: creativeUrl, tagtype: 'iframe' }, + }, + ], + }, + ], + }; + return { + type: 'aps', + version: 1, + accountId: 'fictional-account', + bidId: 'upstream-bid', + creativeId: 'fictional-creative', + tagType: 'iframe', + creativeUrl, + aaxResponse: btoa(JSON.stringify(envelope)), + width: 300, + height: 250, + } as const; +} + +function serviceAt(readNow: () => number) { + return createReservationService({ + now: readNow, + prepareRenderSource: (candidate) => { + const source = parseBidRenderSourceV1(candidate); + return source?.type === 'pbs_cache' ? undefined : source; + }, + }); +} + +function registerRender( + service: ReturnType, + navigation: NavigationSession, + attempt: RenderAttemptScope, + id = reservationId(), + renderSource: unknown = admSource(), + selectedCpm = 1.25 +) { + return service.registerRender({ + reservationId: id, + slot: attempt.slot, + navigation, + attemptId: attempt.id, + renderSource, + winnerContext: { selectedCpm }, + }); +} + +function claim( + service: ReturnType, + navigation: NavigationSession, + attempt: RenderAttemptScope, + id = reservationId(), + pucSource: object = Object.freeze({}) +) { + return service.claim({ + reservationId: id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt, + pucSource, + }); +} + +function tombstone( + service: ReturnType, + navigation: NavigationSession, + attempt: RenderAttemptScope, + id: string, + state: 'disposed' | 'stale' +) { + return service.tombstone( + { + reservationId: id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + attemptId: attempt.id, + }, + state + ); +} + +describe('renderer reservation identity and registration', () => { + it('atomically adopts unexpired first-display tombstones in the local clock epoch', () => { + let now = 100; + const service = serviceAt(() => now); + + expect( + service.adoptFirstDisplayTombstones({ + clockEpochMs: 40, + tombstones: [ + { expiresAtMs: 140, reservationId: reservationId(1) }, + { expiresAtMs: 30, reservationId: reservationId(2) }, + ], + }) + ).toBe(true); + expect(service.recognize(reservationId(1))).toMatchObject({ + recognized: true, + state: 'consumed', + expiresAt: 200, + }); + expect(service.recognize(reservationId(2))).toEqual({ recognized: false }); + + now = 200; + expect(service.recognize(reservationId(1))).toEqual({ recognized: false }); + expect(service.adoptFirstDisplayTombstones({ clockEpochMs: 200, tombstones: [] })).toBe(false); + }); + + it('rejects malformed first-display tombstones without publishing a partial set', () => { + const service = serviceAt(() => 100); + + expect( + service.adoptFirstDisplayTombstones({ + clockEpochMs: 50, + tombstones: [ + { expiresAtMs: 200, reservationId: reservationId(1) }, + { expiresAtMs: 210, reservationId: reservationId(1) }, + ], + }) + ).toBe(false); + expect(service.recognize(reservationId(1))).toEqual({ recognized: false }); + }); + + it.each([ + [reservationId(), true], + [`r1_${'A'.repeat(22)}`, true], + [`r1_${'_'.repeat(22)}`, true], + [`r1_${'-'.repeat(22)}`, true], + [`r1_${'a'.repeat(21)}`, false], + [`r1_${'a'.repeat(23)}`, false], + [`r2_${'a'.repeat(22)}`, false], + [`r1_${'a'.repeat(21)}=`, false], + [`r1_${'a'.repeat(21)}+`, false], + ['', false], + [undefined, false], + ])('validates the exact server-minted identity %j', (candidate, expected) => { + expect(isRendererReservationId(candidate)).toBe(expected); + }); + + it('copies and freezes one exact APS or ADM source without retaining projection input', () => { + const { navigation } = runtimeNavigation(); + const sources = [apsSource(), admSource()]; + + for (const [index, source] of sources.entries()) { + const service = serviceAt(() => 5); + const attempt = renderAttempt(navigation, `slot-${index}`); + const mutable = structuredClone(source) as Record; + expect(registerRender(service, navigation, attempt, reservationId(index), mutable).ok).toBe( + true + ); + mutable.width = 1; + + const result = claim(service, navigation, attempt, reservationId(index)); + expect(result).toMatchObject({ recognized: true, claimed: true }); + if (!result.recognized || !result.claimed) throw new Error('Expected a claim'); + const context = attempt.winnerContext; + if (!context) throw new Error('Expected an admitted winner context'); + const admission = service.consumeClaim(result, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }); + expect(admission?.renderSource).toEqual(source); + expect(admission?.renderSource).not.toBe(mutable); + expect(Object.isFrozen(admission?.renderSource)).toBe(true); + expect(Object.isFrozen(admission?.winnerContext)).toBe(true); + } + }); + + it('binds one consumed claim object to its exact attempt source and winner context', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 5); + const attempt = renderAttempt(navigation); + expect(registerRender(service, navigation, attempt)).toMatchObject({ ok: true }); + const result = claim(service, navigation, attempt); + if (!result.recognized || !result.claimed) throw new Error('Expected a claim'); + expect(Object.getOwnPropertyNames(result).sort()).toEqual([ + 'claimed', + 'expiresAt', + 'pucSource', + 'recognized', + ]); + expect(result).not.toHaveProperty('renderSource'); + expect(result).not.toHaveProperty('winnerContext'); + const context = attempt.winnerContext; + if (!context) throw new Error('Expected an admitted winner context'); + + expect( + Reflect.apply(service.consumeClaim, service, [ + result, + { + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }, + ]) + ).toBeUndefined(); + const replayedAttempt = Object.freeze({ ...attempt }); + expect( + service.consumeClaim(result, { + attempt: replayedAttempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }) + ).toBeUndefined(); + expect( + service.consumeClaim(result, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: Object.freeze({}), + winnerContext: context, + }) + ).toBeUndefined(); + expect( + service.consumeClaim(result, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: Object.freeze({ selectedCpm: context.selectedCpm }), + }) + ).toBeUndefined(); + expect( + service.consumeClaim(Object.freeze({ ...result }), { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }) + ).toBeUndefined(); + + const admission = service.consumeClaim(result, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }); + expect(admission).toEqual({ + renderSource: admSource(), + winnerContext: context, + }); + expect(admission?.winnerContext).toBe(context); + expect(Object.isFrozen(admission)).toBe(true); + expect( + service.consumeClaim(result, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }) + ).toBeUndefined(); + }); + + it.each(['navigation_disposed', 'service_disposed', 'expired'] as const)( + 'invalidates a consumed claim when its authority is %s', + (mode) => { + let now = 5; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const attempt = renderAttempt(navigation); + expect(registerRender(service, navigation, attempt)).toMatchObject({ ok: true }); + const result = claim(service, navigation, attempt); + if (!result.recognized || !result.claimed) throw new Error('Expected a claim'); + const context = attempt.winnerContext; + if (!context) throw new Error('Expected an admitted winner context'); + + if (mode === 'navigation_disposed') navigation.dispose(); + else if (mode === 'service_disposed') service.dispose(); + else now = result.expiresAt; + + expect( + service.consumeClaim(result, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }) + ).toBeUndefined(); + } + ); + + it('rejects duplicate identity against live and tombstoned entries without overwriting either', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + const first = renderAttempt(navigation, 'first'); + const second = renderAttempt(navigation, 'second'); + + expect(registerRender(service, navigation, first)).toMatchObject({ ok: true }); + expect(registerRender(service, navigation, second)).toEqual({ + ok: false, + reason: 'reservation_collision', + }); + expect(claim(service, navigation, first)).toMatchObject({ claimed: true }); + expect(registerRender(service, navigation, second)).toEqual({ + ok: false, + reason: 'reservation_collision', + }); + }); + + it('rejects nonfinite, negative, accessor, and extra-field winner contexts before publication', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + for (const winnerContext of [ + { selectedCpm: Number.NaN }, + { selectedCpm: Number.POSITIVE_INFINITY }, + { selectedCpm: -0.01 }, + { selectedCpm: 1, extra: true }, + Object.defineProperty({}, 'selectedCpm', { enumerable: true, get: () => 1 }), + ]) { + const service = serviceAt(() => 0); + expect( + service.registerRender({ + reservationId: reservationId(), + slot: attempt.slot, + navigation, + attemptId: attempt.id, + renderSource: admSource(), + winnerContext, + }) + ).toEqual({ ok: false, reason: 'invalid_winner_context' }); + expect(service.snapshotInventoryForTest().size).toBe(0); + } + }); + + it('contains hostile sources, owners, and prototype poisoning without partial live publication', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + const hostileSource = Object.defineProperty({}, 'type', { + enumerable: true, + get() { + throw new Error('hostile getter'); + }, + }); + + expect(() => + registerRender(service, navigation, attempt, reservationId(), hostileSource) + ).not.toThrow(); + expect(registerRender(service, navigation, attempt, reservationId(), hostileSource)).toEqual({ + ok: false, + reason: 'invalid_render_source', + }); + + const originalGet = Map.prototype.get; + const originalSet = Map.prototype.set; + const originalDelete = Map.prototype.delete; + Map.prototype.get = function poisonedGet() { + throw new Error('poisoned get'); + }; + Map.prototype.set = function poisonedSet() { + throw new Error('poisoned set'); + }; + Map.prototype.delete = function poisonedDelete() { + throw new Error('poisoned delete'); + }; + try { + expect( + service.registerRender({ + reservationId: reservationId(), + slot: attempt.slot, + navigation: { + generation: navigation.generation, + isCurrent: () => true, + onDispose: vi.fn(), + }, + attemptId: attempt.id, + renderSource: admSource(), + winnerContext: { selectedCpm: 1.25 }, + }) + ).toMatchObject({ ok: true }); + let adopted: WinnerContext | undefined; + expect( + service.claim({ + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt: { + id: attempt.id, + slot: attempt.slot, + get winnerContext() { + return adopted; + }, + isCurrent: () => true, + prepareWinnerContext: (context) => { + return { + commit: () => { + adopted = context; + return true; + }, + rollback: () => { + if (adopted === context) adopted = undefined; + return true; + }, + }; + }, + }, + pucSource: Object.freeze({}), + }) + ).toMatchObject({ claimed: true }); + } finally { + Map.prototype.get = originalGet; + Map.prototype.set = originalSet; + Map.prototype.delete = originalDelete; + } + }); + + it('uses captured identity and UTF-8 validators after their prototypes are poisoned', () => { + const generation = Object.freeze({}); + const renderSource = admSource() as ReservationRenderSource; + const service = createReservationService({ + now: () => 0, + prepareRenderSource: (candidate) => (candidate === renderSource ? renderSource : undefined), + }); + const owner: ReservationOwner = { + generation, + isCurrent: () => true, + onDispose: () => undefined, + }; + const originalRegExpTest = RegExp.prototype.test; + const originalTextEncoderEncode = TextEncoder.prototype.encode; + let validIdentity: boolean | undefined; + let invalidIdentity: boolean | undefined; + let invalidSlot: ReturnType | undefined; + let validRegistration: ReturnType | undefined; + let thrown: unknown; + + RegExp.prototype.test = function poisonedRegExpTest() { + throw new Error('poisoned RegExp.test'); + }; + TextEncoder.prototype.encode = function poisonedTextEncoderEncode() { + throw new Error('poisoned TextEncoder.encode'); + }; + try { + validIdentity = isRendererReservationId(reservationId()); + invalidIdentity = isRendererReservationId('not-a-reservation'); + invalidSlot = service.registerRender({ + reservationId: reservationId(), + slot: 'x'.repeat(257), + navigation: owner, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }); + validRegistration = service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: owner, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }); + } catch (error) { + thrown = error; + } finally { + RegExp.prototype.test = originalRegExpTest; + TextEncoder.prototype.encode = originalTextEncoderEncode; + } + + expect(thrown).toBeUndefined(); + expect(validIdentity).toBe(true); + expect(invalidIdentity).toBe(false); + expect(invalidSlot).toEqual({ ok: false, reason: 'invalid_slot' }); + expect(validRegistration).toMatchObject({ ok: true }); + }); + + it('uses captured code-unit validation when String.charCodeAt returns benign data', () => { + const generation = Object.freeze({}); + const renderSource = admSource() as ReservationRenderSource; + const service = createReservationService({ + now: () => 0, + prepareRenderSource: (candidate) => (candidate === renderSource ? renderSource : undefined), + }); + const originalCharCodeAt = String.prototype.charCodeAt; + const results: ReturnType[] = []; + String.prototype.charCodeAt = () => 0x61; + try { + for (const [index, slot] of ['control\u0000slot', 'lone-surrogate\ud800'].entries()) { + results[results.length] = service.registerRender({ + reservationId: reservationId(index), + slot, + navigation: { generation, isCurrent: () => true, onDispose: () => undefined }, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }); + } + } finally { + String.prototype.charCodeAt = originalCharCodeAt; + } + + expect(results).toEqual([ + { ok: false, reason: 'invalid_slot' }, + { ok: false, reason: 'invalid_slot' }, + ]); + expect(service.snapshotInventoryForTest().size).toBe(0); + }); + + it('contains throwing String.charCodeAt poisoning without publishing', () => { + const generation = Object.freeze({}); + const renderSource = admSource() as ReservationRenderSource; + const service = createReservationService({ + now: () => 0, + prepareRenderSource: (candidate) => (candidate === renderSource ? renderSource : undefined), + }); + const originalCharCodeAt = String.prototype.charCodeAt; + let result: ReturnType | undefined; + let thrown: unknown; + String.prototype.charCodeAt = () => { + throw new Error('poisoned String.charCodeAt'); + }; + try { + result = service.registerRender({ + reservationId: reservationId(), + slot: 'control\u0000slot', + navigation: { generation, isCurrent: () => true, onDispose: () => undefined }, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }); + } catch (error) { + thrown = error; + } finally { + String.prototype.charCodeAt = originalCharCodeAt; + } + + expect(thrown).toBeUndefined(); + expect(result).toEqual({ ok: false, reason: 'invalid_slot' }); + expect(service.snapshotInventoryForTest().size).toBe(0); + }); + + it.each([ + ['throws before applying', false], + ['throws after applying', true], + ] as const)('contains a captured Map.set that %s', async (_name, applyFirst) => { + vi.resetModules(); + const originalSet = Map.prototype.set; + Map.prototype.set = function poisonedReservationSet(key, value) { + if (typeof key !== 'string' || !key.startsWith('r1_')) { + return Reflect.apply(originalSet, this, [key, value]) as Map; + } + if (applyFirst) Reflect.apply(originalSet, this, [key, value]); + throw new Error('captured reservation Map.set failure'); + }; + let isolated: typeof import('../../src/services/reservations'); + try { + isolated = await import('../../src/services/reservations'); + } finally { + Map.prototype.set = originalSet; + } + const generation = Object.freeze({}); + let cleanup: (() => void) | undefined; + const renderSource = admSource() as ReservationRenderSource; + const service = isolated.createReservationService({ + now: () => 0, + prepareRenderSource: (candidate) => (candidate === renderSource ? renderSource : undefined), + }); + let result: ReturnType | undefined; + let thrown: unknown; + try { + result = service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeUndefined(); + if (applyFirst) { + expect(result).toMatchObject({ ok: true }); + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + cleanup?.(); + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + } else { + expect(result).toEqual({ ok: false, reason: 'service_disposed' }); + expect(service.recognize(reservationId())).toEqual({ recognized: false }); + expect(cleanup).toBeTypeOf('function'); + expect(() => cleanup?.()).not.toThrow(); + } + }); + + it.each([ + ['throws before applying', false], + ['throws after applying', true], + ] as const)('contains a captured WeakMap.set that %s', async (_name, applyFirst) => { + vi.resetModules(); + const originalSet = WeakMap.prototype.set; + WeakMap.prototype.set = function poisonedOwnerSet(key, value) { + const record = value as Record | undefined; + if (!record || !('identity' in record) || !('ready' in record)) { + return Reflect.apply(originalSet, this, [key, value]) as WeakMap; + } + if (applyFirst) Reflect.apply(originalSet, this, [key, value]); + throw new Error('captured owner WeakMap.set failure'); + }; + let isolated: typeof import('../../src/services/reservations'); + try { + isolated = await import('../../src/services/reservations'); + } finally { + WeakMap.prototype.set = originalSet; + } + const generation = Object.freeze({}); + let cleanup: (() => void) | undefined; + const renderSource = admSource() as ReservationRenderSource; + const service = isolated.createReservationService({ + now: () => 0, + prepareRenderSource: (candidate) => (candidate === renderSource ? renderSource : undefined), + }); + let result: ReturnType | undefined; + let thrown: unknown; + try { + result = service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeUndefined(); + if (applyFirst) { + expect(result).toMatchObject({ ok: true }); + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + cleanup?.(); + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + } else { + expect(result).toEqual({ ok: false, reason: 'stale_owner' }); + expect(cleanup).toBeUndefined(); + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + } + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); + + it('contains a captured WeakMap.get failure in a navigation callback', async () => { + vi.resetModules(); + const originalGet = WeakMap.prototype.get; + let poisoned = false; + WeakMap.prototype.get = function poisonedOwnerGet(key) { + if (poisoned) throw new Error('captured owner WeakMap.get failure'); + return Reflect.apply(originalGet, this, [key]) as unknown; + }; + let isolated: typeof import('../../src/services/reservations'); + try { + isolated = await import('../../src/services/reservations'); + } finally { + WeakMap.prototype.get = originalGet; + } + const generation = Object.freeze({}); + let cleanup: (() => void) | undefined; + const renderSource = admSource() as ReservationRenderSource; + const service = isolated.createReservationService({ + now: () => 0, + prepareRenderSource: (candidate) => (candidate === renderSource ? renderSource : undefined), + }); + expect( + service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }) + ).toMatchObject({ ok: true }); + poisoned = true; + + expect(() => cleanup?.()).not.toThrow(); + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); + + it('checks publication identity after the final reentrant owner call', () => { + const service = serviceAt(() => 0); + const generation = Object.freeze({}); + let cleanup: (() => void) | undefined; + let currentChecks = 0; + const owner: ReservationOwner = { + generation, + isCurrent: () => { + currentChecks += 1; + if (currentChecks === 3) cleanup?.(); + return true; + }, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }; + + expect( + service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: owner, + attemptId: 'a1_0000000000000000000000', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + }); + + it('tombstones a registration if owner generation changes during disposal publication', () => { + const service = serviceAt(() => 0); + const initialGeneration = Object.freeze({}); + let generation = initialGeneration; + const owner: ReservationOwner = { + get generation() { + return generation; + }, + isCurrent: () => true, + onDispose: () => { + generation = Object.freeze({}); + }, + }; + + expect( + service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: owner, + attemptId: 'a1_0000000000000000000000', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + expect(service.recognize(reservationId())).toMatchObject({ + recognized: true, + state: 'disposed', + }); + }); + + it('preserves the established callback when another identity reuses its live generation', () => { + const service = serviceAt(() => 0); + const generation = Object.freeze({}); + let establishedCleanup: (() => void) | undefined; + const firstOwner: ReservationOwner = { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + establishedCleanup = callback; + }, + }; + expect( + service.registerRender({ + reservationId: reservationId(), + slot: 'first-slot', + navigation: firstOwner, + attemptId: 'a1_0000000000000000000000', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + }) + ).toMatchObject({ ok: true }); + const replacementOnDispose = vi.fn(() => { + throw new Error('replacement callback publication failed'); + }); + + expect( + service.registerRender({ + reservationId: reservationId(1), + slot: 'second-slot', + navigation: { + generation, + isCurrent: () => true, + onDispose: replacementOnDispose, + }, + attemptId: 'a1_0000000000000000000001', + renderSource: admSource(), + winnerContext: { selectedCpm: 2 }, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + expect(replacementOnDispose).not.toHaveBeenCalled(); + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + expect(service.recognize(reservationId(1))).toMatchObject({ state: 'disposed' }); + + establishedCleanup?.(); + + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 2, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); + + it('requires a fresh generation when a different owner identity arrives after expiry', () => { + let now = 0; + const service = serviceAt(() => now); + const generation = Object.freeze({}); + let oldCleanup: (() => void) | undefined; + const oldOwner: ReservationOwner = { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + oldCleanup = callback; + }, + }; + const input = { + reservationId: reservationId(), + slot: 'fictional-slot', + attemptId: 'a1_0000000000000000000000', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + }; + expect(service.registerRender({ ...input, navigation: oldOwner })).toMatchObject({ ok: true }); + now = RENDER_RESERVATION_LIFETIME_MS; + expect(service.recognize(reservationId())).toEqual({ recognized: false }); + + const newOwner: ReservationOwner = { + generation, + isCurrent: () => true, + onDispose: vi.fn(), + }; + expect(service.registerRender({ ...input, navigation: newOwner })).toEqual({ + ok: false, + reason: 'stale_owner', + }); + expect(newOwner.onDispose).not.toHaveBeenCalled(); + oldCleanup?.(); + + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); +}); + +describe('fixed expiry, capacity, and tombstones', () => { + it('is live exactly before the 15-minute boundary and prunes at and after expiry', () => { + for (const offset of [-1, 0, 1]) { + let now = 100; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + const registration = registerRender(service, navigation, attempt); + expect(registration).toEqual({ ok: true, expiresAt: 100 + RENDER_RESERVATION_LIFETIME_MS }); + + now = 100 + RENDER_RESERVATION_LIFETIME_MS + offset; + expect(service.recognize(reservationId()).recognized).toBe(offset < 0); + } + }); + + it.each([ + [ + 'throwing', + (): number => { + throw new Error('clock failed'); + }, + ], + ['nonfinite', (): number => Number.NaN], + ['backward', (): number => 99], + ] as const)('retains and suppresses every known id after a %s clock fault', (_name, fault) => { + let readNow = (): number => 100; + const { navigation } = runtimeNavigation(); + const liveAttempt = renderAttempt(navigation, 'live-slot'); + const tombstonedAttempt = renderAttempt(navigation, 'tombstoned-slot'); + const nextAttempt = renderAttempt(navigation, 'next-slot'); + const service = serviceAt(() => readNow()); + expect(registerRender(service, navigation, liveAttempt, reservationId())).toMatchObject({ + ok: true, + }); + expect(registerRender(service, navigation, tombstonedAttempt, reservationId(1))).toMatchObject({ + ok: true, + }); + expect(tombstone(service, navigation, tombstonedAttempt, reservationId(1), 'disposed')).toBe( + true + ); + + readNow = fault; + + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + expect(service.recognize(reservationId(1))).toMatchObject({ state: 'disposed' }); + expect(claim(service, navigation, liveAttempt)).toEqual({ + recognized: true, + claimed: false, + state: 'renderable', + }); + expect(claim(service, navigation, tombstonedAttempt, reservationId(1))).toEqual({ + recognized: true, + claimed: false, + state: 'disposed', + }); + expect(registerRender(service, navigation, nextAttempt, reservationId(2))).toEqual({ + ok: false, + reason: 'service_disposed', + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + clockFaulted: true, + disposed: false, + size: 2, + live: 1, + tombstones: 1, + }); + }); + + it('releases live render and lease payloads when navigation disposes after a clock fault', () => { + let now = 100; + const { navigation, runtime } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + expect(registerRender(service, navigation, attempt, reservationId())).toEqual({ + ok: true, + expiresAt: 100 + RENDER_RESERVATION_LIFETIME_MS, + }); + expect( + service.registerPrebidLease({ + reservationId: reservationId(1), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: Object.freeze({ cpm: 1 }), + }) + ).toEqual({ ok: true, expiresAt: 100 + PREBID_ADMISSION_LEASE_MS }); + now = Number.NaN; + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + + runtime.replaceNavigation(); + + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: 'disposed', + expiresAt: 100 + RENDER_RESERVATION_LIFETIME_MS, + }); + expect(service.recognize(reservationId(1))).toEqual({ + recognized: true, + state: 'aborted', + expiresAt: 100 + PREBID_ADMISSION_LEASE_MS, + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + clockFaulted: true, + live: 0, + tombstones: 2, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + entriesWithPucSource: 0, + }); + }); + + it('allows exact explicit terminal tombstones after a clock fault', () => { + let now = 100; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + registerRender(service, navigation, attempt, reservationId()); + const bid = Object.freeze({ cpm: 1 }); + const registerLease = (id: string, auctionId: string) => + service.registerPrebidLease({ + reservationId: id, + slot: 'fictional-slot', + navigation, + auctionId, + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + registerLease(reservationId(1), 'single-auction'); + registerLease(reservationId(2), 'group-auction'); + registerLease(reservationId(3), 'group-auction'); + now = Number.NaN; + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + + expect(tombstone(service, navigation, attempt, reservationId(), 'stale')).toBe(true); + expect( + service.tombstonePrebidLease( + { + reservationId: reservationId(1), + auctionId: 'single-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }, + 'prebid_admission_failed' + ) + ).toBe(true); + expect( + service.tombstonePrebidGroup( + { + auctionId: 'group-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }, + 'prebid_selection_timeout' + ) + ).toBe(2); + + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: 'stale', + expiresAt: 100 + RENDER_RESERVATION_LIFETIME_MS, + }); + for (const [index, state] of [ + [1, 'prebid_admission_failed'], + [2, 'prebid_selection_timeout'], + [3, 'prebid_selection_timeout'], + ] as const) { + expect(service.recognize(reservationId(index))).toEqual({ + recognized: true, + state, + expiresAt: 100 + PREBID_ADMISSION_LEASE_MS, + }); + } + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 4, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + entriesWithPucSource: 0, + }); + }); + + it.each([ + ['negative', (): number => -1], + ['nonfinite', (): number => Number.NaN], + [ + 'throwing', + (): number => { + throw new Error('clock failed'); + }, + ], + ['overflowing deadline', (): number => Number.MAX_VALUE], + ] as const)('fails closed without publication for a %s monotonic clock', (_name, now) => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(now); + + expect(registerRender(service, navigation, attempt)).toEqual({ + ok: false, + reason: 'service_disposed', + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + disposed: true, + size: 0, + live: 0, + tombstones: 0, + }); + }); + + it('prunes safely while Array push and iteration prototypes are poisoned', () => { + let now = 0; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + registerRender(service, navigation, attempt); + now = RENDER_RESERVATION_LIFETIME_MS; + const originalPush = Array.prototype.push; + const originalIterator = Array.prototype[Symbol.iterator]; + let recognition: ReturnType | undefined; + Array.prototype.push = function poisonedPush() { + throw new Error('poisoned push'); + }; + Array.prototype[Symbol.iterator] = function poisonedIterator() { + throw new Error('poisoned iterator'); + }; + try { + recognition = service.recognize(reservationId()); + } finally { + Array.prototype.push = originalPush; + Array.prototype[Symbol.iterator] = originalIterator; + } + expect(recognition).toEqual({ recognized: false }); + }); + + it('uses captured Map iterator operations after their prototypes are poisoned', () => { + let now = 0; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + registerRender(service, navigation, attempt); + const originalValues = Map.prototype.values; + const originalEntries = Map.prototype.entries; + const iteratorPrototype = Object.getPrototypeOf(new Map().values()) as { + next: () => IteratorResult; + }; + const originalNext = iteratorPrototype.next; + let recognition: ReturnType | undefined; + Map.prototype.values = function poisonedValues() { + throw new Error('poisoned values'); + }; + Map.prototype.entries = function poisonedEntries() { + throw new Error('poisoned entries'); + }; + iteratorPrototype.next = function poisonedNext() { + throw new Error('poisoned next'); + }; + now = RENDER_RESERVATION_LIFETIME_MS; + try { + recognition = service.recognize(reservationId()); + } finally { + Map.prototype.values = originalValues; + Map.prototype.entries = originalEntries; + iteratorPrototype.next = originalNext; + } + expect(recognition).toEqual({ recognized: false }); + }); + + it('consumption never extends expiry and leaves only minimum suppression metadata', () => { + let now = 200; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + registerRender(service, navigation, attempt); + now = 400; + + expect(claim(service, navigation, attempt)).toMatchObject({ claimed: true }); + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: 'consumed', + expiresAt: 200 + RENDER_RESERVATION_LIFETIME_MS, + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + entriesWithPucSource: 0, + }); + }); + + it.each(['stale', 'disposed'] as const)( + 'retains an exact %s tombstone through the original expiry', + (state) => { + let now = 0; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + registerRender(service, navigation, attempt); + now = 50; + + expect(tombstone(service, navigation, attempt, reservationId(), state)).toBe(true); + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state, + expiresAt: RENDER_RESERVATION_LIFETIME_MS, + }); + expect(service.snapshotInventoryForTest().entriesWithRenderSource).toBe(0); + now = RENDER_RESERVATION_LIFETIME_MS; + expect(service.recognize(reservationId())).toEqual({ recognized: false }); + } + ); + + it('allows only the exact slot, generation, and attempt owner to tombstone a live entry', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt); + const exact = { + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attemptId: attempt.id, + }; + + expect(service.tombstone({ ...exact, slot: 'other-slot' }, 'stale')).toBe(false); + expect(service.tombstone({ ...exact, navigationGeneration: Object.freeze({}) }, 'stale')).toBe( + false + ); + expect(service.tombstone({ ...exact, attemptId: `${attempt.id}-other` }, 'stale')).toBe(false); + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + expect(service.tombstone(exact, 'stale')).toBe(true); + }); + + it('rejects invalid runtime tombstone states without changing live entries', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + const bid = Object.freeze({ cpm: 1 }); + registerRender(service, navigation, attempt, reservationId()); + for (const index of [1, 2]) { + service.registerPrebidLease({ + reservationId: reservationId(index), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + } + const hostileState = Object.defineProperty({}, Symbol.toPrimitive, { + value() { + throw new Error('state must not be coerced'); + }, + }); + + expect( + service.tombstone( + { + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attemptId: attempt.id, + }, + hostileState as never + ) + ).toBe(false); + expect( + service.tombstonePrebidLease( + { + reservationId: reservationId(1), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }, + 'consumed' as never + ) + ).toBe(false); + expect( + service.tombstonePrebidGroup( + { + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }, + 'renderable' as never + ) + ).toBe(0); + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + expect(service.recognize(reservationId(1))).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + expect(service.recognize(reservationId(2))).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ live: 3, tombstones: 0 }); + }); + + it('shares capacity 320 across live and tombstones, never evicts, and still serves oldest', () => { + let now = 0; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const attempts: RenderAttemptScope[] = []; + for (let index = 0; index < 320; index += 1) { + const attempt = renderAttempt(navigation, `slot-${index}`); + attempts.push(attempt); + expect(registerRender(service, navigation, attempt, reservationId(index))).toMatchObject({ + ok: true, + }); + if (index % 2 === 0) { + tombstone(service, navigation, attempt, reservationId(index), 'disposed'); + } + } + const overflow = renderAttempt(navigation, 'overflow'); + + expect(registerRender(service, navigation, overflow, reservationId(320))).toEqual({ + ok: false, + reason: 'registry_full', + }); + expect(claim(service, navigation, attempts[1]!, reservationId(1))).toMatchObject({ + claimed: true, + }); + expect(service.snapshotInventoryForTest().size).toBe(320); + + now = RENDER_RESERVATION_LIFETIME_MS; + expect(registerRender(service, navigation, overflow, reservationId(320))).toMatchObject({ + ok: true, + }); + }); + + it('automatically tombstones navigation-owned live entries and retains no source/context', () => { + const { navigation, runtime } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt); + + runtime.replaceNavigation(); + + expect(service.recognize(reservationId())).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); + + it('installs one owner disposer across sequential expired leases', () => { + let now = 0; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const bid = Object.freeze({ cpm: 1 }); + + for (let index = 0; index < 1_000; index += 1) { + expect( + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: `auction-${index}`, + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }) + ).toMatchObject({ ok: true }); + now += PREBID_ADMISSION_LEASE_MS; + expect(service.recognize(reservationId())).toEqual({ recognized: false }); + } + + expect(navigation.snapshotInventoryForTest().activeDisposers).toBe(1); + expect(service.snapshotInventoryForTest().size).toBe(0); + }); + + it('one owner callback tombstones every live state for its exact generation', () => { + const { navigation, runtime } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt, reservationId()); + service.registerPrebidLease({ + reservationId: reservationId(1), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: Object.freeze({ cpm: 1 }), + }); + + expect(navigation.snapshotInventoryForTest().activeDisposers).toBe(1); + runtime.replaceNavigation(); + + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + expect(service.recognize(reservationId(1))).toMatchObject({ state: 'aborted' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ live: 0, tombstones: 2 }); + }); +}); + +describe('Prebid admission leases and selection', () => { + it('marks a navigation-disposed Prebid lease aborted through its original short expiry', () => { + const { navigation, runtime } = runtimeNavigation(); + const service = serviceAt(() => 10); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: Object.freeze({ cpm: 1 }), + }); + + runtime.replaceNavigation(); + + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: 'aborted', + expiresAt: 10 + PREBID_ADMISSION_LEASE_MS, + }); + }); + + it('does not adopt context when a clock jump makes promotion stale', () => { + let now = 0; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const bid = Object.freeze({ cpm: 1 }); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + const attempt = renderAttempt(navigation); + now = Number.MAX_VALUE; + + expect( + service.promotePrebidSelection({ + reservationId: reservationId(), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }) + ).toEqual({ ok: false, reason: 'reservation_not_live' }); + expect(attempt.winnerContext).toBeUndefined(); + expect(service.snapshotInventoryForTest().live).toBe(0); + }); + + it('requires a frozen bid with exact CPM equality and does not retain native Prebid identity', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + const base = { + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1.25 }, + }; + + expect(service.registerPrebidLease({ ...base, prebidBid: { cpm: 1.25 } })).toEqual({ + ok: false, + reason: 'prebid_cpm_mismatch', + }); + expect( + service.registerPrebidLease({ ...base, prebidBid: Object.freeze({ cpm: 2, adId: 'native' }) }) + ).toEqual({ ok: false, reason: 'prebid_cpm_mismatch' }); + expect( + service.registerPrebidLease({ ...base, prebidBid: Object.freeze({ cpm: 1.25 }) }) + ).toEqual({ ok: true, expiresAt: PREBID_ADMISSION_LEASE_MS }); + expect(service.recognize('native')).toEqual({ recognized: false }); + }); + + it('promotes one selected ADM lease from ten seconds to 15 minutes', () => { + let now = 10; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const bid = Object.freeze({ cpm: 1.25 }); + const base = { + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1.25 }, + prebidBid: bid, + }; + expect(service.registerPrebidLease({ ...base, reservationId: reservationId(1) })).toEqual({ + ok: true, + expiresAt: 10 + PREBID_ADMISSION_LEASE_MS, + }); + expect(service.registerPrebidLease({ ...base, reservationId: reservationId(2) })).toMatchObject( + { + ok: true, + } + ); + const attempt = renderAttempt(navigation); + now = 1_000; + + expect( + service.promotePrebidSelection({ + reservationId: reservationId(1), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }) + ).toEqual({ ok: true, expiresAt: 1_000 + RENDER_RESERVATION_LIFETIME_MS }); + expect(attempt.winnerContext).toEqual({ selectedCpm: 1.25 }); + expect(service.recognize(reservationId(1))).toMatchObject({ + recognized: true, + state: 'renderable', + expiresAt: 1_000 + RENDER_RESERVATION_LIFETIME_MS, + }); + expect(service.recognize(reservationId(2))).toEqual({ + recognized: true, + state: 'unselected', + expiresAt: 10 + PREBID_ADMISSION_LEASE_MS, + }); + const selected = claim(service, navigation, attempt, reservationId(1)); + const winnerContext = attempt.winnerContext; + if (!selected.recognized || !selected.claimed || !winnerContext) { + throw new Error('Expected the promoted ADM lease to remain claimable'); + } + expect( + service.consumeClaim(selected, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext, + }) + ).toEqual({ renderSource: admSource(), winnerContext }); + }); + + it('promotes only before the admission boundary and prunes at and after ten seconds', () => { + for (const offset of [-1, 0, 1]) { + let now = 100; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const bid = Object.freeze({ cpm: 1 }); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + const attempt = renderAttempt(navigation); + now = 100 + PREBID_ADMISSION_LEASE_MS + offset; + + const result = service.promotePrebidSelection({ + reservationId: reservationId(), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }); + expect(result.ok).toBe(offset < 0); + expect(service.recognize(reservationId()).recognized).toBe(offset < 0); + } + }); + + it('tombstones losers only in the selected exact auction and ad unit', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + const bid = Object.freeze({ cpm: 1 }); + const register = (id: string, auctionId: string, adUnitCode: string) => + service.registerPrebidLease({ + reservationId: id, + slot: adUnitCode, + navigation, + auctionId, + adUnitCode, + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + register(reservationId(1), 'selected-auction', 'selected-slot'); + register(reservationId(2), 'selected-auction', 'selected-slot'); + register(reservationId(3), 'other-auction', 'selected-slot'); + register(reservationId(4), 'selected-auction', 'other-slot'); + const attempt = renderAttempt(navigation, 'selected-slot'); + + expect( + service.promotePrebidSelection({ + reservationId: reservationId(1), + auctionId: 'selected-auction', + adUnitCode: 'selected-slot', + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }) + ).toMatchObject({ ok: true }); + expect(service.recognize(reservationId(2))).toMatchObject({ state: 'unselected' }); + expect(service.recognize(reservationId(3))).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + expect(service.recognize(reservationId(4))).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + }); + + it('does not tombstone a same-string loser owned by another navigation generation', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + const bid = Object.freeze({ cpm: 1 }); + const selected = renderAttempt(navigation, 'fictional-slot'); + service.registerPrebidLease({ + reservationId: reservationId(1), + slot: 'fictional-slot', + navigation, + auctionId: 'reused-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + const otherGeneration = Object.freeze({}); + service.registerPrebidLease({ + reservationId: reservationId(2), + slot: 'fictional-slot', + navigation: { + generation: otherGeneration, + isCurrent: () => true, + onDispose: vi.fn(), + }, + auctionId: 'reused-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + + expect( + service.promotePrebidSelection({ + reservationId: reservationId(1), + auctionId: 'reused-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + attempt: selected, + prebidBid: bid, + }) + ).toMatchObject({ ok: true }); + expect(service.recognize(reservationId(2))).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + }); + + it('promotes and tombstones losers atomically under poisoned Array prototypes', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + const bid = Object.freeze({ cpm: 1 }); + for (const id of [reservationId(1), reservationId(2)]) { + service.registerPrebidLease({ + reservationId: id, + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + } + const attempt = renderAttempt(navigation); + const originalPush = Array.prototype.push; + const originalIterator = Array.prototype[Symbol.iterator]; + let result: ReturnType | undefined; + Array.prototype.push = function poisonedPush() { + throw new Error('poisoned push'); + }; + Array.prototype[Symbol.iterator] = function poisonedIterator() { + throw new Error('poisoned iterator'); + }; + try { + result = service.promotePrebidSelection({ + reservationId: reservationId(1), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }); + } finally { + Array.prototype.push = originalPush; + Array.prototype[Symbol.iterator] = originalIterator; + } + expect(result).toMatchObject({ ok: true }); + expect(service.recognize(reservationId(2))).toMatchObject({ state: 'unselected' }); + }); + + it.each(['aborted', 'prebid_selection_timeout', 'unselected'] as const)( + 'tombstones %s leases only through their original admission expiry', + (reason) => { + let now = 25; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 0 }, + prebidBid: Object.freeze({ cpm: 0 }), + }); + now = 50; + + expect( + service.tombstonePrebidGroup( + { + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }, + reason + ) + ).toBe(1); + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: reason, + expiresAt: 25 + PREBID_ADMISSION_LEASE_MS, + }); + } + ); + + it('makes a stale navigation Prebid group tombstone callback inert', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'reused-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: Object.freeze({ cpm: 1 }), + }); + + expect( + service.tombstonePrebidGroup( + { + auctionId: 'reused-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: Object.freeze({}), + }, + 'aborted' + ) + ).toBe(0); + expect(service.recognize(reservationId())).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + expect( + service.tombstonePrebidGroup( + { + auctionId: 'reused-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }, + 'aborted' + ) + ).toBe(1); + }); + + it('suppresses and contract-failure tombstones a PUC claim against a preselection lease', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: attempt.slot, + navigation, + auctionId: 'fictional-auction', + adUnitCode: attempt.slot, + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: Object.freeze({ cpm: 1 }), + }); + + expect(claim(service, navigation, attempt)).toEqual({ + recognized: true, + claimed: false, + state: 'prebid_contract_violation', + }); + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: 'prebid_contract_violation', + expiresAt: PREBID_ADMISSION_LEASE_MS, + }); + expect(attempt.winnerContext).toBeUndefined(); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); + + it.each(['prebid_admission_failed', 'prebid_contract_violation'] as const)( + 'tombstones exact-owner %s admission failure through the original lease', + (state) => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: Object.freeze({ cpm: 1 }), + }); + const exact = { + reservationId: reservationId(), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }; + + expect( + service.tombstonePrebidLease({ ...exact, navigationGeneration: Object.freeze({}) }, state) + ).toBe(false); + expect(service.tombstonePrebidLease(exact, state)).toBe(true); + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state, + expiresAt: PREBID_ADMISSION_LEASE_MS, + }); + } + ); +}); + +describe('atomic claims and disposal', () => { + it('does not acquire, transfer, or consume for a mismatched slot, generation, attempt, or stale owner', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + const source = Object.freeze({}); + const cases = [ + { slot: 'other-slot', generation: navigation.generation, attempted: attempt }, + { slot: attempt.slot, generation: Object.freeze({}), attempted: attempt }, + { + slot: attempt.slot, + generation: navigation.generation, + attempted: { ...attempt, id: `${attempt.id}-other` }, + }, + { + slot: attempt.slot, + generation: navigation.generation, + attempted: { ...attempt, isCurrent: () => false }, + }, + ]; + + for (const [index, candidate] of cases.entries()) { + const id = reservationId(index + 10); + registerRender(service, navigation, attempt, id); + expect( + service.claim({ + reservationId: id, + slot: candidate.slot, + navigationGeneration: candidate.generation, + attempt: candidate.attempted, + pucSource: source, + }) + ).toEqual({ recognized: true, claimed: false, state: 'renderable' }); + expect(service.recognize(id)).toMatchObject({ state: 'renderable' }); + } + expect(attempt.winnerContext).toBeUndefined(); + expect(service.snapshotInventoryForTest().entriesWithPucSource).toBe(0); + }); + it('preserves one ADM source and immutable context after registration input mutation', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + const source = admSource(); + const context = { selectedCpm: 7.5 }; + service.registerRender({ + reservationId: reservationId(), + slot: attempt.slot, + navigation, + attemptId: attempt.id, + renderSource: source, + winnerContext: context, + }); + context.selectedCpm = 99; + const observedStates: string[] = []; + const sink = { + id: attempt.id, + slot: attempt.slot, + get winnerContext(): WinnerContext | undefined { + return attempt.winnerContext; + }, + isCurrent: () => attempt.isCurrent(), + prepareWinnerContext(winnerContext: WinnerContext) { + const recognition = service.recognize(reservationId()); + if (recognition.recognized) observedStates.push(recognition.state); + return attempt.prepareWinnerContext(winnerContext); + }, + }; + + const result = service.claim({ + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt: sink, + pucSource: Object.freeze({}), + }); + + expect(observedStates).toEqual(['renderable']); + expect(result).toMatchObject({ recognized: true, claimed: true }); + expect(attempt.winnerContext).toEqual({ selectedCpm: 7.5 }); + expect(Object.isFrozen(attempt.winnerContext)).toBe(true); + expect(service.recognize(reservationId())).toMatchObject({ state: 'consumed' }); + const winnerContext = attempt.winnerContext; + if (!result.recognized || !result.claimed || !winnerContext) { + throw new Error('Expected one claimed ADM winner'); + } + expect( + service.consumeClaim(result, { + attempt: sink, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext, + }) + ).toEqual({ renderSource: source, winnerContext }); + }); + + it('allows exactly one of two simultaneous/reentrant claims and never replaces its PUC source', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt); + const firstSource = Object.freeze({ name: 'first' }); + const secondSource = Object.freeze({ name: 'second' }); + let nested: ReturnType | undefined; + let acceptedContext: WinnerContext | undefined; + const sink = { + id: attempt.id, + slot: attempt.slot, + get winnerContext(): WinnerContext | undefined { + return acceptedContext; + }, + isCurrent: () => true, + prepareWinnerContext(context: WinnerContext) { + return { + commit(): boolean { + nested = service.claim({ + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt: sink, + pucSource: secondSource, + }); + acceptedContext = context; + return true; + }, + rollback(): boolean { + if (acceptedContext === context) acceptedContext = undefined; + return true; + }, + }; + }, + }; + + const first = service.claim({ + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt: sink, + pucSource: firstSource, + }); + + expect(first).toMatchObject({ recognized: true, claimed: true, pucSource: firstSource }); + expect(nested).toEqual({ recognized: true, claimed: false, state: 'renderable' }); + expect(claim(service, navigation, attempt, reservationId(), secondSource)).toEqual({ + recognized: true, + claimed: false, + state: 'consumed', + }); + }); + + it('terminally suppresses a throwing context preparation without retaining PUC source', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt); + const throwingSink = { + id: attempt.id, + slot: attempt.slot, + winnerContext: undefined, + isCurrent: () => true, + prepareWinnerContext() { + throw new Error('partial transfer failed'); + }, + }; + + expect( + service.claim({ + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt: throwingSink, + pucSource: Object.freeze({}), + }) + ).toEqual({ recognized: true, claimed: false, state: 'stale' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithPucSource: 0, + }); + }); + + it('terminally suppresses a claim when winner admission mutates, reenters, and throws', () => { + const { navigation } = runtimeNavigation(); + const realAttempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, realAttempt); + const firstSource = Object.freeze({ name: 'first' }); + const secondSource = Object.freeze({ name: 'second' }); + let accepted: WinnerContext | undefined; + let nested: ReturnType | undefined; + const attempt = { + id: realAttempt.id, + slot: realAttempt.slot, + get winnerContext(): WinnerContext | undefined { + return accepted; + }, + isCurrent: () => true, + prepareWinnerContext(context: WinnerContext) { + return { + commit(): boolean { + accepted = context; + nested = service.claim({ + reservationId: reservationId(), + slot: realAttempt.slot, + navigationGeneration: navigation.generation, + attempt, + pucSource: secondSource, + }); + throw new Error('commit failed after mutation'); + }, + rollback(): boolean { + if (accepted === context) accepted = undefined; + return true; + }, + }; + }, + }; + + expect( + service.claim({ + reservationId: reservationId(), + slot: realAttempt.slot, + navigationGeneration: navigation.generation, + attempt, + pucSource: firstSource, + }) + ).toEqual({ recognized: true, claimed: false, state: 'stale' }); + expect(nested).toEqual({ recognized: true, claimed: false, state: 'renderable' }); + expect(accepted).toBeUndefined(); + expect(claim(service, navigation, realAttempt, reservationId(), secondSource)).toEqual({ + recognized: true, + claimed: false, + state: 'stale', + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithPucSource: 0, + }); + }); + + it('terminally suppresses a Prebid promotion when winner admission has unknown postcondition', () => { + const { navigation } = runtimeNavigation(); + const realAttempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + const bid = Object.freeze({ cpm: 1 }); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: realAttempt.slot, + navigation, + auctionId: 'fictional-auction', + adUnitCode: realAttempt.slot, + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + let accepted: WinnerContext | undefined; + const attempt = { + id: realAttempt.id, + slot: realAttempt.slot, + get winnerContext(): WinnerContext | undefined { + throw new Error('winner context postcondition unavailable'); + }, + isCurrent: () => true, + prepareWinnerContext(context: WinnerContext) { + return { + commit(): boolean { + accepted = context; + return true; + }, + rollback(): boolean { + if (accepted === context) accepted = undefined; + return true; + }, + }; + }, + }; + + expect( + service.promotePrebidSelection({ + reservationId: reservationId(), + auctionId: 'fictional-auction', + adUnitCode: realAttempt.slot, + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(accepted).toBeUndefined(); + expect(service.recognize(reservationId())).toMatchObject({ state: 'stale' }); + expect( + service.promotePrebidSelection({ + reservationId: reservationId(), + auctionId: 'fictional-auction', + adUnitCode: realAttempt.slot, + navigationGeneration: navigation.generation, + attempt: realAttempt, + prebidBid: bid, + }) + ).toEqual({ ok: false, reason: 'reservation_not_live' }); + }); + + it('uses captured freezing during a claim without retaining busy claim state', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt); + const pucSource = Object.freeze({}); + const originalFreeze = Object.freeze; + let result: ReturnType | undefined; + let thrown: unknown; + + Object.freeze = function poisonedFreeze() { + throw new Error('poisoned Object.freeze'); + }; + try { + result = claim(service, navigation, attempt, reservationId(), pucSource); + } catch (error) { + thrown = error; + } finally { + Object.freeze = originalFreeze; + } + + expect(thrown).toBeUndefined(); + expect(result).toMatchObject({ recognized: true, claimed: true }); + expect(service.recognize(reservationId())).toMatchObject({ state: 'consumed' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithPucSource: 0, + }); + }); + + it('retains only a disposed suppression tombstone when owner publication rolls back', () => { + const service = serviceAt(() => 0); + const generation = Object.freeze({}); + const owner: ReservationOwner = { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + callback(); + throw new Error('publication failed after disposal'); + }, + }; + + expect( + service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: owner, + attemptId: 'a1_0000000000000000000000', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + expect(service.recognize(reservationId())).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); + + it('disposes the whole runtime store without making old identities reusable in that service', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt); + + service.dispose(); + + expect(service.snapshotInventoryForTest()).toMatchObject({ disposed: true, size: 0 }); + expect(registerRender(service, navigation, attempt)).toEqual({ + ok: false, + reason: 'service_disposed', + }); + expect(service.recognize(reservationId())).toEqual({ recognized: false }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts new file mode 100644 index 000000000..3592932b4 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -0,0 +1,4982 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createBrowserGoogletagAdapter, + GoogletagReplacementError, + type GoogletagAdapter, + type GoogletagFacade, + type GoogletagPublisherCallAdmission, + type GoogletagReplacementCommitAdmission, + type GoogletagReplacementDefinition, + type GptSlotTokenV1, +} from '../../src/adapters/googletag'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { createRuntimeSession, type NavigationSession } from '../../src/kernel/sessions'; +import { + MAX_ACTIVE_SLOT_RECORDS, + createBrowserSlotReconciliationBoundary, + createSlotService, + type GptSlotBinding, + type SlotReconciliationBoundary, + type SlotRegistration, + type SlotService, +} from '../../src/services/slots'; +import { + bindCommittedArtifactRetirement, + createCommittedArtifactStore, + type CommittedRenderArtifact, +} from '../../src/services/render'; + +function createNavigation(): NavigationSession { + return createRuntimeWithNavigation().navigation; +} + +function committedArtifact(navigationGeneration: object): CommittedRenderArtifact { + return Object.freeze({ + attemptId: `a1_${'c'.repeat(22)}`, + dispose: vi.fn(), + kind: 'aps_mount' as const, + navigationGeneration, + slot: 'slot', + }); +} + +function createRuntimeWithNavigation() { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(1); + return target; + }, + }), + }); + const result = runtime.startInitialNavigation(); + if (!result.ok) throw new Error('Expected a navigation'); + return { navigation: result.value, runtime }; +} + +function createGptHarness( + options: { + initialLoadDisabled?: boolean; + deferDestroyedResult?: boolean; + missingRefresh?: boolean; + orphanOnReplace?: object; + returnOldOnReplace?: boolean; + synchronousRun?: boolean; + servicesEnabled?: boolean; + } = {} +) { + const listeners = new Map void>>(); + const slots: object[] = []; + const display = vi.fn(); + const refresh = vi.fn(); + const destroySlots = vi.fn((_slots: readonly object[]) => true); + const defineSlot = vi.fn( + (_path: string, _sizes: unknown, elementId: string): object | undefined => { + const slot = { elementId, replacement: true }; + slots.push(slot); + return slot; + } + ); + const addService = vi.fn(); + const operationDisposals: Array> = []; + const bindingToken = Object.freeze({}); + const traceTokens = new WeakMap(); + let nextTraceToken = 1; + let deferredDestroyedResolved = false; + let resolveDeferredDestroyedPromise!: () => void; + const deferredDestroyedPromise = new Promise((resolve) => { + resolveDeferredDestroyedPromise = resolve; + }); + let deferredDestroyedUsed = false; + const facade: GoogletagFacade = Object.freeze({ + bindingToken: () => bindingToken, + clearTargeting: vi.fn(), + enableServices: vi.fn(), + transactionalDefine: () => Object.freeze({ status: 'discarded' as const }), + display, + getTargeting: vi.fn(() => []), + observeTargeting: () => Object.assign(vi.fn(), { isCurrent: () => true }), + refresh: options.missingRefresh + ? (undefined as unknown as GoogletagFacade['refresh']) + : refresh, + serviceState: () => + Object.freeze({ + apiReady: true, + initialLoadDisabled: options.initialLoadDisabled === true, + pubadsReady: options.servicesEnabled !== false, + }), + setTargeting: vi.fn(), + slotElementId: () => undefined, + slots: () => Object.freeze([...slots]), + subscribe: (eventType: string, listener: (event: unknown) => void) => { + const registered = listeners.get(eventType) ?? new Set(); + registered.add(listener); + listeners.set(eventType, registered); + return () => registered.delete(listener); + }, + transactionalReplace: ( + oldSlot: object, + definition: GoogletagReplacementDefinition | undefined, + isCurrent: () => boolean, + prepareCommit: (replacement: object) => GoogletagReplacementCommitAdmission + ) => { + if (!destroySlots([oldSlot])) throw new Error('gpt_request_failed'); + if (!definition || !isCurrent()) return Object.freeze({ status: 'destroyed' as const }); + const replacement = options.returnOldOnReplace + ? oldSlot + : defineSlot(definition.adUnitPath, definition.sizes, definition.elementId); + if (!replacement) throw new GoogletagReplacementError(undefined, true); + if (replacement === oldSlot) { + if (!destroySlots([replacement])) { + throw new GoogletagReplacementError(replacement, true); + } + throw new GoogletagReplacementError(undefined, true); + } + if (!isCurrent()) { + destroySlots([replacement]); + return Object.freeze({ status: 'destroyed' as const }); + } + addService(replacement); + if (options.orphanOnReplace) { + throw new GoogletagReplacementError(options.orphanOnReplace, true); + } + if (!isCurrent()) { + destroySlots([replacement]); + return Object.freeze({ status: 'destroyed' as const }); + } + const admission = prepareCommit(replacement); + if (!admission.commit()) { + admission.rollback(); + destroySlots([replacement]); + throw new Error('gpt_request_failed'); + } + if (!isCurrent()) { + admission.rollback(); + destroySlots([replacement]); + return Object.freeze({ status: 'destroyed' as const }); + } + return Object.freeze({ status: 'replaced' as const, slot: replacement }); + }, + }); + const adapter: GoogletagAdapter = Object.freeze({ + bindingStatus: () => 'present', + enqueueGamAttribution: vi.fn(() => true), + diagnosticsIdentity: () => undefined, + dispose: vi.fn(), + notifyReady: vi.fn(), + observeDiagnostics: () => vi.fn(), + observePublisherCalls: () => vi.fn(), + traceToken: (slot: object) => { + let token = traceTokens.get(slot); + if (!token) { + token = `gt1_${nextTraceToken.toString(36)}` as GptSlotTokenV1; + nextTraceToken += 1; + traceTokens.set(slot, token); + } + return token; + }, + run: (command: (gpt: Readonly) => T) => { + let disposed = false; + const dispose = vi.fn(() => { + disposed = true; + }); + operationDisposals.push(dispose); + let result: Promise; + if (options.synchronousRun !== false) { + try { + const value = command(facade); + const deferResult = + options.deferDestroyedResult === true && + !deferredDestroyedUsed && + typeof value === 'object' && + value !== null && + 'status' in value && + value.status === 'destroyed'; + if (deferResult) { + deferredDestroyedUsed = true; + result = deferredDestroyedPromise.then(() => value); + } else { + result = Promise.resolve(value); + } + } catch (error) { + result = Promise.reject(error); + } + } else { + result = Promise.resolve().then(() => { + if (disposed) throw new Error('disposed'); + return command(facade); + }); + } + return Object.freeze({ + status: 'present' as const, + result, + dispose, + }); + }, + }); + return { + adapter, + addService, + defineSlot, + destroySlots, + display, + emit: (type: string, event: unknown) => { + for (const listener of listeners.get(type) ?? []) listener(event); + }, + facade, + operationDisposals, + refresh, + resolveDeferredDestroyed: () => { + if (deferredDestroyedResolved) return; + deferredDestroyedResolved = true; + resolveDeferredDestroyedPromise(); + }, + }; +} + +function serverRegistration( + id: string, + overrides: Partial = {} +): SlotRegistration { + return { + registeredSlotId: id, + source: 'server', + ...overrides, + }; +} + +function bindTrustedSlot(service: SlotService, navigation: NavigationSession, id = 'slot') { + const slot = { id }; + expect( + service.register(navigation, [ + serverRegistration(id, { + adUnitCode: `/network/${id}`, + domAliases: [`${id}-div`], + }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, id, { + definition: { + adUnitPath: `/network/${id}`, + elementId: `${id}-div`, + sizes: Object.freeze([[300, 250]]), + }, + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + return slot; +} + +function createReconciliationBoundary() { + let listener: (() => void) | undefined; + const connected = new WeakSet(); + const elements = new Map(); + const observe = vi.fn((callback: () => void) => { + listener = callback; + return vi.fn(() => { + if (listener === callback) listener = undefined; + }); + }); + const boundary: SlotReconciliationBoundary = Object.freeze({ + observe, + isConnected: (element: object) => connected.has(element), + resolve: (elementIds: readonly string[]) => { + const matches = new Set(); + let matchedId: string | undefined; + for (const elementId of elementIds) { + for (const element of elements.get(elementId) ?? []) { + if (!connected.has(element)) continue; + matches.add(element); + matchedId = elementId; + } + } + if (matches.size === 0) return Object.freeze({ status: 'unresolved' as const }); + if (matches.size !== 1 || matchedId === undefined) { + return Object.freeze({ status: 'ambiguous' as const }); + } + return Object.freeze({ + status: 'unique' as const, + element: [...matches][0]!, + elementId: matchedId, + }); + }, + }); + const put = (elementId: string, element: object): void => { + connected.add(element); + elements.set(elementId, [element]); + }; + const replace = (elementId: string, element: object): void => { + const previous = elements.get(elementId) ?? []; + for (const candidate of previous) connected.delete(candidate); + put(elementId, element); + listener?.(); + }; + const replaceAmbiguously = (elementId: string, replacements: readonly object[]): void => { + const previous = elements.get(elementId) ?? []; + for (const candidate of previous) connected.delete(candidate); + for (const replacement of replacements) connected.add(replacement); + elements.set(elementId, [...replacements]); + listener?.(); + }; + const disconnect = (elementId: string): void => { + for (const candidate of elements.get(elementId) ?? []) connected.delete(candidate); + elements.delete(elementId); + listener?.(); + }; + return { + boundary, + disconnect, + observe, + put, + replace, + replaceAmbiguously, + trigger: () => listener?.(), + }; +} + +describe('slot registry', () => { + afterEach(() => vi.useRealTimers()); + + it('copies the adapter-owned canonical token into the adopted SlotRecord', () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const physical = {}; + expect(service.register(navigation, [serverRegistration('token-slot')])).toMatchObject({ + ok: true, + }); + + expect( + service.adoptGptSlot(navigation.generation, 'token-slot', { + ownership: 'publisher', + slot: physical, + }) + ).toEqual({ ok: true }); + const record = service.resolveRegisteredSlot('token-slot'); + expect(record?.traceToken).toBe('gt1_1'); + expect(Object.isFrozen(record)).toBe(true); + expect(harness.adapter.traceToken(physical)).toBe(record?.traceToken); + }); + + it('accepts exact nonempty 256-byte ids and rejects empty, 257-byte, and ASCII controls', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const valid = `${'a'.repeat(254)}é`; + + expect(new TextEncoder().encode(valid)).toHaveLength(256); + expect(service.register(navigation, [serverRegistration(valid)])).toMatchObject({ ok: true }); + + for (const invalid of [ + '', + 'a'.repeat(257), + 'nul\0id', + 'line\nid', + `del${String.fromCharCode(0x7f)}id`, + ]) { + expect(service.register(navigation, [serverRegistration(invalid)])).toEqual({ + ok: false, + reason: 'invalid_slot_id', + }); + } + + expect( + service.register(navigation, [serverRegistration(`c1${String.fromCharCode(0x85)}id`)]) + ).toMatchObject({ ok: true }); + }); + + it('reserves the combined 256-record capacity atomically', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const first = Array.from({ length: 255 }, (_, index) => serverRegistration(`server-${index}`)); + + expect(service.register(navigation, first)).toMatchObject({ ok: true }); + expect( + service.register(navigation, [ + { registeredSlotId: 'programmatic-256', source: 'programmatic' }, + ]) + ).toMatchObject({ ok: true }); + expect(service.snapshotForTest().records).toBe(MAX_ACTIVE_SLOT_RECORDS); + expect( + service.register(navigation, [ + { registeredSlotId: 'programmatic-257', source: 'programmatic' }, + ]) + ).toEqual({ ok: false, reason: 'registry_capacity' }); + expect(service.resolveRegisteredSlot('programmatic-257')).toBeUndefined(); + expect(service.snapshotForTest().records).toBe(MAX_ACTIVE_SLOT_RECORDS); + }); + + it('snapshots navigation-local registration order with detached programmatic auction units', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const directAuctionUnit = Object.freeze({ code: 'programmatic' }); + + expect( + service.register(navigation, [ + serverRegistration('server'), + { + directAuctionUnit, + registeredSlotId: 'programmatic', + source: 'programmatic', + }, + ]) + ).toMatchObject({ ok: true }); + expect(service.snapshotRegisteredSlots(navigation)).toEqual([ + expect.objectContaining({ ordinal: 0, registeredSlotId: 'server', source: 'server' }), + expect.objectContaining({ + directAuctionUnit, + ordinal: 1, + registeredSlotId: 'programmatic', + source: 'programmatic', + }), + ]); + expect(Object.isFrozen(service.snapshotRegisteredSlots(navigation))).toBe(true); + + expect(service.adoptRegistrationHighWater(navigation.generation, 9)).toBe(true); + expect(service.adoptRegistrationHighWater(navigation.generation, 10)).toBe(false); + expect( + service.register(navigation, [{ registeredSlotId: 'after-handoff', source: 'programmatic' }]) + ).toMatchObject({ + ok: true, + records: [expect.objectContaining({ ordinal: 9, registeredSlotId: 'after-handoff' })], + }); + + expect( + service.register(navigation, [ + { + directAuctionUnit: { code: 'unfrozen' }, + registeredSlotId: 'unfrozen', + source: 'programmatic', + }, + ]) + ).toEqual({ ok: false, reason: 'invalid_slot_id' }); + + runtime.dispose(); + expect(service.snapshotRegisteredSlots(navigation)).toBeUndefined(); + }); + + it('rejects exact registered-id collisions without partial indexes', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + expect(service.register(navigation, [serverRegistration('existing')])).toMatchObject({ + ok: true, + }); + + expect( + service.register(navigation, [ + serverRegistration('fresh', { domAliases: ['fresh-div'] }), + serverRegistration('existing', { domAliases: ['leaked-div'] }), + ]) + ).toEqual({ ok: false, reason: 'duplicate_slot' }); + expect(service.resolveRegisteredSlot('fresh')).toBeUndefined(); + expect(service.resolveDomAlias('fresh-div')).toBeUndefined(); + }); + + it('resolves only unique ad-unit codes and DOM aliases without normalizing or choosing first', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + expect( + service.register(navigation, [ + serverRegistration('Exact-Slot', { adUnitCode: '/same', domAliases: ['same-div'] }), + serverRegistration('other', { adUnitCode: '/same', domAliases: ['same-div'] }), + ]) + ).toMatchObject({ ok: true }); + + expect(service.resolveRegisteredSlot('Exact-Slot')?.registeredSlotId).toBe('Exact-Slot'); + expect(service.resolveRegisteredSlot('exact-slot')).toBeUndefined(); + expect(service.resolveAdUnitCode('/same')).toBeUndefined(); + expect(service.resolveDomAlias('same-div')).toBeUndefined(); + }); + + it('binds one GPT object identity to at most one record and releases navigation records', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const shared = {}; + expect( + service.register(navigation, [serverRegistration('one'), serverRegistration('two')]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'one', { + ownership: 'publisher', + slot: shared, + }) + ).toEqual({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'two', { + ownership: 'publisher', + slot: shared, + }) + ).toEqual({ ok: false, reason: 'gpt_object_collision' }); + + navigation.dispose(); + expect(service.snapshotForTest().records).toBe(0); + expect(service.resolveRegisteredSlot('one')).toBeUndefined(); + }); + + it('latches a publication request to the exact bound GPT identity', async () => { + const gpt = createGptHarness(); + const service = createSlotService({ googletag: gpt.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + + const stale = service.request({ + expectedSlot: {}, + intentId: 'stale-publication', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(stale.result).resolves.toEqual({ status: 'failed', reason: 'slot_unresolved' }); + expect(gpt.display).not.toHaveBeenCalled(); + + const current = service.request({ + expectedSlot: slot, + intentId: 'current-publication', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await Promise.resolve(); + expect(current.status).toBe('active'); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(slot); + }); + + it('publishes the exact TS request intent before GPT starts and isolates observer failure', async () => { + const gpt = createGptHarness(); + const observed = vi.fn((input: Readonly<{ registeredSlotId: string; slot: object }>) => { + expect(input).toEqual({ registeredSlotId: 'slot', slot }); + expect(Object.isFrozen(input)).toBe(true); + expect(gpt.display).not.toHaveBeenCalled(); + throw new Error('fictional diagnostics failure'); + }); + const service = createSlotService({ + googletag: gpt.adapter, + onTrustedServerRequest: observed, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + + const request = service.request({ + expectedSlot: slot, + intentId: 'diagnostic-publication', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(observed).toHaveBeenCalledExactlyOnceWith({ registeredSlotId: 'slot', slot }); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(slot); + expect(request.status).toBe('active'); + }); + + it('does not publish a request fact when disabled-load display is claimed synchronously', async () => { + const gpt = createGptHarness({ initialLoadDisabled: true }); + const observed = vi.fn(); + const service = createSlotService({ + googletag: gpt.adapter, + onTrustedServerRequest: observed, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + gpt.display.mockImplementationOnce(() => gpt.emit('slotRequested', { slot })); + + const request = service.request({ + expectedSlot: slot, + intentId: 'synchronous-publisher-claim', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'cycle_unattributable', + }); + + expect(observed).not.toHaveBeenCalled(); + expect(gpt.refresh).not.toHaveBeenCalled(); + }); + + it('enables GPT services at the shared request boundary before display', async () => { + const gpt = createGptHarness({ servicesEnabled: false }); + const service = createSlotService({ googletag: gpt.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + + const request = service.request({ + expectedSlot: slot, + intentId: 'enable-services-before-display', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(gpt.facade.enableServices).toHaveBeenCalledOnce(); + expect(vi.mocked(gpt.facade.enableServices).mock.invocationCallOrder[0]).toBeLessThan( + gpt.display.mock.invocationCallOrder[0]! + ); + expect(request.status).toBe('active'); + }); + + it('recognizes the exact live GPT binding regardless of who defined the slot', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const trustedSlot = bindTrustedSlot(service, navigation, 'trusted'); + + expect(service.isBoundGptSlot(navigation.generation, 'trusted', trustedSlot)).toBe(true); + expect(service.isBoundGptSlot(navigation.generation, 'other', trustedSlot)).toBe(false); + expect(service.isBoundGptSlot({}, 'trusted', trustedSlot)).toBe(false); + expect(service.isBoundGptSlot(navigation.generation, 'trusted', {})).toBe(false); + + const publisherSlot = {}; + expect(service.register(navigation, [serverRegistration('publisher')])).toMatchObject({ + ok: true, + }); + expect( + service.adoptGptSlot(navigation.generation, 'publisher', { + ownership: 'publisher', + slot: publisherSlot, + }) + ).toEqual({ ok: true }); + expect(service.isBoundGptSlot(navigation.generation, 'publisher', publisherSlot)).toBe(true); + + runtime.dispose(); + expect(service.isBoundGptSlot(navigation.generation, 'trusted', trustedSlot)).toBe(false); + }); + + it('hands an exact late publisher definition the TS slot and consumes only duplicate requests', async () => { + const gpt = createGptHarness({ initialLoadDisabled: true }); + const warnPublisherHandoffMismatch = vi.fn(() => { + throw new Error('fictional local logger failure'); + }); + const service = createSlotService({ + googletag: gpt.adapter, + warnPublisherHandoffMismatch, + }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const slot = bindTrustedSlot(service, navigation); + + expect( + service.claimPublisherGptSlot({ + adUnitPath: '/publisher/mismatch', + elementId: 'slot-div', + initialLoadDisabled: true, + sizes: Object.freeze([[728, 90]]), + }) + ).toEqual({ action: 'handoff', slot }); + expect(warnPublisherHandoffMismatch).toHaveBeenCalledExactlyOnceWith( + 'GPT publisher handoff metadata mismatch', + Object.freeze({ formatsMismatch: true, pathMismatch: true }) + ); + expect(JSON.stringify(warnPublisherHandoffMismatch.mock.calls[0]).length).toBeLessThanOrEqual( + 128 + ); + expect( + service.preparePublisherDisplay({ initialLoadDisabled: true, target: 'slot-div' }) + ).toEqual({ action: 'suppress' }); + expect( + service.preparePublisherDisplay({ initialLoadDisabled: true, target: 'slot-div' }) + ).toEqual({ action: 'forward' }); + + const unrelated = {}; + expect( + service.preparePublisherRefresh({ + requestedSlots: undefined, + slots: Object.freeze([slot, unrelated]), + }) + ).toEqual({ action: 'replace', slots: [unrelated] }); + const forwardedRefresh = service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }); + expect(forwardedRefresh.action).toBe('forward'); + if (forwardedRefresh.action === 'forward') { + expect(forwardedRefresh.admission).toBeDefined(); + forwardedRefresh.admission?.commit(); + } + + const request = service.request({ + intentId: 'after-publisher-refresh', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'cycle_unattributable', + }); + + runtime.dispose(); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + }); + + it('does not warn when an exact publisher handoff matches path and formats', () => { + const warnPublisherHandoffMismatch = vi.fn(); + const service = createSlotService({ + googletag: createGptHarness().adapter, + warnPublisherHandoffMismatch, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + + expect( + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: false, + sizes: Object.freeze([[300, 250]]), + }) + ).toEqual({ action: 'handoff', slot }); + expect(warnPublisherHandoffMismatch).not.toHaveBeenCalled(); + }); + + it('rolls back a pending publisher display without settling active or queued TS work', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + expect( + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: false, + sizes: [300, 250], + }) + ).toEqual({ action: 'handoff', slot }); + expect( + service.preparePublisherDisplay({ initialLoadDisabled: false, target: 'slot-div' }) + ).toEqual({ action: 'suppress' }); + const active = service.request({ + intentId: 'active-before-publisher-display', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + const queued = service.request({ + intentId: 'queued-before-publisher-display', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + expect(active.status).toBe('active'); + expect(queued.status).toBe('queued'); + + const decision = service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: 'slot-div', + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + expect(decision.action).toBe('forward'); + expect(decision.admission).toBeDefined(); + expect(active.status).toBe('active'); + expect(queued.status).toBe('queued'); + + decision.admission?.rollback(); + decision.admission?.rollback(); + + expect(active.status).toBe('active'); + expect(queued.status).toBe('queued'); + service.dispose(); + await expect(active.result).resolves.toMatchObject({ status: 'cancelled' }); + await expect(queued.result).resolves.toMatchObject({ status: 'cancelled' }); + }); + + it('keeps a publisher cycle consumed before display rollback and makes later rollback inert', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: false, + sizes: [300, 250], + }); + service.preparePublisherDisplay({ initialLoadDisabled: false, target: 'slot-div' }); + const decision = service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: 'slot-div', + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + expect(decision.admission).toBeDefined(); + + service.handleGptEvent('slotRequested', { slot }); + expect(service.snapshotForTest().cycles).toBe(1); + decision.admission?.rollback(); + decision.admission?.commit(); + expect(service.snapshotForTest().cycles).toBe(1); + + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + expect(service.snapshotForTest().cycles).toBe(0); + }); + + it('rolls back repeated display plus explicit and global refresh admissions without residue', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'first'); + const second = bindTrustedSlot(service, navigation, 'second'); + for (const registeredSlotId of ['first', 'second'] as const) { + service.claimPublisherGptSlot({ + adUnitPath: `/network/${registeredSlotId}`, + elementId: `${registeredSlotId}-div`, + initialLoadDisabled: false, + sizes: [300, 250], + }); + service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: `${registeredSlotId}-div`, + }); + } + + for (let attempt = 0; attempt < 70; attempt += 1) { + const display = service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: 'first-div', + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + expect(display.admission).toBeDefined(); + display.admission?.rollback(); + } + const explicit = service.preparePublisherRefresh({ + requestedSlots: Object.freeze([first]), + slots: Object.freeze([first]), + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + const global = service.preparePublisherRefresh({ + requestedSlots: undefined, + slots: Object.freeze([first, second]), + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + expect(explicit.admission).toBeDefined(); + expect(global.admission).toBeDefined(); + explicit.admission?.rollback(); + global.admission?.rollback(); + + const firstRequest = service.request({ + intentId: 'after-rolled-back-explicit-refresh', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first', + requestClass: 'primary', + }); + const secondRequest = service.request({ + intentId: 'after-rolled-back-global-refresh', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'second', + requestClass: 'primary', + }); + expect(firstRequest.status).toBe('active'); + expect(secondRequest.status).toBe('active'); + }); + + it('commits a global refresh only for the publisher physicals snapshotted before native entry', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'first'); + bindTrustedSlot(service, navigation, 'second'); + service.claimPublisherGptSlot({ + adUnitPath: '/network/first', + elementId: 'first-div', + initialLoadDisabled: false, + sizes: [300, 250], + }); + const global = service.preparePublisherRefresh({ + requestedSlots: undefined, + slots: Object.freeze([first]), + }); + expect(global.action).toBe('forward'); + if (global.action !== 'forward') throw new Error('Expected global refresh forwarding'); + expect(global.admission).toBeDefined(); + + service.claimPublisherGptSlot({ + adUnitPath: '/network/second', + elementId: 'second-div', + initialLoadDisabled: false, + sizes: [300, 250], + }); + global.admission?.commit(); + + const firstRequest = service.request({ + intentId: 'global-snapshot-first', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first', + requestClass: 'primary', + }); + const secondRequest = service.request({ + intentId: 'global-snapshot-second', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'second', + requestClass: 'primary', + }); + await expect(firstRequest.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + expect(secondRequest.status).toBe('active'); + }); + + it('makes a pending publisher admission inert after navigation and service disposal', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + bindTrustedSlot(service, navigation); + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: false, + sizes: [300, 250], + }); + service.preparePublisherDisplay({ initialLoadDisabled: false, target: 'slot-div' }); + const decision = service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: 'slot-div', + }); + expect(decision.action).toBe('forward'); + if (decision.action !== 'forward') throw new Error('Expected display forwarding'); + expect(decision.admission).toBeDefined(); + + runtime.dispose(); + expect(() => decision.admission?.commit()).not.toThrow(); + expect(() => decision.admission?.rollback()).not.toThrow(); + service.dispose(); + expect(() => decision.admission?.commit()).not.toThrow(); + expect(() => decision.admission?.rollback()).not.toThrow(); + }); + + it('hydrates only one disconnected TS fallback with the configured prefix, path, and sizes', () => { + const dom = createReconciliationBoundary(); + const firstElement = {}; + const secondElement = {}; + dom.put('slot-first', firstElement); + dom.put('slot-second', secondElement); + const warnPublisherHandoffMismatch = vi.fn(); + const service = createSlotService({ + googletag: createGptHarness().adapter, + reconciliation: dom.boundary, + warnPublisherHandoffMismatch, + }); + const navigation = createNavigation(); + expect( + service.register(navigation, [serverRegistration('first'), serverRegistration('second')]) + ).toMatchObject({ ok: true }); + const first = {}; + const second = {}; + for (const [id, slot] of [ + ['first', first], + ['second', second], + ] as const) { + expect( + service.adoptGptSlot(navigation.generation, id, { + definition: { + adUnitPath: '/network/hydrated', + elementId: `slot-${id}`, + sizes: Object.freeze([[300, 250]]), + }, + elementIdPrefix: 'slot-', + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + dom.disconnect(`slot-${id}`); + } + + const hydration = Object.freeze({ + adUnitPath: '/network/hydrated', + elementId: 'slot-hydrated', + initialLoadDisabled: false, + sizes: Object.freeze([300, 250]), + }); + expect(service.claimPublisherGptSlot(hydration)).toEqual({ action: 'forward' }); + expect(service.recordPublisherDestruction(second)).toBe(true); + expect( + service.claimPublisherGptSlot({ ...hydration, adUnitPath: '/network/mismatch' }) + ).toEqual({ action: 'forward' }); + expect(service.claimPublisherGptSlot({ ...hydration, sizes: [728, 90] })).toEqual({ + action: 'forward', + }); + expect(service.claimPublisherGptSlot(hydration)).toEqual({ action: 'handoff', slot: first }); + expect(warnPublisherHandoffMismatch).not.toHaveBeenCalled(); + }); + + it('suppresses the exact first explicit refresh after a disabled-load handoff', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + expect( + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: true, + sizes: [300, 250], + }) + ).toEqual({ action: 'handoff', slot }); + + expect( + service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }) + ).toEqual({ action: 'suppress' }); + const forwarded = service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }); + expect(forwarded.action).toBe('forward'); + if (forwarded.action === 'forward') { + expect(forwarded.admission).toBeDefined(); + forwarded.admission?.commit(); + } + }); + + it('uses captured Set validation intrinsics on a hostile page', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const originalHas = Set.prototype.has; + const originalAdd = Set.prototype.add; + Set.prototype.has = function (): boolean { + throw new Error('poisoned has'); + } as typeof Set.prototype.has; + Set.prototype.add = function (): Set { + throw new Error('poisoned add'); + } as typeof Set.prototype.add; + let result: ReturnType | undefined; + try { + result = service.register(navigation, [ + serverRegistration('captured', { domAliases: ['captured-div'] }), + ]); + } finally { + Set.prototype.has = originalHas; + Set.prototype.add = originalAdd; + } + expect(result).toMatchObject({ ok: true }); + expect(service.resolveDomAlias('captured-div')?.registeredSlotId).toBe('captured'); + }); + + it('rolls back GPT identity publication when ownership becomes stale during adoption', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + expect(service.register(navigation, [serverRegistration('old')])).toMatchObject({ ok: true }); + const slot = {}; + const racedBinding = Object.defineProperties( + {}, + { + definition: { value: undefined }, + ownership: { + get: () => { + runtime.replaceNavigation(); + return 'publisher'; + }, + }, + slot: { value: slot }, + } + ) as GptSlotBinding; + + expect(service.adoptGptSlot(navigation.generation, 'old', racedBinding)).toEqual({ + ok: false, + reason: 'stale_owner', + }); + const next = runtime.currentNavigation; + if (!next) throw new Error('Expected replacement navigation'); + expect(service.register(next, [serverRegistration('next')])).toMatchObject({ ok: true }); + expect(service.adoptGptSlot(next.generation, 'next', { ownership: 'publisher', slot })).toEqual( + { ok: true } + ); + }); + + it('conditionally deletes a WeakMap identity published just before a stale-owner check', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + let phase: 'adopt' | 'register' | 'steady' = 'register'; + let adoptChecks = 0; + const generation = {}; + const owner = { + generation, + isCurrent: () => { + if (phase !== 'adopt') return true; + adoptChecks += 1; + return adoptChecks < 3; + }, + onDispose: vi.fn(), + } as unknown as NavigationSession; + expect(service.register(owner, [serverRegistration('slot')])).toMatchObject({ ok: true }); + const slot = {}; + phase = 'adopt'; + + expect(service.adoptGptSlot(generation, 'slot', { ownership: 'publisher', slot })).toEqual({ + ok: false, + reason: 'stale_owner', + }); + phase = 'steady'; + expect(service.adoptGptSlot(generation, 'slot', { ownership: 'publisher', slot })).toEqual({ + ok: true, + }); + }); +}); + +describe('navigation-owned DOM reconciliation', () => { + afterEach(() => vi.useRealTimers()); + + async function completedApsMountCycle( + ownership: 'publisher' | 'trusted_server' = 'trusted_server', + disposeCommittedArtifact = vi.fn() + ) { + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const element = Object.freeze({ element: 'top-slot' }); + dom.put('slot-div', element); + const service = createSlotService({ + bindCommittedArtifactRetirement, + disposeCommittedArtifact, + googletag: gpt.adapter, + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + let slot: object; + if (ownership === 'trusted_server') { + slot = bindTrustedSlot(service, navigation); + } else { + slot = Object.freeze({ publisher: true }); + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ + ok: true, + }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition: Object.freeze({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: Object.freeze([[300, 250] as const]), + }), + ownership, + slot, + }) + ).toEqual({ ok: true }); + } + const intentId = `a1_${'m'.repeat(22)}`; + const request = service.request({ + intentId, + expectedSlot: slot, + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'aps-top-mount', + slot, + }); + await expect(request.result).resolves.toMatchObject({ status: 'rendered' }); + return { disposeCommittedArtifact, dom, element, intentId, navigation, service, slot }; + } + + it('claims one exact completed TS cycle and invalidates it on DOM replacement', async () => { + const h = await completedApsMountCycle(); + const binding = h.service.resolveApsMountBinding(h.navigation.generation, 'slot', h.intentId); + + expect(binding).toMatchObject({ element: h.element, physicalSlot: h.slot }); + expect(binding?.isCurrent()).toBe(true); + expect( + h.service.resolveApsMountBinding(h.navigation.generation, 'slot', h.intentId) + ).toBeUndefined(); + + h.dom.replace('slot-div', Object.freeze({ element: 'replacement' })); + expect(binding?.isCurrent()).toBe(false); + }); + + it('refuses a publisher-owned cycle when its exact host was replaced before binding', async () => { + const h = await completedApsMountCycle('publisher'); + h.dom.replace('slot-div', Object.freeze({ element: 'publisher-replacement' })); + + expect( + h.service.resolveApsMountBinding(h.navigation.generation, 'slot', h.intentId) + ).toBeUndefined(); + }); + + it('retires a committed overlay for a publisher cycle but retains it for an exact TS replacement', async () => { + const publisher = await completedApsMountCycle('publisher'); + const publisherBinding = publisher.service.resolveApsMountBinding( + publisher.navigation.generation, + 'slot', + publisher.intentId + )!; + const publisherArtifact = committedArtifact(publisher.navigation.generation); + expect(publisherBinding.bindArtifact(publisherArtifact)?.commit()).toBe(true); + + publisher.service.handleGptEvent('slotRequested', { slot: publisher.slot }); + expect(publisher.disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + publisher.navigation.generation, + 'slot', + publisherArtifact + ); + + const trusted = await completedApsMountCycle(); + const trustedBinding = trusted.service.resolveApsMountBinding( + trusted.navigation.generation, + 'slot', + trusted.intentId + )!; + const trustedArtifact = committedArtifact(trusted.navigation.generation); + expect(trustedBinding.bindArtifact(trustedArtifact)?.commit()).toBe(true); + const replacement = trusted.service.request({ + expectedSlot: trusted.slot, + intentId: 'exact-ts-replacement', + navigationGeneration: trusted.navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + + trusted.service.handleGptEvent('slotRequested', { slot: trusted.slot }); + expect(trusted.disposeCommittedArtifact).not.toHaveBeenCalled(); + replacement.dispose(); + expect(trusted.disposeCommittedArtifact).not.toHaveBeenCalled(); + }); + + it('restores the prior physical artifact when a provisional replacement is released', async () => { + const h = await completedApsMountCycle(); + const prior = committedArtifact(h.navigation.generation); + expect(h.service.adoptCommittedArtifact(h.navigation.generation, 'slot', prior)).toBe(true); + const binding = h.service.resolveApsMountBinding(h.navigation.generation, 'slot', h.intentId)!; + const replacement = Object.freeze({ + ...committedArtifact(h.navigation.generation), + attemptId: `a1_${'d'.repeat(22)}`, + }); + const admission = binding.bindArtifact(replacement)!; + expect(admission.commit()).toBe(true); + + admission.release(); + admission.rollback(); + h.service.handleGptEvent('slotRequested', { slot: h.slot }); + + expect(h.disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + h.navigation.generation, + 'slot', + prior + ); + }); + + it('refuses missing, ambiguous, disconnected, and stale APS mount bindings', async () => { + const ambiguous = await completedApsMountCycle(); + ambiguous.dom.replaceAmbiguously('slot-div', [ + Object.freeze({ element: 'first' }), + Object.freeze({ element: 'second' }), + ]); + expect( + ambiguous.service.resolveApsMountBinding( + ambiguous.navigation.generation, + 'slot', + ambiguous.intentId + ) + ).toBeUndefined(); + + const disconnected = await completedApsMountCycle(); + disconnected.dom.disconnect('slot-div'); + expect( + disconnected.service.resolveApsMountBinding( + disconnected.navigation.generation, + 'slot', + disconnected.intentId + ) + ).toBeUndefined(); + + const stale = await completedApsMountCycle(); + stale.navigation.dispose(); + expect( + stale.service.resolveApsMountBinding(stale.navigation.generation, 'slot', stale.intentId) + ).toBeUndefined(); + expect( + stale.service.resolveApsMountBinding(stale.navigation.generation, 'missing', stale.intentId) + ).toBeUndefined(); + }); + + it('installs reconciliation only for one explicit reversible deferred owner', () => { + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const service = createSlotService({ + googletag: gpt.adapter, + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + + expect(dom.observe).not.toHaveBeenCalled(); + const release = service.activateReconciliation(); + expect(dom.observe).toHaveBeenCalledOnce(); + const disconnect = dom.observe.mock.results[0]?.value; + expect(typeof disconnect).toBe('function'); + expect(() => service.activateReconciliation()).toThrow('unavailable'); + + release(); + release(); + expect(disconnect).toHaveBeenCalledOnce(); + + const releaseAgain = service.activateReconciliation(); + expect(dom.observe).toHaveBeenCalledTimes(2); + releaseAgain(); + expect(dom.observe.mock.results[1]?.value).toHaveBeenCalledOnce(); + }); + + it('preserves the physical slot when DOM connectivity cannot be established', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: Object.freeze({ + ...dom.boundary, + isConnected: () => { + throw new Error('fictional DOM connectivity failure'); + }, + }), + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.trigger(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(gpt.destroySlots).not.toHaveBeenCalled(); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + }); + + it('reconciles a TS slot whose original DOM element was already absent at adoption', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.put('slot-div', {}); + dom.trigger(); + await vi.advanceTimersByTimeAsync(250); + + expect(gpt.defineSlot).toHaveBeenCalledExactlyOnceWith( + '/network/slot', + [[300, 250]], + 'slot-div' + ); + }); + + it('clears an adopted physical artifact association when its store retires the exact artifact', () => { + const gpt = createGptHarness(); + const service = createSlotService({ + bindCommittedArtifactRetirement, + googletag: gpt.adapter, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const store = createCommittedArtifactStore(); + const first = committedArtifact(navigation.generation); + const second = committedArtifact(navigation.generation); + expect(store.promote(first)).toBe(true); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', first)).toBe(true); + + expect(store.release(first)).toBe(true); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', second)).toBe(true); + }); + + it('debounces an exact disconnected TS slot through the 249/250 ms boundary', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const disposeCommittedArtifact = vi.fn(() => { + throw new Error('fictional artifact cleanup failure'); + }); + dom.put('slot-div', {}); + const service = createSlotService({ + bindCommittedArtifactRetirement, + disposeCommittedArtifact, + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const artifact = committedArtifact(navigation.generation); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', artifact)).toBe(true); + service.activate(); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(249); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(gpt.destroySlots).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ id: 'slot' }), + ]); + expect(gpt.defineSlot).toHaveBeenCalledExactlyOnceWith( + '/network/slot', + [[300, 250]], + 'slot-div' + ); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + navigation.generation, + 'slot', + artifact + ); + + const request = service.request({ + intentId: 'after-rebind', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + expect(request.status).toBe('active'); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(gpt.defineSlot.mock.results[0]?.value); + }); + + it('settles an invocation tied to the orphan before publishing the replacement', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + const orphan = bindTrustedSlot(service, navigation); + service.activate(); + const request = service.request({ + intentId: 'before-rebind', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(orphan); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'gpt_request_failed', + }); + await vi.advanceTimersByTimeAsync(3_000); + expect(gpt.destroySlots).toHaveBeenCalledExactlyOnceWith([orphan]); + + service.request({ + intentId: 'after-rebind', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + expect(gpt.display).toHaveBeenLastCalledWith(gpt.defineSlot.mock.results[0]?.value); + }); + + it('runs one final unresolved pass at 5,000 ms and settles exact work', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(2_999); + const request = service.request({ + intentId: 'orphaned', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(2_000); + expect(request.status).toBe('active'); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + await expect(request.result).resolves.toEqual({ status: 'failed', reason: 'slot_unresolved' }); + expect(gpt.destroySlots).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ id: 'slot' }), + ]); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + }); + + it.each([ + ['unresolved', 'destroy_false'], + ['unresolved', 'destroy_throw'], + ['ambiguous', 'destroy_false'], + ['ambiguous', 'destroy_throw'], + ] as const)('settles final %s cleanup %s as gpt_request_failed', async (resolution, failure) => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + if (failure === 'destroy_false') gpt.destroySlots.mockReturnValue(false); + else { + gpt.destroySlots.mockImplementation(() => { + throw new Error('fictional destroy failure'); + }); + } + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + if (resolution === 'ambiguous') dom.replaceAmbiguously('slot-div', [{}, {}]); + else dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(4_999); + const request = service.request({ + intentId: `${resolution}-${failure}`, + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(1); + + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'gpt_request_failed', + }); + expect(gpt.destroySlots).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ id: 'slot' }), + ]); + }); + + it('keeps final cleanup pending and lets navigation cancellation beat its late result', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness({ deferDestroyedResult: true }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + service.activate(); + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(4_999); + const request = service.request({ + intentId: 'navigation-wins-late-cleanup', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + vi.advanceTimersByTime(1); + expect(request.status).toBe('active'); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + const nextResult = runtime.replaceNavigation(); + expect(nextResult.ok).toBe(true); + if (!nextResult.ok) throw new Error('Expected replacement navigation'); + const next = nextResult.value; + await expect(request.result).resolves.toEqual({ + status: 'cancelled', + reason: 'navigation_disposed', + }); + expect( + service.register(next, [ + serverRegistration('slot', { + adUnitCode: '/network/slot', + domAliases: ['slot-div'], + }), + ]) + ).toEqual({ ok: false, reason: 'slot_quarantined' }); + + gpt.resolveDeferredDestroyed(); + await Promise.resolve(); + await Promise.resolve(); + + expect(request.status).toBe('terminal'); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + const replacement = bindTrustedSlot(service, next); + gpt.resolveDeferredDestroyed(); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'late-old-slot', + slot: oldSlot, + }); + expect(service.recordPublisherDestruction(oldSlot)).toBe(false); + expect(service.isBoundGptSlot(next.generation, 'slot', replacement)).toBe(true); + }); + + it('lets request supersession win while final cleanup completes later', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness({ synchronousRun: false }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(4_999); + const request = service.request({ + intentId: 'supersession-wins-late-cleanup', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + vi.advanceTimersByTime(1); + expect(request.status).toBe('active'); + request.dispose(); + await expect(request.result).resolves.toEqual({ + status: 'cancelled', + reason: 'superseded', + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(request.status).toBe('terminal'); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + }); + + it('releases the exact committed artifact before retiring a failed reconciliation', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const disposeCommittedArtifact = vi.fn(); + dom.put('slot-div', {}); + const service = createSlotService({ + bindCommittedArtifactRetirement, + disposeCommittedArtifact, + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const artifact = committedArtifact(navigation.generation); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', artifact)).toBe(true); + service.activate(); + + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(5_000); + + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + navigation.generation, + 'slot', + artifact + ); + expect(disposeCommittedArtifact.mock.invocationCallOrder[0]).toBeLessThan( + gpt.destroySlots.mock.invocationCallOrder[0] as number + ); + expect(gpt.destroySlots).toHaveBeenCalledOnce(); + }); + + it('commits a unique replacement found only by the final 5,000 ms pass', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(250); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(4_749); + dom.put('slot-div', {}); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(gpt.defineSlot).toHaveBeenCalledExactlyOnceWith( + '/network/slot', + [[300, 250]], + 'slot-div' + ); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + }); + + it('keeps an ambiguous replacement unresolved through the final pass', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + dom.replaceAmbiguously('slot-div', [{}, {}]); + await vi.advanceTimersByTimeAsync(2_999); + const request = service.request({ + intentId: 'ambiguous', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(2_001); + await expect(request.result).resolves.toEqual({ status: 'failed', reason: 'slot_unresolved' }); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + }); + + it.each(['destroy_false', 'destroy_throw', 'define'] as const)( + 'settles %s transaction failure as gpt_request_failed without a second physical slot', + async (failure) => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + if (failure === 'destroy_false') gpt.destroySlots.mockReturnValue(false); + else if (failure === 'destroy_throw') { + gpt.destroySlots.mockImplementation(() => { + throw new Error('fictional destroy failure'); + }); + } else gpt.defineSlot.mockReturnValueOnce(undefined); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + const request = service.request({ + intentId: `failed-${failure}`, + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'gpt_request_failed', + }); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + expect(gpt.defineSlot).toHaveBeenCalledTimes(failure === 'define' ? 1 : 0); + expect(service.snapshotForTest().physicalSlots).toBe(0); + } + ); + + it('quarantines an exact replacement candidate the adapter could not destroy', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const orphan = Object.freeze({ orphan: true }); + const gpt = createGptHarness({ orphanOnReplace: orphan }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + const request = service.request({ + intentId: 'orphaned-replacement', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'gpt_request_failed', + }); + const replacementBinding = { + definition: { + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: Object.freeze([[300, 250]]), + }, + ownership: 'trusted_server' as const, + slot: Object.freeze({ replacementAfterOrphan: true }), + }; + expect(service.adoptGptSlot(navigation.generation, 'slot', replacementBinding)).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + + expect(service.recordPublisherDestruction(orphan)).toBe(true); + expect(service.adoptGptSlot(navigation.generation, 'slot', replacementBinding)).toEqual({ + ok: true, + }); + }); + + it('lets expiry beat a final-pass replacement that cannot commit synchronously', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness({ synchronousRun: false }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + await Promise.resolve(); + + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(250); + await vi.advanceTimersByTimeAsync(4_749); + dom.put('slot-div', {}); + const request = service.request({ + intentId: 'expiry-wins', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(1); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'slot_unresolved', + }); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it('lets publisher ownership transfer cancel a queued reconciliation transaction', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness({ synchronousRun: false }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + await Promise.resolve(); + + dom.replace('slot-div', {}); + vi.advanceTimersByTime(250); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + ownership: 'publisher', + slot, + }) + ).toEqual({ ok: true }); + await Promise.resolve(); + await Promise.resolve(); + + expect(gpt.defineSlot).not.toHaveBeenCalled(); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('allows two successful rebinds and fails a third disconnect immediately', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + expect(gpt.defineSlot).toHaveBeenCalledTimes(2); + + const request = service.request({ + intentId: 'capacity', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + dom.disconnect('slot-div'); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'reconciliation_capacity', + }); + expect(gpt.defineSlot).toHaveBeenCalledTimes(2); + expect(gpt.destroySlots).toHaveBeenCalledTimes(3); + }); + + it('cancels reconciliation on publisher transfer and disconnects with navigation', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + dom.disconnect('slot-div'); + + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + ownership: 'publisher', + slot, + }) + ).toEqual({ ok: true }); + await vi.advanceTimersByTimeAsync(5_000); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + + navigation.dispose(); + expect(dom.observe).toHaveBeenCalledTimes(1); + dom.trigger(); + await vi.runAllTimersAsync(); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + }); +}); + +describe('browser reconciliation boundary', () => { + it('resolves only one exact connected element and releases its observer', async () => { + const boundary = createBrowserSlotReconciliationBoundary(document, MutationObserver); + expect(boundary).toBeDefined(); + if (!boundary) throw new Error('Expected the browser reconciliation boundary'); + const host = document.createElement('section'); + const first = document.createElement('div'); + first.id = 'tsjs-reconciliation-exact'; + host.append(first); + document.body.append(host); + const callback = vi.fn(); + const release = boundary.observe(callback); + + expect(boundary.resolve(['tsjs-reconciliation-exact'])).toEqual({ + status: 'unique', + element: first, + elementId: 'tsjs-reconciliation-exact', + }); + expect(boundary.isConnected(first)).toBe(true); + + const duplicate = document.createElement('div'); + duplicate.id = first.id; + host.append(duplicate); + await vi.waitFor(() => expect(callback).toHaveBeenCalled()); + expect(boundary.resolve([first.id])).toEqual({ status: 'ambiguous' }); + + const callsBeforeRelease = callback.mock.calls.length; + release(); + host.remove(); + await Promise.resolve(); + expect(callback).toHaveBeenCalledTimes(callsBeforeRelease); + expect(boundary.isConnected(first)).toBe(false); + expect(boundary.resolve([first.id])).toEqual({ status: 'unresolved' }); + }); +}); + +function createReplacementHarness() { + const replacement = { addService: vi.fn() }; + const destroySlots = vi.fn((_slots: readonly object[]) => true); + const defineSlot = vi.fn((): object | undefined => replacement); + const pubads = { + addEventListener: vi.fn(), + getSlots: () => [], + refresh: vi.fn(), + removeEventListener: vi.fn(), + }; + const adapter = createBrowserGoogletagAdapter({ + googletag: { + apiReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot, + destroySlots, + display: vi.fn(), + pubads: () => pubads, + pubadsReady: true, + }, + }); + return { adapter, defineSlot, destroySlots, pubads, replacement }; +} + +describe('adapter-owned GPT replacement transaction', () => { + const definition = Object.freeze({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: Object.freeze([[300, 250]]), + }); + const commitReplacement = () => Object.freeze({ commit: () => true, rollback: vi.fn() }); + + it.each(['throw', 'false'] as const)( + 'never publishes a second physical slot after %s failure', + async (failure) => { + const harness = createReplacementHarness(); + if (failure === 'throw') { + harness.destroySlots.mockImplementation(() => { + throw new Error('destroy failed'); + }); + } else if (failure === 'false') { + harness.destroySlots.mockReturnValue(false); + } + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace({}, definition, () => true, commitReplacement) + ); + + await expect(operation.result).rejects.toBeDefined(); + expect(harness.defineSlot).not.toHaveBeenCalled(); + expect(harness.replacement.addService).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['after-destroy', 1, 0, 1], + ['after-define', 2, 1, 2], + ['after-addService', 3, 1, 2], + ] as const)( + 'checks stale generation %s and cleans any newly-defined object', + async (_site, staleAt, expectedDefinitions, expectedDestroys) => { + const harness = createReplacementHarness(); + let checks = 0; + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace( + {}, + definition, + () => { + checks += 1; + return checks < staleAt; + }, + commitReplacement + ) + ); + + await expect(operation.result).resolves.toEqual({ status: 'destroyed' }); + expect(harness.defineSlot).toHaveBeenCalledTimes(expectedDefinitions); + expect(harness.destroySlots).toHaveBeenCalledTimes(expectedDestroys); + expect(harness.replacement.addService).toHaveBeenCalledTimes(staleAt === 3 ? 1 : 0); + } + ); + + it('surfaces failure to destroy a newly-defined stale replacement', async () => { + const harness = createReplacementHarness(); + harness.destroySlots.mockReturnValueOnce(true).mockReturnValueOnce(false); + let checks = 0; + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace( + {}, + definition, + () => { + checks += 1; + return checks < 2; + }, + commitReplacement + ) + ); + + await expect(operation.result).rejects.toBeDefined(); + expect(harness.destroySlots).toHaveBeenCalledTimes(2); + }); + + it('normalizes a defineSlot throw after destroying the old slot', async () => { + const harness = createReplacementHarness(); + const publisherFailure = new Error('publisher define failed'); + harness.defineSlot.mockImplementation(() => { + throw publisherFailure; + }); + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace({}, definition, () => true, commitReplacement) + ); + + await expect(operation.result).rejects.toMatchObject({ + cause: publisherFailure, + code: 'gpt_replacement_failed', + oldSlotDestroyed: true, + orphanedSlot: undefined, + }); + expect(harness.destroySlots).toHaveBeenCalledOnce(); + }); + + it('normalizes a generation callback throw after destroying the old slot', async () => { + const harness = createReplacementHarness(); + const ownerFailure = new Error('generation check failed'); + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace( + {}, + definition, + () => { + throw ownerFailure; + }, + commitReplacement + ) + ); + + await expect(operation.result).rejects.toMatchObject({ + cause: ownerFailure, + code: 'gpt_replacement_failed', + oldSlotDestroyed: true, + orphanedSlot: undefined, + }); + expect(harness.defineSlot).not.toHaveBeenCalled(); + expect(harness.destroySlots).toHaveBeenCalledOnce(); + }); + + it('normalizes commit-admission throws and destroys the exact uncommitted candidate', async () => { + const harness = createReplacementHarness(); + const admissionFailure = new Error('commit admission failed'); + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace( + {}, + definition, + () => true, + () => { + throw admissionFailure; + } + ) + ); + + await expect(operation.result).rejects.toMatchObject({ + cause: admissionFailure, + code: 'gpt_replacement_failed', + oldSlotDestroyed: true, + orphanedSlot: undefined, + }); + expect(harness.replacement.addService).not.toHaveBeenCalled(); + expect(harness.destroySlots).toHaveBeenCalledTimes(2); + expect(harness.destroySlots).toHaveBeenNthCalledWith(2, [harness.replacement]); + }); + + it('leaves the service unbound after the real adapter destroys old then defineSlot throws', async () => { + vi.useFakeTimers(); + const harness = createReplacementHarness(); + harness.defineSlot.mockImplementation(() => { + throw new Error('publisher define failed'); + }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const oldSlot = { old: true }; + expect( + service.register(navigation, [ + serverRegistration('slot', { + adUnitCode: '/network/slot', + domAliases: ['slot-div'], + }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition, + ownership: 'trusted_server', + slot: oldSlot, + }) + ).toEqual({ ok: true }); + const request = service.request({ + intentId: 'real-define-throw', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect(service.recordPublisherDestruction(oldSlot)).toBe(false); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition, + ownership: 'trusted_server', + slot: { retry: true }, + }) + ).toEqual({ ok: true }); + }); + + it('leaves the service unbound when its generation check throws after old-slot destruction', async () => { + vi.useFakeTimers(); + const harness = createReplacementHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const oldSlot = { old: true }; + let oldSlotDestroyed = false; + const ownerFailure = new Error('owner check failed after destroy'); + const isCurrent = vi.spyOn(navigation, 'isCurrent').mockImplementation(() => { + if (oldSlotDestroyed) throw ownerFailure; + return true; + }); + harness.destroySlots.mockImplementation((slots) => { + if (slots[0] === oldSlot) oldSlotDestroyed = true; + return true; + }); + expect( + service.register(navigation, [ + serverRegistration('slot', { + adUnitCode: '/network/slot', + domAliases: ['slot-div'], + }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition, + ownership: 'trusted_server', + slot: oldSlot, + }) + ).toEqual({ ok: true }); + const request = service.request({ + intentId: 'real-current-throw', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect(service.recordPublisherDestruction(oldSlot)).toBe(false); + isCurrent.mockImplementation(() => true); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition, + ownership: 'trusted_server', + slot: { retry: true }, + }) + ).toEqual({ ok: true }); + }); + + it('rejects a defineSlot candidate that is the retired old object', async () => { + const harness = createReplacementHarness(); + const oldSlot = { addService: vi.fn() }; + harness.defineSlot.mockReturnValue(oldSlot); + const commit = vi.fn(); + const operation = harness.adapter.run((gpt) => + ( + gpt.transactionalReplace as unknown as ( + old: object, + candidateDefinition: GoogletagReplacementDefinition, + current: () => boolean, + prepareCommit: (candidate: object) => { commit: () => boolean; rollback: () => void } + ) => unknown + )( + oldSlot, + definition, + () => true, + () => ({ commit, rollback: vi.fn() }) + ) + ); + + await expect(operation.result).rejects.toBeDefined(); + expect(commit).not.toHaveBeenCalled(); + expect(harness.replacement.addService).not.toHaveBeenCalled(); + }); + + it('surfaces the reused old identity when rejecting it cannot clean it up', async () => { + const harness = createReplacementHarness(); + const oldSlot = { addService: vi.fn() }; + harness.defineSlot.mockReturnValue(oldSlot); + harness.destroySlots.mockReturnValueOnce(true).mockReturnValueOnce(false); + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace(oldSlot, definition, () => true, commitReplacement) + ); + + await expect(operation.result).rejects.toMatchObject({ + code: 'gpt_replacement_failed', + oldSlotDestroyed: true, + orphanedSlot: oldSlot, + }); + expect(harness.destroySlots).toHaveBeenCalledTimes(2); + }); + + it('rolls back a synchronous service commit when the post-commit generation check is stale', async () => { + const harness = createReplacementHarness(); + let checks = 0; + let bound: object | undefined; + const rollback = vi.fn(() => { + bound = undefined; + }); + const operation = harness.adapter.run((gpt) => + ( + gpt.transactionalReplace as unknown as ( + old: object, + candidateDefinition: GoogletagReplacementDefinition, + current: () => boolean, + prepareCommit: (candidate: object) => { commit: () => boolean; rollback: () => void } + ) => unknown + )( + {}, + definition, + () => { + checks += 1; + return checks < 4; + }, + (candidate) => ({ + commit: () => { + bound = candidate; + return true; + }, + rollback, + }) + ) + ); + + await expect(operation.result).resolves.toEqual({ status: 'destroyed' }); + expect(rollback).toHaveBeenCalledOnce(); + expect(bound).toBeUndefined(); + expect(harness.destroySlots).toHaveBeenCalledTimes(2); + }); + + it('surfaces the exact orphan candidate when post-commit cleanup cannot destroy it', async () => { + const harness = createReplacementHarness(); + harness.destroySlots.mockReturnValueOnce(true).mockReturnValueOnce(false); + const rollback = vi.fn(); + let checks = 0; + const operation = harness.adapter.run((gpt) => + ( + gpt.transactionalReplace as unknown as ( + old: object, + candidateDefinition: GoogletagReplacementDefinition, + current: () => boolean, + prepareCommit: (candidate: object) => { commit: () => boolean; rollback: () => void } + ) => unknown + )( + {}, + definition, + () => { + checks += 1; + return checks < 4; + }, + () => ({ commit: () => true, rollback }) + ) + ); + + await expect(operation.result).rejects.toMatchObject({ + code: 'gpt_replacement_failed', + orphanedSlot: harness.replacement, + }); + expect(rollback).toHaveBeenCalledOnce(); + }); +}); + +function readyListenerBinding() { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + const pubads = { + addEventListener, + getSlots: () => [], + refresh: vi.fn(), + removeEventListener, + }; + return { + addEventListener, + binding: { + apiReady: true, + cmd: { push: (command: () => void) => command() }, + display: vi.fn(), + pubads: () => pubads, + pubadsReady: true, + }, + removeEventListener, + }; +} + +describe('binding-aware GPT listener activation', () => { + it('installs observation without timers and starts readiness only after commit', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const service = createSlotService({ googletag: adapter }); + service.activate(); + expect(vi.getTimerCount()).toBe(0); + + const missing = service.start(); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(10_000); + await expect(missing.result).rejects.toMatchObject({ code: 'external_ready_timeout' }); + + const ready = readyListenerBinding(); + target.googletag = ready.binding; + await expect(service.start().result).resolves.toBeUndefined(); + await expect(service.start().result).resolves.toBeUndefined(); + + expect(ready.addEventListener.mock.calls.map(([type]) => type)).toEqual([ + 'slotRequested', + 'slotRenderEnded', + ]); + }); + + it('subscribes a replacement binding before allowing later operations without duplicating either', async () => { + const first = readyListenerBinding(); + const second = readyListenerBinding(); + const target: { googletag?: unknown } = { googletag: first.binding }; + const adapter = createBrowserGoogletagAdapter(target); + const service = createSlotService({ googletag: adapter }); + service.activate(); + await expect(service.start().result).resolves.toBeUndefined(); + target.googletag = second.binding; + await expect(service.start().result).resolves.toBeUndefined(); + await expect(service.start().result).resolves.toBeUndefined(); + + expect(first.addEventListener).toHaveBeenCalledTimes(2); + expect(second.addEventListener).toHaveBeenCalledTimes(2); + expect(first.removeEventListener).toHaveBeenCalledTimes(2); + service.dispose(); + expect(first.removeEventListener).toHaveBeenCalledTimes(2); + expect(second.removeEventListener).toHaveBeenCalledTimes(2); + }); +}); + +describe('physical GPT cycles', () => { + afterEach(() => vi.useRealTimers()); + + it('preserves external_queue_full when GPT readiness admission is saturated', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const service = createSlotService({ googletag: adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + for (let index = 0; index < 64; index += 1) { + const queued = adapter.run(() => undefined); + void queued.result.catch(() => undefined); + } + + const request = service.request({ + intentId: 'queue-capacity', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'external_queue_full', + }); + service.dispose(); + adapter.dispose(); + }); + + it('preserves external_ready_timeout when GPT never becomes ready', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const service = createSlotService({ googletag: adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'readiness-deadline', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(10_000); + + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'external_ready_timeout', + }); + service.dispose(); + adapter.dispose(); + }); + + it('records intent before a synchronous slotRequested event and supports SRA per slot', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'first'); + const second = bindTrustedSlot(service, navigation, 'second'); + harness.display.mockImplementation((slot: object) => { + service.handleGptEvent('slotRequested', { slot }); + }); + + const firstRequest = service.request({ + intentId: 'intent-first', + navigationGeneration: navigation.generation, + operation: 'display', + requestClass: 'primary', + registeredSlotId: 'first', + }); + const secondRequest = service.request({ + intentId: 'intent-second', + navigationGeneration: navigation.generation, + operation: 'display', + requestClass: 'primary', + registeredSlotId: 'second', + }); + await Promise.resolve(); + expect(harness.display.mock.calls.map(([slot]) => slot)).toEqual([first, second]); + + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'response-first', + slot: first, + }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: true, + responseIdentifier: 'response-second', + slot: second, + }); + await expect(firstRequest.result).resolves.toEqual({ + responseIdentifier: 'response-first', + status: 'rendered', + }); + await expect(secondRequest.result).resolves.toEqual({ + responseIdentifier: 'response-second', + status: 'empty', + }); + }); + + it('uses display only for registration under disabled initial load and one exact refresh', async () => { + const harness = createGptHarness({ initialLoadDisabled: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + + service.request({ + intentId: 'intent', + navigationGeneration: navigation.generation, + operation: 'display', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + + expect(harness.display).toHaveBeenCalledExactlyOnceWith(slot); + expect(harness.refresh).toHaveBeenCalledExactlyOnceWith( + [slot], + Object.freeze({ changeCorrelator: false }) + ); + }); + + it('treats a slotRequested raised by disabled-load display as publisher overlap and skips refresh', async () => { + const harness = createGptHarness({ initialLoadDisabled: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + harness.display.mockImplementation(() => { + service.handleGptEvent('slotRequested', { slot }); + }); + const request = service.request({ + intentId: 'display-overlap', + navigationGeneration: navigation.generation, + operation: 'display', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + + await expect(request.result).resolves.toEqual({ + reason: 'cycle_unattributable', + status: 'failed', + }); + expect(harness.refresh).not.toHaveBeenCalled(); + }); + + it('fails a disabled-initial-load request when refresh throws', async () => { + const harness = createGptHarness({ initialLoadDisabled: true }); + harness.refresh.mockImplementation(() => { + throw new Error('refresh unavailable'); + }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + + const request = service.request({ + intentId: 'intent', + navigationGeneration: navigation.generation, + operation: 'display', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + + await expect(request.result).resolves.toEqual({ + reason: 'gpt_request_failed', + status: 'failed', + }); + }); + + it('fails a disabled-initial-load request when refresh is unavailable', async () => { + const harness = createGptHarness({ initialLoadDisabled: true, missingRefresh: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'missing-refresh', + navigationGeneration: navigation.generation, + operation: 'display', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + + await expect(request.result).resolves.toEqual({ + reason: 'gpt_request_failed', + status: 'failed', + }); + }); + + it('records every SRA intent before one refresh and fans out events by object identity', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'sra-first'); + const second = bindTrustedSlot(service, navigation, 'sra-second'); + + const requests = service.requestBatch([ + { + intentId: 'sra-intent-first', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'sra-first', + }, + { + intentId: 'sra-intent-second', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'sra-second', + }, + ]); + await Promise.resolve(); + expect(harness.refresh).toHaveBeenCalledExactlyOnceWith( + [first, second], + Object.freeze({ changeCorrelator: false }) + ); + service.handleGptEvent('slotRequested', { slot: first }); + service.handleGptEvent('slotRequested', { slot: second }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'sra-first-response', + slot: first, + }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: true, + responseIdentifier: 'sra-second-response', + slot: second, + }); + await expect(Promise.all(requests.map(({ result }) => result))).resolves.toEqual([ + { responseIdentifier: 'sra-first-response', status: 'rendered' }, + { responseIdentifier: 'sra-second-response', status: 'empty' }, + ]); + }); + + it('arms every display intent before a synchronous SRA request can emit sibling events', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'display-first'); + const second = bindTrustedSlot(service, navigation, 'display-second'); + harness.display.mockImplementation(() => { + harness.emit('slotRequested', { slot: first }); + harness.emit('slotRequested', { slot: second }); + harness.emit('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'display-first-response', + slot: first, + }); + harness.emit('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'display-second-response', + slot: second, + }); + }); + const displayBatch = [ + { + expectedSlot: first, + intentId: 'display-first-intent', + navigationGeneration: navigation.generation, + operation: 'display' as const, + registeredSlotId: 'display-first', + requestClass: 'primary', + }, + { + expectedSlot: second, + intentId: 'display-second-intent', + navigationGeneration: navigation.generation, + operation: 'display' as const, + registeredSlotId: 'display-second', + requestClass: 'primary', + }, + ] as const; + const runtimeRequestBatch = service.requestBatch as unknown as ( + inputs: typeof displayBatch + ) => readonly ReturnType[]; + + const requests = runtimeRequestBatch(displayBatch); + await expect(Promise.all(requests.map(({ result }) => result))).resolves.toEqual([ + { responseIdentifier: 'display-first-response', status: 'rendered' }, + { responseIdentifier: 'display-second-response', status: 'rendered' }, + ]); + expect(harness.display).toHaveBeenCalledOnce(); + expect(harness.refresh).not.toHaveBeenCalled(); + }); + + it.each(['unknown-slot', 'duplicate-slot', 'duplicate-intent', 'mixed-navigation'] as const)( + 'prevalidates the entire SRA batch atomically: %s', + (failure) => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const firstNavigation = createNavigation(); + const secondNavigation = createNavigation(); + bindTrustedSlot(service, firstNavigation, 'first'); + bindTrustedSlot(service, firstNavigation, 'second'); + bindTrustedSlot(service, secondNavigation, 'other-navigation'); + const first = { + intentId: 'first-intent', + navigationGeneration: firstNavigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'first', + requestClass: 'primary', + }; + const second = { + intentId: failure === 'duplicate-intent' ? first.intentId : 'second-intent', + navigationGeneration: + failure === 'mixed-navigation' ? secondNavigation.generation : firstNavigation.generation, + operation: 'refresh' as const, + registeredSlotId: + failure === 'unknown-slot' + ? 'missing' + : failure === 'duplicate-slot' + ? first.registeredSlotId + : failure === 'mixed-navigation' + ? 'other-navigation' + : 'second', + requestClass: 'primary', + }; + const inventory = service.snapshotForTest(); + + expect(service.requestBatch([first, second])).toEqual([]); + expect(service.snapshotForTest()).toEqual(inventory); + expect(harness.refresh).not.toHaveBeenCalled(); + } + ); + + it('treats an empty SRA batch as an inert rejection', () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + expect(service.requestBatch([])).toEqual([]); + expect(service.snapshotForTest()).toEqual({ + cycles: 0, + intents: 0, + physicalSlots: 0, + records: 0, + }); + expect(harness.refresh).not.toHaveBeenCalled(); + }); + + it('contains a throwing batch length read before validation', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const hostileInputs = new Proxy([], { + get: (target, key, receiver) => { + if (key === 'length') throw new Error('hostile batch length'); + return Reflect.get(target, key, receiver); + }, + }); + const runtimeRequestBatch = service.requestBatch as unknown as ( + inputs: readonly object[] + ) => unknown; + let outcome: unknown; + + expect(() => { + outcome = runtimeRequestBatch(hostileInputs); + }).not.toThrow(); + expect(outcome).toEqual([]); + expect(service.snapshotForTest()).toEqual({ + cycles: 0, + intents: 0, + physicalSlots: 0, + records: 0, + }); + }); + + it('does not leak partial admission through a poisoned Array map', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation, 'map-first'); + bindTrustedSlot(service, navigation, 'map-second'); + const inputs = [ + { + intentId: 'poison-map-first', + navigationGeneration: navigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'map-first', + requestClass: 'primary', + }, + { + intentId: 'poison-map-second', + navigationGeneration: navigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'map-second', + requestClass: 'primary', + }, + ]; + const originalMap = Array.prototype.map; + Array.prototype.map = function ( + this: Value[], + callback: (value: Value, index: number, array: Value[]) => Result, + thisArgument?: unknown + ): Result[] { + let targeted = false; + for (let index = 0; index < this.length; index += 1) { + const value = this[index] as { intentId?: unknown } | undefined; + if (value?.intentId === 'poison-map-first') targeted = true; + } + if (targeted) { + Reflect.apply(callback, thisArgument, [this[0], 0, this]); + throw new Error('poisoned map after partial admission'); + } + return Reflect.apply(originalMap, this, [callback, thisArgument]) as Result[]; + }; + let handles: readonly ReturnType[] | undefined; + let escaped: unknown; + try { + handles = service.requestBatch(inputs); + } catch (error) { + escaped = error; + } finally { + Array.prototype.map = originalMap; + } + + expect(escaped).toBeUndefined(); + expect(handles).toHaveLength(2); + for (const handle of handles ?? []) handle.dispose(); + await expect(Promise.all((handles ?? []).map(({ result }) => result))).resolves.toEqual([ + { reason: 'superseded', status: 'cancelled' }, + { reason: 'superseded', status: 'cancelled' }, + ]); + await Promise.resolve(); + expect(harness.refresh).not.toHaveBeenCalled(); + expect(service.snapshotForTest().intents).toBe(0); + }); + + it('does not leak post-admission intents through a poisoned Array iterator', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation, 'iterator-first'); + bindTrustedSlot(service, navigation, 'iterator-second'); + const inputs = [ + { + intentId: 'poison-iterator-first', + navigationGeneration: navigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'iterator-first', + requestClass: 'primary', + }, + { + intentId: 'poison-iterator-second', + navigationGeneration: navigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'iterator-second', + requestClass: 'primary', + }, + ]; + const originalIterator = Array.prototype[Symbol.iterator]; + Array.prototype[Symbol.iterator] = function (): ArrayIterator { + for (let index = 0; index < this.length; index += 1) { + const value = this[index] as { intentId?: unknown } | undefined; + if (value?.intentId === 'poison-iterator-first') { + throw new Error('poisoned iterator after admission'); + } + } + return Reflect.apply(originalIterator, this, []) as ArrayIterator; + }; + let handles: readonly ReturnType[] | undefined; + let escaped: unknown; + try { + handles = service.requestBatch(inputs); + } catch (error) { + escaped = error; + } finally { + Array.prototype[Symbol.iterator] = originalIterator; + } + + expect(escaped).toBeUndefined(); + expect(handles).toHaveLength(2); + for (let index = 0; index < (handles?.length ?? 0); index += 1) { + handles?.[index]?.dispose(); + } + await expect(Promise.all((handles ?? []).map(({ result }) => result))).resolves.toEqual([ + { reason: 'superseded', status: 'cancelled' }, + { reason: 'superseded', status: 'cancelled' }, + ]); + await Promise.resolve(); + expect(harness.refresh).not.toHaveBeenCalled(); + expect(service.snapshotForTest().intents).toBe(0); + }); + + it('rolls back every admitted batch handle when a later request unexpectedly throws', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + let poison = false; + let batchChecks = 0; + const owner = { + generation: {}, + isCurrent: () => { + if (!poison) return true; + batchChecks += 1; + if (batchChecks === 4) throw new Error('second request admission failed'); + return true; + }, + onDispose: vi.fn(), + } as unknown as NavigationSession; + bindTrustedSlot(service, owner, 'rollback-first'); + bindTrustedSlot(service, owner, 'rollback-second'); + const inventory = service.snapshotForTest(); + poison = true; + + expect( + service.requestBatch([ + { + intentId: 'rollback-first', + navigationGeneration: owner.generation, + operation: 'refresh', + registeredSlotId: 'rollback-first', + requestClass: 'primary', + }, + { + intentId: 'rollback-second', + navigationGeneration: owner.generation, + operation: 'refresh', + registeredSlotId: 'rollback-second', + requestClass: 'primary', + }, + ]) + ).toEqual([]); + expect(service.snapshotForTest()).toEqual(inventory); + }); + + it('keeps publisher display intent publisher-owned and fails ambiguous overlap', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + expect(service.recordPublisherIntent(slot)).toBe(true); + + const request = service.request({ + intentId: 'intent', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + + await expect(request.result).resolves.toEqual({ + reason: 'cycle_unattributable', + status: 'failed', + }); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'publisher', + slot, + }); + expect(harness.refresh).not.toHaveBeenCalled(); + }); + + it('allows one queued replacement, supersedes its same-class predecessor, and rejects opposite overlap', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = (intentId: string, requestClass: string) => + service.request({ + intentId, + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass, + registeredSlotId: 'slot', + }); + const active = request('active', 'primary'); + const replaced = request('queued-one', 'primary'); + const queued = request('queued-two', 'primary'); + await expect(replaced.result).resolves.toEqual({ + reason: 'superseded', + status: 'cancelled', + }); + const conflicting = request('queued-fallback', 'fallback'); + await expect(queued.result).resolves.toEqual({ + reason: 'cycle_unattributable', + status: 'failed', + }); + await expect(conflicting.result).resolves.toEqual({ + reason: 'cycle_unattributable', + status: 'failed', + }); + await expect(active.result).resolves.toEqual({ + reason: 'cycle_unattributable', + status: 'failed', + }); + }); + + it('queues one same-class replacement behind an open trusted-server cycle', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const first = service.request({ + intentId: 'first-primary', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + + const second = service.request({ + intentId: 'second-primary', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + expect(second.status).toBe('queued'); + expect(harness.refresh).toHaveBeenCalledTimes(1); + + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'first-primary-response', + slot, + }); + await expect(first.result).resolves.toEqual({ + responseIdentifier: 'first-primary-response', + status: 'rendered', + }); + await Promise.resolve(); + expect(harness.refresh).toHaveBeenCalledTimes(2); + + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: true, + responseIdentifier: 'second-primary-response', + slot, + }); + await expect(second.result).resolves.toEqual({ + responseIdentifier: 'second-primary-response', + status: 'empty', + }); + }); + + it('promotes a queued replacement when its active predecessor cancels before invocation', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const first = service.request({ + intentId: 'cancelled-before-invocation', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + const second = service.request({ + intentId: 'promoted-after-cancellation', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + expect(second.status).toBe('queued'); + + first.dispose(); + await expect(first.result).resolves.toEqual({ + reason: 'superseded', + status: 'cancelled', + }); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.refresh).toHaveBeenCalledTimes(1); + expect(second.status).toBe('active'); + + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'promoted-response', + slot, + }); + await expect(second.result).resolves.toEqual({ + responseIdentifier: 'promoted-response', + status: 'rendered', + }); + expect(service.snapshotForTest()).toMatchObject({ cycles: 0, intents: 0 }); + }); + + it('fails active and queued TS work when publisher intent makes ownership ambiguous', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const active = service.request({ + intentId: 'active', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + const queued = service.request({ + intentId: 'queued', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + + expect(service.recordPublisherIntent(slot)).toBe(true); + await expect(active.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + await expect(queued.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'publisher', + slot, + }); + expect(service.snapshotForTest().intents).toBe(0); + }); + + it('disposes an operation that settled synchronously before its handle was published', async () => { + const harness = createGptHarness({ synchronousRun: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + harness.refresh.mockImplementation(() => { + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'synchronous', + slot, + }); + }); + + const request = service.request({ + intentId: 'synchronous', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await expect(request.result).resolves.toMatchObject({ status: 'rendered' }); + expect( + harness.operationDisposals[harness.operationDisposals.length - 1] + ).toHaveBeenCalledOnce(); + }); + + it('safe-retires an invoked pre-cycle cancellation instead of clearing its only safety timer', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'cancelled', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + request.dispose(); + await expect(request.result).resolves.toMatchObject({ reason: 'superseded' }); + await Promise.resolve(); + + expect(harness.destroySlots).toHaveBeenCalledTimes(1); + expect(harness.defineSlot).toHaveBeenCalledTimes(1); + }); + + it.each([ + [2_999, true], + [3_001, false], + ] as const)('arbitrates slotRequested at %i ms without timeout re-arm', async (at, wins) => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: `intent-${at}`, + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + + if (at < 3_000) { + await vi.advanceTimersByTimeAsync(at); + service.handleGptEvent('slotRequested', { slot }); + } else { + await vi.advanceTimersByTimeAsync(at); + service.handleGptEvent('slotRequested', { slot }); + } + + if (wins) { + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: `response-${at}`, + slot, + }); + await expect(request.result).resolves.toMatchObject({ status: 'rendered' }); + } else { + await expect(request.result).resolves.toEqual({ + reason: 'gpt_request_timeout', + status: 'failed', + }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: `late-${at}`, + slot, + }); + expect(service.snapshotForTest().cycles).toBe(0); + } + }); + + it.each(['event-first', 'timeout-first'] as const)( + 'arbitrates callback registration order at the exact 3,000 ms boundary: %s', + async (order) => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + if (order === 'event-first') { + setTimeout(() => service.handleGptEvent('slotRequested', { slot }), 3_000); + } + const request = service.request({ + intentId: order, + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + if (order === 'timeout-first') { + setTimeout(() => service.handleGptEvent('slotRequested', { slot }), 3_000); + } + await vi.advanceTimersByTimeAsync(3_000); + + if (order === 'event-first') { + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: order, + slot, + }); + await expect(request.result).resolves.toMatchObject({ status: 'rendered' }); + } else { + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + } + } + ); + + it.each([ + [9_999, true], + [10_001, false], + ] as const)('arbitrates slotRenderEnded at %i ms from invocation', async (at, wins) => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: `intent-${at}`, + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + + if (at < 10_000) { + await vi.advanceTimersByTimeAsync(at); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: `response-${at}`, + slot, + }); + } else { + await vi.advanceTimersByTimeAsync(at); + } + + await expect(request.result).resolves.toEqual( + wins + ? { responseIdentifier: `response-${at}`, status: 'rendered' } + : { reason: 'gpt_completion_timeout', status: 'failed' } + ); + }); + + it.each(['event-first', 'timeout-first'] as const)( + 'arbitrates callback registration order at the exact 10,000 ms boundary: %s', + async (order) => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: `completion-${order}`, + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + if (order === 'event-first') { + setTimeout( + () => + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: order, + slot, + }), + 10_000 + ); + } + service.handleGptEvent('slotRequested', { slot }); + if (order === 'timeout-first') { + setTimeout( + () => + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: order, + slot, + }), + 10_000 + ); + } + await vi.advanceTimersByTimeAsync(10_000); + + await expect(request.result).resolves.toMatchObject( + order === 'event-first' ? { status: 'rendered' } : { reason: 'gpt_completion_timeout' } + ); + } + ); + + it('deduplicates a response identifier without completing a replacement cycle', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const first = service.request({ + intentId: 'first', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'duplicate', + slot, + }); + await expect(first.result).resolves.toMatchObject({ status: 'rendered' }); + + const second = service.request({ + intentId: 'second', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'duplicate', + slot, + }); + await vi.advanceTimersByTimeAsync(10_000); + await expect(second.result).resolves.toEqual({ + reason: 'gpt_completion_timeout', + status: 'failed', + }); + }); + + it('recovers a completion timeout through the exact destroy/redefine transaction', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const disposeCommittedArtifact = vi.fn(); + const service = createSlotService({ + bindCommittedArtifactRetirement, + disposeCommittedArtifact, + googletag: harness.adapter, + }); + const navigation = createNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + const artifact = committedArtifact(navigation.generation); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', artifact)).toBe(true); + const first = service.request({ + intentId: 'completion-timeout', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot: oldSlot }); + await vi.advanceTimersByTimeAsync(10_000); + await expect(first.result).resolves.toMatchObject({ reason: 'gpt_completion_timeout' }); + await Promise.resolve(); + const replacement = harness.defineSlot.mock.results[0]?.value; + if (typeof replacement !== 'object' || replacement === null) { + throw new Error('Expected completion-timeout replacement'); + } + expect(harness.destroySlots).toHaveBeenCalledExactlyOnceWith([oldSlot]); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + navigation.generation, + 'slot', + artifact + ); + + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'late-completion', + slot: oldSlot, + }); + const recovered = service.request({ + intentId: 'recovered', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + expect(recovered.status).toBe('active'); + expect(harness.refresh).toHaveBeenLastCalledWith( + [replacement], + Object.freeze({ changeCorrelator: false }) + ); + service.handleGptEvent('slotRequested', { slot: replacement }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'replacement-completion', + slot: replacement, + }); + await expect(recovered.result).resolves.toEqual({ + responseIdentifier: 'replacement-completion', + status: 'rendered', + }); + }); + + it('never releases publisher request-timeout quarantine from later GPT events', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = { publisher: true }; + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + ownership: 'publisher', + slot, + }) + ).toEqual({ ok: true }); + const timedOut = service.request({ + intentId: 'publisher-timeout', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(timedOut.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'unattributable-late', + slot, + }); + const later = service.request({ + intentId: 'publisher-later', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await expect(later.result).resolves.toEqual({ + reason: 'gpt_request_failed', + status: 'failed', + }); + }); + + it.each(['throw', 'false'] as const)( + 'keeps one retired object and quarantines failed request-timeout recovery: %s', + async (failure) => { + vi.useFakeTimers(); + const harness = createGptHarness(); + if (failure === 'throw') { + harness.destroySlots.mockImplementation(() => { + throw new Error('destroy failed'); + }); + } else { + harness.destroySlots.mockReturnValue(false); + } + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const timedOut = service.request({ + intentId: 'timed-out', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(timedOut.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + const later = service.request({ + intentId: 'later', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await expect(later.result).resolves.toEqual({ + reason: 'gpt_request_failed', + status: 'failed', + }); + expect(harness.defineSlot).not.toHaveBeenCalled(); + expect(service.snapshotForTest().physicalSlots).toBe(1); + } + ); + + it('binds one successful request-timeout replacement and ignores events from the retired object', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + const timedOut = service.request({ + intentId: 'timed-out', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(timedOut.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + const replacement = harness.defineSlot.mock.results[0]?.value; + if (typeof replacement !== 'object' || replacement === null) { + throw new Error('Expected a replacement slot'); + } + + service.handleGptEvent('slotRequested', { slot: oldSlot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'retired-old', + slot: oldSlot, + }); + const later = service.request({ + intentId: 'later', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + expect(harness.refresh).toHaveBeenLastCalledWith( + [replacement], + Object.freeze({ changeCorrelator: false }) + ); + service.handleGptEvent('slotRequested', { slot: replacement }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'replacement', + slot: replacement, + }); + await expect(later.result).resolves.toMatchObject({ status: 'rendered' }); + }); + + it('destroys a replacement created after generation became stale and never binds it', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + harness.defineSlot.mockImplementation((_path, _sizes, elementId) => { + const replacement = { elementId, replacement: true }; + navigation.dispose(); + return replacement; + }); + const request = service.request({ + intentId: 'stale', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect(harness.destroySlots).toHaveBeenCalledTimes(2); + expect(service.resolveRegisteredSlot('slot')).toBeUndefined(); + }); + + it('keeps publisher-owned navigation quarantine until its exact completion', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = { publisher: true }; + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + ownership: 'publisher', + slot, + }) + ).toEqual({ ok: true }); + service.recordPublisherIntent(slot); + service.handleGptEvent('slotRequested', { slot }); + navigation.dispose(); + expect(harness.destroySlots).not.toHaveBeenCalled(); + + const next = createNavigation(); + expect(service.register(next, [serverRegistration('next')])).toMatchObject({ ok: true }); + expect(service.adoptGptSlot(next.generation, 'next', { ownership: 'publisher', slot })).toEqual( + { ok: false, reason: 'slot_quarantined' } + ); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'old-navigation', + slot, + }); + expect(service.adoptGptSlot(next.generation, 'next', { ownership: 'publisher', slot })).toEqual( + { ok: true } + ); + }); + + it('blocks an active publisher placement across navigation until its completion drains', () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const registration = serverRegistration('slot', { + adUnitCode: '/network/slot', + domAliases: ['slot-div'], + }); + const slot = { publisher: true }; + expect(service.register(navigation, [registration])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { ownership: 'publisher', slot }) + ).toEqual({ ok: true }); + expect(service.recordPublisherIntent(slot)).toBe(true); + service.handleGptEvent('slotRequested', { slot }); + navigation.dispose(); + + const next = createNavigation(); + expect(service.register(next, [registration])).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + expect(service.register(next, [registration])).toMatchObject({ ok: true }); + }); + + it.each(['before', 'after'] as const)( + 'keeps an old completion inert %s replacement completion on the same DOM id', + async (order) => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + const oldRequest = service.request({ + intentId: 'old', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot: oldSlot }); + + const replaced = runtime.replaceNavigation(); + if (!replaced.ok) throw new Error('Expected replacement navigation'); + await expect(oldRequest.result).resolves.toMatchObject({ reason: 'navigation_disposed' }); + const newSlot = bindTrustedSlot(service, replaced.value); + const newRequest = service.request({ + intentId: 'new', + navigationGeneration: replaced.value.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot: newSlot }); + const finishOld = () => + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: `old-${order}`, + slot: oldSlot, + }); + const finishNew = () => + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: `new-${order}`, + slot: newSlot, + }); + if (order === 'before') { + finishOld(); + finishNew(); + } else { + finishNew(); + finishOld(); + } + + await expect(newRequest.result).resolves.toEqual({ + responseIdentifier: `new-${order}`, + status: 'rendered', + }); + expect(service.snapshotForTest().cycles).toBe(0); + } + ); + + it('releases a navigation-disposed TS physical slot with no late cycle bookkeeping', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + + navigation.dispose(); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.destroySlots).toHaveBeenCalledTimes(1); + expect(service.snapshotForTest()).toMatchObject({ physicalSlots: 0, records: 0 }); + }); +}); + +describe('Task 11 adversarial ownership review', () => { + afterEach(() => vi.useRealTimers()); + + it('accepts paired UTF-16 surrogates and rejects unpaired identities and aliases', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + + expect(service.register(navigation, [serverRegistration('paired-😀')])).toMatchObject({ + ok: true, + }); + + for (const registration of [ + serverRegistration('broken-\ud800'), + serverRegistration('broken-\udc00'), + serverRegistration('slot', { adUnitCode: 'path-\ud800' }), + serverRegistration('slot', { domAliases: ['alias-\udc00'] }), + ]) { + expect(service.register(navigation, [registration])).toEqual({ + ok: false, + reason: 'invalid_slot_id', + }); + } + }); + + it('re-adopts an idle publisher object without retaining its old navigation strongly', () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const slot = { publisher: true }; + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { ownership: 'publisher', slot }) + ).toEqual({ ok: true }); + + const next = runtime.replaceNavigation(); + if (!next.ok) throw new Error('Expected replacement navigation'); + expect(service.snapshotForTest()).toMatchObject({ physicalSlots: 0, records: 0 }); + expect(service.register(next.value, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(next.value.generation, 'slot', { ownership: 'publisher', slot }) + ).toEqual({ ok: true }); + expect(service.snapshotForTest().physicalSlots).toBe(1); + }); + + it('rejects an existing GPT identity when the destination record already owns another object', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const first = {}; + const second = {}; + expect( + service.register(navigation, [serverRegistration('one'), serverRegistration('two')]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'one', { ownership: 'publisher', slot: first }) + ).toEqual({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'two', { ownership: 'publisher', slot: second }) + ).toEqual({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'two', { ownership: 'publisher', slot: first }) + ).toEqual({ ok: false, reason: 'gpt_object_collision' }); + }); + + it('releases an exact publisher quarantine only through explicit publisher destruction', async () => { + vi.useFakeTimers(); + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const slot = { publisher: true }; + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { ownership: 'publisher', slot }) + ).toEqual({ ok: true }); + const request = service.request({ + intentId: 'publisher-timeout', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + + const next = runtime.replaceNavigation(); + if (!next.ok) throw new Error('Expected replacement navigation'); + expect(service.register(next.value, [serverRegistration('slot')])).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + expect(service.recordPublisherDestruction(slot)).toBe(true); + expect(service.register(next.value, [serverRegistration('slot')])).toMatchObject({ ok: true }); + }); + + it('quarantines every failed TS placement key and never retries its destroy on navigation', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + harness.destroySlots.mockReturnValue(false); + const service = createSlotService({ googletag: harness.adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'timeout', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + expect(harness.destroySlots).toHaveBeenCalledTimes(1); + + const next = runtime.replaceNavigation(); + if (!next.ok) throw new Error('Expected replacement navigation'); + await Promise.resolve(); + expect(harness.destroySlots).toHaveBeenCalledTimes(1); + for (const registration of [ + serverRegistration('slot'), + serverRegistration('other-id', { adUnitCode: '/network/slot' }), + serverRegistration('other-alias', { domAliases: ['slot-div'] }), + ]) { + expect(service.register(next.value, [registration])).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + } + expect(service.recordPublisherDestruction(slot)).toBe(true); + expect(service.register(next.value, [serverRegistration('slot')])).toMatchObject({ ok: true }); + }); + + it('requires a usable replacement definition for trusted-server adoption', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + ownership: 'trusted_server', + slot: {}, + }) + ).toEqual({ ok: false, reason: 'gpt_request_failed' }); + }); + + it('reads a replacement definition once and owns an immutable placement snapshot', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + expect( + service.register(navigation, [ + serverRegistration('slot', { + adUnitCode: '/network/original', + domAliases: ['original-div'], + }), + ]) + ).toMatchObject({ ok: true }); + let adUnitPath = '/network/original'; + let elementId = 'original-div'; + const sizes = [[300, 250]]; + const reads = { adUnitPath: 0, elementId: 0, sizes: 0 }; + const definition = { + get adUnitPath() { + reads.adUnitPath += 1; + return adUnitPath; + }, + get elementId() { + reads.elementId += 1; + return elementId; + }, + get sizes() { + reads.sizes += 1; + return sizes; + }, + }; + const slot = { original: true }; + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition, + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + expect(reads).toEqual({ adUnitPath: 1, elementId: 1, sizes: 1 }); + + adUnitPath = '/network/redirected'; + elementId = 'redirected-div'; + sizes[0] = [999, 999]; + const request = service.request({ + intentId: 'immutable-definition', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect(reads).toEqual({ adUnitPath: 1, elementId: 1, sizes: 1 }); + expect(harness.defineSlot).toHaveBeenCalledWith( + '/network/original', + [[300, 250]], + 'original-div' + ); + }); + + it.each(['outer-array', 'inner-pair'] as const)( + 'contains a hostile replacement sizes graph without adoption mutation: %s', + (failure) => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ + ok: true, + }); + const innerPair = new Proxy([300, 250], { + get: (target, key, receiver) => { + if (failure === 'inner-pair' && key === '0') throw new Error('hostile pair index'); + return Reflect.get(target, key, receiver); + }, + }); + const sizes = new Proxy([innerPair], { + get: (target, key, receiver) => { + if (failure === 'outer-array' && key === 'length') { + throw new Error('hostile sizes length'); + } + return Reflect.get(target, key, receiver); + }, + }); + const inventory = service.snapshotForTest(); + + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition: { + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes, + }, + ownership: 'trusted_server', + slot: {}, + }) + ).toEqual({ ok: false, reason: 'gpt_request_failed' }); + expect(service.snapshotForTest()).toEqual(inventory); + expect(service.resolveRegisteredSlot('slot')).toBeDefined(); + } + ); + + it('counts multiple publisher intents and preserves two publisher cycles', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + expect(service.recordPublisherIntent(slot)).toBe(true); + expect(service.recordPublisherIntent(slot)).toBe(true); + + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + service.handleGptEvent('slotRequested', { slot }); + expect(service.snapshotForTest().cycles).toBe(1); + service.handleGptEvent('slotRenderEnded', { isEmpty: true, slot }); + expect(service.snapshotForTest().cycles).toBe(0); + }); + + it('returns an exact accepted cycle handle and retires it on replacement and navigation', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + + const first = service.handleGptEvent('slotRequested', { slot }); + expect(Object.isFrozen(first)).toBe(true); + expect(Reflect.ownKeys(first ?? {})).toEqual(['isRetired']); + expect(first?.isRetired()).toBe(false); + expect(service.handleGptEvent('slotRequested', { slot })).toBeUndefined(); + expect( + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'first-response', + slot, + }) + ).toBe(first); + expect(first?.isRetired()).toBe(false); + + const second = service.handleGptEvent('slotRequested', { slot }); + expect(second).not.toBe(first); + expect(first?.isRetired()).toBe(true); + expect(second?.isRetired()).toBe(false); + + navigation.dispose(); + expect(second?.isRetired()).toBe(true); + }); + + it('bounds publisher intent accounting and fails closed on overflow', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + for (let index = 0; index < 64; index += 1) { + expect(service.recordPublisherIntent(slot)).toBe(true); + } + expect(service.recordPublisherIntent(slot)).toBe(false); + + const blocked = service.request({ + intentId: 'publisher-overflow', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(blocked.result).resolves.toEqual({ + reason: 'gpt_request_failed', + status: 'failed', + }); + }); + + it('fails and conservatively drains a TS cycle overlapped by publisher intent', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const active = service.request({ + intentId: 'active', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + + expect(service.recordPublisherIntent(slot)).toBe(true); + await expect(active.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + const blocked = service.request({ + intentId: 'blocked', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(blocked.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + service.handleGptEvent('slotRequested', { slot }); + expect(service.snapshotForTest().cycles).toBe(1); + }); + + it('rejects the first opposite-class queued request with the active intent', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const active = service.request({ + intentId: 'active', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + const opposite = service.request({ + intentId: 'opposite', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'fallback', + }); + + await expect(active.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + await expect(opposite.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + }); + + it('quarantines a synchronous requested cycle when the external invocation then throws', async () => { + const harness = createGptHarness({ synchronousRun: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + harness.refresh.mockImplementation(() => { + service.handleGptEvent('slotRequested', { slot }); + throw new Error('after-side-effect'); + }); + const request = service.request({ + intentId: 'partial', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_failed' }); + const blocked = service.request({ + intentId: 'blocked', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(blocked.result).resolves.toMatchObject({ reason: 'slot_quarantined' }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + }); + + it('keeps a shared synchronous SRA operation alive for an unfinished sibling', async () => { + const harness = createGptHarness({ synchronousRun: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'first'); + const second = bindTrustedSlot(service, navigation, 'second'); + harness.refresh.mockImplementation(() => { + service.handleGptEvent('slotRequested', { slot: first }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot: first }); + service.handleGptEvent('slotRequested', { slot: second }); + }); + const requests = service.requestBatch([ + { + intentId: 'first', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first', + requestClass: 'primary', + }, + { + intentId: 'second', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'second', + requestClass: 'primary', + }, + ]); + await expect(requests[0]?.result).resolves.toMatchObject({ status: 'rendered' }); + expect(requests[1]?.status).toBe('active'); + expect( + harness.operationDisposals[harness.operationDisposals.length - 1] + ).not.toHaveBeenCalled(); + service.handleGptEvent('slotRenderEnded', { isEmpty: true, slot: second }); + await expect(requests[1]?.result).resolves.toMatchObject({ status: 'empty' }); + }); + + it('does not invoke an SRA batch after its subscription continuation is disposed', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation, 'deferred-first'); + bindTrustedSlot(service, navigation, 'deferred-second'); + const requests = service.requestBatch([ + { + intentId: 'deferred-first', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'deferred-first', + requestClass: 'primary', + }, + { + intentId: 'deferred-second', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'deferred-second', + requestClass: 'primary', + }, + ]); + navigation.dispose(); + + await expect(Promise.all(requests.map(({ result }) => result))).resolves.toEqual([ + { reason: 'navigation_disposed', status: 'cancelled' }, + { reason: 'navigation_disposed', status: 'cancelled' }, + ]); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.refresh).not.toHaveBeenCalled(); + }); + + it('enforces delayed-handler deadlines from invocation with a monotonic injected clock', async () => { + vi.useFakeTimers(); + let current = 100; + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter, now: () => current }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'delayed', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + current = 3_101; + service.handleGptEvent('slotRequested', { slot }); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + }); + + it('does not let a timer fire before the injected clock reaches its deadline', async () => { + vi.useFakeTimers(); + let current = 0; + const service = createSlotService({ + googletag: createGptHarness().adapter, + now: () => current, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'lagged-clock', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + current = 2_999; + await vi.advanceTimersByTimeAsync(3_000); + expect(request.status).toBe('active'); + current = 3_000; + await vi.advanceTimersByTimeAsync(1); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + }); + + it('fails closed on malformed completion truth instead of rendering it', async () => { + vi.useFakeTimers(); + const malformedEvents = [ + {}, + { isEmpty: 'false' }, + Object.defineProperty({}, 'isEmpty', { get: () => false }), + ]; + for (let index = 0; index < malformedEvents.length; index += 1) { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation, `slot-${index}`); + const request = service.request({ + intentId: `malformed-${index}`, + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: `slot-${index}`, + requestClass: 'primary', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + const event = { slot }; + const malformed = malformedEvents[index]; + const descriptor = malformed + ? Object.getOwnPropertyDescriptor(malformed, 'isEmpty') + : undefined; + if (descriptor) Object.defineProperty(event, 'isEmpty', descriptor); + service.handleGptEvent('slotRenderEnded', event); + expect(request.status).toBe('active'); + await vi.advanceTimersByTimeAsync(10_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_completion_timeout' }); + } + }); + + it('enforces the completion deadline in the handler when timer delivery is blocked', async () => { + vi.useFakeTimers(); + let current = 0; + const harness = createGptHarness(); + const service = createSlotService({ + googletag: harness.adapter, + now: () => current, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'blocked-completion-timer', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + current = 100; + service.handleGptEvent('slotRequested', { slot }); + current = 10_001; + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_completion_timeout' }); + expect(service.snapshotForTest().cycles).toBe(0); + const replacement = harness.defineSlot.mock.results[0]?.value; + if (typeof replacement !== 'object' || replacement === null) { + throw new Error('Expected handler-enforced timeout replacement'); + } + + const next = service.request({ + intentId: 'after-late-exact-completion', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + expect(harness.refresh).toHaveBeenCalledTimes(2); + service.handleGptEvent('slotRequested', { slot: replacement }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot: replacement }); + await expect(next.result).resolves.toMatchObject({ status: 'rendered' }); + }); + + it('fails active and queued work when publisher intent overlaps the opened TS cycle', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const active = service.request({ + intentId: 'active', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + const queued = service.request({ + intentId: 'queued', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + + expect(service.recordPublisherIntent(slot)).toBe(true); + await expect(active.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + await expect(queued.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + }); + + it('keeps promoted listeners across an async command that emits synchronously and then throws', async () => { + const commands: Array<() => void> = []; + const listeners = new Map void>>(); + const pubads = { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const current = listeners.get(type) ?? new Set(); + current.add(listener); + listeners.set(type, current); + }, + getSlots: () => [slot], + refresh: vi.fn(() => { + for (const listener of listeners.get('slotRequested') ?? []) listener({ slot }); + throw new Error('after synchronous event'); + }), + removeEventListener: (type: string, listener: (event: unknown) => void) => { + listeners.get(type)?.delete(listener); + }, + }; + const adapter = createBrowserGoogletagAdapter({ + googletag: { + apiReady: true, + cmd: { push: (command: () => void) => commands.push(command) }, + display: vi.fn(), + pubads: () => pubads, + pubadsReady: true, + }, + }); + const service = createSlotService({ googletag: adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'async-partial', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + commands.shift()?.(); + await Promise.resolve(); + await Promise.resolve(); + commands.shift()?.(); + + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_failed' }); + expect(listeners.get('slotRequested')?.size).toBe(1); + expect(listeners.get('slotRenderEnded')?.size).toBe(1); + for (const listener of listeners.get('slotRenderEnded') ?? []) { + listener({ isEmpty: false, slot }); + } + }); + + it('quarantines every synchronously opened SRA cycle when shared refresh throws', async () => { + const harness = createGptHarness({ synchronousRun: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'first-partial'); + const second = bindTrustedSlot(service, navigation, 'second-partial'); + harness.refresh.mockImplementation(() => { + service.handleGptEvent('slotRequested', { slot: first }); + service.handleGptEvent('slotRequested', { slot: second }); + throw new Error('shared refresh failed'); + }); + const requests = service.requestBatch([ + { + intentId: 'first-partial', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first-partial', + requestClass: 'primary', + }, + { + intentId: 'second-partial', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'second-partial', + requestClass: 'primary', + }, + ]); + + await expect(Promise.all(requests.map(({ result }) => result))).resolves.toEqual([ + { reason: 'gpt_request_failed', status: 'failed' }, + { reason: 'gpt_request_failed', status: 'failed' }, + ]); + expect(service.snapshotForTest().cycles).toBe(2); + }); + + it('tracks an exact orphan candidate until publisher destruction releases its placement', async () => { + vi.useFakeTimers(); + const orphan = { orphan: true }; + const harness = createGptHarness({ orphanOnReplace: orphan }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'orphan', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + expect(service.recordPublisherDestruction(orphan)).toBe(true); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition: { + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot: { replacementAfterOrphan: true }, + }) + ).toEqual({ ok: true }); + }); + + it('retains a reused old identity when rejecting it cannot destroy the candidate', async () => { + vi.useFakeTimers(); + const harness = createGptHarness({ returnOldOnReplace: true }); + harness.destroySlots.mockReturnValueOnce(true).mockReturnValueOnce(false); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'reused-old-orphan', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition: { + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot: { blocked: true }, + }) + ).toEqual({ ok: false, reason: 'slot_quarantined' }); + expect(service.recordPublisherDestruction(oldSlot)).toBe(true); + }); + + it.each([true, false])( + 'never cleans or republishes a replacement candidate owned by another record: cleanup=%s', + async (candidateCleanupWouldSucceed) => { + vi.useFakeTimers(); + const harness = createReplacementHarness(); + harness.destroySlots + .mockReturnValueOnce(true) + .mockReturnValueOnce(candidateCleanupWouldSucceed); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const firstDefinition = Object.freeze({ + adUnitPath: '/network/first', + elementId: 'first-div', + sizes: Object.freeze([[300, 250]]), + }); + const secondDefinition = Object.freeze({ + adUnitPath: '/network/second', + elementId: 'second-div', + sizes: Object.freeze([[300, 250]]), + }); + const oldSlot = { old: true }; + expect( + service.register(navigation, [ + serverRegistration('first', { + adUnitCode: firstDefinition.adUnitPath, + domAliases: [firstDefinition.elementId], + }), + serverRegistration('second', { + adUnitCode: secondDefinition.adUnitPath, + domAliases: [secondDefinition.elementId], + }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'first', { + definition: firstDefinition, + ownership: 'trusted_server', + slot: oldSlot, + }) + ).toEqual({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'second', { + definition: secondDefinition, + ownership: 'trusted_server', + slot: harness.replacement, + }) + ).toEqual({ ok: true }); + const request = service.request({ + intentId: `collision-${String(candidateCleanupWouldSucceed)}`, + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect(harness.destroySlots).toHaveBeenCalledOnce(); + expect(harness.replacement.addService).not.toHaveBeenCalled(); + expect( + service.adoptGptSlot(navigation.generation, 'second', { + definition: secondDefinition, + ownership: 'trusted_server', + slot: harness.replacement, + }) + ).toEqual({ ok: true }); + const blocked = service.request({ + intentId: 'original-remains-quarantined', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first', + requestClass: 'primary', + }); + await expect(blocked.result).resolves.toMatchObject({ reason: 'gpt_request_failed' }); + } + ); + + it('leaves a clean define failure unbound and immediately re-adoptable', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + harness.defineSlot.mockReturnValue(undefined); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'define-failure', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition: { + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot: { retry: true }, + }) + ).toEqual({ ok: true }); + }); + + it('deletes a stale destroyed identity so a later navigation may adopt it', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + harness.defineSlot.mockImplementation((_path, _sizes, elementId) => { + const candidate = { elementId }; + runtime.replaceNavigation(); + return candidate; + }); + const request = service.request({ + intentId: 'stale-destroyed', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + const next = runtime.currentNavigation; + if (!next) throw new Error('Expected replacement navigation'); + expect(service.register(next, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(next.generation, 'slot', { ownership: 'publisher', slot: oldSlot }) + ).toEqual({ ok: true }); + }); + + it.each(['single', 'batch'] as const)( + 'rolls back provisional service subscription admission after %s preflight rejection', + async (kind) => { + const harness = createGptHarness({ synchronousRun: true }); + const subscribe = vi.fn((_type: string, _listener: (event: unknown) => void) => vi.fn()); + const facade = Object.freeze({ ...harness.facade, subscribe }); + let rejectNext = true; + const adapter: GoogletagAdapter = Object.freeze({ + bindingStatus: () => 'present', + enqueueGamAttribution: vi.fn(() => true), + diagnosticsIdentity: () => undefined, + dispose: vi.fn(), + notifyReady: vi.fn(), + observeDiagnostics: () => vi.fn(), + observePublisherCalls: () => vi.fn(), + traceToken: () => undefined, + run: (command: (gpt: Readonly) => T) => { + let value: T; + try { + value = command(facade); + } catch (error) { + return Object.freeze({ + status: 'present' as const, + result: Promise.reject(error), + dispose: vi.fn(), + }); + } + const result = rejectNext + ? Promise.reject(new Error('post-command rejection')) + : Promise.resolve(value); + rejectNext = false; + return Object.freeze({ status: 'present' as const, result, dispose: vi.fn() }); + }, + }); + const service = createSlotService({ googletag: adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const input = { + intentId: 'preflight', + navigationGeneration: navigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'slot', + requestClass: 'primary', + }; + const failed = kind === 'single' ? [service.request(input)] : service.requestBatch([input]); + await expect(failed[0]?.result).resolves.toMatchObject({ reason: 'gpt_request_failed' }); + const retried = service.request({ ...input, intentId: 'retry' }); + await Promise.resolve(); + + expect(subscribe).toHaveBeenCalledTimes(4); + retried.dispose(); + } + ); + + it('fails closed after bounded placement quarantine storage saturates', () => { + const harness = createGptHarness(); + harness.destroySlots.mockReturnValue(false); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + for (let recordIndex = 0; recordIndex < 9; recordIndex += 1) { + const id = `saturated-${recordIndex}`; + const aliases = Array.from({ length: 256 }, (_, aliasIndex) => `${id}-alias-${aliasIndex}`); + expect( + service.register(navigation, [ + serverRegistration(id, { adUnitCode: `/network/${id}`, domAliases: aliases }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, id, { + definition: { + adUnitPath: `/network/${id}`, + elementId: aliases[0] ?? `${id}-div`, + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot: { id }, + }) + ).toEqual({ ok: true }); + } + navigation.dispose(); + const next = createNavigation(); + expect(service.register(next, [serverRegistration('unrelated')])).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + }); + + it('clears saturated placement quarantine only after every saturated owner releases once', () => { + const harness = createGptHarness(); + harness.destroySlots.mockReturnValue(false); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const oldSlots: object[] = []; + for (let recordIndex = 0; recordIndex < 9; recordIndex += 1) { + const id = `recover-saturated-${recordIndex}`; + const aliases = Array.from({ length: 256 }, (_, aliasIndex) => `${id}-${aliasIndex}`); + const slot = { id }; + oldSlots[oldSlots.length] = slot; + expect( + service.register(navigation, [ + serverRegistration(id, { adUnitCode: `/network/${id}`, domAliases: aliases }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, id, { + definition: { + adUnitPath: `/network/${id}`, + elementId: aliases[0] ?? `${id}-div`, + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + } + navigation.dispose(); + const next = createNavigation(); + expect(service.register(next, [serverRegistration('unrelated-after-saturation')])).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + + for (let index = 0; index < oldSlots.length - 1; index += 1) { + expect(service.recordPublisherDestruction(oldSlots[index] as object)).toBe(true); + } + expect(service.recordPublisherDestruction(oldSlots[7] as object)).toBe(false); + expect(service.register(next, [serverRegistration('unrelated-after-saturation')])).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + + expect(service.recordPublisherDestruction(oldSlots[8] as object)).toBe(true); + expect( + service.register(next, [serverRegistration('unrelated-after-saturation')]) + ).toMatchObject({ + ok: true, + }); + }); + + it.each(['throw-before', 'mutate-then-throw'] as const)( + 'releases only confirmed shared-key quarantine increments: %s', + async (failure) => { + const originalSet = Map.prototype.set; + let poison = false; + Map.prototype.set = function ( + this: Map, + key: Key, + value: Value + ): Map { + const targeted = poison && key === ('ad-unit:/shared' as Key) && value === (2 as Value); + if (targeted && failure === 'throw-before') throw new Error('failed before increment'); + const result = Reflect.apply(originalSet, this, [key, value]) as Map; + if (targeted) throw new Error('failed after increment'); + return result; + }; + vi.resetModules(); + let fresh: typeof import('../../src/services/slots'); + try { + fresh = await import('../../src/services/slots'); + } finally { + Map.prototype.set = originalSet; + } + const harness = createGptHarness(); + harness.destroySlots.mockReturnValue(false); + const service = fresh.createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const firstSlot = { first: true }; + const secondSlot = { second: true }; + for (const [id, slot] of [ + ['first', firstSlot], + ['second', secondSlot], + ] as const) { + expect( + service.register(navigation, [ + serverRegistration(id, { adUnitCode: '/shared', domAliases: [`${id}-div`] }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, id, { + definition: { + adUnitPath: `/network/${id}`, + elementId: `${id}-div`, + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + } + poison = true; + navigation.dispose(); + const next = createNavigation(); + + expect(service.recordPublisherDestruction(secondSlot)).toBe(true); + expect(service.recordPublisherDestruction(secondSlot)).toBe(false); + expect( + service.register(next, [serverRegistration('third', { adUnitCode: '/shared' })]) + ).toEqual({ ok: false, reason: 'slot_quarantined' }); + + expect(service.recordPublisherDestruction(firstSlot)).toBe(true); + expect( + service.register(next, [serverRegistration('third', { adUnitCode: '/shared' })]) + ).toMatchObject({ ok: true }); + } + ); + + it('rolls back a Map publication whose captured set mutates and then throws', async () => { + const originalSet = Map.prototype.set; + let poison = false; + Map.prototype.set = function (this: Map, key: K, value: V): Map { + const result = Reflect.apply(originalSet, this, [key, value]) as Map; + if (poison && key === 'mutate-then-throw-slot') throw new Error('mutated then threw'); + return result; + }; + vi.resetModules(); + let fresh: typeof import('../../src/services/slots'); + try { + fresh = await import('../../src/services/slots'); + } finally { + Map.prototype.set = originalSet; + } + const service = fresh.createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + poison = true; + expect(service.register(navigation, [serverRegistration('mutate-then-throw-slot')])).toEqual({ + ok: false, + reason: 'stale_owner', + }); + poison = false; + expect(service.resolveRegisteredSlot('mutate-then-throw-slot')).toBeUndefined(); + expect(service.snapshotForTest().records).toBe(0); + }); + + it('uses captured iterator next intrinsics after publisher prototype poisoning', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const mapIteratorPrototype = Object.getPrototypeOf(new Map().values()) as { + next: () => IteratorResult; + }; + const setIteratorPrototype = Object.getPrototypeOf(new Set().values()) as { + next: () => IteratorResult; + }; + const mapNext = mapIteratorPrototype.next; + const setNext = setIteratorPrototype.next; + mapIteratorPrototype.next = () => { + throw new Error('poisoned map iterator'); + }; + setIteratorPrototype.next = () => { + throw new Error('poisoned set iterator'); + }; + try { + expect(service.snapshotForTest()).toMatchObject({ physicalSlots: 1, records: 1 }); + expect(() => service.dispose()).not.toThrow(); + } finally { + mapIteratorPrototype.next = mapNext; + setIteratorPrototype.next = setNext; + } + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/targeting.test.ts b/crates/trusted-server-js/lib/test/services/targeting.test.ts new file mode 100644 index 000000000..a3dfd09f2 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -0,0 +1,832 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createBrowserGoogletagAdapter } from '../../src/adapters/googletag'; +import { createTargetingService } from '../../src/services/targeting'; + +function createTargetingHarness(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + const clearTargeting = vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }); + const getTargeting = vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])); + const setTargeting = vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + return { clearTargeting, getTargeting, setTargeting, values }; +} + +describe('owner-aware targeting journal', () => { + it('adopts an installed first-display value without rewriting it and restores its predecessor', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['trusted'] }); + + const frame = service.adopt( + slot, + 'key', + 'trusted', + Object.freeze(['publisher']), + 'first-display-owner', + targeting + ); + + expect(frame).toBeDefined(); + expect(targeting.setTargeting).not.toHaveBeenCalled(); + frame?.release(); + expect(targeting.values.get('key')).toEqual(['publisher']); + }); + + it('rejects adoption when its targeting read observes a same-value publisher write', async () => { + const values = new Map([['key', ['trusted']]]); + let reenter = true; + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => { + if (reenter) { + reenter = false; + slot.setTargeting(key, 'trusted'); + } + return Object.freeze([...(values.get(key) ?? [])]); + }), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + }; + const adapter = adapterForTargetingSlot(slot); + const service = createTargetingService(); + await expect(service.observePublisherMutations(slot, adapter).result).resolves.toBeUndefined(); + + const ownership = await adapter.run((gpt) => + service.adopt(slot, 'key', 'trusted', Object.freeze(['publisher']), 'handoff-owner', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + + expect(ownership).toBeUndefined(); + ownership?.release(); + expect(values.get('key')).toEqual(['trusted']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('restores the exact publisher predecessor after the current TS owner releases', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const frame = service.own(slot, 'key', 'trusted', 'owner-one', targeting); + expect(frame).toBeDefined(); + expect(targeting.values.get('key')).toEqual(['trusted']); + + frame?.release(); + + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(targeting.setTargeting).toHaveBeenLastCalledWith('key', ['publisher']); + }); + + it('keeps equal-string generations distinct and rebases non-top release without a GPT write', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const older = service.own(slot, 'key', 'same', 'older', targeting); + const newer = service.own(slot, 'key', 'same', 'newer', targeting); + targeting.setTargeting.mockClear(); + + older?.release(); + expect(targeting.setTargeting).not.toHaveBeenCalled(); + expect(targeting.clearTargeting).not.toHaveBeenCalled(); + newer?.release(); + + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(targeting.setTargeting).toHaveBeenCalledExactlyOnceWith('key', ['publisher']); + }); + + it.each(['same', 'different'] as const)( + 'invalidates the restoration chain before a publisher %s-value write', + (publisherValue) => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const frame = service.own(slot, 'key', 'same', 'owner', targeting); + + service.invalidatePublisherMutation(slot, 'key'); + targeting.setTargeting('key', publisherValue === 'same' ? 'same' : 'publisher-new'); + targeting.setTargeting.mockClear(); + frame?.release(); + + expect(targeting.setTargeting).not.toHaveBeenCalled(); + expect(targeting.clearTargeting).not.toHaveBeenCalled(); + expect(targeting.values.get('key')).toEqual([ + publisherValue === 'same' ? 'same' : 'publisher-new', + ]); + } + ); + + it('invalidates one key or all keys for publisher clear operations', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ one: ['publisher-one'], two: ['publisher-two'] }); + const one = service.own(slot, 'one', 'ts-one', 'owner', targeting); + const two = service.own(slot, 'two', 'ts-two', 'owner', targeting); + service.invalidatePublisherMutation(slot, 'one'); + targeting.clearTargeting('one'); + one?.release(); + expect(targeting.values.get('one')).toBeUndefined(); + + service.invalidatePublisherMutation(slot); + targeting.clearTargeting(); + two?.release(); + expect(targeting.values.size).toBe(0); + }); + + it('drops a stale chain instead of overwriting a publisher mutation before the next TS write', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const stale = service.own(slot, 'key', 'old-ts', 'old-owner', targeting); + targeting.setTargeting('key', 'publisher-race'); + const current = service.own(slot, 'key', 'new-ts', 'new-owner', targeting); + stale?.release(); + current?.release(); + + expect(targeting.values.get('key')).toEqual(['publisher-race']); + }); + + it('preserves sibling-key journals when a stale key is replaced', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ one: ['publisher-one'], two: ['publisher-two'] }); + const stale = service.own(slot, 'one', 'old-one', 'old-owner', targeting); + const sibling = service.own(slot, 'two', 'trusted-two', 'sibling-owner', targeting); + targeting.setTargeting('one', 'publisher-race'); + + const current = service.own(slot, 'one', 'new-one', 'new-owner', targeting); + expect(service.snapshotForTest()).toEqual({ frames: 2, slots: 1 }); + stale?.release(); + current?.release(); + sibling?.release(); + + expect(targeting.values.get('one')).toEqual(['publisher-race']); + expect(targeting.values.get('two')).toEqual(['publisher-two']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('rolls back publication when setTargeting throws and contains cleanup failures', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + targeting.setTargeting.mockImplementationOnce(() => { + throw new Error('set failed'); + }); + + expect(() => service.own(slot, 'key', 'ts', 'owner', targeting)).toThrow('set failed'); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + + const frame = service.own(slot, 'key', 'ts', 'owner', targeting); + targeting.setTargeting.mockImplementationOnce(() => { + throw new Error('restore failed'); + }); + expect(() => frame?.release()).not.toThrow(); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + service.disposeOwner('owner'); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('uses the real adapter to invalidate before publisher set, per-key clear, and clear-all', async () => { + const values = new Map([['key', ['publisher']]]); + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + }; + const serviceObject = { + addEventListener: vi.fn(), + getSlots: () => [slot], + refresh: vi.fn(), + removeEventListener: vi.fn(), + }; + const googletag = { + apiReady: true, + cmd: { push: (command: () => void) => command() }, + display: vi.fn(), + pubads: () => serviceObject, + pubadsReady: true, + }; + const adapter = createBrowserGoogletagAdapter({ googletag }); + const service = createTargetingService(); + const observation = service.observePublisherMutations(slot, adapter); + await expect(observation.result).resolves.toBeUndefined(); + const write = adapter.run((gpt) => + service.own(slot, 'key', 'trusted', 'owner', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ); + const frame = await write.result; + expect(values.get('key')).toEqual(['trusted']); + + slot.setTargeting('key', 'publisher-new'); + frame?.release(); + expect(values.get('key')).toEqual(['publisher-new']); + + const perKey = await adapter.run((gpt) => + service.own(slot, 'key', 'trusted-two', 'owner-two', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + slot.clearTargeting('key'); + perKey?.release(); + expect(values.get('key')).toBeUndefined(); + + values.set('key', ['publisher-three']); + const clearAll = await adapter.run((gpt) => + service.own(slot, 'key', 'trusted-three', 'owner-three', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + slot.clearTargeting(); + clearAll?.release(); + expect(values.size).toBe(0); + }); + + it('invalidates a TS journal when its captured native setter reenters a same-value publisher set', async () => { + const values = new Map([['key', ['publisher']]]); + let reentered = false; + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + if (reentered) return; + reentered = true; + slot.setTargeting(key, value); + }), + }; + const adapter = adapterForTargetingSlot(slot); + const service = createTargetingService(); + await expect(service.observePublisherMutations(slot, adapter).result).resolves.toBeUndefined(); + + const frame = await adapter.run((gpt) => + service.own(slot, 'key', 'trusted', 'owner', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + frame?.release(); + + expect(values.get('key')).toEqual(['trusted']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it.each(['same_set', 'different_set', 'per_key_clear', 'clear_all'] as const)( + 'invalidates after publisher wrapper replacement for %s without calling that replacement on release', + async (mutation) => { + const values = new Map([ + ['key', ['publisher']], + ['sibling', ['publisher-sibling']], + ]); + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + }; + const adapter = adapterForTargetingSlot(slot); + const service = createTargetingService(); + await expect( + service.observePublisherMutations(slot, adapter).result + ).resolves.toBeUndefined(); + const frame = await adapter.run((gpt) => + service.own(slot, 'key', 'trusted', 'owner', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + + const publisherSet = vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + const publisherClear = vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }); + if (mutation === 'same_set' || mutation === 'different_set') { + slot.setTargeting = publisherSet; + slot.setTargeting('key', mutation === 'same_set' ? 'trusted' : 'publisher-new'); + } else { + slot.clearTargeting = publisherClear; + slot.clearTargeting(mutation === 'per_key_clear' ? 'key' : undefined); + } + + frame?.release(); + + expect(publisherSet).toHaveBeenCalledTimes( + mutation === 'same_set' || mutation === 'different_set' ? 1 : 0 + ); + expect(publisherClear).toHaveBeenCalledTimes( + mutation === 'per_key_clear' || mutation === 'clear_all' ? 1 : 0 + ); + if (mutation === 'same_set') expect(values.get('key')).toEqual(['trusted']); + else if (mutation === 'different_set') expect(values.get('key')).toEqual(['publisher-new']); + else expect(values.get('key')).toBeUndefined(); + if (mutation === 'clear_all') expect(values.size).toBe(0); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + } + ); + + it('invalidates when a targeting read replaces an observed wrapper during release', async () => { + const values = new Map([['key', ['publisher']]]); + const publisherReplacement = vi.fn((key: string, value: string | readonly string[]): void => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + }; + const adapter = adapterForTargetingSlot(slot); + const service = createTargetingService(); + await expect(service.observePublisherMutations(slot, adapter).result).resolves.toBeUndefined(); + const frame = service.own(slot, 'key', 'trusted', 'owner', { + clearTargeting: (key) => { + adapter.run((gpt) => gpt.clearTargeting(slot, key)); + }, + getTargeting: (key) => { + let current: readonly string[] = Object.freeze([]); + adapter.run((gpt) => { + current = gpt.getTargeting(slot, key); + }); + return current; + }, + setTargeting: (key, value) => { + adapter.run((gpt) => gpt.setTargeting(slot, key, value)); + }, + }); + slot.getTargeting.mockImplementationOnce((key: string) => { + slot.setTargeting = publisherReplacement; + return Object.freeze([...(values.get(key) ?? [])]); + }); + + frame?.release(); + + expect(publisherReplacement).not.toHaveBeenCalled(); + expect(values.get('key')).toEqual(['trusted']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); +}); + +function adapterForTargetingSlot(slot: object) { + const pubads = { + addEventListener: vi.fn(), + getSlots: () => [slot], + refresh: vi.fn(), + removeEventListener: vi.fn(), + }; + return createBrowserGoogletagAdapter({ + googletag: { + apiReady: true, + cmd: { push: (command: () => void) => command() }, + display: vi.fn(), + pubads: () => pubads, + pubadsReady: true, + }, + }); +} + +describe('adapter-owned targeting interception', () => { + it('suppresses TS facade writes and preserves publisher order, arguments, return, and throw', async () => { + const order: string[] = []; + const publisherError = new Error('native clear failed'); + const setTargeting = vi.fn((key: string, value: string) => { + order.push(`native-set:${key}:${value}`); + return 'native-result'; + }); + const clearTargeting = vi.fn(() => { + order.push('native-clear'); + throw publisherError; + }); + const slot = { clearTargeting, getTargeting: () => [], setTargeting }; + const adapter = adapterForTargetingSlot(slot); + const observer = vi.fn((_slot: object, key?: string) => order.push(`observer:${key ?? '*'}`)); + const operation = adapter.run((gpt) => { + gpt.observeTargeting(slot, { beforePublisherMutation: observer }); + gpt.setTargeting(slot, 'ts-key', 'ts-value'); + }); + await expect(operation.result).resolves.toBeUndefined(); + expect(observer).not.toHaveBeenCalled(); + order.length = 0; + + expect(slot.setTargeting('publisher-key', 'publisher-value')).toBe('native-result'); + expect(order).toEqual(['observer:publisher-key', 'native-set:publisher-key:publisher-value']); + order.length = 0; + expect(() => slot.clearTargeting()).toThrow(publisherError); + expect(order).toEqual(['observer:*', 'native-clear']); + }); + + it('uses one wrapper with independent observers and restores exactly after out-of-order release', async () => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const slot = { + clearTargeting: originalClear, + getTargeting: () => [], + setTargeting: originalSet, + }; + const adapter = adapterForTargetingSlot(slot); + const first = vi.fn(); + const second = vi.fn(); + const releases = await adapter.run( + (gpt) => + [ + gpt.observeTargeting(slot, { beforePublisherMutation: first }), + gpt.observeTargeting(slot, { beforePublisherMutation: second }), + ] as const + ).result; + const installedSet = slot.setTargeting; + + slot.setTargeting('both', 'value'); + expect(first).toHaveBeenCalledOnce(); + expect(second).toHaveBeenCalledOnce(); + releases[0](); + expect(slot.setTargeting).toBe(installedSet); + slot.setTargeting('second', 'value'); + expect(first).toHaveBeenCalledOnce(); + expect(second).toHaveBeenCalledTimes(2); + releases[1](); + + expect(slot.setTargeting).toBe(originalSet); + expect(slot.clearTargeting).toBe(originalClear); + slot.setTargeting('native', 'value'); + expect(second).toHaveBeenCalledTimes(2); + }); + + it('reports wrapper replacement fail-closed and never overwrites a publisher replacement', async () => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const replacementSet = vi.fn(); + const target = { + clearTargeting: originalClear, + getTargeting: () => [], + setTargeting: originalSet, + }; + let trapDescriptors = false; + const slot = new Proxy(target, { + getOwnPropertyDescriptor: (current, key) => { + if (trapDescriptors) throw new Error('publisher descriptor trap'); + return Reflect.getOwnPropertyDescriptor(current, key); + }, + }); + const adapter = adapterForTargetingSlot(slot); + const observation = await adapter.run((gpt) => + gpt.observeTargeting(slot, { beforePublisherMutation: vi.fn() }) + ).result; + + expect(observation.isCurrent()).toBe(true); + target.setTargeting = replacementSet; + expect(observation.isCurrent()).toBe(false); + observation(); + expect(target.setTargeting).toBe(replacementSet); + expect(target.clearTargeting).toBe(originalClear); + + const trapped = await adapter.run((gpt) => + gpt.observeTargeting(slot, { beforePublisherMutation: vi.fn() }) + ).result; + trapDescriptors = true; + expect(() => trapped.isCurrent()).not.toThrow(); + expect(trapped.isCurrent()).toBe(false); + expect(() => trapped()).not.toThrow(); + }); + + it('rolls back the first method when transactional observer installation cannot wrap the second', async () => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const slot = { getTargeting: () => [], setTargeting: originalSet } as unknown as { + clearTargeting: () => void; + getTargeting: () => readonly string[]; + setTargeting: (key: string, value: string) => void; + }; + Object.defineProperty(slot, 'clearTargeting', { + configurable: false, + value: originalClear, + writable: false, + }); + const adapter = adapterForTargetingSlot(slot); + const operation = adapter.run((gpt) => + gpt.observeTargeting(slot, { beforePublisherMutation: vi.fn() }) + ); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(slot.setTargeting).toBe(originalSet); + expect(slot.clearTargeting).toBe(originalClear); + }); + + it.each(['false', 'throw'] as const)( + 'compare-restores setTargeting when a Proxy define trap mutates then returns %s', + async (failure) => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const target = { + clearTargeting: originalClear, + getTargeting: () => [], + setTargeting: originalSet, + }; + let attempted = false; + const slot = new Proxy(target, { + defineProperty: (current, key, descriptor) => { + const result = Reflect.defineProperty(current, key, descriptor); + if (key === 'setTargeting' && !attempted) { + attempted = true; + if (failure === 'throw') throw new Error('mutated then threw'); + return false; + } + return result; + }, + }); + const adapter = adapterForTargetingSlot(slot); + const operation = adapter.run((gpt) => + gpt.observeTargeting(slot, { beforePublisherMutation: vi.fn() }) + ); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(target.setTargeting).toBe(originalSet); + expect(target.clearTargeting).toBe(originalClear); + } + ); + + it.each(['false', 'throw'] as const)( + 'restores both wrappers when the clearTargeting Proxy define trap mutates then returns %s', + async (failure) => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const target = { + clearTargeting: originalClear, + getTargeting: () => [], + setTargeting: originalSet, + }; + let attempted = false; + const slot = new Proxy(target, { + defineProperty: (current, key, descriptor) => { + const result = Reflect.defineProperty(current, key, descriptor); + if (key === 'clearTargeting' && !attempted) { + attempted = true; + if (failure === 'throw') throw new Error('mutated then threw'); + return false; + } + return result; + }, + }); + const adapter = adapterForTargetingSlot(slot); + const operation = adapter.run((gpt) => + gpt.observeTargeting(slot, { beforePublisherMutation: vi.fn() }) + ); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(target.setTargeting).toBe(originalSet); + expect(target.clearTargeting).toBe(originalClear); + } + ); + + it('lets one observation dispose its wrappers after its adapter operation settled', async () => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const slot = { + clearTargeting: originalClear, + getTargeting: () => [], + setTargeting: originalSet, + }; + const adapter = adapterForTargetingSlot(slot); + const service = createTargetingService(); + const observation = service.observePublisherMutations(slot, adapter); + await expect(observation.result).resolves.toBeUndefined(); + expect(slot.setTargeting).not.toBe(originalSet); + + observation.dispose(); + + expect(slot.setTargeting).toBe(originalSet); + expect(slot.clearTargeting).toBe(originalClear); + }); +}); + +describe('targeting mutate-then-throw recovery', () => { + it('rejects a successful no-op write and removes only its failed frame', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const older = service.own(slot, 'key', 'older', 'older-owner', targeting); + targeting.setTargeting.mockImplementationOnce(() => undefined); + + expect(() => service.own(slot, 'key', 'newer', 'newer-owner', targeting)).toThrow( + 'GPT targeting postcondition failed' + ); + expect(targeting.values.get('key')).toEqual(['older']); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + older?.release(); + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('retains owner-disposable quarantine when a successful write leaves the wrong value', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + targeting.setTargeting.mockImplementationOnce((key) => { + targeting.values.set(key, Object.freeze(['wrong-value'])); + }); + + expect(() => service.own(slot, 'key', 'trusted', 'owner', targeting)).toThrow( + 'GPT targeting postcondition failed' + ); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + service.disposeOwner('owner'); + expect(targeting.values.get('key')).toEqual(['wrong-value']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('restores the publisher predecessor when installation mutates then throws', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + targeting.setTargeting.mockImplementationOnce((key, value) => { + targeting.values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + throw new Error('mutated then threw'); + }); + + expect(() => service.own(slot, 'key', 'trusted', 'owner', targeting)).toThrow( + 'mutated then threw' + ); + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('retains owner-disposable quarantine when failed restoration did not mutate', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const frame = service.own(slot, 'key', 'trusted', 'owner', targeting); + targeting.setTargeting.mockImplementationOnce(() => { + throw new Error('failed before mutation'); + }); + + frame?.release(); + + expect(targeting.values.get('key')).toEqual(['trusted']); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + service.disposeOwner('owner'); + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('removes ownership when restoration mutates to the predecessor and then throws', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const frame = service.own(slot, 'key', 'trusted', 'owner', targeting); + targeting.setTargeting.mockImplementationOnce((key, value) => { + targeting.values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + throw new Error('mutated then threw'); + }); + + frame?.release(); + + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('retains an owner-disposable frame when post-failure state cannot be read', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + targeting.setTargeting.mockImplementationOnce((key, value) => { + targeting.values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + throw new Error('mutated then threw'); + }); + targeting.getTargeting + .mockImplementationOnce(() => ['publisher']) + .mockImplementationOnce(() => { + throw new Error('unreadable after failure'); + }); + + expect(() => service.own(slot, 'key', 'trusted', 'owner', targeting)).toThrow( + 'mutated then threw' + ); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + targeting.getTargeting.mockImplementation((key: string) => + Object.freeze([...(targeting.values.get(key) ?? [])]) + ); + service.disposeOwner('owner'); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('retains owner-disposable quarantine when failed installation leaves unknown state', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + targeting.setTargeting.mockImplementationOnce((key) => { + targeting.values.set(key, Object.freeze(['publisher-interference'])); + throw new Error('mutated unpredictably then threw'); + }); + + expect(() => service.own(slot, 'key', 'trusted', 'owner', targeting)).toThrow( + 'mutated unpredictably then threw' + ); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + service.disposeOwner('owner'); + expect(targeting.values.get('key')).toEqual(['publisher-interference']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('rolls back only a newer failed publication when the older TS value never changed', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const older = service.own(slot, 'key', 'older', 'older-owner', targeting); + targeting.setTargeting.mockImplementationOnce(() => { + throw new Error('newer failed before mutation'); + }); + + expect(() => service.own(slot, 'key', 'newer', 'newer-owner', targeting)).toThrow( + 'newer failed before mutation' + ); + expect(targeting.values.get('key')).toEqual(['older']); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + older?.release(); + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('releases service observation ownership when adapter promotion rejects', async () => { + const externalRelease = vi.fn(); + const facade = { + observeTargeting: () => externalRelease, + } as never; + const adapter = { + run: (command: (gpt: never) => void) => { + command(facade); + return Object.freeze({ + status: 'incompatible' as const, + result: Promise.reject(new Error('promotion rejected')), + dispose: vi.fn(), + }); + }, + } as never; + const service = createTargetingService(); + const observation = service.observePublisherMutations({}, adapter); + + await expect(observation.result).rejects.toThrow('promotion rejected'); + expect(externalRelease).toHaveBeenCalledOnce(); + service.dispose(); + expect(externalRelease).toHaveBeenCalledOnce(); + }); + + it('disposes frames through captured Set iterator next after prototype poisoning', () => { + const service = createTargetingService(); + const targeting = createTargetingHarness({ key: ['publisher'] }); + service.own({}, 'key', 'trusted', 'owner', targeting); + const iteratorPrototype = Object.getPrototypeOf(new Set().values()) as { + next: () => IteratorResult; + }; + const originalNext = iteratorPrototype.next; + iteratorPrototype.next = () => { + throw new Error('poisoned iterator'); + }; + try { + expect(() => service.disposeOwner('owner')).not.toThrow(); + } finally { + iteratorPrototype.next = originalNext; + } + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts b/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts index 881a4515f..92f4d0593 100644 --- a/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts +++ b/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts @@ -1,37 +1,61 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { createBeaconGuard, BeaconGuardConfig } from '../../src/shared/beacon_guard'; +import { createBeaconGuard } from '../../src/shared/beacon_guard'; +import type { BeaconGuardConfig } from '../../src/shared/beacon_guard'; + +function hasHttpHostname(url: string, hostname: string): boolean { + try { + const parsed = new URL(url); + return ( + (parsed.protocol === 'https:' || parsed.protocol === 'http:') && parsed.hostname === hostname + ); + } catch { + return false; + } +} + +function rewriteToProxy(url: string, proxyPath: string): string { + const parsed = new URL(url); + return `http://localhost${proxyPath}${parsed.pathname}${parsed.search}${parsed.hash}`; +} describe('Beacon Guard', () => { - let originalSendBeacon: typeof navigator.sendBeacon; - let originalFetch: typeof window.fetch; + let originalSendBeaconDescriptor: PropertyDescriptor | undefined; + let originalFetchDescriptor: PropertyDescriptor | undefined; let sendBeaconSpy: ReturnType; let fetchSpy: ReturnType; let config: BeaconGuardConfig; beforeEach(() => { // Save originals - originalSendBeacon = navigator.sendBeacon; - originalFetch = window.fetch; + originalSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + originalFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); // Create spies that simulate real sendBeacon/fetch behaviour sendBeaconSpy = vi.fn(() => true); - navigator.sendBeacon = sendBeaconSpy; + navigator.sendBeacon = sendBeaconSpy as typeof navigator.sendBeacon; fetchSpy = vi.fn(() => Promise.resolve(new Response('', { status: 200 }))); - window.fetch = fetchSpy; + window.fetch = fetchSpy as typeof window.fetch; config = { name: 'Test', - isTargetUrl: (url: string) => url.includes('analytics.example.com'), - rewriteUrl: (url: string) => - url.replace(/https?:\/\/analytics\.example\.com/, 'http://localhost/proxy'), + isTargetUrl: (url: string) => hasHttpHostname(url, 'analytics.example.com'), + rewriteUrl: (url: string) => rewriteToProxy(url, '/proxy'), }; }); afterEach(() => { - navigator.sendBeacon = originalSendBeacon; - window.fetch = originalFetch; + if (originalSendBeaconDescriptor) { + Object.defineProperty(navigator, 'sendBeacon', originalSendBeaconDescriptor); + } else { + Reflect.deleteProperty(navigator, 'sendBeacon'); + } + if (originalFetchDescriptor) { + Object.defineProperty(window, 'fetch', originalFetchDescriptor); + } else { + Reflect.deleteProperty(window, 'fetch'); + } }); describe('createBeaconGuard', () => { @@ -82,6 +106,18 @@ describe('Beacon Guard', () => { expect(sendBeaconSpy).toHaveBeenCalledWith('https://other.example.com/track', 'data'); }); + it.each([ + 'https://analytics.example.com.evil.test/collect', + 'https://analytics.example.com@evil.test/collect', + ])('should pass through an analytics hostname lookalike: %s', (url) => { + const guard = createBeaconGuard(config); + guard.install(); + + navigator.sendBeacon(url, 'data'); + + expect(sendBeaconSpy).toHaveBeenCalledWith(url, 'data'); + }); + it('should forward body data', () => { const guard = createBeaconGuard(config); guard.install(); @@ -130,7 +166,7 @@ describe('Beacon Guard', () => { await window.fetch(request); // The spy should receive a new Request with the rewritten URL - const calledArg = fetchSpy.mock.calls[0][0]; + const calledArg = fetchSpy.mock.calls[0]![0] as Request; expect(calledArg).toBeInstanceOf(Request); expect(calledArg.url).toContain('/proxy/g/collect?tid=G-TEST'); }); @@ -146,12 +182,177 @@ describe('Beacon Guard', () => { }); }); + it('restores the exact publisher-owned descriptors on reset', () => { + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const guard = createBeaconGuard(config); + + guard.install(); + guard.reset(); + + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconDescriptor); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); + }); + + it.each(['sendBeacon', 'fetch'] as const)( + 'leaves a publisher %s replacement intact while releasing the other wrapper', + (replaced) => { + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const guard = createBeaconGuard(config); + guard.install(); + const replacementSendBeacon = vi.fn(() => false) as typeof navigator.sendBeacon; + const replacementFetch = vi.fn(() => Promise.resolve(new Response())) as typeof window.fetch; + if (replaced === 'sendBeacon') navigator.sendBeacon = replacementSendBeacon; + else window.fetch = replacementFetch; + + guard.reset(); + + if (replaced === 'sendBeacon') { + expect(navigator.sendBeacon).toBe(replacementSendBeacon); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); + } else { + expect(window.fetch).toBe(replacementFetch); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual( + sendBeaconDescriptor + ); + } + } + ); + + it('leaves descriptor-attribute changes to the installed wrappers intact', () => { + const guard = createBeaconGuard(config); + guard.install(); + const installedSendBeacon = navigator.sendBeacon; + const installedFetch = window.fetch; + const sendBeaconReplacement = { + configurable: true, + enumerable: false, + value: installedSendBeacon, + writable: true, + } satisfies PropertyDescriptor; + const fetchReplacement = { + configurable: true, + enumerable: false, + value: installedFetch, + writable: true, + } satisfies PropertyDescriptor; + Object.defineProperty(navigator, 'sendBeacon', sendBeaconReplacement); + Object.defineProperty(window, 'fetch', fetchReplacement); + + guard.reset(); + + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconReplacement); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchReplacement); + }); + + it('does not invoke or replace hostile publisher accessors during reset', () => { + const guard = createBeaconGuard(config); + guard.install(); + const sendBeaconGetter = vi.fn(() => { + throw new Error('sendBeacon getter must remain inert'); + }); + const fetchGetter = vi.fn(() => { + throw new Error('fetch getter must remain inert'); + }); + const sendBeaconReplacement = { + configurable: true, + enumerable: true, + get: sendBeaconGetter, + } satisfies PropertyDescriptor; + const fetchReplacement = { + configurable: true, + enumerable: true, + get: fetchGetter, + } satisfies PropertyDescriptor; + Object.defineProperty(navigator, 'sendBeacon', sendBeaconReplacement); + Object.defineProperty(window, 'fetch', fetchReplacement); + + expect(() => guard.reset()).not.toThrow(); + + expect(sendBeaconGetter).not.toHaveBeenCalled(); + expect(fetchGetter).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconReplacement); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchReplacement); + }); + + it('isolates hostile descriptor inspection and still releases the other wrapper', () => { + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const guard = createBeaconGuard(config); + guard.install(); + const installedSendBeacon = navigator.sendBeacon; + const nativeDescriptor = Object.getOwnPropertyDescriptor; + const descriptor = vi + .spyOn(Object, 'getOwnPropertyDescriptor') + .mockImplementation((target, property) => { + if (target === navigator && property === 'sendBeacon') { + throw new Error('publisher descriptor inspection failed'); + } + return nativeDescriptor(target, property); + }); + + expect(() => guard.reset()).not.toThrow(); + descriptor.mockRestore(); + + expect(navigator.sendBeacon).toBe(installedSendBeacon); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); + }); + + it('releases an installed wrapper after a later patch assignment fails', () => { + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + if (!fetchDescriptor || !('value' in fetchDescriptor)) { + throw new Error('test requires an own fetch data descriptor'); + } + const nonWritableFetchDescriptor = { + ...fetchDescriptor, + writable: false, + } satisfies PropertyDescriptor; + Object.defineProperty(window, 'fetch', nonWritableFetchDescriptor); + const guard = createBeaconGuard(config); + + expect(() => guard.install()).toThrow(TypeError); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).not.toEqual( + sendBeaconDescriptor + ); + + expect(() => guard.reset()).not.toThrow(); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconDescriptor); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(nonWritableFetchDescriptor); + }); + + it('restores stacked guards in reverse order and remains idempotent', () => { + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const first = createBeaconGuard(config); + const second = createBeaconGuard({ + ...config, + name: 'Second', + }); + first.install(); + const firstSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const firstFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + second.install(); + + second.reset(); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual( + firstSendBeaconDescriptor + ); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(firstFetchDescriptor); + + first.reset(); + first.reset(); + second.reset(); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconDescriptor); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); + }); + describe('multiple guards', () => { it('should allow independent guards to coexist', () => { const config2: BeaconGuardConfig = { name: 'Other', - isTargetUrl: (url: string) => url.includes('other-tracker.com'), - rewriteUrl: (url: string) => url.replace(/https?:\/\/other-tracker\.com/, '/other-proxy'), + isTargetUrl: (url: string) => hasHttpHostname(url, 'other-tracker.com'), + rewriteUrl: (url: string) => rewriteToProxy(url, '/other-proxy'), }; const guard1 = createBeaconGuard(config); @@ -160,8 +361,12 @@ describe('Beacon Guard', () => { guard1.install(); guard2.install(); + const lookalikeUrl = 'https://other-tracker.com.evil.test/collect'; + navigator.sendBeacon(lookalikeUrl, 'data'); + expect(guard1.isInstalled()).toBe(true); expect(guard2.isInstalled()).toBe(true); + expect(sendBeaconSpy).toHaveBeenCalledWith(lookalikeUrl, 'data'); }); }); }); diff --git a/crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts b/crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts index 84cf21ae3..a2a17ebfd 100644 --- a/crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts +++ b/crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts @@ -82,6 +82,11 @@ describe('DOM insertion dispatcher', () => { container.appendChild(permutiveScript); expect(permutiveScript.src).toContain('/integrations/permutive/sdk'); + const permutiveLookalike = document.createElement('script'); + permutiveLookalike.src = 'https://cdn.permutive.com.attacker.example/abc123-web.js'; + container.appendChild(permutiveLookalike); + expect(permutiveLookalike.src).toBe('https://cdn.permutive.com.attacker.example/abc123-web.js'); + const dataDomeScript = document.createElement('script'); dataDomeScript.src = 'https://js.datadome.co/tags.js'; container.appendChild(dataDomeScript); diff --git a/crates/trusted-server-js/lib/test/shared/origin.test.ts b/crates/trusted-server-js/lib/test/shared/origin.test.ts index 9b0963398..b5951c973 100644 --- a/crates/trusted-server-js/lib/test/shared/origin.test.ts +++ b/crates/trusted-server-js/lib/test/shared/origin.test.ts @@ -1,36 +1,39 @@ import { describe, expect, it } from 'vitest'; -import { normalizeTrustedOrigin } from '../../src/shared/origin'; +import { trustedDocumentHttpOrigin, trustedHttpOrigin } from '../../src/shared/origin'; -describe('shared/origin.ts', () => { - it('accepts ordinary http(s) origins', () => { - expect(normalizeTrustedOrigin('https://news.publisher.example')).toBe( - 'https://news.publisher.example' +describe('trustedHttpOrigin', () => { + it('derives the exact publisher origin from a stamped or inherited base URL', () => { + expect(trustedHttpOrigin('https://publisher.example')).toBe('https://publisher.example'); + expect(trustedHttpOrigin('http://publisher.example:8080/path/index.html')).toBe( + 'http://publisher.example:8080' ); - expect(normalizeTrustedOrigin('http://localhost:7676')).toBe('http://localhost:7676'); }); - it('accepts IPv6 literal origins', () => { - // A DNS-shaped pattern rejects bracketed hosts, which would drop the stamp - // and push the opaque-origin runtime onto the -sensitive baseURI. - expect(normalizeTrustedOrigin('http://[::1]:7676')).toBe('http://[::1]:7676'); - expect(normalizeTrustedOrigin('https://[2001:db8::1]')).toBe('https://[2001:db8::1]'); + it.each([ + '', + 'about:srcdoc', + 'data:text/html,creative', + 'javascript:alert(1)', + 'https://user:password@publisher.example/path', + ])('fails closed for an unusable trusted base URL: %s', (candidate) => { + expect(trustedHttpOrigin(candidate)).toBe(''); }); +}); - it('normalizes a full URL down to its origin', () => { - expect(normalizeTrustedOrigin('https://publisher.example/some/page?q=1#frag')).toBe( - 'https://publisher.example' - ); +describe('trustedDocumentHttpOrigin', () => { + it('keeps a real document origin authoritative over a creative-only stamp', () => { + expect( + trustedDocumentHttpOrigin( + 'https://publisher.example', + 'https://publisher-script-spoof.example' + ) + ).toBe('https://publisher.example'); }); - it('rejects opaque, non-http(s), and unparseable values', () => { - expect(normalizeTrustedOrigin('null')).toBe(''); - expect(normalizeTrustedOrigin('about:srcdoc')).toBe(''); - expect(normalizeTrustedOrigin('javascript:alert(1)')).toBe(''); - expect(normalizeTrustedOrigin('data:text/html,x')).toBe(''); - expect(normalizeTrustedOrigin('/first-party/proxy')).toBe(''); - expect(normalizeTrustedOrigin('')).toBe(''); - expect(normalizeTrustedOrigin(undefined)).toBe(''); - expect(normalizeTrustedOrigin(42)).toBe(''); + it('uses the stamped base only for an opaque document origin', () => { + expect(trustedDocumentHttpOrigin('null', 'https://publisher.example/article')).toBe( + 'https://publisher.example' + ); }); }); diff --git a/crates/trusted-server-js/lib/test/shared/scheduler.test.ts b/crates/trusted-server-js/lib/test/shared/scheduler.test.ts index aa4a21ecc..59f8a6bd9 100644 --- a/crates/trusted-server-js/lib/test/shared/scheduler.test.ts +++ b/crates/trusted-server-js/lib/test/shared/scheduler.test.ts @@ -42,4 +42,18 @@ describe('shared/scheduler', () => { await Promise.resolve(); expect(perform).toHaveBeenCalledTimes(2); }); + + it('cancels queued and future work after disposal', async () => { + const perform = vi.fn(); + const schedule = createMutationScheduler(perform); + const el = document.createElement('div'); + + schedule(el); + schedule.dispose(); + await Promise.resolve(); + schedule(el); + await Promise.resolve(); + + expect(perform).not.toHaveBeenCalled(); + }); }); diff --git a/crates/trusted-server-js/lib/test/shared/script_guard.test.ts b/crates/trusted-server-js/lib/test/shared/script_guard.test.ts new file mode 100644 index 000000000..15a2771c0 --- /dev/null +++ b/crates/trusted-server-js/lib/test/shared/script_guard.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createScriptGuard } from '../../src/shared/script_guard'; + +describe('shared layered script guard', () => { + const guards: Array<{ reset(): void }> = []; + + afterEach(() => { + for (let index = guards.length - 1; index >= 0; index -= 1) guards[index]?.reset(); + guards.length = 0; + }); + + it('owns document-write rewriting and restores the exact native method', () => { + const nativeWrite = vi.fn<(...args: string[]) => void>(); + document.write = nativeWrite as unknown as typeof document.write; + const guard = createScriptGuard({ + deepInterception: { documentWriteUrlHint: 'sdk.example' }, + id: 'shared-layered-test', + isTargetUrl: (url) => new URL(url, window.location.href).hostname === 'sdk.example', + rewriteUrl: (url) => { + const parsed = new URL(url, window.location.href); + return `${window.location.origin}/proxy${parsed.pathname}`; + }, + }); + guards.push(guard); + + guard.install(); + const installedWrite = document.write; + document.write(''); + + expect(nativeWrite).toHaveBeenCalledTimes(1); + expect(nativeWrite.mock.calls[0]?.[0]).toContain('/proxy/runtime.js'); + + guard.reset(); + expect(document.write).toBe(nativeWrite); + expect(installedWrite).not.toBe(nativeWrite); + }); + + it('removes fallback instance src descriptors during reset', () => { + const nativeGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const descriptorSpy = vi + .spyOn(Object, 'getOwnPropertyDescriptor') + .mockImplementation( + (target: object, property: PropertyKey): PropertyDescriptor | undefined => { + if (target === HTMLScriptElement.prototype && property === 'src') return undefined; + return nativeGetOwnPropertyDescriptor(target, property); + } + ); + const guard = createScriptGuard({ + deepInterception: { documentWriteUrlHint: 'sdk.example' }, + id: 'shared-layered-instance-test', + isTargetUrl: (url) => new URL(url, window.location.href).hostname === 'sdk.example', + rewriteUrl: (url) => { + const parsed = new URL(url, window.location.href); + return `${window.location.origin}/proxy${parsed.pathname}`; + }, + }); + guards.push(guard); + + try { + guard.install(); + const script = document.createElement('script'); + script.src = 'https://sdk.example/first.js'; + expect(script.src).toContain('/proxy/first.js'); + + guard.reset(); + script.src = 'https://sdk.example/after-reset.js'; + expect(script.src).toBe('https://sdk.example/after-reset.js'); + } finally { + descriptorSpy.mockRestore(); + } + }); +}); diff --git a/crates/trusted-server-js/lib/tsconfig.json b/crates/trusted-server-js/lib/tsconfig.json index b17377a14..4c2fed413 100644 --- a/crates/trusted-server-js/lib/tsconfig.json +++ b/crates/trusted-server-js/lib/tsconfig.json @@ -1,16 +1,21 @@ { "compilerOptions": { "target": "ES2018", - "lib": ["ES2020", "DOM"], + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "noImplicitOverride": true, + "useUnknownInCatchVariables": true, "skipLibCheck": true, "noEmit": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "types": ["vitest/globals", "node"] + "types": ["vitest/globals", "node", "vite/client"] }, "include": ["src", "test"] } diff --git a/crates/trusted-server-js/lib/vite.config.ts b/crates/trusted-server-js/lib/vite.config.ts index a28bcc092..9ed8544ee 100644 --- a/crates/trusted-server-js/lib/vite.config.ts +++ b/crates/trusted-server-js/lib/vite.config.ts @@ -2,4 +2,8 @@ import { defineConfig } from 'vite'; // Build configuration has moved to build-all.mjs. // This file is retained for vitest, which uses it for test resolution. -export default defineConfig({}); +export default defineConfig({ + define: { + __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__: JSON.stringify(20), + }, +}); diff --git a/crates/trusted-server-js/lib/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index acb591cdc..8bea63182 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -1,19 +1,38 @@ import path from 'node:path'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; + +import { RELEASE_CATALOG } from './src/kernel/release_catalog.ts'; + +const integrationIds = RELEASE_CATALOG.map(({ id }) => id); export default defineConfig({ + define: { + __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify('a'.repeat(64)), + __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: JSON.stringify(integrationIds), + __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__: JSON.stringify(integrationIds.length), + __TSJS_EMBEDDED_RUNTIME_CATALOG_V1__: JSON.stringify( + RELEASE_CATALOG.map(({ id, phase, trigger, config, consumes, provides }) => ({ + id, + phase, + trigger, + config, + consumes, + provides, + })) + ), + }, resolve: { alias: { // prebid.js doesn't expose src/adapterManager.js via its package // "exports" map, but we need it for client-side bidder validation. // Map the specifier to the actual dist file. 'prebid.js/src/adapterManager.js': path.resolve( - __dirname, + import.meta.dirname, 'node_modules/prebid.js/dist/src/src/adapterManager.js' ), 'prebid.js/src/adRendering.js': path.resolve( - __dirname, + import.meta.dirname, 'node_modules/prebid.js/dist/src/src/adRendering.js' ), }, @@ -21,11 +40,19 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, - // Run tests in the main thread to avoid spawning - // child processes/workers, which are blocked in this sandbox. - threads: false, - // Explicitly use thread pool (no forks) when workers are enabled. - // Kept for clarity if threads are re-enabled later. + // These suites deliberately use node:test. CI invokes them through their + // package scripts; importing them through Vitest either rewrites the VM + // contract fixture or leaves Vitest with no registered suite. + exclude: [ + ...configDefaults.exclude, + 'test/contract/aps-renderer-es5.test.mjs', + 'test/eslint/no-adtech-globals.test.mjs', + 'test/build/*.test.mjs', + ], + // Bound JSDOM concurrency so the package-wide suite does not starve its + // five-second lifecycle assertions while retaining per-file isolation. + maxWorkers: 2, + // Use worker threads rather than child processes. pool: 'threads', setupFiles: [], // The GPT diagnostics export contract is expressed as `expectTypeOf` diff --git a/crates/trusted-server-js/src/bundle.rs b/crates/trusted-server-js/src/bundle.rs index be5aa35cc..0a742be0d 100644 --- a/crates/trusted-server-js/src/bundle.rs +++ b/crates/trusted-server-js/src/bundle.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, MutexGuard, OnceLock}; use hex::encode; @@ -6,6 +6,73 @@ use sha2::{Digest as _, Sha256}; include!(concat!(env!("OUT_DIR"), "/tsjs_modules.rs")); +/// Release artifact role recorded in the generated inventory. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TsjsArtifactRole { + /// Inline minimal bootstrap controller and fallback artifact. + Bootstrap, + /// Base of the parser-blocking provisional first-display artifact. + FirstDisplayBase, + /// One closed, server-selected provisional first-display slice. + FirstDisplaySlice, + /// Sole TSJS kernel artifact. + Core, + /// Catalogued takeover or deferred integration module. + Integration, +} + +/// Fixed catalog phase for one integration module. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TsjsModulePhase { + /// Parser-blocking provisional phase, disposed or adopted after protected paint. + FirstDisplay, + /// Persistent module prepared for the atomic runtime takeover. + Takeover, + /// Authenticated module loaded only after the protected phase gate. + Deferred, +} + +/// Immutable generated artifact metadata shared with the server. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TsjsArtifactMetadata { + /// Canonical artifact identifier. + pub id: &'static str, + /// Artifact release role. + pub role: TsjsArtifactRole, + /// Catalog phase for integration artifacts. + pub phase: Option, + /// Fixed deferred trigger, when applicable. + pub trigger: Option<&'static str>, + /// Declared consumed capability edges. + pub inputs: &'static [&'static str], + /// Server-owned inclusion predicate from the canonical catalog. + pub include: Option<&'static str>, + /// Declared provided capability keys. + pub outputs: &'static [&'static str], + /// Generated artifact filename. + pub file: &'static str, + /// SHA-256 over exact uncompressed response bytes. + pub hash: &'static str, +} + +/// Maximum catalogued takeover modules. +pub const MAX_TAKEOVER_MODULES: usize = GENERATED_MAX_TAKEOVER_MODULES; +/// Maximum integrations in one boot manifest. +pub const MAX_MANIFEST_MODULES: usize = GENERATED_MAX_MANIFEST_MODULES; +/// Return the sentinel-normalized release identifier shared by every bundle. +#[must_use] +#[inline] +pub const fn release_id() -> &'static str { + TSJS_RELEASE_ID +} + +/// Return the generated, executable TSJS bootstrap controller. +#[must_use] +#[inline] +pub const fn bootstrap_bundle() -> &'static str { + TSJS_BOOTSTRAP +} + /// Return the JS bundle content for a given module ID (e.g., "core", "prebid"). #[must_use] #[inline] @@ -17,7 +84,123 @@ pub fn module_bundle(id: &str) -> Option<&'static str> { #[must_use] #[inline] pub fn all_module_ids() -> Vec<&'static str> { - TSJS_MODULES.iter().map(|module| module.id).collect() + TSJS_ARTIFACTS + .iter() + .filter(|artifact| artifact.role == "core" || artifact.role == "integration") + .map(|artifact| artifact.id) + .collect() +} + +/// Return all closed first-display component IDs in canonical mask order. +#[must_use] +pub fn all_first_display_ids() -> Vec<&'static str> { + TSJS_ARTIFACTS + .iter() + .filter(|artifact| { + artifact.role == "first_display_base" || artifact.role == "first_display_slice" + }) + .map(|artifact| artifact.id) + .collect() +} + +/// Return whether the generated release admits this exact mask under every size ceiling. +#[must_use] +pub fn first_display_mask_is_permitted(mask: u16) -> bool { + PERMITTED_FIRST_DISPLAY_MASKS.binary_search(&mask).is_ok() +} + +/// Return all catalogued integration IDs in canonical phase/injection order. +#[must_use] +pub fn all_integration_ids() -> Vec<&'static str> { + TSJS_ARTIFACTS + .iter() + .filter(|artifact| artifact.role == "integration") + .map(|artifact| artifact.id) + .collect() +} + +/// Return generated metadata for bootstrap, core, and every catalog module. +#[must_use] +pub fn all_artifact_metadata() -> Vec { + TSJS_ARTIFACTS.iter().map(public_metadata).collect() +} + +/// Return generated metadata for the twenty integration modules. +#[must_use] +pub fn all_integration_metadata() -> Vec { + TSJS_ARTIFACTS + .iter() + .filter(|artifact| artifact.role == "integration") + .map(public_metadata) + .collect() +} + +/// Return generated metadata for the base and thirteen first-display slices. +#[must_use] +pub fn all_first_display_metadata() -> Vec { + TSJS_ARTIFACTS + .iter() + .filter(|artifact| { + artifact.role == "first_display_base" || artifact.role == "first_display_slice" + }) + .map(public_metadata) + .collect() +} + +/// Return the exact generated bytes for one closed first-display component. +#[must_use] +pub fn first_display_component_bundle(id: &str) -> Option<&'static str> { + TSJS_ARTIFACTS + .iter() + .find(|artifact| { + (artifact.role == "first_display_base" || artifact.role == "first_display_slice") + && artifact.id == id + }) + .map(|artifact| artifact.bundle) +} + +/// Compose selected optional first-display slices in canonical order. +/// +/// The logical base is physically owned by the inline bootstrap. Unknown, +/// duplicate, or explicitly supplied base IDs fail closed. +#[must_use] +pub fn concatenate_first_display_slices(ids: &[&str]) -> Option { + let selected = ids.iter().copied().collect::>(); + if selected.len() != ids.len() || selected.contains("first_display") { + return None; + } + if selected.iter().any(|id| { + !TSJS_ARTIFACTS + .iter() + .any(|artifact| artifact.role == "first_display_slice" && artifact.id == *id) + }) { + return None; + } + let parts = TSJS_ARTIFACTS + .iter() + .filter(|artifact| artifact.role == "first_display_slice" && selected.contains(artifact.id)) + .map(|artifact| artifact.bundle) + .collect::>(); + Some(parts.join(";\n")) +} + +/// SHA-256 of one validated, canonically ordered first-display composition. +#[must_use] +pub fn concatenated_first_display_hash(ids: &[&str]) -> Option { + concatenate_first_display_slices(ids).map(|body| { + let mut hasher = Sha256::new(); + hasher.update(body.as_bytes()); + encode(hasher.finalize()) + }) +} + +/// Return generated metadata for a catalogued integration module. +#[must_use] +pub fn integration_metadata(id: &str) -> Option { + TSJS_ARTIFACTS + .iter() + .find(|artifact| artifact.role == "integration" && artifact.id == id) + .map(public_metadata) } /// Concatenate core + the requested integration modules into a single JS string. @@ -58,8 +241,11 @@ pub fn concatenated_hash(ids: &[&str]) -> String { /// Used for cache-busting URLs of deferred modules served individually. #[must_use] #[inline] -pub fn single_module_hash(id: &str) -> Option<&'static str> { - module_meta_map().get(id).map(|module| module.sha256) +pub fn single_module_hash(id: &str) -> Option { + TSJS_ARTIFACTS + .iter() + .find(|artifact| artifact.role == "integration" && artifact.id == id) + .map(|artifact| artifact.hash.to_owned()) } fn concatenated_module_ids(ids: &[&str]) -> Vec<&'static str> { @@ -108,12 +294,14 @@ where } } -fn module_meta_map() -> &'static HashMap<&'static str, &'static TsjsModuleMeta> { - static MAP: OnceLock> = OnceLock::new(); +fn module_meta_map() -> &'static HashMap<&'static str, &'static TsjsGeneratedArtifactMeta> { + static MAP: OnceLock> = + OnceLock::new(); MAP.get_or_init(|| { - TSJS_MODULES + TSJS_ARTIFACTS .iter() - .map(|module| (module.id, module)) + .filter(|artifact| artifact.role == "core" || artifact.role == "integration") + .map(|artifact| (artifact.id, artifact)) .collect() }) } @@ -130,57 +318,141 @@ fn concatenated_hash_cache() -> &'static Mutex, String CACHE.get_or_init(|| Mutex::new(HashMap::new())) } +fn public_metadata(artifact: &TsjsGeneratedArtifactMeta) -> TsjsArtifactMetadata { + TsjsArtifactMetadata { + id: artifact.id, + role: match artifact.role { + "bootstrap" => TsjsArtifactRole::Bootstrap, + "first_display_base" => TsjsArtifactRole::FirstDisplayBase, + "first_display_slice" => TsjsArtifactRole::FirstDisplaySlice, + "core" => TsjsArtifactRole::Core, + "integration" => TsjsArtifactRole::Integration, + _ => unreachable!("generated artifact role should be validated"), + }, + phase: artifact.phase.map(|phase| match phase { + "first_display" => TsjsModulePhase::FirstDisplay, + "takeover" => TsjsModulePhase::Takeover, + "deferred" => TsjsModulePhase::Deferred, + _ => unreachable!("generated artifact phase should be validated"), + }), + trigger: artifact.trigger, + include: artifact.include, + inputs: artifact.inputs, + outputs: artifact.outputs, + file: artifact.file, + hash: artifact.hash, + } +} + #[cfg(test)] mod tests { use super::*; - fn sha256_hex(bytes: &[u8]) -> String { - encode(Sha256::digest(bytes)) + #[test] + fn generated_catalog_metadata_has_exact_phase_order_and_derived_capacities() { + let metadata = all_integration_metadata(); + let generated = include_str!(concat!(env!("OUT_DIR"), "/tsjs_modules.rs")); + + assert_eq!(metadata.len(), 20, "should embed all catalog modules"); + assert_eq!(MAX_TAKEOVER_MODULES, 14); + assert_eq!(MAX_MANIFEST_MODULES, 20); + assert!( + !generated.contains("INTERNAL_DIAGNOSTICS_SUBSCRIPTIONS"), + "the synchronous diagnostics ingress must not generate subscription capacity" + ); + assert_eq!(metadata[0].id, "render_runtime"); + assert_eq!(metadata[0].phase, Some(TsjsModulePhase::Takeover)); + assert_eq!(metadata[13].id, "testlight"); + assert_eq!(metadata[13].phase, Some(TsjsModulePhase::Takeover)); + assert_eq!(metadata[14].id, "diagnostics_presentation"); + assert_eq!(metadata[14].phase, Some(TsjsModulePhase::Deferred)); + assert_eq!(metadata[19].id, "sourcepoint_lifecycle"); + assert_eq!(metadata[19].trigger, Some("first_display_or_idle")); + assert_eq!( + metadata[0].outputs, + &[ + "slots.v1", + "auction.v1", + "render.v1", + "messages.v1", + "trace.v1", + "trace.presentation.v1", + "direct.v1" + ] + ); } #[test] - fn generated_single_module_hashes_match_bundle_contents() { - for id in all_module_ids() { - let bundle = module_bundle(id).expect("should have module bundle"); - let generated_hash = single_module_hash(id).expect("should have generated hash"); + fn generated_artifact_inventory_includes_bootstrap_core_and_catalog_once() { + let artifacts = all_artifact_metadata(); - assert_eq!( - generated_hash, - sha256_hex(bundle.as_bytes()), - "generated hash for module {id} should match included bundle bytes" - ); - } + assert_eq!(artifacts.len(), 36); + assert_eq!(artifacts[0].id, "bootstrap"); + assert_eq!(artifacts[0].role, TsjsArtifactRole::Bootstrap); + assert_eq!(artifacts[1].id, "first_display"); + assert_eq!(artifacts[1].role, TsjsArtifactRole::FirstDisplayBase); + assert!( + artifacts[2..15] + .iter() + .all(|artifact| artifact.role == TsjsArtifactRole::FirstDisplaySlice) + ); + assert_eq!(artifacts[15].id, "core"); + assert_eq!(artifacts[15].role, TsjsArtifactRole::Core); + assert!( + artifacts[16..] + .iter() + .all(|artifact| artifact.role == TsjsArtifactRole::Integration) + ); + assert_eq!(all_module_ids().len(), 21); + assert_eq!(all_first_display_ids().len(), 14); } #[test] - fn concatenated_hash_matches_concatenated_bundle_contents() { - let available_ids = all_module_ids(); - let non_core_ids = available_ids - .iter() - .copied() - .filter(|id| *id != "core") - .take(3) - .collect::>(); - - let mut cases: Vec> = vec![Vec::new()]; - if let Some(first) = non_core_ids.first().copied() { - cases.push(vec![first]); - } - if non_core_ids.len() >= 2 { - cases.push(non_core_ids[..2].to_vec()); - cases.push(non_core_ids[..2].iter().rev().copied().collect()); - } - if non_core_ids.len() >= 3 { - cases.push(non_core_ids[..3].to_vec()); - } + fn first_display_composition_is_closed_and_canonical() { + let body = concatenate_first_display_slices(&["gpt_initial", "aps_initial"]) + .expect("should compose known unique slices"); + let base = first_display_component_bundle("first_display") + .expect("should embed first-display base"); + let aps = first_display_component_bundle("aps_initial") + .expect("should embed APS first-display slice"); + let gpt = first_display_component_bundle("gpt_initial") + .expect("should embed GPT first-display slice"); - for ids in cases { - let concatenated = concatenate_modules(&ids); + assert_eq!( + body, + [aps, gpt].join(";\n"), + "should exclude the logical base marker and compose selected slices canonically" + ); + assert!(!body.contains(base)); + assert!(concatenate_first_display_slices(&["aps_initial", "aps_initial"]).is_none()); + assert!(concatenate_first_display_slices(&["first_display"]).is_none()); + assert!(concatenate_first_display_slices(&["unknown"]).is_none()); + } + + #[test] + fn generated_runtime_modules_resolve_from_artifact_metadata() { + for artifact in TSJS_ARTIFACTS + .iter() + .filter(|artifact| artifact.role == "core" || artifact.role == "integration") + { assert_eq!( - concatenated_hash(&ids), - sha256_hex(concatenated.as_bytes()), - "concatenated hash should match concatenated bundle bytes for {ids:?}" + module_bundle(artifact.id), + Some(artifact.bundle), + "generated module bundle should resolve for {}", + artifact.id ); } } + + #[test] + fn concatenated_hash_matches_the_generated_module_bytes() { + let ids = ["gpt", "prebid"]; + let body = concatenate_modules(&ids); + + assert_eq!( + concatenated_hash(&ids), + encode(Sha256::digest(body.as_bytes())), + "concatenated hash should cover the exact generated bytes" + ); + } } diff --git a/crates/trusted-server-js/src/lib.rs b/crates/trusted-server-js/src/lib.rs index 2c816b154..a286ae4d5 100644 --- a/crates/trusted-server-js/src/lib.rs +++ b/crates/trusted-server-js/src/lib.rs @@ -6,5 +6,10 @@ pub mod bundle; pub use bundle::{ - all_module_ids, concatenate_modules, concatenated_hash, module_bundle, single_module_hash, + MAX_MANIFEST_MODULES, MAX_TAKEOVER_MODULES, TsjsArtifactMetadata, TsjsArtifactRole, + TsjsModulePhase, all_artifact_metadata, all_first_display_ids, all_first_display_metadata, + all_integration_ids, all_integration_metadata, all_module_ids, bootstrap_bundle, + concatenate_first_display_slices, concatenate_modules, concatenated_first_display_hash, + concatenated_hash, first_display_component_bundle, first_display_mask_is_permitted, + integration_metadata, module_bundle, release_id, single_module_hash, }; diff --git a/docs/.gitignore b/docs/.gitignore index 57a09c39d..097c22936 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,3 +1,4 @@ node_modules .vitepress/dist .vitepress/cache +.vitepress/.temp diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index fe2e54fdf..eca67a728 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -156,13 +156,8 @@ sequenceDiagram %% === Creative Rendering === rect rgb(239,246,255) Note over Client,Mock: Creative Rendering - alt APS winner - Client->>Client: Validate renderer descriptor
Create opaque sandbox iframe
Load /integrations/aps/renderer - 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 - end + Client->>Client: Validate renderer descriptor
Create opaque sandbox iframe
Load /integrations/aps/renderer/v2 + Note right of Client: Fragment-bound nonce and one-time acknowledgement
No allow-same-origin on the outer frame deactivate Client end ``` @@ -704,7 +699,7 @@ Each proxied URL includes a `tstoken` HMAC signature for tamper protection. See ### Full example -```toml +````toml [auction] enabled = true sanitize_creatives = false # Opt-in; blanks script-based creatives when enabled @@ -739,12 +734,61 @@ account_id = "example-aps-account" debug = false allow_script_creatives = false -[integrations.aps] -enabled = true -rendering_mode = "trusted_server" -[auction.bidders.example-server] -provider = "pbs-main" +### Configuration Reference + +#### `[auction]` + +| Field | Type | Default | Description | +| -------------------- | -------- | ------- | --------------------------------------------------------------- | +| `enabled` | bool | `false` | Enable the auction system | +| `sanitize_creatives` | bool | `false` | Strip executable markup from winning-bid `adm` before delivery | +| `rewrite_creatives` | bool | `true` | Rewrite winning-bid `adm` through first-party endpoints | +| `providers` | string[] | `[]` | Ordered list of provider names to call | +| `mediator` | string? | `null` | Provider name to use as mediator (enables `parallel_mediation`) | +| `timeout_ms` | u32 | `2000` | Overall auction timeout in milliseconds | + +Both creative-processing fields must be present in the TOML for their +environment overrides to apply; see +[Environment Variable Overrides](#environment-variable-overrides). + +#### `[integrations.prebid]` + +| Field | Type | Default | Description | +| ---------------- | -------- | ----------------- | -------------------------------------------------------------------------------------- | +| `enabled` | bool | `true` | Enable Prebid provider | +| `server_url` | string | — | Prebid Server URL (required) | +| `timeout_ms` | u32 | `1000` | Request timeout | +| `bidders` | string[] | `["mocktioneer"]` | Default bidders when not specified per-slot | +| `auto_configure` | bool | `true` | Auto-remove client-side prebid.js scripts | +| `debug` | bool | `false` | Enable Prebid debug mode (sets `ext.prebid.debug` and `ext.prebid.returnallbidstatus`) | +| `test_mode` | bool | `false` | Set OpenRTB `test: 1` for non-billable test traffic | + +#### APS provider profile + +APS is enabled only by an `[auction.providers.]` row with +`profile = "aps"`. Its common `endpoint` and `timeout_ms` fields belong on that +provider row. Put `account_id`, `debug`, `inventory_domain`, +`inventory_page_origin`, and `allow_script_creatives` under the provider's +`profile_config` table. The compiled plan also owns browser renderer and runner +route registration; there is no separate browser-mode switch. + +#### `[integrations.adserver_mock]` + +| Field | Type | Default | Description | +| ------------- | ------ | ---------------------------------------- | ------------------------- | +| `enabled` | bool | `false` | Enable mediator | +| `endpoint` | string | `http://localhost:6767/adserver/mediate` | Mediator service endpoint | +| `timeout_ms` | u32 | `500` | Request timeout | +| `price_floor` | f64? | `null` | Global price floor CPM | + +### Timeout Tuning + +The orchestrator timeout should exceed the sum of provider timeouts to allow all providers to respond. Providers that exceed their individual timeouts are collected as they finish — the orchestrator doesn't wait indefinitely. + +```toml +[auction] +timeout_ms = 2000 # Overall ceiling [integrations.prebid] enabled = true @@ -760,7 +804,7 @@ allowed_domains = ["assets.example.com"] enabled = true endpoint = "https://mediator.example.com/mediate" timeout_ms = 500 -``` +```` `[auction.providers]` is a map, not a provider-name list. Each provider ID owns endpoint/backend correlation and telemetry. `[auction.bidders]` maps each diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 0e0ee9553..8bd721504 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -294,6 +294,7 @@ Given a site whose ad units track the section, the run produces: ```toml [creative_opportunities] +enabled = true gam_network_id = "99999" section_root = "homepage" section_segment = 0 diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 313599048..d58fa8c6f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -873,11 +873,16 @@ fetches never carry Basic credentials, so every visitor gets `401` — on `/_ts/page-bids` that means no ads after any client-side navigation. Match the admin routes specifically (`^/_ts/admin`) instead. -Upgrading from a release before `/_ts/page-bids` existed: if any handler -pattern covers it, narrow the pattern. The Trusted Server JS bundle falls back -to the deprecated `/__ts/page-bids` alias in the meantime, but that alias is -scheduled for removal -([#970](https://github.com/IABTechLab/trusted-server/issues/970)). +If an older deployment used a different SPA auction path, update its handler +rules at the same time as the TSJS cutover. `/_ts/page-bids` is the only SPA +auction endpoint; older path spellings are unknown routes. + +The `/integrations/aps/*` family is different: its renderer and live-runner +routes are reserved before `[[handlers]]` is evaluated. They are browser-facing +resources and are intentionally anonymous, so a handler pattern that matches +`/integrations/aps/` does **not** add Basic Auth. Apply any admission control, +rate limiting, or request shielding for those routes in the deployment platform, +not through `[[handlers]]`. ::: @@ -1816,37 +1821,9 @@ Defines the ad slots the trusted server offers on a page: which pages each slot appears on (`page_patterns`), its supported sizes (`formats`), and the GAM ad unit it maps to (`gam_unit_path`). -`enabled` is the dedicated server-side ad-template switch. It defaults to `true` -for compatibility with existing configurations. Set it to `false` to stop -publisher HTML and SPA page-bids template delivery while retaining the slot -configuration and direct `POST /auction` endpoint. - -#### Publisher document cache policy - -For a successful GET publisher document, Trusted Server applies the -browser-only `Cache-Control: private, max-age=60` policy from -[#1007](https://github.com/IABTechLab/trusted-server/issues/1007) when the -server-side ad stack is structurally inactive. Trusted Server also applies this -policy to a subsequent `304 Not Modified` response so revalidation cannot -restore the origin freshness policy. This includes an absent -`[creative_opportunities]` section, `enabled = false`, no slot matching the -path, or a disabled auction. The `private` directive prevents shared caches -that use `Cache-Control` from storing the document. The policy replaces the -origin browser cache policy except when the origin sends `private` or -`no-store`, which are preserved. Bot, prefetch, and consent-denied requests -also retain the origin policy because they can produce a request-specific -representation for the same URL. Error responses and non-document requests -retain the origin policy. - -Trusted Server leaves origin validators and CDN-specific cache headers -unchanged. Those headers continue to control supporting CDNs independently of -the browser-only policy. If a response using the generated inactive-stack -policy later carries `Set-Cookie`, cookie privacy finalization replaces it with -`Cache-Control: private, max-age=0` and removes the CDN-specific cache headers. - ```toml [creative_opportunities] -enabled = true # set to false to disable server-side ad templates +enabled = true gam_network_id = "123456789" price_granularity = "dense" @@ -2073,9 +2050,18 @@ publisher-specific. Startup fails if `{section}` is used without a valid `section_root`. Startup rejects a blank `gam_network_id` only when an absent path/default or a `{network_id}` template consumes it; static paths and templates without `{network_id}` do not consume it. A -`[creative_opportunities]` block with `enabled = false` or no slots is -inactive, so no publisher templates are delivered and its `gam_network_id` is -not checked when no slot uses it. +`enabled = false` explicitly disables publisher and page-bids template delivery: +Trusted Server performs no publisher slot matching or automatic auction, injects no +initial slot/auction projection, and does not install SPA page-bids navigation hooks. +`/_ts/page-bids` returns the canonical empty projection. Direct `POST /auction` +remains live under its ordinary auction and consent gates. A successful inactive +publisher HTML `GET` receives `Cache-Control: max-age=60` unless the origin policy +contains `private` or `no-store` (case-insensitive); origin validators and +surrogate/CDN directives remain intact. Non-HTML, failed, and non-`GET` responses +retain the origin cache policy. An enabled block with no slots has no templates to +match, so its `gam_network_id` is not checked. The `enabled` key is required whenever +the table is present, and disabling delivery does not bypass startup validation of +its slots, assembly mode, or template-cache fields. Both knobs are config-driven, so the URL→section convention stays with the publisher: `section_segment` selects which segment names the section, and diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index 24e8dc0c3..b50b1d354 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -71,12 +71,12 @@ rewrite_creatives = true 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` | 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, including for body-less fragments; 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 @@ -108,8 +108,9 @@ separately constrained asset capability is tracked in The second is **dynamic** resource signing, which rewrites URLs on elements a creative inserts at runtime. It is installed -only when `renderGuard` is enabled in `tsCreativeConfig`, and that is `false` -by default — deployments using the default configuration are unaffected. Where +only when `renderGuard` is enabled in the immutable +`window.tsjs.boot.creative` configuration, and that is `false` by default — +deployments using the default configuration are unaffected. Where it is enabled, runtime-inserted ``/`