diff --git a/.github/workflows/aps-real-gam.yml b/.github/workflows/aps-real-gam.yml new file mode 100644 index 000000000..2b6879e4e --- /dev/null +++ b/.github/workflows/aps-real-gam.yml @@ -0,0 +1,118 @@ +name: "APS real-GAM attestation" +run-name: >- + APS real-GAM / ${{ inputs.evidence_id }} / ${{ inputs.release_id }} + +permissions: + contents: read + +on: + 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_RELEASE_ID: ${{ inputs.release_id }} + run: | + test -n "$TS_REAL_GAM_PAGE_URL" + test -n "$TS_REAL_GAM_AUTH_HEADER" + test -n "$TS_REAL_GAM_EXPECTED_RELEASE_ID" + test -n "$DISPATCH_RELEASE_ID" + test "$DISPATCH_RELEASE_ID" = "$TS_REAL_GAM_EXPECTED_RELEASE_ID" + test -n "${{ inputs.evidence_id }}" + test -n "${{ inputs.previous_artifact_id }}" + + - name: Read Node.js version + id: node-version + run: echo "version=$(awk '$1 == \"nodejs\" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.version }} + 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 + working-directory: crates/trusted-server-integration-tests/browser + run: >- + npm exec -- playwright test + --config=playwright.real-gam.config.ts + tests/shared/aps-real-gam.spec.ts + --project=chromium --project=firefox --project=webkit + + - name: Write release attestation + if: always() + env: + ATTESTATION_EVIDENCE_ID: ${{ inputs.evidence_id }} + ATTESTATION_RELEASE_ID: ${{ inputs.release_id }} + ATTESTATION_PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }} + ATTESTATION_TEST_OUTCOME: ${{ steps.real-gam.outcome }} + run: >- + node -e 'const fs=require("node:fs"); + const path="crates/trusted-server-integration-tests/browser/real-gam-evidence/attestation-v1.json"; + fs.mkdirSync(require("node:path").dirname(path),{recursive:true}); + fs.writeFileSync(path,JSON.stringify({schemaVersion:1,evidenceId:process.env.ATTESTATION_EVIDENCE_ID, + releaseId:process.env.ATTESTATION_RELEASE_ID,previousArtifactId:process.env.ATTESTATION_PREVIOUS_ARTIFACT_ID, + commitSha:process.env.GITHUB_SHA,runId:process.env.GITHUB_RUN_ID, + conclusion:process.env.ATTESTATION_TEST_OUTCOME},null,2)+"\n",{mode:384});' + + - name: Scrub browser evidence before upload + if: always() + env: + REAL_GAM_TEST_OUTCOME: ${{ steps.real-gam.outcome }} + working-directory: crates/trusted-server-integration-tests/browser + run: >- + node -e 'const fs=require("node:fs"),path=require("node:path"); + const roots=["real-gam-evidence","playwright-report","test-results"]; + const forbiddenExt=new Set([".har",".zip",".webm"]), secrets=[process.env.TS_REAL_GAM_PAGE_URL,process.env.TS_REAL_GAM_AUTH_HEADER].filter(Boolean); + const files=[]; const walk=p=>{if(!fs.existsSync(p))return; for(const e of fs.readdirSync(p,{withFileTypes:true})){const q=path.join(p,e.name); e.isDirectory()?walk(q):files.push(q)}}; roots.forEach(walk); + for(const file of files){if(forbiddenExt.has(path.extname(file)))throw Error("native capture forbidden: "+path.extname(file)); const body=fs.readFileSync(file); for(const secret of secrets){if(body.includes(Buffer.from(secret)))throw Error("protected value found in browser evidence")}} + const traces=files.filter(file=>file.endsWith("sanitized-trace-v1.json")); + for(const file of traces){const text=fs.readFileSync(file,"utf8"); if(/"(?:accountId|aaxResponse|adm|authorization|creativeBody|descriptor|lifecycleTicket|nonce|postData|requestHeaders|responseBody|responseHeaders)"\s*:/.test(text)||text.includes("- + Integration Tests / ${{ inputs.evidence_id || format('PR {0}', github.event.pull_request.number) }} permissions: contents: read @@ -9,6 +11,11 @@ on: pull_request: types: [opened, synchronize, reopened] workflow_dispatch: + inputs: + evidence_id: + description: Unique identifier used to bind this run to an evidence artifact + required: true + type: string env: ORIGIN_PORT: 8888 @@ -152,10 +159,58 @@ jobs: VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml RUST_LOG: info + aps-runner-proxy: + name: APS runner proxy (${{ matrix.runtime }}) + 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: "4.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 needs: prepare-artifacts - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -244,3 +299,79 @@ jobs: name: playwright-traces path: crates/trusted-server-integration-tests/browser/test-results/ retention-days: 7 + + - name: Record TSJS pre-change performance evidence + if: github.event_name == 'workflow_dispatch' + working-directory: crates/trusted-server-integration-tests/browser + env: + WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml + TEST_FRAMEWORK: nextjs + TSJS_PERF_MODE: baseline + TSJS_PERF_OUTPUT: crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json + TSJS_PERF_MACHINE_CLASS: github-hosted:ubuntu-24.04 + TSJS_EVIDENCE_ID: ${{ inputs.evidence_id }} + run: >- + npm exec -- playwright test + tests/shared/tsjs-performance.spec.ts + --project=chromium + + - name: Upload TSJS pre-change performance evidence + if: github.event_name == 'workflow_dispatch' && always() + uses: actions/upload-artifact@v4 + with: + name: tsjs-performance-${{ inputs.evidence_id }} + path: crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json + if-no-files-found: error + retention-days: 30 + + browser-tests-aps-tsjs-conformance: + name: browser integration tests (APS/TSJS conformance) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Set up APS/TSJS browser test runtime + id: shared-setup + uses: ./.github/actions/setup-integration-test-env + with: + origin-port: ${{ env.ORIGIN_PORT }} + install-viceroy: "true" + build-wasm: "false" + build-axum: "false" + build-test-images: "false" + build-cloudflare: "false" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.shared-setup.outputs.node-version }} + cache: npm + cache-dependency-path: | + crates/trusted-server-integration-tests/browser/package-lock.json + crates/trusted-server-js/lib/package-lock.json + + - name: Run focused APS/TSJS three-browser conformance matrix + env: + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + TS_BROWSER_FRAMEWORKS: nextjs + TS_BROWSER_PROJECTS: chromium,firefox,webkit + run: >- + ./scripts/integration-tests-browser.sh + tests/shared/aps-renderer.spec.ts + tests/shared/aps-puc-lifecycle.spec.ts + tests/shared/tsjs-runtime.spec.ts + tests/shared/creative-sandbox.spec.ts + tests/nextjs/gpt-diagnostics.spec.ts + tests/nextjs/navigation.spec.ts + --project=chromium --project=firefox --project=webkit + + - name: Upload APS/TSJS Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-aps-tsjs-conformance + path: crates/trusted-server-integration-tests/browser/playwright-report/ + retention-days: 7 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fb0233b03..aba3a993e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -246,5 +246,29 @@ jobs: - name: Build bundle run: npm run build + - name: Verify release inventory + run: npm run test:release + + - name: Enforce bundle budgets + run: npm run check:bundle + + - name: Typecheck full TSJS package + run: npm run typecheck + + - name: Lint full TSJS package + run: npm run lint + + - name: Verify generated APS renderer contract + run: npm run check:aps-contract + + - name: Enforce hard-cutover architecture + run: npm run check:architecture + + - name: Run embedded APS renderer contract + run: node --test test/contract/aps-renderer-es5.test.mjs + + - name: Verify rc/july adoption manifest + run: node --test test/contract/rc-july-adoption.test.mjs + - name: Run unit tests run: npm test -- --run diff --git a/.tool-versions b/.tool-versions index 758146800..5330e3de6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,5 +1,5 @@ fastly 15.1.0 rust 1.95.0 nodejs 24.12.0 -viceroy 0.17.0 +viceroy 0.19.0 wasmtime 44.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a7e5028..e6df87edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Protocol-relative creative URLs now honor `rewrite.exclude_domains`, so excluded creative assets stay direct and excluded absolute or protocol-relative URLs submitted to `/first-party/sign` are rejected. -- Server-side ad template bids now always carry `hb_adid` in `window.tsjs.bids`. Bidders that return neither a Prebid Cache UUID nor an `adid` previously produced no `hb_adid` at all, so no `hb_adid` GPT targeting key was set and the Universal Creative render bridge had nothing to match — the winning creative never rendered. The OpenRTB bid `id`, which is mandatory per spec, is now the last-resort source; `cache_id` and `adid` still take priority where present. Blank `cacheId`/`adid` values no longer win that precedence and emit an unusable empty `hb_adid`, and `hb_cache_host`/`hb_cache_path` are now emitted only alongside a real Prebid Cache UUID — without one they pointed the Universal Creative at a guaranteed cache miss instead of letting it fall through to the inline creative. +- The canonical browser auction projection now rejects blank upstream bid IDs per winner, uses a server-minted renderer reservation as the sole GAM render identity, and emits cache coordinates only as part of a validated cache render source. This replaces the legacy `window.tsjs.bids` `hb_adid` fallback chain. ### Added @@ -34,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added typed APS renderer transport for direct auctions and GAM/Prebid Universal Creative, using a minimized one-bid envelope, a fragment-bound nonce, and an opaque sandboxed renderer endpoint. - Added the `[auction].rewrite_creatives` (default `true`) and `[auction].sanitize_creatives` (default `false`) options. `rewrite_creatives` rewrites winning-bid adm to first-party endpoints across `POST /auction` and publisher SSAT/page-bids delivery (proxy/click URL conversion, bidder `` removal; creative TSJS injection on `POST /auction` only). Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery. - `creative_opportunities.slot.gam_unit_path` is now a template supporting `{network_id}`, `{slot_id}`, and `{section}`, so a publisher whose ad unit varies by site section expresses it in one slot rule instead of one per (slot × section). `{section}` derives from the request path: `[creative_opportunities].section_segment` selects which path segment names the section (0-based, default `0`; set `1` for locale-prefixed URLs), and `section_root` supplies the value for paths with no such segment. `section_root` is required when a template uses `{section}`. Existing static and absent `gam_unit_path` configs are unchanged. Startup rejects a blank `gam_network_id` only when an absent/default path or `{network_id}` template consumes it. Trusted Server conservatively caps whole rendered dynamic paths at 100 UTF-8 bytes, informed by Google's 100-character per-ad-unit-code limit; an over-limit request-specific path omits that slot without failing the response. During typed/startup finalization, every placeholder-bearing template that omits `section_segment` materializes `section_segment = 0`, so an older binary rejects the blob loudly. Static and absent paths remain legacy-schema compatible only when both `section_root` and `section_segment` are omitted. Before rolling back below this feature, replace or remove dynamic paths, remove both keys, re-push and finalize the config, then roll back the binary. +- Added opt-in APS HTTP debug metadata for controlled test sites, exposing the direct request and response under `/auction` provider metadata using the Prebid Server `debug.httpcalls` shape. +- Added typed APS renderer transport for direct auctions and GAM/Prebid Universal Creative, using a minimized one-bid envelope, a fragment-bound nonce, and an opaque sandboxed renderer endpoint. - Added Osano consent mirror integration docs and public enablement guidance. - Implemented basic authentication for configurable endpoint paths (#73) - Added integrations guide with example `testlight` integration diff --git a/CLAUDE.md b/CLAUDE.md index 546a3bf52..dd1c4041f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Supporting files: `edgezero.toml`, `fastly.toml`, | WASM target | `wasm32-wasip1` | | Node | 24.12.0 (from `.tool-versions`) | | Fastly CLI | 15.1.0 (from `.tool-versions`) | -| Viceroy | 0.17.0 (from `.tool-versions`) | +| Viceroy | 0.19.0 (from `.tool-versions`) | | Wasmtime | 44.0.1 (from `.tool-versions`) | --- @@ -139,7 +139,7 @@ cd crates/trusted-server-js/lib && node build-all.mjs ### Install prerequisites ```bash -cargo install viceroy --version 0.17.0 --locked --force +cargo install viceroy --version 0.19.0 --locked --force ``` --- diff --git a/Cargo.lock b/Cargo.lock index cb8f40c68..159548088 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5287,6 +5287,7 @@ dependencies = [ "trusted-server-core", "url", "urlencoding", + "web-time", ] [[package]] @@ -5415,6 +5416,7 @@ dependencies = [ "reqwest 0.12.28", "scraper", "serde_json", + "tempfile", "testcontainers", "tokio", "toml", @@ -5432,6 +5434,8 @@ version = "0.1.0" dependencies = [ "build-print", "hex", + "serde", + "serde_json", "sha2 0.10.9", "which", ] diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 15b6ee59d..ab9a72942 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -18,8 +18,13 @@ path = "src/lib.rs" name = "trusted-server-axum" path = "src/main.rs" +[features] +default = [] +aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"] + [dependencies] async-trait = { workspace = true } +axum = { workspace = true } edgezero-adapter-axum = { workspace = true, features = ["axum"] } edgezero-core = { workspace = true } error-stack = { workspace = true } @@ -31,7 +36,6 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "ti trusted-server-core = { workspace = true } [dev-dependencies] -axum = { workspace = true } base64 = { workspace = true } temp-env = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 85bd2a211..a5f57043a 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -19,8 +19,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, buffer_publisher_response_async, - handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_PATH, buffer_publisher_response_async, handle_page_bids, + handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -80,6 +80,87 @@ 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> { + 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 // --------------------------------------------------------------------------- @@ -346,13 +427,12 @@ fn named_routes() -> [NamedRoute; 14] { 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", diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 960982176..7e0efdd37 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,61 @@ 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 { + let response = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async move { + let request = + match edgezero_adapter_axum::request::into_core_request(request).await { + Ok(request) => request, + Err(error) => { + log::warn!("reserved APS request conversion failed: {error:?}"); + return Err(axum::http::StatusCode::BAD_REQUEST); + } + }; + match dispatcher.dispatch(request).await { + Some(response) => Ok(response), + None => { + log::error!( + "reserved APS entry route reached a request outside its family" + ); + Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + } + } + }) + }); + match response { + Ok(response) => edgezero_adapter_axum::response::into_axum_response(response), + Err(status) => axum::response::IntoResponse::into_response(status), + } + } + }); + let app = Router::new() + .route("/integrations/aps", reserved.clone()) + .route("/integrations/aps/{*rest}", reserved) + .fallback_service(EdgeZeroAxumService::new(TrustedServerApp::routes())); + let listener = tokio::net::TcpListener::bind(config.addr) + .await + .expect("should bind the configured address"); log::info!("Listening on http://{}", config.addr); - let router = TrustedServerApp::routes(); - if let Err(err) = AxumDevServer::with_config(router, config).run() { - log::error!("trusted-server-adapter-axum failed: {err}"); - std::process::exit(1); + let server = axum::serve(listener, app); + let result = if config.enable_ctrl_c { + server + .with_graceful_shutdown(async { + if let Err(error) = tokio::signal::ctrl_c().await { + log::error!("failed to install Ctrl-C handler: {error}"); + } + }) + .await + } else { + server.await + }; + if let Err(error) = result { + log::error!("trusted-server-adapter-axum failed: {error}"); } } diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index a511daab2..a44ceb147 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -11,7 +11,8 @@ use error_stack::{Report, ResultExt as _}; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, + PlatformSecretStore, PlatformSelectResult, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, + RawProxyPolicyV1, RawProxyResponseV1, RuntimeServices, StoreId, StoreName, }; // --------------------------------------------------------------------------- @@ -285,6 +286,9 @@ pub struct AxumPlatformHttpClient { client: reqwest::Client, } +#[cfg(feature = "aps-runner-proxy-integration-test")] +const APS_RUNNER_PROXY_TEST_ENDPOINT_ENV: &str = "TS_APS_RUNNER_PROXY_TEST_ENDPOINT"; + impl AxumPlatformHttpClient { /// Create a new client with sensible dev-server timeouts. /// @@ -307,6 +311,38 @@ impl AxumPlatformHttpClient { } } + #[cfg(feature = "aps-runner-proxy-integration-test")] + fn aps_runner_proxy_test_transport_uri( + logical_uri: &str, + ) -> Result, Report> { + use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL; + + if logical_uri != APS_RUNNER_UPSTREAM_URL { + return Ok(None); + } + let endpoint = std::env::var(APS_RUNNER_PROXY_TEST_ENDPOINT_ENV).map_err(|_| { + Report::new(PlatformError::HttpClient).attach( + "APS runner proxy integration artifact requires its loopback fixture endpoint", + ) + })?; + let parsed = reqwest::Url::parse(&endpoint) + .change_context(PlatformError::HttpClient) + .attach("invalid APS runner proxy integration fixture endpoint")?; + if parsed.scheme() != "http" + || !matches!(parsed.host_str(), Some("127.0.0.1" | "::1")) + || parsed.port().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(Report::new(PlatformError::HttpClient).attach( + "APS runner proxy integration fixture endpoint must be an explicit loopback HTTP URL", + )); + } + Ok(Some(parsed.into())) + } + /// Drain `body` to a `Vec`. /// /// For `Body::Stream` this awaits every chunk in the current async context @@ -380,6 +416,127 @@ impl AxumPlatformHttpClient { Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) } + + fn raw_header_evidence( + headers: &reqwest::header::HeaderMap, + name: reqwest::header::HeaderName, + ) -> ProxyHeaderEvidenceV1 { + ProxyHeaderEvidenceV1::Occurrences( + headers + .get_all(name) + .iter() + .map(|value| value.as_bytes().to_vec()) + .collect(), + ) + } + + fn canonical_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option { + let ProxyHeaderEvidenceV1::Occurrences(values) = evidence else { + return None; + }; + let [value] = values.as_slice() else { + return None; + }; + if value.is_empty() + || !value.iter().all(u8::is_ascii_digit) + || (value.len() > 1 && value[0] == b'0') + { + return None; + } + std::str::from_utf8(value).ok()?.parse().ok() + } + + async fn execute_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + if request.image_optimizer.is_some() || request.stream_response { + return Err(Report::new(PlatformError::HttpClient) + .attach("unsupported option on Axum raw proxy request")); + } + + let logical_uri = request.request.uri().to_string(); + #[cfg(feature = "aps-runner-proxy-integration-test")] + let transport_uri = Self::aps_runner_proxy_test_transport_uri(&logical_uri)?; + #[cfg(feature = "aps-runner-proxy-integration-test")] + let uri = transport_uri.as_deref().unwrap_or(&logical_uri); + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] + let uri = logical_uri.as_str(); + let method = reqwest::Method::from_bytes(request.request.method().as_str().as_bytes()) + .change_context(PlatformError::HttpClient)?; + let mut builder = self.client.request(method, uri); + for (name, value) in request.request.headers() { + builder = builder.header(name.as_str(), value.as_bytes()); + } + #[cfg(feature = "aps-runner-proxy-integration-test")] + if transport_uri.is_some() { + builder = builder + .header(reqwest::header::HOST, "client.aps.amazon-adsystem.com") + .header("x-ts-aps-logical-url", logical_uri.as_str()); + } + let (_, request_body) = request.request.into_parts(); + let request_body = Self::buffer_body(request_body).await?; + if !request_body.is_empty() { + builder = builder.body(request_body); + } + + tokio::time::timeout(policy.total_timeout, async move { + let mut response = tokio::time::timeout(policy.first_byte_timeout, builder.send()) + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy first-byte deadline exceeded") + })? + .change_context(PlatformError::HttpClient)?; + let evidence = ProxyResponseEvidenceV1 { + status: response.status().as_u16(), + content_type: Self::raw_header_evidence( + response.headers(), + reqwest::header::CONTENT_TYPE, + ), + content_encoding: Self::raw_header_evidence( + response.headers(), + reqwest::header::CONTENT_ENCODING, + ), + content_length: Self::raw_header_evidence( + response.headers(), + reqwest::header::CONTENT_LENGTH, + ), + }; + if Self::canonical_declared_length(&evidence.content_length) + .is_some_and(|length| length > policy.max_response_bytes) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy declared body exceeds configured cap")); + } + + let mut body = Vec::new(); + loop { + let chunk = tokio::time::timeout(policy.blocking_read_timeout, response.chunk()) + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy blocking-read deadline exceeded") + })? + .change_context(PlatformError::HttpClient)?; + let Some(chunk) = chunk else { break }; + let next_len = body.len().checked_add(chunk.len()).ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow") + })?; + if next_len > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy body exceeds configured cap")); + } + body.extend_from_slice(&chunk); + } + Ok(RawProxyResponseV1 { evidence, body }) + }) + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient).attach("raw proxy total deadline exceeded") + })? + } } impl Default for AxumPlatformHttpClient { @@ -397,6 +554,14 @@ impl PlatformHttpClient for AxumPlatformHttpClient { self.execute(request).await } + async fn send_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + self.execute_raw_proxy_v1(request, policy).await + } + async fn send_async( &self, request: PlatformHttpRequest, @@ -756,6 +921,223 @@ mod tests { ); } + fn raw_proxy_request(url: &str) -> PlatformHttpRequest { + PlatformHttpRequest::new( + edgezero_core::http::request_builder() + .uri(url) + .header(header::ACCEPT_ENCODING, "identity") + .body(EdgeBody::empty()) + .expect("should build raw proxy request"), + "test_backend", + ) + } + + fn raw_proxy_policy(timeout: Duration, max_response_bytes: usize) -> RawProxyPolicyV1 { + RawProxyPolicyV1 { + total_timeout: timeout, + first_byte_timeout: timeout, + blocking_read_timeout: timeout, + max_response_bytes, + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_preserves_header_occurrences_and_exact_bytes() { + let url = serve_raw_response( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/javascript\r\n\ + Content-Encoding: identity\r\n\ + Content-Length: 2\r\n\ + Set-Cookie: must-not-enter-core=1\r\n\ + \r\n\ + ok", + ) + .await; + + let response = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&url), + raw_proxy_policy(Duration::from_secs(1), 2), + ) + .await + .expect("valid raw response should be collected"); + + assert_eq!(response.evidence.status, 200); + assert_eq!( + response.evidence.content_type, + ProxyHeaderEvidenceV1::one("application/javascript") + ); + assert_eq!( + response.evidence.content_encoding, + ProxyHeaderEvidenceV1::one("identity") + ); + assert_eq!( + response.evidence.content_length, + ProxyHeaderEvidenceV1::one("2") + ); + assert_eq!(response.body, b"ok"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_preserves_duplicate_security_headers_for_core_rejection() { + let url = serve_raw_response( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/javascript\r\n\ + Content-Type: text/javascript\r\n\ + Content-Length: 2\r\n\ + \r\n\ + ok", + ) + .await; + + let response = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&url), + raw_proxy_policy(Duration::from_secs(1), 2), + ) + .await + .expect("transport should preserve duplicate evidence"); + + assert_eq!( + response.evidence.content_type, + ProxyHeaderEvidenceV1::Occurrences(vec![ + b"application/javascript".to_vec(), + b"text/javascript".to_vec(), + ]) + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_cancels_on_body_overflow_and_total_deadline() { + let overflow_url = serve_raw_response( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/javascript\r\n\ + Transfer-Encoding: chunked\r\n\ + \r\n\ + 2\r\n\ + ok\r\n\ + 0\r\n\ + \r\n", + ) + .await; + let overflow = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&overflow_url), + raw_proxy_policy(Duration::from_secs(1), 1), + ) + .await; + assert!(overflow.is_err(), "one byte over the cap must fail"); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind deadline test server"); + let addr = listener.local_addr().expect("should read local address"); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("should accept request"); + let mut request = [0; 1024]; + let _ = stream + .read(&mut request) + .await + .expect("should read request"); + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: 2\r\n\r\nok", + ) + .await; + }); + let deadline = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&format!("http://{addr}/")), + raw_proxy_policy(Duration::from_millis(20), 2), + ) + .await; + assert!(deadline.is_err(), "total deadline must cover first byte"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_enforces_first_byte_and_blocking_read_deadlines() { + let first_byte_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind first-byte deadline server"); + let first_byte_addr = first_byte_listener + .local_addr() + .expect("should read first-byte server address"); + tokio::spawn(async move { + let (mut stream, _) = first_byte_listener + .accept() + .await + .expect("should accept first-byte request"); + let mut request = [0; 1024]; + let _ = stream + .read(&mut request) + .await + .expect("should read first-byte request"); + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: 2\r\n\r\nok", + ) + .await; + }); + let first_byte = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&format!("http://{first_byte_addr}/")), + RawProxyPolicyV1 { + total_timeout: Duration::from_secs(1), + first_byte_timeout: Duration::from_millis(20), + blocking_read_timeout: Duration::from_secs(1), + max_response_bytes: 2, + }, + ) + .await; + assert!( + first_byte.is_err(), + "response headers after the first-byte deadline must fail" + ); + + let body_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind blocking-read deadline server"); + let body_addr = body_listener + .local_addr() + .expect("should read blocking-read server address"); + tokio::spawn(async move { + let (mut stream, _) = body_listener + .accept() + .await + .expect("should accept blocking-read request"); + let mut request = [0; 1024]; + let _ = stream + .read(&mut request) + .await + .expect("should read blocking-read request"); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nTransfer-Encoding: chunked\r\n\r\n1\r\no\r\n", + ) + .await + .expect("should write first body chunk"); + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = stream.write_all(b"1\r\nk\r\n0\r\n\r\n").await; + }); + let blocking_read = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&format!("http://{body_addr}/")), + RawProxyPolicyV1 { + total_timeout: Duration::from_secs(1), + first_byte_timeout: Duration::from_secs(1), + blocking_read_timeout: Duration::from_millis(20), + max_response_bytes: 2, + }, + ) + .await; + assert!( + blocking_read.is_err(), + "a body read blocked past its deadline must fail" + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn select_attributes_failed_backend_name() { // Bind and immediately drop a listener so the port is closed — the diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 03caa3d11..ab26b78e1 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -18,14 +18,19 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// The settings baked into the binary contain placeholder secrets that /// `get_settings()` rejects by design, which would turn every route into a /// startup error page (and its route table into the fallback-only set). -fn test_router() -> edgezero_core::router::RouterService { - let settings = trusted_server_core::settings::Settings::from_toml( +fn test_settings() -> trusted_server_core::settings::Settings { + trusted_server_core::settings::Settings::from_toml( r#" [[handlers]] path = "^/_ts/admin" username = "admin" password = "admin-pass" + [[handlers]] + path = "^/integrations/aps" + username = "aps-user" + password = "aps-pass" + [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -34,14 +39,33 @@ fn test_router() -> edgezero_core::router::RouterService { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [integrations.aps] + enabled = true + account_id = "route-test-aps-account" + allow_script_creatives = true "#, ) - .expect("should parse route test settings"); + .expect("should parse route test settings") +} - TrustedServerApp::routes_with_settings(settings) +fn test_router() -> edgezero_core::router::RouterService { + TrustedServerApp::routes_with_settings(test_settings()) .expect("should build router from test settings") } +async fn route_reserved(request: Request) -> axum::http::Response { + let request = edgezero_adapter_axum::request::into_core_request(request) + .await + .expect("should convert reserved APS request"); + let response = + trusted_server_adapter_axum::app::dispatch_reserved_with_settings(test_settings(), request) + .await + .expect("should build APS dispatcher") + .expect("APS family should be reserved"); + edgezero_adapter_axum::response::into_axum_response(response) +} + fn make_service() -> EdgeZeroAxumService { EdgeZeroAxumService::new(test_router()) } @@ -77,16 +101,9 @@ fn all_explicit_routes_are_registered() { ("POST", "/admin/keys/rotate"), ("POST", "/admin/keys/deactivate"), ("POST", "/auction"), - // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both - // paths are spelled out as literals rather than referencing - // `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH` so this test pins the - // actual URL the tsjs client fetches — asserting a const against itself - // would still pass if the const's value changed out from under the - // client. + // Pin the canonical literal fetched by the hard-cutover client. ("GET", "/_ts/page-bids"), ("OPTIONS", "/_ts/page-bids"), - ("GET", "/__ts/page-bids"), - ("OPTIONS", "/__ts/page-bids"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), ("GET", "/first-party/sign"), @@ -98,6 +115,9 @@ fn all_explicit_routes_are_registered() { for (method, path) in expected { assert_route_registered(method, path); } + for method in LEGACY_ADMIN_DENY_METHODS { + assert_route_registered(method, "/__ts/page-bids"); + } } /// Verify the legacy non-`/_ts` admin aliases ARE registered — to the local @@ -208,6 +228,101 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn aps_cutover_renderer_and_family_failures_are_local() { + let renderer = Request::builder() + .method("GET") + .uri("/integrations/aps/renderer/v1") + .header("authorization", "Bearer must-not-reach-publisher") + .body(AxumBody::empty()) + .expect("should build APS renderer request"); + let response = route_reserved(renderer).await; + assert_eq!(response.status().as_u16(), 200); + assert_eq!( + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()), + Some("text/html; charset=utf-8") + ); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable") + ); + assert!(response.headers().get("x-frame-options").is_none()); + let body = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("renderer body should be bounded"); + let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(!body.contains("client.aps.amazon-adsystem.com")); + + for (method, path, expected) in [ + ("POST", "/integrations/aps/runner.js", 405), + ("TRACE", "/integrations/aps/renderer/v1", 405), + ("CONNECT", "/integrations/aps/renderer/v1", 405), + ("PROPFIND", "/integrations/aps/renderer/v1", 405), + ("GET", "/integrations/aps/renderer", 404), + ("GET", "/integrations/aps/renderer/v2", 404), + ("GET", "/integrations/aps/runner/v1.js", 404), + ("GET", "/integrations/aps", 404), + ] { + let request = Request::builder() + .method(method) + .uri(path) + .header("authorization", "Bearer must-not-reach-publisher") + .body(AxumBody::empty()) + .expect("should build APS family request"); + let response = route_reserved(request).await; + assert_eq!(response.status().as_u16(), expected, "{method} {path}"); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "{method} {path}" + ); + assert!( + response.headers().get("x-geo-info-available").is_none(), + "{method} {path} must not receive generic finalizer headers" + ); + if expected == 405 { + assert_eq!( + response + .headers() + .get("allow") + .and_then(|v| v.to_str().ok()), + Some("GET") + ); + assert_eq!(response.headers().len(), 2, "{method} {path}"); + } else { + assert_eq!(response.headers().len(), 1, "{method} {path}"); + } + let body = axum::body::to_bytes(response.into_body(), 1) + .await + .expect("local APS failure body should be empty"); + assert!(body.is_empty(), "{method} {path}"); + } + + let protected_control = Request::builder() + .method("GET") + .uri("/integrations/apsx") + .body(AxumBody::empty()) + .expect("should build protected non-APS boundary request"); + let response = make_service() + .ready() + .await + .expect("should be ready") + .call(protected_control) + .await + .expect("should auth-gate non-APS boundary request"); + assert_eq!(response.status().as_u16(), 401); +} + // --------------------------------------------------------------------------- // Middleware tests // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 097844012..e4e5e4ca7 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -19,6 +19,7 @@ crate-type = ["cdylib", "rlib"] default = [] # Keep for explicit `cargo check --features cloudflare --target wasm32-unknown-unknown` cloudflare = ["edgezero-adapter-cloudflare/cloudflare", "dep:worker"] +aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"] [dependencies] async-trait = { workspace = true } diff --git a/crates/trusted-server-adapter-cloudflare/build.sh b/crates/trusted-server-adapter-cloudflare/build.sh index dcabdee8e..cbd78c23a 100644 --- a/crates/trusted-server-adapter-cloudflare/build.sh +++ b/crates/trusted-server-adapter-cloudflare/build.sh @@ -33,4 +33,9 @@ if [ -z "$WORKER_VERSION" ]; then echo "error: could not determine the worker crate version from Cargo.lock" >&2 exit 1 fi -cargo install -q --force --version "=$WORKER_VERSION" worker-build && worker-build --release +cargo install -q --force --version "=$WORKER_VERSION" worker-build +if [ -n "${TS_WORKER_BUILD_FEATURES:-}" ]; then + worker-build --release . --features "$TS_WORKER_BUILD_FEATURES" +else + worker-build --release +fi diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index dc7a7e91f..22f551102 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -21,9 +21,8 @@ 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, @@ -118,6 +117,47 @@ fn build_state_with_settings( })) } +async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { + if !state.registry.has_reserved_path(req.uri().path()) { + return None; + } + let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default()); + let services = build_runtime_services(&ctx); + Some( + state + .registry + .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) + .await + .expect("reserved path should have a hard-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + +/// Dispatch a reserved request using explicit settings. +/// +/// # Errors +/// +/// Returns an error when the adapter state cannot be built from `settings`. +pub async fn dispatch_reserved_with_settings( + settings: Settings, + req: Request, +) -> Result, Report> { + let state = build_state_with_settings(settings)?; + Ok(dispatch_reserved_for_state(&state, req).await) +} + +/// Dispatch a reserved request using the configured adapter state. +/// +/// # Errors +/// +/// Returns an error when the configured adapter state cannot be built. +pub async fn dispatch_reserved( + req: Request, +) -> Result, Report> { + let state = build_state()?; + Ok(dispatch_reserved_for_state(&state, req).await) +} + // --------------------------------------------------------------------------- // Per-request RuntimeServices // --------------------------------------------------------------------------- @@ -544,15 +584,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 { @@ -566,10 +599,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 { @@ -583,6 +614,9 @@ fn build_router(state: &Arc) -> RouterService { ); router = router.route("/admin/keys/deactivate", method, legacy_admin_deny.clone()); } + for method in publisher_fallback_methods() { + router = router.route("/__ts/page-bids", method, legacy_admin_deny.clone()); + } for method in publisher_fallback_methods() { router = router.route("/", method.clone(), fallback.clone()); diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs index 2ce435b17..3ab7d3434 100644 --- a/crates/trusted-server-adapter-cloudflare/src/lib.rs +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -15,6 +15,11 @@ pub mod platform; #[cfg(target_arch = "wasm32")] use worker::{Context, Env, Request, Response, Result, event}; +#[cfg(any(target_arch = "wasm32", test))] +fn preserved_reserved_method(value: &str) -> Option { + edgezero_core::http::Method::from_bytes(value.as_bytes()).ok() +} + #[cfg(target_arch = "wasm32")] #[event(fetch)] /// Dispatches an incoming Cloudflare Worker fetch event. @@ -28,6 +33,31 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { app::set_cloudflare_config_json(config.to_string()); } + let is_reserved = req + .url() + .is_ok_and(|url| trusted_server_core::integrations::aps::is_aps_family_path(url.path())); + if is_reserved { + // workers-rs maps unknown methods to GET; the underlying Fetch request + // preserves the original method token, so capture it before conversion. + let method = preserved_reserved_method(&req.inner().method()).ok_or_else(|| { + worker::Error::RustError("reserved APS request method is invalid".to_string()) + })?; + let mut request = edgezero_adapter_cloudflare::request::into_core_request(req, env, ctx) + .await + .map_err(|error| worker::Error::RustError(error.to_string()))?; + *request.method_mut() = method; + let response = app::dispatch_reserved(request) + .await + .map_err(|error| worker::Error::RustError(error.to_string()))? + .ok_or_else(|| { + worker::Error::RustError( + "reserved APS path has no hard-cutover handler".to_string(), + ) + })?; + return edgezero_adapter_cloudflare::response::from_core_response(response) + .map_err(|error| worker::Error::RustError(error.to_string())); + } + match edgezero_adapter_cloudflare::run_app::(req, env, ctx).await { Ok(resp) => Ok(resp), Err(e) => { @@ -36,3 +66,16 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { } } } + +#[cfg(test)] +mod tests { + use super::preserved_reserved_method; + + #[test] + fn reserved_method_parser_preserves_extension_methods() { + let method = preserved_reserved_method("PROPFIND") + .expect("should preserve a syntactically valid extension method"); + + assert_eq!(method.as_str(), "PROPFIND"); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index fff0bfed1..ba57bead6 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -20,6 +20,7 @@ use error_stack::ResultExt as _; #[cfg(target_arch = "wasm32")] use trusted_server_core::platform::{ PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, + ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1, }; // --------------------------------------------------------------------------- @@ -204,7 +205,13 @@ struct CloudflarePendingResponse { /// fetch layer; the Workers runtime's global CPU budget (~30 s on paid plans) /// is the only implicit deadline. #[cfg(target_arch = "wasm32")] -pub struct CloudflareHttpClient; +pub struct CloudflareHttpClient { + #[cfg(feature = "aps-runner-proxy-integration-test")] + aps_runner_proxy_test_fetcher: Option, +} + +#[cfg(all(target_arch = "wasm32", feature = "aps-runner-proxy-integration-test"))] +const APS_RUNNER_PROXY_TEST_SERVICE_BINDING: &str = "APS_RUNNER_PROXY_FIXTURE"; /// Maximum buffered upstream response body, mirroring the Fastly adapter's cap. /// @@ -286,6 +293,27 @@ fn outbound_cache_mode(bypass_cache: bool) -> OutboundCacheMode { #[cfg(target_arch = "wasm32")] impl CloudflareHttpClient { + fn new(request_context: &edgezero_core::context::RequestContext) -> Self { + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] + let _ = request_context; + #[cfg(feature = "aps-runner-proxy-integration-test")] + let aps_runner_proxy_test_fetcher = + edgezero_adapter_cloudflare::context::CloudflareRequestContext::get( + request_context.request(), + ) + .and_then(|cloudflare_context| { + cloudflare_context + .env() + .service(APS_RUNNER_PROXY_TEST_SERVICE_BINDING) + .ok() + }); + + Self { + #[cfg(feature = "aps-runner-proxy-integration-test")] + aps_runner_proxy_test_fetcher, + } + } + async fn execute( &self, request: PlatformHttpRequest, @@ -444,6 +472,195 @@ 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 { + #[cfg(feature = "aps-runner-proxy-integration-test")] + let mut 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 mut response = { + let fetch = Fetch::Request(worker_request); + fetch + .send_with_signal(&signal) + .await + .change_context(PlatformError::HttpClient)? + }; + 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(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.change_context(PlatformError::HttpClient)?; + let next_len = body.len().checked_add(chunk.len()).ok_or_else(|| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy body length overflow") + })?; + if next_len > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy body exceeds configured cap")); + } + body.extend_from_slice(&chunk); + } + body + } + }; + if body.len() > policy.max_response_bytes { + body.clear(); + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy buffered body exceeds configured cap")); + } + Ok(RawProxyResponseV1 { evidence, body }) + } + .boxed_local(); + let deadline = worker::Delay::from(policy.total_timeout).boxed_local(); + + match futures::future::select(operation, deadline).await { + Either::Left((result, _)) => { + if result.is_err() { + controller.abort(); + } + result + } + Either::Right(((), _)) => { + controller.abort(); + Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded")) + } + } + } } #[cfg(target_arch = "wasm32")] @@ -456,6 +673,14 @@ impl PlatformHttpClient for CloudflareHttpClient { self.execute(request).await } + async fn send_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + self.execute_raw_proxy_v1(request, policy).await + } + fn supports_concurrent_fanout(&self) -> bool { // `send_async` executes each request eagerly, so multiple pending // requests run sequentially. The auction orchestrator checks this @@ -602,7 +827,7 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R let client_ip = extract_client_ip(ctx); #[cfg(target_arch = "wasm32")] - let http_client: Arc = Arc::new(CloudflareHttpClient); + let http_client: Arc = Arc::new(CloudflareHttpClient::new(ctx)); #[cfg(not(target_arch = "wasm32"))] let http_client: Arc = Arc::new(UnavailableHttpClient); diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 09e3ed324..861813359 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -21,14 +21,19 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// The handler regex is the production-shaped `^/_ts/admin`, matching /// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical /// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. -fn test_router() -> RouterService { - let settings = Settings::from_toml( +fn test_settings() -> Settings { + Settings::from_toml( r#" [[handlers]] path = "^/_ts/admin" username = "admin" password = "admin-pass" + [[handlers]] + path = "^/integrations/aps" + username = "aps-user" + password = "aps-pass" + [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -37,11 +42,18 @@ fn test_router() -> RouterService { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [integrations.aps] + enabled = true + account_id = "route-test-aps-account" + allow_script_creatives = true "#, ) - .expect("should parse route test settings"); + .expect("should parse route test settings") +} - TrustedServerApp::routes_with_settings(settings) +fn test_router() -> RouterService { + TrustedServerApp::routes_with_settings(test_settings()) .expect("should build router from test settings") } @@ -58,6 +70,13 @@ async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } +async fn route_reserved(req: Request) -> Response { + trusted_server_adapter_cloudflare::app::dispatch_reserved_with_settings(test_settings(), req) + .await + .expect("should build APS dispatcher") + .expect("APS family should be reserved") +} + fn assert_route_registered(method: &str, path: &str) { let routes = registered_routes(); assert!( @@ -101,6 +120,74 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn aps_cutover_renderer_and_family_failures_are_local() { + let renderer = request_builder() + .method("GET") + .uri("/integrations/aps/renderer/v1") + .header("authorization", "Bearer must-not-reach-publisher") + .body(edgezero_core::body::Body::empty()) + .expect("should build APS renderer request"); + let response = route_reserved(renderer).await; + assert_eq!(response.status().as_u16(), 200); + assert_eq!( + response.headers()["content-type"], + "text/html; charset=utf-8" + ); + assert_eq!( + response.headers()["cache-control"], + "public, max-age=31536000, immutable" + ); + assert!(!response.headers().contains_key("x-frame-options")); + let body = response.into_body().into_bytes().unwrap_or_default(); + let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(!body.contains("client.aps.amazon-adsystem.com")); + + for (method, path, expected) in [ + ("POST", "/integrations/aps/runner.js", 405), + ("TRACE", "/integrations/aps/renderer/v1", 405), + ("CONNECT", "/integrations/aps/renderer/v1", 405), + ("PROPFIND", "/integrations/aps/renderer/v1", 405), + ("GET", "/integrations/aps/renderer", 404), + ("GET", "/integrations/aps/renderer/v2", 404), + ("GET", "/integrations/aps/runner/v1.js", 404), + ("GET", "/integrations/aps", 404), + ] { + let request = request_builder() + .method(method) + .uri(path) + .header("authorization", "Bearer must-not-reach-publisher") + .body(edgezero_core::body::Body::empty()) + .expect("should build APS family request"); + let response = route_reserved(request).await; + assert_eq!(response.status().as_u16(), expected, "{method} {path}"); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response.headers().contains_key("x-geo-info-available")); + if expected == 405 { + assert_eq!(response.headers()["allow"], "GET"); + assert_eq!(response.headers().len(), 2, "{method} {path}"); + } else { + assert_eq!(response.headers().len(), 1, "{method} {path}"); + } + assert!( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .is_empty() + ); + } + + let protected_control = request_builder() + .method("GET") + .uri("/integrations/apsx") + .body(edgezero_core::body::Body::empty()) + .expect("should build protected non-APS boundary request"); + let response = route(test_router(), protected_control).await; + assert_eq!(response.status().as_u16(), 401); +} + // --------------------------------------------------------------------------- // Middleware regression tests — verify FinalizeResponseMiddleware and // AuthMiddleware are wired so they cannot be removed silently. @@ -216,16 +303,9 @@ fn all_explicit_routes_are_registered() { ("POST", "/_ts/admin/keys/rotate"), ("POST", "/_ts/admin/keys/deactivate"), ("POST", "/auction"), - // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both - // paths are spelled out as literals rather than referencing - // `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH` so this test pins the - // actual URL the tsjs client fetches — asserting a const against itself - // would still pass if the const's value changed out from under the - // client. + // Pin the canonical literal fetched by the hard-cutover client. ("GET", "/_ts/page-bids"), ("OPTIONS", "/_ts/page-bids"), - ("GET", "/__ts/page-bids"), - ("OPTIONS", "/__ts/page-bids"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), ("GET", "/first-party/sign"), @@ -237,6 +317,9 @@ fn all_explicit_routes_are_registered() { for (method, path) in expected { assert_route_registered(method, path); } + for method in LEGACY_ADMIN_DENY_METHODS { + assert_route_registered(method, "/__ts/page-bids"); + } for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { for method in LEGACY_ADMIN_DENY_METHODS { diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml b/crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml new file mode 100644 index 000000000..90ec710b0 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml @@ -0,0 +1,16 @@ +name = "trusted-server-aps-runner-proxy-integration" +main = "build/index.js" +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat", "cache_option_enabled"] + +[[kv_namespaces]] +binding = "TRUSTED_SERVER_KV" +id = "aps-runner-proxy-local-kv" + +[[services]] +binding = "APS_RUNNER_PROXY_FIXTURE" +service = "aps-runner-proxy-fixture" + +[vars] +# Replaced in a temporary copy by the integration-test controller. +TRUSTED_SERVER_CONFIG = "{}" diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index b6bc0f1a1..78477f714 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -10,6 +10,10 @@ version = { workspace = true } [lints] workspace = true +[features] +default = [] +aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"] + [dependencies] async-trait = { workspace = true } base64 = { workspace = true } @@ -29,6 +33,7 @@ sha2 = { workspace = true } trusted-server-core = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } +web-time = { workspace = true } [dev-dependencies] bytes = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 8e56916b2..cc24e343e 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -118,9 +118,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, handle_page_bids, - handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, - publisher_response_into_streaming_response, + AuctionDispatch, PAGE_BIDS_PATH, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, publisher_response_into_streaming_response, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -165,6 +164,25 @@ pub(crate) fn build_state() -> Result, Report> build_state_from_settings(load_settings_from_config_store()?) } +pub(crate) async fn dispatch_reserved_for_state( + state: &Arc, + req: Request, +) -> Option { + if !state.registry.has_reserved_path(req.uri().path()) { + return None; + } + let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default()); + let services = build_per_request_services(state, &ctx); + Some( + state + .registry + .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) + .await + .expect("reserved path should have a hard-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + pub(crate) fn load_settings_from_config_store() -> Result> { let store_name = default_config_store_name(); let config_key = default_config_key(); @@ -1110,15 +1128,12 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, }, - // Deprecated double-underscore alias. tsjs bundles served before the - // `/_ts/page-bids` rename keep requesting this path from already-loaded - // pages and browser caches; dropping it would strand SPA navigations - // without ads until those bundles age out. See `PAGE_BIDS_LEGACY_PATH`; - // removal is tracked by IABTechLab/trusted-server#970. + // A removed route must be denied here, before the publisher fallback, so + // its response is always a local unknown-route result rather than an alias. NamedRoute { - path: PAGE_BIDS_LEGACY_PATH, - primary_methods: &[Method::GET, Method::OPTIONS], - handler: NamedRouteHandler::PageBids, + path: "/__ts/page-bids", + primary_methods: LEGACY_ADMIN_DENY_METHODS, + handler: NamedRouteHandler::LegacyAdminDenied, }, NamedRoute { path: "/first-party/proxy", @@ -1246,9 +1261,11 @@ impl Hooks for TrustedServerApp { mod tests { use std::sync::Arc; + #[cfg(feature = "aps-runner-proxy-integration-test")] + use super::dispatch_reserved_for_state; use super::{ - AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, - TrustedServerApp, build_state_from_settings, startup_error_router, + AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_PATH, TrustedServerApp, + build_state_from_settings, startup_error_router, }; use bytes::Bytes; use edgezero_core::body::Body; @@ -1352,6 +1369,11 @@ mod tests { username = "admin" password = "admin-pass" + [[handlers]] + path = "^/integrations/aps" + username = "aps-user" + password = "aps-pass" + [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -1374,6 +1396,11 @@ mod tests { server_url = "https://test-prebid.com/openrtb2/auction" external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" + [integrations.aps] + enabled = true + account_id = "route-test-aps-account" + allow_script_creatives = true + [auction] enabled = true providers = ["prebid"] @@ -1388,6 +1415,100 @@ mod tests { TrustedServerApp::routes_for_state(&state) } + #[cfg(feature = "aps-runner-proxy-integration-test")] + fn route_reserved(request: edgezero_core::http::Request) -> Response { + let state = build_state_from_settings(test_settings()).expect("should build test state"); + block_on(dispatch_reserved_for_state(&state, request)) + .expect("APS family should be reserved") + } + + #[cfg(feature = "aps-runner-proxy-integration-test")] + #[test] + fn aps_cutover_renderer_and_family_failures_are_local() { + let response = route_reserved(empty_request(Method::GET, "/integrations/aps/renderer/v1")); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "public, max-age=31536000, immutable" + ); + assert!(!response.headers().contains_key("x-frame-options")); + let body = response.into_body().into_bytes().unwrap_or_default(); + let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(!body.contains("client.aps.amazon-adsystem.com")); + + for (method, path, expected) in [ + ( + Method::POST, + "/integrations/aps/runner.js", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + Method::TRACE, + "/integrations/aps/renderer/v1", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + Method::CONNECT, + "/integrations/aps/renderer/v1", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + Method::from_bytes(b"PROPFIND").expect("PROPFIND should be a valid method"), + "/integrations/aps/renderer/v1", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + Method::GET, + "/integrations/aps/renderer", + StatusCode::NOT_FOUND, + ), + ( + Method::GET, + "/integrations/aps/renderer/v2", + StatusCode::NOT_FOUND, + ), + ( + Method::GET, + "/integrations/aps/runner/v1.js", + StatusCode::NOT_FOUND, + ), + (Method::GET, "/integrations/aps", StatusCode::NOT_FOUND), + ] { + let mut request = empty_request(method.clone(), path); + request.headers_mut().insert( + header::AUTHORIZATION, + "Bearer must-not-reach-publisher" + .parse() + .expect("should parse authorization header"), + ); + let response = route_reserved(request); + assert_eq!(response.status(), expected, "{method} {path}"); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert!(!response.headers().contains_key(HEADER_X_GEO_INFO_AVAILABLE)); + if expected == StatusCode::METHOD_NOT_ALLOWED { + assert_eq!(response.headers()[header::ALLOW], "GET"); + } + assert!( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .is_empty() + ); + } + + let response = route( + &test_router(), + empty_request(Method::GET, "/integrations/apsx"), + ); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + /// Builds a router whose `AppState` uses a registry containing the given /// request filters (and no routes), so dispatch-level request-filter /// behavior can be exercised without a real integration. @@ -1661,45 +1782,29 @@ mod tests { } #[test] - fn page_bids_serves_canonical_path_and_deprecated_alias() { - // The SPA re-auction endpoint lives at the canonical single-underscore - // `/_ts/page-bids`, matching every other internal route. The deprecated - // `/__ts/page-bids` alias must stay registered to the same handler with - // the same methods until pre-rename tsjs bundles age out of browser - // caches — dropping it would leave those clients without ads on SPA - // navigations. - // - // The paths are literals, not `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`. - // Looking a route up by the same const it was registered with is - // tautological: it keeps passing if the const's value changes, which is - // exactly the break that would silently desync the server from the tsjs - // client's hardcoded fetch path. Pin the consts to their literals too so - // a rename has to be deliberate. + fn page_bids_keeps_the_canonical_handler_and_denies_the_removed_alias_locally() { + // The hard cutover exposes only the canonical single-underscore page-bids + // handler. The removed path is an explicit local 404, never an alias or + // publisher-fallback route. assert_eq!( PAGE_BIDS_PATH, "/_ts/page-bids", "canonical page-bids path must match the path tsjs fetches" ); - assert_eq!( - PAGE_BIDS_LEGACY_PATH, "/__ts/page-bids", - "legacy alias must match the path pre-rename tsjs bundles fetch" - ); - - for path in ["/_ts/page-bids", "/__ts/page-bids"] { - let route = NAMED_ROUTES - .iter() - .find(|route| route.path == path) - .unwrap_or_else(|| panic!("{path} should be registered")); - - assert!( - matches!(route.handler, NamedRouteHandler::PageBids), - "{path} must map to the page-bids handler" - ); - assert_eq!( - route.primary_methods, - &[Method::GET, Method::OPTIONS], - "{path} must handle GET and OPTIONS directly, not fall through to the publisher" - ); - } + let route = NAMED_ROUTES + .iter() + .find(|route| route.path == "/_ts/page-bids") + .expect("canonical page-bids path should be registered"); + assert!(matches!(route.handler, NamedRouteHandler::PageBids)); + assert_eq!(route.primary_methods, &[Method::GET, Method::OPTIONS]); + let removed = NAMED_ROUTES + .iter() + .find(|route| route.path == "/__ts/page-bids") + .expect("removed page-bids path should be denied locally"); + assert!(matches!( + removed.handler, + NamedRouteHandler::LegacyAdminDenied + )); + assert_eq!(removed.primary_methods, super::LEGACY_ADMIN_DENY_METHODS); } #[test] diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 39d35b198..d5c2c2119 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -167,7 +167,20 @@ fn edgezero_main(mut req: FastlyRequest) { core_req.extensions_mut().insert(config_store); core_req.extensions_mut().insert(device_signals); core_req.extensions_mut().insert(client_info); - match futures::executor::block_on(app.router().oneshot(core_req)) { + let routed = if let Some(state) = app_state + .as_ref() + .filter(|state| state.registry.has_reserved_path(core_req.uri().path())) + { + Ok( + futures::executor::block_on(crate::app::dispatch_reserved_for_state( + state, core_req, + )) + .expect("reserved path should dispatch before RouterService"), + ) + } else { + futures::executor::block_on(app.router().oneshot(core_req)) + }; + match routed { Ok(response) => response, Err(error) => edge_error_response(error), } @@ -186,7 +199,12 @@ fn edgezero_main(mut req: FastlyRequest) { let asset_cache_policy = response.extensions_mut().remove::(); let request_filter_effects = response.extensions_mut().remove::(); - if !take_finalize_sentinel(&mut response) { + let should_finalize = response + .extensions() + .get::() + .is_none() + && !take_finalize_sentinel(&mut response); + if should_finalize { if let Some(settings) = settings_snapshot.as_deref() { apply_entry_point_finalize_headers(settings, &mut response, client_ip); } else { diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 9e7920e1c..9d40360cc 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -4,6 +4,7 @@ use std::io::Read as _; use std::net::IpAddr; use std::sync::Arc; +use std::time::Duration; use bytes::Bytes; use edgezero_adapter_fastly::key_value_store::FastlyKvStore; @@ -13,13 +14,18 @@ use fastly::geo::{Geo, geo_lookup}; use fastly::{ConfigStore, Request, SecretStore}; use crate::backend::BackendConfig; +#[cfg(feature = "aps-runner-proxy-integration-test")] +use trusted_server_core::integrations::aps::{ + APS_RUNNER_BLOCKING_READ_TIMEOUT, APS_RUNNER_FIRST_BYTE_TIMEOUT, APS_RUNNER_UPSTREAM_URL, +}; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, + PlatformSecretStore, PlatformSelectResult, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, + RawProxyPolicyV1, RawProxyResponseV1, StoreId, StoreName, }; // --------------------------------------------------------------------------- @@ -531,6 +537,31 @@ fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool) } } +fn fastly_raw_header_evidence(response: &fastly::Response, name: &str) -> ProxyHeaderEvidenceV1 { + ProxyHeaderEvidenceV1::Occurrences( + response + .get_header_all(name) + .map(|value| value.as_bytes().to_vec()) + .collect(), + ) +} + +fn canonical_fastly_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option { + let ProxyHeaderEvidenceV1::Occurrences(values) = evidence else { + return None; + }; + let [value] = values.as_slice() else { + return None; + }; + if value.is_empty() + || !value.iter().all(u8::is_ascii_digit) + || (value.len() > 1 && value[0] == b'0') + { + return None; + } + std::str::from_utf8(value).ok()?.parse().ok() +} + /// Fastly implementation of [`PlatformHttpClient`]. /// /// - [`send`](PlatformHttpClient::send) converts the platform request to a @@ -545,6 +576,67 @@ fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool) /// `fastly::http::request::select()`. pub struct FastlyPlatformHttpClient; +#[cfg(feature = "aps-runner-proxy-integration-test")] +const APS_RUNNER_PROXY_TEST_BACKEND: &str = "aps_runner_proxy_fixture"; +const RAW_PROXY_DEADLINE_SAFETY_MARGIN: Duration = Duration::from_millis(250); +const RAW_PROXY_PENDING_POLL_INTERVAL: Duration = Duration::from_millis(5); + +fn raw_proxy_call_start_deadline(policy: RawProxyPolicyV1) -> Option { + policy.total_timeout.checked_sub( + policy + .blocking_read_timeout + .checked_add(RAW_PROXY_DEADLINE_SAFETY_MARGIN)?, + ) +} + +fn raw_proxy_pending_poll_sleep(elapsed: Duration, deadline: Duration) -> Duration { + deadline + .saturating_sub(elapsed) + .min(RAW_PROXY_PENDING_POLL_INTERVAL) +} + +#[cfg(feature = "aps-runner-proxy-integration-test")] +fn aps_runner_proxy_test_backend( + policy: RawProxyPolicyV1, +) -> Result> { + if policy.first_byte_timeout != APS_RUNNER_FIRST_BYTE_TIMEOUT + || policy.blocking_read_timeout != APS_RUNNER_BLOCKING_READ_TIMEOUT + { + return Err(Report::new(PlatformError::HttpClient) + .attach("APS runner raw proxy policy does not match the static fixture timeouts")); + } + let fixture = fastly::Backend::from_name(APS_RUNNER_PROXY_TEST_BACKEND) + .change_context(PlatformError::HttpClient)?; + if !fixture.exists() || fixture.is_ssl() { + return Err(Report::new(PlatformError::HttpClient) + .attach("APS runner fixture backend must exist as plain HTTP")); + } + let fixture_host = fixture.get_host(); + let fixture_address = fixture_host.parse::().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("APS runner fixture backend host must be a literal IP address") + })?; + if !fixture_address.is_loopback() { + return Err(Report::new(PlatformError::HttpClient) + .attach("APS runner fixture backend host must be loopback")); + } + let logical_url = + url::Url::parse(APS_RUNNER_UPSTREAM_URL).change_context(PlatformError::HttpClient)?; + let logical_host = logical_url.host_str().ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("APS runner logical URL must contain a host") + })?; + if fixture + .get_host_override() + .as_ref() + .and_then(|host| host.to_str().ok()) + != Some(logical_host) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("APS runner fixture backend must preserve the logical host")); + } + Ok(APS_RUNNER_PROXY_TEST_BACKEND.to_string()) +} + #[async_trait::async_trait(?Send)] impl PlatformHttpClient for FastlyPlatformHttpClient { fn supports_streaming_responses(&self) -> bool { @@ -571,6 +663,109 @@ 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(); + let call_start_deadline = raw_proxy_call_start_deadline(policy).ok_or_else(|| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy timeout cannot reserve one bounded body read") + })?; + if policy.first_byte_timeout > call_start_deadline { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy first-byte timeout exceeds reduced 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 mut pending = fastly_request + .send_async(&backend_name) + .change_context(PlatformError::HttpClient)?; + let mut response = loop { + if started.elapsed() >= call_start_deadline { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy reduced deadline exceeded before response headers")); + } + match pending.poll() { + fastly::http::request::PollResult::Pending(next) => { + pending = next; + let sleep = + raw_proxy_pending_poll_sleep(started.elapsed(), call_start_deadline); + if !sleep.is_zero() { + std::thread::sleep(sleep); + } + if started.elapsed() >= call_start_deadline { + return Err(Report::new(PlatformError::HttpClient).attach( + "raw proxy reduced deadline exceeded while polling response headers", + )); + } + } + fastly::http::request::PollResult::Done(result) => { + break result.change_context(PlatformError::HttpClient)?; + } + } + }; + + 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() >= call_start_deadline { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy reduced deadline exceeded before blocking body read")); + } + let read = reader + .read(&mut chunk) + .change_context(PlatformError::HttpClient)?; + if started.elapsed() >= policy.total_timeout { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded while reading body")); + } + if read == 0 { + break; + } + let next_len = body.len().checked_add(read).ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow") + })?; + if next_len > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy body exceeds configured cap")); + } + body.extend_from_slice(&chunk[..read]); + } + + Ok(RawProxyResponseV1 { evidence, body }) + } + async fn send_async( &self, request: PlatformHttpRequest, @@ -760,6 +955,22 @@ mod tests { ); } + #[test] + fn raw_proxy_pending_poll_sleep_is_bounded_by_interval_and_deadline() { + assert_eq!( + raw_proxy_pending_poll_sleep(Duration::from_secs(1), Duration::from_secs(4)), + RAW_PROXY_PENDING_POLL_INTERVAL + ); + assert_eq!( + raw_proxy_pending_poll_sleep(Duration::from_millis(3_998), Duration::from_secs(4),), + Duration::from_millis(2) + ); + assert_eq!( + raw_proxy_pending_poll_sleep(Duration::from_secs(4), Duration::from_secs(4)), + Duration::ZERO + ); + } + // --- 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 4f4ba5133..d87fe4320 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -10,6 +10,8 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +#[cfg(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32"))] +use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -20,9 +22,8 @@ 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, @@ -50,11 +51,26 @@ pub struct AppState { /// /// Returns an error when settings, the auction orchestrator, or the integration /// registry fail to initialise. +#[cfg(not(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32")))] fn build_state() -> Result, Report> { let settings = Settings::from_toml(include_str!("../../../trusted-server.example.toml"))?; build_state_with_settings(settings) } +#[cfg(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32"))] +fn build_state() -> Result, Report> { + let envelope = + futures::executor::block_on(spin_sdk::variables::get("v_trusted_x5fserver_x5fconfig")) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "failed to read the Spin APS proxy test app config".to_string(), + }) + .attach(error.to_string()) + })?; + let settings = settings_from_config_blob(&envelope)?; + build_state_with_settings(settings) +} + /// Build the application state from explicit settings. /// /// # Errors @@ -74,6 +90,49 @@ fn build_state_with_settings( })) } +async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { + if !state.registry.has_reserved_path(req.uri().path()) { + return None; + } + let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default()); + let services = build_runtime_services(&ctx); + Some( + state + .registry + .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) + .await + .expect("reserved path should have a hard-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + +/// Dispatch a reserved APS request using explicit settings. +/// +/// # Errors +/// +/// Returns an error when the application state cannot be +/// initialized from `settings`. +pub async fn dispatch_reserved_with_settings( + settings: Settings, + req: Request, +) -> Result, Report> { + let state = build_state_with_settings(settings)?; + Ok(dispatch_reserved_for_state(&state, req).await) +} + +/// Dispatch a reserved APS request using startup settings. +/// +/// # Errors +/// +/// Returns an error when startup settings or the application +/// state cannot be initialized. +pub async fn dispatch_reserved( + req: Request, +) -> Result, Report> { + let state = build_state()?; + Ok(dispatch_reserved_for_state(&state, req).await) +} + // --------------------------------------------------------------------------- // Publisher response helper // --------------------------------------------------------------------------- @@ -154,7 +213,7 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 14] { ("/_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]), @@ -777,18 +836,8 @@ fn build_router(state: &Arc) -> RouterService { .post("/_ts/admin/keys/deactivate", admin_not_supported_handler) .get("/_ts/trace", trace_mode_handler) .post("/auction", auction_handler) - .get(PAGE_BIDS_PATH, page_bids_handler.clone()) + .get(PAGE_BIDS_PATH, page_bids_handler) .route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_options_handler) - // Deprecated double-underscore alias, kept so tsjs bundles served - // before the `/_ts/page-bids` rename keep getting ads on SPA - // navigations until they age out of browser caches. See - // `PAGE_BIDS_LEGACY_PATH`. - .get(PAGE_BIDS_LEGACY_PATH, page_bids_handler) - .route( - PAGE_BIDS_LEGACY_PATH, - Method::OPTIONS, - page_bids_options_handler, - ) .get("/first-party/proxy", fp_proxy_handler) .get("/first-party/click", fp_click_handler) .get("/first-party/sign", fp_sign_handler) @@ -799,6 +848,7 @@ fn build_router(state: &Arc) -> RouterService { for method in LEGACY_ADMIN_DENY_METHODS { builder = builder.route("/admin/keys/rotate", method.clone(), legacy_admin_deny); builder = builder.route("/admin/keys/deactivate", method.clone(), legacy_admin_deny); + builder = builder.route("/__ts/page-bids", method.clone(), legacy_admin_deny); } // Mirror the Fastly/Axum publisher fallback: every supported method that is diff --git a/crates/trusted-server-adapter-spin/src/lib.rs b/crates/trusted-server-adapter-spin/src/lib.rs index f47877ff2..5a6b20bc1 100644 --- a/crates/trusted-server-adapter-spin/src/lib.rs +++ b/crates/trusted-server-adapter-spin/src/lib.rs @@ -13,5 +13,15 @@ use spin_sdk::http_service; #[http_service] // FORCED: edgezero_adapter_spin::run_app returns anyhow::Result — EdgeZero SDK constraint, not a project choice. async fn handle(req: Request) -> anyhow::Result { + if trusted_server_core::integrations::aps::is_aps_family_path(req.uri().path()) { + let request = edgezero_adapter_spin::request::into_core_request(req).await?; + let response = app::dispatch_reserved(request) + .await + .map_err(|error| anyhow::anyhow!("{error:?}"))? + .expect("reserved APS path should dispatch before RouterService"); + return edgezero_adapter_spin::response::from_core_response(response) + .await + .map_err(Into::into); + } edgezero_adapter_spin::run_app::(req).await } diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 492f1a518..e5b5f2daf 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -25,7 +25,8 @@ use std::io::Read as _; use trusted_server_core::platform::PlatformHttpRequest; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use trusted_server_core::platform::{ - PlatformPendingRequest, PlatformResponse, PlatformSelectResult, + PlatformPendingRequest, PlatformResponse, PlatformSelectResult, ProxyHeaderEvidenceV1, + ProxyResponseEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1, }; // 8 MiB ceiling: conservative for ad-server responses while leaving headroom in @@ -472,8 +473,56 @@ struct SpinPendingResponse { #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub struct SpinPlatformHttpClient; +#[cfg(all( + feature = "aps-runner-proxy-integration-test", + any(test, all(feature = "spin", target_arch = "wasm32")) +))] +fn aps_runner_proxy_transport_uri( + logical_uri: &str, + endpoint: &str, +) -> Result, Report> { + use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL; + + if logical_uri != APS_RUNNER_UPSTREAM_URL { + return Ok(None); + } + let parsed: edgezero_core::http::Uri = endpoint.parse().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("invalid APS runner proxy integration fixture endpoint") + })?; + if parsed.scheme_str() != Some("http") + || !matches!(parsed.host(), Some("127.0.0.1" | "::1")) + || parsed.port_u16().is_none() + || parsed.path().is_empty() + || parsed.query().is_some() + { + return Err(Report::new(PlatformError::HttpClient).attach( + "APS runner proxy integration fixture endpoint must be an explicit loopback HTTP URL", + )); + } + Ok(Some(endpoint.to_owned())) +} + #[cfg(all(feature = "spin", target_arch = "wasm32"))] impl SpinPlatformHttpClient { + #[cfg(all( + feature = "aps-runner-proxy-integration-test", + feature = "spin", + target_arch = "wasm32" + ))] + async fn aps_runner_proxy_test_transport_uri( + logical_uri: &str, + ) -> Result, Report> { + let endpoint = spin_sdk::variables::get("aps_runner_proxy_test_endpoint") + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient).attach( + "APS runner proxy integration artifact requires its loopback fixture endpoint", + ) + })?; + aps_runner_proxy_transport_uri(logical_uri, &endpoint) + } + async fn execute( &self, request: PlatformHttpRequest, @@ -559,6 +608,173 @@ impl SpinPlatformHttpClient { Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) } + + fn raw_header_evidence( + headers: &spin_sdk::wasip3::http::types::Headers, + name: &str, + ) -> ProxyHeaderEvidenceV1 { + ProxyHeaderEvidenceV1::Occurrences(headers.get(name)) + } + + fn canonical_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option { + let ProxyHeaderEvidenceV1::Occurrences(values) = evidence else { + return None; + }; + let [value] = values.as_slice() else { + return None; + }; + if value.is_empty() + || !value.iter().all(u8::is_ascii_digit) + || (value.len() > 1 && value[0] == b'0') + { + return None; + } + std::str::from_utf8(value).ok()?.parse().ok() + } + + async fn execute_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + use futures::{FutureExt as _, future::Either}; + use spin_sdk::http::IntoRequest as _; + use spin_sdk::wasip3::http::types::RequestOptions; + use spin_sdk::wasip3::http_compat::{IncomingResponseBody, RequestOptionsExtension}; + + reject_unsupported_request_contracts(&request)?; + let method = request.request.method().clone(); + let logical_uri = request.request.uri().to_string(); + #[cfg(feature = "aps-runner-proxy-integration-test")] + let transport_uri = Self::aps_runner_proxy_test_transport_uri(&logical_uri).await?; + let mut builder = spin_sdk::http::Request::builder() + .method(into_spin_method(&method)) + .uri(&logical_uri); + for (name, value) in request.request.headers() { + if is_wasi_forbidden_outbound_header(name.as_str()) { + continue; + } + builder = builder.header(name.as_str(), value.as_bytes()); + } + #[cfg(feature = "aps-runner-proxy-integration-test")] + if transport_uri.is_some() { + builder = builder.header("x-ts-aps-logical-url", logical_uri); + } + + let (_, request_body) = request.request.into_parts(); + let request_body = match request_body { + edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), + edgezero_core::body::Body::Stream(_) => { + return Err(Report::new(PlatformError::HttpClient) + .attach("streaming request bodies are not supported on Spin raw proxy")); + } + }; + let mut spin_request = builder + .body(spin_sdk::http::FullBody::new(Bytes::from(request_body))) + .map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to build Spin raw proxy request: {error}")) + })?; + + // Spin/Wasmtime owns the wire `Host` header and forbids guests from + // setting it. Keep the fixed APS URL through the core→adapter contract, + // then apply the loopback-only integration target at the final lowering + // boundary. Production builds have no transport override constructor. + #[cfg(feature = "aps-runner-proxy-integration-test")] + if let Some(transport_uri) = transport_uri { + *spin_request.uri_mut() = transport_uri.parse().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("failed to lower APS loopback transport URI") + })?; + } + + let timeout_nanos = policy.total_timeout.as_nanos().try_into().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy timeout exceeds WASI HTTP duration range") + })?; + let options = RequestOptions::new(); + options + .set_connect_timeout(Some(timeout_nanos)) + .map_err(|_| { + Report::new(PlatformError::Unsupported) + .attach("Spin raw proxy connect timeout is unavailable") + })?; + options + .set_first_byte_timeout(Some(timeout_nanos)) + .map_err(|_| { + Report::new(PlatformError::Unsupported) + .attach("Spin raw proxy first-byte timeout is unavailable") + })?; + options + .set_between_bytes_timeout(Some(timeout_nanos)) + .map_err(|_| { + Report::new(PlatformError::Unsupported) + .attach("Spin raw proxy between-bytes timeout is unavailable") + })?; + spin_request + .extensions_mut() + .insert(RequestOptionsExtension(options)); + let wasi_request = spin_request.into_request().map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to lower Spin raw proxy request: {error}")) + })?; + + let operation = async move { + let response = spin_sdk::wasip3::http::client::send(wasi_request) + .await + .map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("Spin raw proxy request failed: {error}")) + })?; + let status = response.get_status_code(); + let headers = response.get_headers(); + let evidence = ProxyResponseEvidenceV1 { + status, + content_type: Self::raw_header_evidence(&headers, "content-type"), + content_encoding: Self::raw_header_evidence(&headers, "content-encoding"), + content_length: Self::raw_header_evidence(&headers, "content-length"), + }; + if Self::canonical_declared_length(&evidence.content_length) + .is_some_and(|length| length > policy.max_response_bytes) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy declared body exceeds configured cap")); + } + + let mut incoming = IncomingResponseBody::new(response).map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to open Spin raw proxy body: {error}")) + })?; + let mut body = Vec::new(); + while let Some(frame) = incoming.frame().await { + let frame = frame.map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to read Spin raw proxy body: {error}")) + })?; + let Ok(data) = frame.into_data() else { + continue; + }; + let next_len = body.len().checked_add(data.len()).ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow") + })?; + if next_len > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy body exceeds configured cap")); + } + body.extend_from_slice(&data); + } + Ok(RawProxyResponseV1 { evidence, body }) + } + .boxed_local(); + let deadline = spin_sdk::time::sleep(policy.total_timeout).boxed_local(); + match futures::future::select(operation, deadline).await { + Either::Left((result, _)) => result, + Either::Right(((), _)) => { + Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded")) + } + } + } } #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -578,6 +794,14 @@ impl PlatformHttpClient for SpinPlatformHttpClient { self.execute(request).await } + async fn send_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + self.execute_raw_proxy_v1(request, policy).await + } + async fn send_async( &self, request: PlatformHttpRequest, @@ -801,6 +1025,22 @@ mod tests { use flate2::write::GzEncoder; use std::io::Write as _; + #[cfg(feature = "aps-runner-proxy-integration-test")] + #[test] + fn aps_test_transport_mapping_preserves_logical_authority_until_lowering() { + use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL; + + let endpoint = "http://127.0.0.1:49152/prebid-creative.js"; + let transport = aps_runner_proxy_transport_uri(APS_RUNNER_UPSTREAM_URL, endpoint) + .expect("loopback integration endpoint should be accepted") + .expect("fixed APS URL should select the integration transport"); + assert_eq!(transport.to_string(), endpoint); + let logical: edgezero_core::http::Uri = APS_RUNNER_UPSTREAM_URL + .parse() + .expect("fixed APS URL should parse"); + assert_eq!(logical.host(), Some("client.aps.amazon-adsystem.com")); + } + fn make_ctx_without_spin_context() -> RequestContext { let req = request_builder() .method("GET") diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 2f7b1037e..72a32710a 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -20,14 +20,19 @@ use trusted_server_core::settings::Settings; /// The handler regex is the production-shaped `^/_ts/admin`, matching /// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical /// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. -fn test_router() -> RouterService { - let settings = Settings::from_toml( +fn test_settings() -> Settings { + Settings::from_toml( r#" [[handlers]] path = "^/_ts/admin" username = "admin" password = "admin-pass" + [[handlers]] + path = "^/integrations/aps" + username = "aps-user" + password = "aps-pass" + [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -36,11 +41,18 @@ fn test_router() -> RouterService { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [integrations.aps] + enabled = true + account_id = "route-test-aps-account" + allow_script_creatives = true "#, ) - .expect("should parse route test settings"); + .expect("should parse route test settings") +} - TrustedServerApp::routes_with_settings(settings) +fn test_router() -> RouterService { + TrustedServerApp::routes_with_settings(test_settings()) .expect("should build router from test settings") } @@ -48,6 +60,13 @@ async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } +async fn route_reserved(req: Request) -> Response { + trusted_server_adapter_spin::app::dispatch_reserved_with_settings(test_settings(), req) + .await + .expect("should build APS dispatcher") + .expect("APS family should be reserved") +} + #[test] fn routes_build_without_panic() { // build_state() may fail (no real settings in CI) — startup_error_router @@ -55,6 +74,74 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn aps_cutover_renderer_and_family_failures_are_local() { + let renderer = request_builder() + .method("GET") + .uri("/integrations/aps/renderer/v1") + .header("authorization", "Bearer must-not-reach-publisher") + .body(edgezero_core::body::Body::empty()) + .expect("should build APS renderer request"); + let response = route_reserved(renderer).await; + assert_eq!(response.status().as_u16(), 200); + assert_eq!( + response.headers()["content-type"], + "text/html; charset=utf-8" + ); + assert_eq!( + response.headers()["cache-control"], + "public, max-age=31536000, immutable" + ); + assert!(!response.headers().contains_key("x-frame-options")); + let body = response.into_body().into_bytes().unwrap_or_default(); + let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(!body.contains("client.aps.amazon-adsystem.com")); + + for (method, path, expected) in [ + ("POST", "/integrations/aps/runner.js", 405), + ("TRACE", "/integrations/aps/renderer/v1", 405), + ("CONNECT", "/integrations/aps/renderer/v1", 405), + ("PROPFIND", "/integrations/aps/renderer/v1", 405), + ("GET", "/integrations/aps/renderer", 404), + ("GET", "/integrations/aps/renderer/v2", 404), + ("GET", "/integrations/aps/runner/v1.js", 404), + ("GET", "/integrations/aps", 404), + ] { + let request = request_builder() + .method(method) + .uri(path) + .header("authorization", "Bearer must-not-reach-publisher") + .body(edgezero_core::body::Body::empty()) + .expect("should build APS family request"); + let response = route_reserved(request).await; + assert_eq!(response.status().as_u16(), expected, "{method} {path}"); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response.headers().contains_key("x-geo-info-available")); + if expected == 405 { + assert_eq!(response.headers()["allow"], "GET"); + assert_eq!(response.headers().len(), 2, "{method} {path}"); + } else { + assert_eq!(response.headers().len(), 1, "{method} {path}"); + } + assert!( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .is_empty() + ); + } + + let protected_control = request_builder() + .method("GET") + .uri("/integrations/apsx") + .body(edgezero_core::body::Body::empty()) + .expect("should build protected non-APS boundary request"); + let response = route(test_router(), protected_control).await; + assert_eq!(response.status().as_u16(), 401); +} + #[test] fn edgezero_manifest_loads_and_resolves_spin_stores() { let loader = edgezero_core::manifest::ManifestLoader::load_from_str(include_str!( @@ -330,54 +417,30 @@ async fn auction_is_routed() { assert_ne!(resp.status().as_u16(), 404, "/auction must be routed"); } -/// `GET` on the SPA re-auction endpoint must reach the page-bids handler on -/// both the canonical path and its deprecated `/__ts/` alias. -/// -/// The alias is what pre-rename tsjs bundles still request, and on a SPA that -/// path is what delivers ads for in-session navigations — so a dropped or -/// misspelled registration silently costs revenue rather than erroring loudly. -/// Spin registers `GET` and `OPTIONS` separately, so the preflight-denial parity -/// test does not imply the `GET` side is wired. -/// -/// Paths are literals rather than `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`: -/// this pins the actual URL the client fetches, which asserting a const against -/// itself would not. -/// -/// These test settings configure no creative opportunities, so the handler's own -/// deterministic answer is a 404 `Creative opportunities not configured`. That -/// body is the anchor: an unregistered path would instead fall through to the -/// publisher fallback and attempt an outbound fetch to the (nonexistent) test -/// origin, which cannot produce this message. A bare `!= 404` check would be -/// wrong here — the handler legitimately returns 404 under this config. +/// The canonical SPA re-auction path reaches page-bids, while the removed +/// double-underscore alias is denied locally with 404. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn page_bids_get_is_routed_on_canonical_path_and_alias() { - let mut responses = Vec::new(); - - for path in ["/_ts/page-bids", "/__ts/page-bids"] { - let req = request_builder() - .method("GET") - .uri(path) - .header("sec-fetch-site", "same-origin") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let resp = route(test_router(), req).await; - let status = resp.status().as_u16(); - let body = String::from_utf8_lossy(&resp.into_body().into_bytes().unwrap_or_default()) +async fn page_bids_get_is_routed_only_on_the_canonical_path() { + let canonical = request_builder() + .method("GET") + .uri("/_ts/page-bids") + .header("sec-fetch-site", "same-origin") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let canonical = route(test_router(), canonical).await; + let canonical_body = + String::from_utf8_lossy(&canonical.into_body().into_bytes().unwrap_or_default()) .into_owned(); + assert!(canonical_body.contains("Creative opportunities not configured")); - assert!( - body.contains("Creative opportunities not configured"), - "GET {path} must reach the page-bids handler, \ - got status {status} body {body:?}" - ); - - responses.push((status, body)); - } - - assert_eq!( - responses[0], responses[1], - "the deprecated alias must answer identically to the canonical path" - ); + let former_alias = request_builder() + .method("GET") + .uri("/__ts/page-bids") + .header("sec-fetch-site", "same-origin") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let former_alias = route(test_router(), former_alias).await; + assert_eq!(former_alias.status().as_u16(), 404); } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 7c1303dd4..dd78a1538 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -13,6 +13,7 @@ fn make_config() -> HtmlProcessorConfig { ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, } } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 2ff539ecb..aebb523bf 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -1,6 +1,6 @@ //! HTTP endpoint handlers for auction requests. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; @@ -19,20 +19,21 @@ use crate::ec::log_id; use crate::ec::prebid_eids::parse_prebid_eids_cookie; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; +use crate::http_util::RequestInfo; use crate::openrtb::{Eid, Uid}; use crate::platform::RuntimeServices; use crate::settings::Settings; use super::AuctionOrchestrator; -use super::formats::{ - convert_to_openrtb_response, convert_to_openrtb_response_with_report, - convert_tsjs_to_auction_request, -}; +use super::formats::{attach_auction_response_headers, convert_tsjs_to_auction_request}; use super::telemetry::{ AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events, emit_auction_events_best_effort_lazy, }; -use super::types::AuctionContext; +use super::types::{ + AuctionContext, AuctionDecisionSetV1, AuctionRequest, AuctionSlotFailureReason, + SlotAuctionDecisionV1, SystemAuctionIdentityGenerator, +}; const MAX_CLIENT_EID_SOURCES: usize = 64; const MAX_CLIENT_UIDS_PER_SOURCE: usize = 32; @@ -44,6 +45,67 @@ 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, + 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`]. @@ -68,6 +130,10 @@ const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; /// When `bids` is supplied, each entry's `bidder`/`params` pair is forwarded /// directly as `imp.ext.prebid.bidder.`. /// +/// APS `OpenRTB` demand is never forwarded through Prebid Server. An ad unit +/// whose only bidder is `aps` intentionally does not use PBS stored-request +/// fallback; configure a non-APS PBS bidder for stored-request demand instead. +/// /// ## Context passthrough (`config`) /// /// The optional `config` object is filtered through @@ -163,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. @@ -216,15 +298,21 @@ pub async fn handle_auction( provider_responses: Vec::new(), mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1::failed( + &auction_request, + AuctionSlotFailureReason::ConsentDenied, + ), total_time_ms: 0, metadata: HashMap::new(), }; - return convert_to_openrtb_response( + return Ok(exact_auction_response_v1( &empty_result, settings, &auction_request, + &request_origin, ec_context.ec_allowed(), - ); + )? + .response); } // Parse client-provided EIDs from the current request body. When the @@ -321,10 +409,11 @@ pub async fn handle_auction( } }; - let conversion = match convert_to_openrtb_response_with_report( + let conversion = match exact_auction_response_v1( &result, settings, &auction_request, + &request_origin, ec_context.ec_allowed(), ) { Ok(conversion) => conversion, @@ -352,7 +441,7 @@ pub async fn handle_auction( AuctionTerminalOutcome::Completed { request: &auction_request, result: &result, - delivered_winner_slots: Some(&conversion.delivery.delivered_winner_slots), + delivered_winner_slots: Some(&conversion.delivered_winner_slots), }, ) }) @@ -361,8 +450,8 @@ pub async fn handle_auction( log::info!( "Auction completed: {} providers, {} delivered winning bids, {} dropped winners, {}ms total", result.provider_responses.len(), - conversion.delivery.delivered_winner_slots.len(), - conversion.delivery.dropped_winner_count, + conversion.delivered_winner_slots.len(), + conversion.dropped_winner_count, result.total_time_ms ); @@ -855,6 +944,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 9419276c8..e202f4a90 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, DeviceInfo, MediaType, OrchestratorExt, ProviderSummary, - PublisherInfo, SiteInfo, UserInfo, + AdFormat, AdSlot, AuctionDecisionSetV1, AuctionDropReason, AuctionDropReasons, AuctionRequest, + AuctionSlotFailureReason, BidRenderSourceV1, BrowserAuctionBidV1, BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, CacheFetchPolicyV1, 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,562 @@ 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(), + }) + } + + /// Validate and deep-own the immutable cache fetch base used by projection. + pub(crate) fn canonicalize_cache_fetch_policy_v1( + base_url: &str, + ) -> Result> { + ensure!( + !base_url.is_empty() + && base_url.len() <= 4096 + && !base_url + .chars() + .any(|character| matches!(character, '\0'..='\u{1f}' | '\u{7f}')), + projection_contract_error("Cache policy base URL violates the byte grammar") + ); + let parsed = Url::parse(base_url) + .map_err(|_| projection_contract_error("Cache policy base URL is invalid"))?; + ensure!( + parsed.scheme() == "https" + && parsed.host_str().is_some() + && parsed.username().is_empty() + && parsed.password().is_none() + && parsed.query().is_none() + && parsed.fragment().is_none() + && parsed.path() != "/", + projection_contract_error("Cache policy base URL is not a trusted fixed endpoint") + ); + Ok(CacheFetchPolicyV1 { + version: 1, + base_url: base_url.to_string(), + }) + } + + fn is_base64url_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') + } + + fn valid_auction_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') + }) + } + + fn valid_candidate_id(value: &str) -> bool { + value.len() == 12 && value.bytes().all(is_base64url_byte) + } + + fn valid_renderer_reservation_id(value: &str) -> bool { + value + .strip_prefix("r1_") + .is_some_and(|token| token.len() == 22 && token.bytes().all(is_base64url_byte)) + } + + fn valid_provider_name(value: &str) -> bool { + let bytes = value.as_bytes(); + (1..=64).contains(&bytes.len()) + && bytes[0].is_ascii_alphanumeric() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b'-')) + } + + fn valid_bounded_text(value: &str, maximum_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= maximum_bytes + && !value + .chars() + .any(|character| matches!(character, '\0'..='\u{1f}' | '\u{7f}')) + } + + fn valid_targeting_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= 20 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + } + + fn valid_targeting(targeting: &BTreeMap) -> bool { + targeting.len() <= MAX_BROWSER_AUCTION_TARGETING_ENTRIES + && targeting.iter().all(|(key, value)| { + key != "hb_adid" + && valid_targeting_key(key) + && valid_bounded_text(value, 160) + && value.chars().count() <= 40 + }) + } + + fn valid_render_dimension(value: u32) -> bool { + (RENDER_DIMENSION_MIN..=RENDER_DIMENSION_MAX).contains(&u64::from(value)) + } + + fn valid_cache_id(value: &str) -> bool { + let Ok(uuid) = Uuid::parse_str(value) else { + return false; + }; + uuid.hyphenated().to_string().eq_ignore_ascii_case(value) + && matches!(uuid.get_version_num(), 1..=5) + && uuid.get_variant() == uuid::Variant::RFC4122 + } + + fn valid_cache_fetch_url(fetch_url: &str, cache_id: &str) -> bool { + if fetch_url.len() > 4096 { + return false; + } + let Ok(url) = Url::parse(fetch_url) else { + return false; + }; + let query = format!("uuid={cache_id}"); + url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none() + && url.query() == Some(query.as_str()) + && url + .query_pairs() + .exactly_one() + .is_ok_and(|(key, value)| key == "uuid" && value == cache_id) + } + + trait ExactlyOne: Iterator + Sized { + fn exactly_one(mut self) -> Result { + let Some(value) = self.next() else { + return Err(()); + }; + if self.next().is_some() { + return Err(()); + } + Ok(value) + } + } + + impl ExactlyOne for I {} + + 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::Cache(source) => (source.width, source.height), + } + } + + fn valid_render_source(source: &BidRenderSourceV1, publisher_origin: &str) -> bool { + let (width, height) = render_source_dimensions(source); + if !valid_render_dimension(width) || !valid_render_dimension(height) { + return false; + } + + match source { + BidRenderSourceV1::Aps(source) => { + 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) => { + source.version == 1 && !source.adm.is_empty() && source.adm.len() <= 512 * 1024 + } + BidRenderSourceV1::Cache(source) => { + source.version == 1 + && valid_cache_id(&source.cache_id) + && valid_cache_fetch_url(&source.fetch_url, &source.cache_id) + } + } + } + + 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_renderer_reservation_id(&bid.renderer_reservation_id) + && valid_render_source(&bid.render_source, publisher_origin) + } + + 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) + && reservation_ids.insert(bid.renderer_reservation_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", + ) + })?; + ensure!( + projection.slots.len() == projection.auction.results.len(), + projection_contract_error( + "Browser auction slots must cover every decision for browser boot" + ) + ); + + 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); + TrustedServerSeatBidV1 { + seat: &bid.provider, + bid: vec![TrustedServerOpenRtbBidV1 { + id: &bid.renderer_reservation_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, canonicalize_cache_fetch_policy_v1, + serialize_trusted_server_auction_response_v1, +}; + /// Delivery facts produced while serializing winning bids. #[derive(Debug, Default)] pub(crate) struct AuctionDeliveryReport { @@ -289,20 +849,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, @@ -333,30 +884,64 @@ pub fn convert_to_openrtb_response( 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 mut delivery = AuctionDeliveryReport::default(); for (slot_id, bid) in &result.winning_bids { - let price = bid.price.ok_or_else(|| { - Report::new(TrustedServerError::Auction { - message: format!( - "Winning bid for slot '{}' from '{}' has no decoded price", - slot_id, bid.bidder - ), - }) - })?; + let Some(price) = bid.price else { + log::warn!( + "Auction {}: skipping winning bid for slot '{}' from '{}' because it has no decoded price", + auction_request.id, + slot_id, + bid.bidder + ); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::InvalidPrice, + ); + continue; + }; let bid_context = format!( "auction {} slot {} bidder {}", @@ -365,21 +950,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 remains on the mandatory sanitize/rewrite path. A - // typed renderer is serialized separately and never enters the HTML sanitizer. - 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", - 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 remains on the mandatory sanitize/rewrite path. A + // typed render source is serialized separately and never enters the + // HTML sanitizer. + let (adm, ext) = if let Some(raw_creative) = creative { let processed = creative::process_auction_creative(settings, raw_creative); log::debug!( @@ -405,7 +998,11 @@ pub(crate) fn convert_to_openrtb_response_with_report( 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)) @@ -416,7 +1013,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; }; @@ -490,36 +1091,17 @@ pub(crate) fn convert_to_openrtb_response_with_report( message: "Failed to build auction response".to_string(), })?; - // Signal consent status independently of whether EIDs were resolved. - if ec_allowed { - response - .headers_mut() - .insert(HEADER_X_TS_EC_CONSENT, HeaderValue::from_static("ok")); - } + attach_auction_response_headers(&mut response, auction_request, ec_allowed)?; - // 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")); - } - } - - Ok(OpenRtbResponseConversion { response, delivery }) + Ok((response, delivery)) } #[cfg(test)] mod tests { use super::*; use crate::auction::types::{ - ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderer, BidStatus, + ApsRendererV1, ApsTagType, AuctionDecisionSetV1, AuctionResponse, Bid, BidRenderSourceV1, + BidStatus, }; use crate::openrtb::{Eid, Uid}; use crate::platform::test_support::noop_services; @@ -575,6 +1157,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(), } @@ -583,6 +1170,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()), @@ -623,6 +1213,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(), } @@ -1385,6 +1980,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(), }; @@ -1413,7 +2013,7 @@ mod tests { 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(), @@ -1433,24 +2033,18 @@ mod tests { (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, 2); - assert_eq!( - conversion.delivery.dropped_winner_reasons["no_render_source"], - 2 - ); - 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") @@ -1483,12 +2077,12 @@ mod tests { } #[test] - fn convert_to_openrtb_response_prefers_creative_when_both_render_sources_exist() { + 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(), @@ -1502,14 +2096,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]; - - assert_eq!(bid["adm"], "
Ad
"); assert!( - bid.get("ext").is_none(), - "should omit renderer extension when creative markup wins precedence" + json["seatbid"].as_array().is_none_or(Vec::is_empty), + "should not serialize an ambiguous winner" + ); + 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" ); } @@ -1522,7 +2118,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(), @@ -1589,6 +2185,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(), }; @@ -1630,6 +2231,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(), }; @@ -1726,17 +2332,25 @@ mod tests { } #[test] - fn convert_to_openrtb_response_errors_when_winning_bid_has_no_price() { + fn convert_to_openrtb_response_drops_winning_bid_without_price() { let settings = make_settings(); let auction_request = make_auction_request(); let result = make_result(make_bid("div-gpt-top", "appnexus", None)); - let err = convert_to_openrtb_response(&result, &settings, &auction_request, false) - .expect_err("should reject winning bid without decoded price"); - - assert!( - format!("{err:?}").contains("has no decoded price"), - "should explain missing decoded price" + let conversion = + convert_to_openrtb_response_with_report(&result, &settings, &auction_request, false) + .expect("should omit a winner without a decoded price"); + assert!(conversion.delivery.delivered_winner_slots.is_empty()); + assert_eq!(conversion.delivery.dropped_winner_count, 1); + assert_eq!( + 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" ); } @@ -1762,10 +2376,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") @@ -1938,4 +2558,277 @@ 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: 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 cache_policy_requires_one_exact_trusted_https_base() { + let policy = canonicalize_cache_fetch_policy_v1("https://cache.example:8443/pbc/v1/cache") + .expect("valid cache policy should canonicalize"); + assert_eq!(policy.version, 1); + assert_eq!(policy.base_url, "https://cache.example:8443/pbc/v1/cache"); + assert_eq!( + serde_json::to_value(&policy).expect("cache policy should serialize"), + serde_json::json!({ + "version": 1, + "baseUrl": "https://cache.example:8443/pbc/v1/cache" + }), + "the pure policy must be ready for the exact tsjs.boot.cachePolicy member" + ); + + for invalid in [ + "http://cache.example/pbc/v1/cache", + "https://user@cache.example/pbc/v1/cache", + "https://cache.example/", + "https://cache.example/pbc/v1/cache?existing=1", + "https://cache.example/pbc/v1/cache#fragment", + ] { + assert!( + canonicalize_cache_fetch_policy_v1(invalid).is_err(), + "should reject {invalid}" + ); + } + } + + #[test] + fn invalid_selected_winner_becomes_winner_not_renderable() { + let mut input = projection_with_adm_lengths(&[1]); + input.bids[0].renderer_reservation_id = "not-a-reservation".to_string(); + + let canonical = + canonicalize_browser_auction_projection_v1(input, "https://publisher.example") + .expect("selected projection failure should remain an explicit slot result"); + + assert!(canonical.projection.bids.is_empty()); + assert_eq!( + canonical.projection.auction.results, + vec![SlotAuctionDecisionV1::Failed { + slot: "slot-0".to_string(), + reason: crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable, + }] + ); + } + + #[test] + fn canonical_projection_enforces_exact_eight_mib_all_winner_reduction() { + let mut lengths = vec![512 * 1024; 15]; + lengths.push(1); + let baseline = projection_with_adm_lengths(&lengths); + let baseline_len = serde_json::to_vec(&baseline) + .expect("typed baseline should serialize") + .len(); + let exact_tail = 1 + MAX_BROWSER_AUCTION_PROJECTION_BYTES - baseline_len; + assert!( + exact_tail <= 512 * 1024, + "tail ADM should remain individually valid" + ); + + for (delta, should_reduce) in [(-1_isize, false), (0, false), (1, true)] { + lengths[15] = exact_tail + .checked_add_signed(delta) + .expect("positive exact tail"); + let input = projection_with_adm_lengths(&lengths); + let canonical = + canonicalize_browser_auction_projection_v1(input, "https://publisher.example") + .expect("boundary projection should canonicalize or reduce"); + assert_eq!(canonical.reduced_for_size, should_reduce, "delta {delta}"); + assert!(canonical.json.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES); + if should_reduce { + assert!(canonical.projection.bids.is_empty()); + assert!(canonical.projection.auction.results.iter().all(|result| matches!( + result, + SlotAuctionDecisionV1::Failed { + reason: crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable, + .. + } + ))); + let wire: JsonValue = serde_json::from_slice( + &serialize_trusted_server_auction_response_v1(&canonical) + .expect("reduced exact response should serialize"), + ) + .expect("reduced exact response should be JSON"); + assert_eq!(wire["seatbid"], json!([])); + } else { + assert_eq!( + canonical.json.len(), + MAX_BROWSER_AUCTION_PROJECTION_BYTES + .checked_add_signed(delta) + .expect("boundary size should remain positive") + ); + if delta == 0 { + let wire = serialize_trusted_server_auction_response_v1(&canonical) + .expect("exact-boundary response should serialize"); + assert!( + wire.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES, + "exact response should not exceed the admitted projection cap" + ); + } + } + } + } + + #[test] + fn exact_openrtb_serializer_uses_reservation_and_trusted_server_join_only() { + let canonical = canonicalize_browser_auction_projection_v1( + projection_with_adm_lengths(&[7]), + "https://publisher.example", + ) + .expect("projection should canonicalize"); + + let json: JsonValue = serde_json::from_slice( + &serialize_trusted_server_auction_response_v1(&canonical) + .expect("exact response should serialize"), + ) + .expect("exact response should be JSON"); + + let bid = &json["seatbid"][0]["bid"][0]; + assert_eq!(bid["id"], projection_reservation_id(0)); + assert_eq!(bid["impid"], "slot-0"); + assert!( + bid.get("adm").is_none(), + "tagged render_source should be the sole browser authority" + ); + assert_eq!(json["cur"], "USD"); + assert_eq!( + bid["ext"]["trusted_server"], + json!({ + "candidate_id": projection_candidate_id(0), + "slot_id": "slot-0", + "render_source": { + "type": "adm", + "version": 1, + "adm": "xxxxxxx", + "width": 300, + "height": 250, + } + }) + ); + assert_eq!( + json["ext"]["trusted_server"]["slot_results"], + serde_json::to_value(&canonical.projection.auction) + .expect("decision set should serialize") + ); + } + + #[test] + fn exact_openrtb_serializer_carries_identity_generation_failure_without_a_bid() { + let canonical = canonicalize_browser_auction_projection_v1( + BrowserAuctionProjectionV1 { + version: 1, + auction: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-identity-failure".to_string(), + results: vec![SlotAuctionDecisionV1::Failed { + slot: "slot-0".to_string(), + reason: crate::auction::types::AuctionSlotFailureReason::IdentityGenerationFailed, + }], + }, + slots: Vec::new(), + bids: Vec::new(), + }, + "https://publisher.example", + ) + .expect("identity failure decision should canonicalize"); + + let json: JsonValue = serde_json::from_slice( + &serialize_trusted_server_auction_response_v1(&canonical) + .expect("identity failure response should serialize"), + ) + .expect("identity failure response should be JSON"); + + assert_eq!(json["seatbid"], json!([])); + assert_eq!( + json["ext"]["trusted_server"]["slot_results"]["results"][0], + json!({ + "slot": "slot-0", + "outcome": "failed", + "reason": "identity_generation_failed", + }) + ); + } } diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 9ceb629b4..e2c04c14a 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -12,9 +12,25 @@ use crate::error::TrustedServerError; use crate::platform::{PlatformPendingRequest, RuntimeServices}; use super::config::AuctionConfig; -use super::provider::{AuctionProvider, ProviderParseState, ProviderRequestOutcome}; +use super::provider::{ + AuctionProvider, ProviderParseState, ProviderRequestOutcome, ProviderSlotDisposition, + ProviderSlotOutcome, +}; use super::telemetry::AbandonedProviderCall; -use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; +use super::types::{ + AuctionContext, AuctionDecisionSetV1, AuctionDropReason, AuctionIdentityGenerator, + AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, BidStatus, + SlotAuctionDecisionV1, SystemAuctionIdentityGenerator, mint_response_unique_base64url_identity, +}; + +const CANDIDATE_ID_BYTES: usize = 9; +const CANDIDATE_ID_COLLISION_RETRIES: usize = 8; +const MAX_UPSTREAM_BID_ID_BYTES: usize = 64; + +struct NormalizedProviderResponses { + outcomes: Vec, + candidates: HashMap, +} /// In-flight auction requests dispatched to SSP backends. /// @@ -169,6 +185,23 @@ fn provider_timeout_response(provider_name: &str, response_time_ms: u64) -> Auct .with_metadata("message", serde_json::json!("Provider request timed out")) } +fn canonical_provider_response( + expected_provider: &str, + response: AuctionResponse, +) -> AuctionResponse { + if response.provider == expected_provider { + response + } else { + log::warn!( + "Provider '{}' returned response identity '{}'; rejecting mismatched response", + expected_provider, + response.provider + ); + AuctionResponse::error(expected_provider, response.response_time_ms) + .with_drop_reason(AuctionDropReason::InvalidProviderResponse) + } +} + /// Compute the remaining time budget from a deadline. /// /// Returns the number of milliseconds left before `timeout_ms` is exceeded, @@ -192,6 +225,7 @@ fn snapshot_context_request(request: &Request) -> Request { pub struct AuctionOrchestrator { config: AuctionConfig, providers: HashMap>, + identity_generator: Arc, } impl AuctionOrchestrator { @@ -201,6 +235,19 @@ impl AuctionOrchestrator { Self { config, providers: HashMap::new(), + identity_generator: Arc::new(SystemAuctionIdentityGenerator), + } + } + + #[cfg(test)] + fn with_identity_generator( + config: AuctionConfig, + identity_generator: Arc, + ) -> Self { + Self { + config, + providers: HashMap::new(), + identity_generator, } } @@ -225,13 +272,9 @@ impl AuctionOrchestrator { return Ok(()); } - // A provider listed twice would launch the same auction request twice - // (its backend name canonicalizes identically), so the duplicate is - // detected only after the second outbound send has already fired. Reject - // it at startup instead. - let mut seen = HashSet::new(); + let mut configured_providers = HashSet::new(); for provider_name in &self.config.providers { - if !seen.insert(provider_name.as_str()) { + if !configured_providers.insert(provider_name.as_str()) { return Err(Report::new(TrustedServerError::Configuration { message: format!( "Auction provider `{provider_name}` is listed more than once in [auction].providers; each provider may appear at most once" @@ -240,12 +283,8 @@ impl AuctionOrchestrator { } } - // A provider that is also the mediator would be called twice per - // auction — once in the bidding phase and again as the mediator. The - // mediator's own demand already flows through its mediation response, - // so the overlap is never a legitimate configuration. if let Some(mediator_name) = &self.config.mediator - && seen.contains(mediator_name.as_str()) + && configured_providers.contains(mediator_name.as_str()) { return Err(Report::new(TrustedServerError::Configuration { message: format!( @@ -272,6 +311,347 @@ impl AuctionOrchestrator { Ok(()) } + fn provider_is_eligible_for_slot( + &self, + provider_name: &str, + slot: &super::types::AdSlot, + ) -> bool { + self.providers.get(provider_name).is_some_and(|provider| { + provider.is_enabled() + && slot + .formats + .iter() + .any(|format| provider.supports_media_type(&format.media_type)) + }) + } + + fn eligible_slot_ids(&self, provider_name: &str, request: &AuctionRequest) -> HashSet { + request + .slots + .iter() + .filter(|slot| self.provider_is_eligible_for_slot(provider_name, slot)) + .map(|slot| slot.id.clone()) + .collect() + } + + fn valid_upstream_bid_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_UPSTREAM_BID_ID_BYTES + && !value.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) + } + + fn mint_candidate_id(&self, issued: &mut HashSet) -> Option { + let candidate_id = mint_response_unique_base64url_identity( + self.identity_generator.as_ref(), + issued, + "", + CANDIDATE_ID_BYTES, + CANDIDATE_ID_COLLISION_RETRIES, + )?; + debug_assert_eq!(candidate_id.len(), 12); + Some(candidate_id) + } + + fn response_failure_reason(response: &AuctionResponse) -> Option { + if response.status == BidStatus::Error || response.status == BidStatus::Pending { + return match response + .metadata + .get("error_type") + .and_then(serde_json::Value::as_str) + { + Some(ERROR_TYPE_TIMEOUT) => Some(AuctionSlotFailureReason::ProviderTimeout), + Some(ERROR_TYPE_PARSE_RESPONSE) => { + Some(AuctionSlotFailureReason::InvalidProviderResponse) + } + _ => { + let invalid = response + .metadata + .get("drop_reasons") + .and_then(serde_json::Value::as_object) + .is_some_and(|reasons| reasons.contains_key("invalid_provider_response")); + Some(if invalid { + AuctionSlotFailureReason::InvalidProviderResponse + } else { + AuctionSlotFailureReason::ProviderError + }) + } + }; + } + + None + } + + fn normalize_provider_responses( + &self, + request: &AuctionRequest, + responses: &mut [AuctionResponse], + ) -> NormalizedProviderResponses { + let requested_slots: HashMap<&str, &super::types::AdSlot> = request + .slots + .iter() + .map(|slot| (slot.id.as_str(), slot)) + .collect(); + let mut issued_candidate_ids = HashSet::new(); + let mut candidates = HashMap::new(); + let mut outcomes = Vec::new(); + + for response in responses { + let eligible_slots = self.eligible_slot_ids(&response.provider, request); + let response_failure = Self::response_failure_reason(response); + let mut upstream_counts = HashMap::::new(); + for bid in &response.bids { + if let Some(upstream_id) = bid.bid_id.as_deref() + && Self::valid_upstream_bid_id(upstream_id) + { + *upstream_counts.entry(upstream_id.to_string()).or_default() += 1; + } + } + + let mut invalid_slots = HashMap::::new(); + let mut global_invalid = false; + let mut accepted = Vec::new(); + for mut bid in core::mem::take(&mut response.bids) { + let requested_slot = requested_slots.get(bid.slot_id.as_str()).copied(); + let slot_is_eligible = eligible_slots.contains(&bid.slot_id); + let dimensions_match = requested_slot.is_some_and(|slot| { + slot.formats.iter().any(|format| { + format.width == bid.width + && format.height == bid.height + && self + .providers + .get(&response.provider) + .is_some_and(|provider| { + provider.supports_media_type(&format.media_type) + }) + }) + }); + let upstream_id = bid.bid_id.as_deref(); + let upstream_is_valid = upstream_id.is_some_and(Self::valid_upstream_bid_id); + let upstream_is_unique = upstream_id.is_some_and(|upstream_id| { + upstream_counts.get(upstream_id).copied() == Some(1) + }); + let bid_is_valid = response.status == BidStatus::Success + && slot_is_eligible + && dimensions_match + && upstream_is_valid + && upstream_is_unique + && bid.currency == "USD" + && bid + .price + .is_some_and(|price| price.is_finite() && price >= 0.0); + + if !bid_is_valid { + if requested_slot.is_some() { + invalid_slots + .entry(bid.slot_id.clone()) + .or_insert(AuctionSlotFailureReason::InvalidProviderResponse); + } else { + global_invalid = true; + } + continue; + } + + let Some(candidate_id) = self.mint_candidate_id(&mut issued_candidate_ids) else { + invalid_slots + .insert(bid.slot_id.clone(), AuctionSlotFailureReason::InternalError); + continue; + }; + bid.candidate_id = Some(candidate_id.clone()); + bid.candidate_provider = Some(response.provider.clone()); + bid.renderer_reservation_id = None; + candidates.insert(candidate_id, bid.clone()); + accepted.push(bid); + } + let internally_failed_slots: HashSet<&str> = invalid_slots + .iter() + .filter_map(|(slot, reason)| { + (*reason == AuctionSlotFailureReason::InternalError).then_some(slot.as_str()) + }) + .collect(); + if !internally_failed_slots.is_empty() { + accepted.retain(|bid| !internally_failed_slots.contains(bid.slot_id.as_str())); + candidates.retain(|_, bid| { + bid.candidate_provider.as_deref() != Some(response.provider.as_str()) + || !internally_failed_slots.contains(bid.slot_id.as_str()) + }); + } + response.bids = accepted; + + for slot in &request.slots { + if !eligible_slots.contains(&slot.id) { + continue; + } + let slot_candidates: Vec = response + .bids + .iter() + .filter(|bid| bid.slot_id == slot.id) + .cloned() + .collect(); + let disposition = if !slot_candidates.is_empty() { + ProviderSlotDisposition::Candidates(slot_candidates) + } else if let Some(reason) = invalid_slots.get(&slot.id).copied() { + ProviderSlotDisposition::Failed(reason) + } else if global_invalid { + ProviderSlotDisposition::Failed( + AuctionSlotFailureReason::InvalidProviderResponse, + ) + } else if let Some(reason) = response_failure { + ProviderSlotDisposition::Failed(reason) + } else { + ProviderSlotDisposition::NoBid + }; + outcomes.push(ProviderSlotOutcome { + provider: response.provider.clone(), + slot: slot.id.clone(), + disposition, + }); + } + } + + NormalizedProviderResponses { + outcomes, + candidates, + } + } + + fn build_decision_set( + &self, + request: &AuctionRequest, + outcomes: &[ProviderSlotOutcome], + winning_bids: &HashMap, + mediation_failed: bool, + ) -> AuctionDecisionSetV1 { + let results = request + .slots + .iter() + .map(|slot| { + if let Some(winner) = winning_bids.get(&slot.id) { + return winner.candidate_id.as_ref().map_or_else( + || SlotAuctionDecisionV1::Failed { + slot: slot.id.clone(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }, + |candidate_id| SlotAuctionDecisionV1::Winner { + slot: slot.id.clone(), + candidate_id: candidate_id.clone(), + }, + ); + } + + let eligible_provider_count = self + .config + .provider_names() + .iter() + .filter(|provider| self.provider_is_eligible_for_slot(provider, slot)) + .count(); + if eligible_provider_count == 0 { + return SlotAuctionDecisionV1::Failed { + slot: slot.id.clone(), + reason: AuctionSlotFailureReason::SlotNotEligible, + }; + } + + let mut failures: Vec = outcomes + .iter() + .filter(|outcome| outcome.slot == slot.id) + .filter_map(|outcome| match outcome.disposition { + ProviderSlotDisposition::Failed(reason) => Some(reason), + ProviderSlotDisposition::Candidates(_) | ProviderSlotDisposition::NoBid => { + None + } + }) + .collect(); + if mediation_failed { + failures.push(AuctionSlotFailureReason::MediationFailed); + } + failures.sort_by_key(|reason| reason.priority()); + failures.first().copied().map_or_else( + || SlotAuctionDecisionV1::NoBid { + slot: slot.id.clone(), + }, + |reason| SlotAuctionDecisionV1::Failed { + slot: slot.id.clone(), + reason, + }, + ) + }) + .collect(); + + AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results, + } + } + + fn resolve_mediator_candidates( + mediator_response: AuctionResponse, + candidates: &HashMap, + ) -> Result { + if mediator_response.status == BidStatus::Error + || mediator_response.status == BidStatus::Pending + { + return Err(()); + } + + let mut seen = HashSet::new(); + let mut seen_slots = HashSet::new(); + let mut resolved = Vec::with_capacity(mediator_response.bids.len()); + for selection in &mediator_response.bids { + let Some(candidate_id) = selection.candidate_id.as_deref() else { + return Err(()); + }; + if !seen.insert(candidate_id.to_string()) { + return Err(()); + } + let Some(source) = candidates.get(candidate_id) else { + return Err(()); + }; + let Some(selected_price) = selection + .price + .filter(|price| price.is_finite() && *price >= 0.0) + else { + return Err(()); + }; + let source_authority_matches = selection.slot_id == source.slot_id + && selection.candidate_provider == source.candidate_provider + && selection.currency == source.currency + && selection.creative == source.creative + && selection.adomain == source.adomain + && selection.bidder == source.bidder + && selection.width == source.width + && selection.height == source.height + && selection.nurl == source.nurl + && selection.burl == source.burl + && selection.bid_id == source.bid_id + && selection.ad_id == source.ad_id + && selection.creative_id == source.creative_id + && selection.renderer == source.renderer + && selection.cache_id == source.cache_id + && selection.cache_host == source.cache_host + && selection.cache_path == source.cache_path; + if !seen_slots.insert(source.slot_id.as_str()) || !source_authority_matches { + return Err(()); + } + + let mut restored = source.clone(); + restored.price = Some(selected_price); + resolved.push(restored); + } + + Ok(AuctionResponse { + provider: mediator_response.provider, + status: if resolved.is_empty() { + BidStatus::NoBid + } else { + BidStatus::Success + }, + bids: resolved, + response_time_ms: mediator_response.response_time_ms, + metadata: mediator_response.metadata, + }) + } + /// Execute an auction using the auto-detected strategy. /// /// Strategy is determined by mediator configuration: @@ -289,6 +669,20 @@ impl AuctionOrchestrator { ) -> Result> { let start_time = Instant::now(); + if !self.config.enabled { + return Ok(OrchestrationResult { + provider_responses: Vec::new(), + mediator_response: None, + winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1::failed( + request, + AuctionSlotFailureReason::AuctionDisabled, + ), + total_time_ms: 0, + metadata: HashMap::new(), + }); + } + // Auto-detect strategy based on mediator configuration let (strategy_name, result) = if self.config.has_mediator() { ( @@ -325,122 +719,125 @@ impl AuctionOrchestrator { context: &AuctionContext<'_>, ) -> Result> { let mediation_start = Instant::now(); - let provider_responses = self.run_providers_parallel(request, context).await?; + let mut provider_responses = self.run_providers_parallel(request, context).await?; + let normalized = self.normalize_provider_responses(request, &mut provider_responses); let floor_prices = self.floor_prices_by_slot(request); - let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { - let mediator = self.get_provider(mediator_name)?; - - log::info!( - "Sending {} provider responses to mediator: {}", - provider_responses.len(), - mediator.provider_name() - ); - - // Give the mediator only the remaining time from the auction - // deadline, not the full timeout — the bidding phase already - // consumed part of it. - let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); - - if remaining_ms == 0 { - log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); - 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, - // Bound by both the remaining auction budget and the mediator's - // own configured timeout, matching the dispatched collect path. - // The platform canonicalizes the value for backend-name - // stability (see - // `PlatformBackend::canonicalize_transport_timeout_ms`). - timeout_ms: context - .services - .backend() - .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()), - provider_responses: Some(&provider_responses), - services: context.services, - }; - - let start_time = Instant::now(); - let mediator_resp = match mediator - .request_bids(request, &mediator_context) - .await - .change_context(TrustedServerError::Auction { - message: format!("Mediator {} failed to launch", mediator.provider_name()), - })? { - ProviderRequestOutcome::Immediate(response) => response, - ProviderRequestOutcome::Pending { - request: pending, - parse_state, - } => { - let platform_resp = mediator_context - .services - .http_client() - .wait(pending) - .await - .change_context(TrustedServerError::Auction { - message: format!( - "Mediator {} request failed", + let mut mediation_failed = false; + let mut mediator_response = None; + let mut winning_bids = None; + + if let Some(mediator_name) = &self.config.mediator { + if let Some(mediator) = self.providers.get(mediator_name) { + log::info!( + "Sending {} provider responses to mediator: {}", + provider_responses.len(), + mediator.provider_name() + ); + let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); + if remaining_ms == 0 { + log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); + mediation_failed = true; + } else { + let mediator_context = AuctionContext { + settings: context.settings, + request: context.request, + timeout_ms: context + .services + .backend() + .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()), + provider_responses: Some(&provider_responses), + services: context.services, + }; + let start_time = Instant::now(); + let raw_response = match mediator.request_bids(request, &mediator_context).await + { + Ok(ProviderRequestOutcome::Immediate(response)) => Some(response), + Ok(ProviderRequestOutcome::Pending { + request: pending, + parse_state, + }) => match mediator_context.services.http_client().wait(pending).await { + Ok(platform_response) => mediator + .parse_response_with_context_and_state( + platform_response, + start_time.elapsed().as_millis() as u64, + request, + &mediator_context, + parse_state.as_deref(), + ) + .await + .inspect_err(|error| { + log::warn!( + "Mediator '{}' parse failed: {error:?}", + mediator.provider_name() + ); + }) + .ok(), + Err(error) => { + log::warn!( + "Mediator '{}' request failed: {error:?}", + mediator.provider_name() + ); + None + } + }, + Err(error) => { + log::warn!( + "Mediator '{}' failed to launch: {error:?}", mediator.provider_name() - ), - })?; - - mediator - .parse_response_with_context_and_state( - platform_resp, - start_time.elapsed().as_millis() as u64, - request, - &mediator_context, - parse_state.as_deref(), - ) - .await - .change_context(TrustedServerError::Auction { - message: format!("Mediator {} parse failed", mediator.provider_name()), - })? - } - }; + ); + None + } + }; - // Extract only mediator bids with comparable numeric prices. - let winning = mediator_resp - .bids - .iter() - .filter_map(|bid| { - if bid.price.is_none() { - log::warn!( - "Mediator '{}' returned bid for slot '{}' without a price - skipping", - mediator.provider_name(), - bid.slot_id - ); - None + if let Some(raw_response) = raw_response { + mediator_response = Some(raw_response.clone()); + match Self::resolve_mediator_candidates( + raw_response, + &normalized.candidates, + ) { + Ok(resolved) => { + let selected = resolved + .bids + .iter() + .map(|bid| (bid.slot_id.clone(), bid.clone())) + .collect(); + winning_bids = + Some(self.apply_floor_prices(selected, &floor_prices)); + mediator_response = Some(resolved); + } + Err(()) => { + log::warn!( + "Mediator '{}' returned invalid candidate provenance", + mediator.provider_name() + ); + mediation_failed = true; + } + } } else { - Some((bid.slot_id.clone(), bid.clone())) + mediation_failed = true; } - }) - .collect(); + } + } else { + log::warn!("Mediator '{}' not registered", mediator_name); + mediation_failed = true; + } + } - ( - Some(mediator_resp), - self.apply_floor_prices(winning, &floor_prices), - ) - } else { - // No mediator - select best bid per slot from bidder responses - let winning = self.select_winning_bids(&provider_responses, &floor_prices); - (None, winning) - }; + let winning_bids = winning_bids + .unwrap_or_else(|| self.select_winning_bids(&provider_responses, &floor_prices)); + let decision_set = self.build_decision_set( + request, + &normalized.outcomes, + &winning_bids, + mediation_failed, + ); Ok(OrchestrationResult { provider_responses, mediator_response, winning_bids, + decision_set, total_time_ms: 0, // Will be set by caller metadata: HashMap::new(), }) @@ -452,14 +849,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(), }) @@ -477,9 +878,7 @@ impl AuctionOrchestrator { let provider_names = self.config.provider_names(); if provider_names.is_empty() { - return Err(Report::new(TrustedServerError::Auction { - message: "No providers configured".to_string(), - })); + return Ok(Vec::new()); } // Reject multi-provider fan-out before any request launches when the @@ -488,14 +887,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!( @@ -511,8 +910,6 @@ impl AuctionOrchestrator { let mut backend_to_provider: HashMap = HashMap::new(); let mut pending_requests: Vec = Vec::new(); let mut responses = Vec::new(); - let mut immediate_response_count = 0usize; - for provider_name in provider_names { let provider = match self.providers.get(provider_name) { Some(p) => p, @@ -532,17 +929,18 @@ impl AuctionOrchestrator { // Give each provider only the remaining time from the auction // deadline so that backend transport timeouts do not extend past - // the overall budget. The platform canonicalizes the value for - // backend-name stability (see - // `PlatformBackend::canonicalize_transport_timeout_ms`). + // the overall budget. Canonicalizing keeps backend names stable. let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); let effective_timeout = context .services .backend() .canonicalize_transport_timeout_ms(remaining_ms, provider.timeout_ms()); + // The deadline gate intentionally precedes `request_bids`: zero + // 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; } @@ -556,8 +954,7 @@ impl AuctionOrchestrator { && backend_to_provider.contains_key(&predicted) { log::warn!( - "Provider '{}' predicted backend name '{}' already claimed by another provider \ - this auction; skipping launch before dispatch to avoid a duplicate request", + "Provider '{}' predicted backend name '{}' already belongs to another provider; skipping launch", provider.provider_name(), predicted, ); @@ -611,8 +1008,18 @@ impl AuctionOrchestrator { // instead of overwriting the correlation entry. if backend_to_provider.contains_key(&request_backend_name) { log::warn!( - "Provider '{}' resolved to backend name '{}' already claimed by another \ - provider this auction; skipping launch to avoid response misattribution", + "Provider '{}' pending request has no backend name; response cannot be correlated", + provider.provider_name() + ); + responses.push(provider_launch_failed_response( + provider.provider_name(), + start_time.elapsed().as_millis() as u64, + )); + continue; + }; + if backend_to_provider.contains_key(&request_backend_name) { + log::warn!( + "Provider '{}' resolved backend name '{}' already belongs to another provider; skipping launch", provider.provider_name(), request_backend_name, ); @@ -639,12 +1046,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; @@ -662,15 +1071,7 @@ impl AuctionOrchestrator { } if pending_requests.is_empty() { - if immediate_response_count > 0 { - return Ok(responses); - } - return Err(Report::new(TrustedServerError::Auction { - message: format!( - "All {} configured provider(s) skipped or failed to launch", - provider_names.len() - ), - })); + return Ok(responses); } let deadline = Duration::from_millis(u64::from(context.timeout_ms)); @@ -743,7 +1144,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] @@ -851,9 +1255,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, }; @@ -920,23 +1335,6 @@ impl AuctionOrchestrator { .collect() } - /// Get a provider by name. - fn get_provider( - &self, - name: &str, - ) -> Result<&Arc, Report> { - self.providers.get(name).ok_or_else(|| { - log::warn!( - "Provider '{}' configured but not registered. Available providers: {:?}", - name, - self.providers.keys().collect::>() - ); - Report::new(TrustedServerError::Auction { - message: format!("Provider '{}' not registered", name), - }) - }) - } - /// Dispatch SSP bid requests without blocking WASM. /// /// Calls each enabled provider's [`AuctionProvider::request_bids`] (which @@ -1001,14 +1399,14 @@ impl AuctionOrchestrator { continue; } - // Remaining budget canonicalized by the platform for backend-name - // stability (see `PlatformBackend::canonicalize_transport_timeout_ms`). let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); let effective_timeout = context .services .backend() .canonicalize_transport_timeout_ms(remaining_ms, provider.timeout_ms()); + // Match the synchronous path's strict deadline semantics: do not + // invoke even an immediate provider after the budget reaches zero. if effective_timeout == 0 { log::warn!( "Auction timeout ({}ms) exhausted before launching '{}' — skipping", @@ -1026,8 +1424,7 @@ impl AuctionOrchestrator { && backend_to_provider.contains_key(&predicted) { log::warn!( - "Provider '{}' predicted backend name '{}' already claimed by another provider \ - this auction; skipping dispatch before send to avoid a duplicate request", + "Provider '{}' predicted backend name '{}' already belongs to another provider; skipping dispatch", provider.provider_name(), predicted, ); @@ -1102,7 +1499,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; @@ -1240,7 +1640,10 @@ impl AuctionOrchestrator { auction_response.bids.len(), auction_response.response_time_ms ); - responses.push(auction_response); + responses.push(canonical_provider_response( + &state.provider_name, + auction_response, + )); } Err(e) => { log::warn!( @@ -1316,54 +1719,25 @@ impl AuctionOrchestrator { )); } backend_to_provider.clear(); - - let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { - match self.providers.get(mediator_name.as_str()) { - Some(mediator) => { - // Cap the mediator at whichever is tighter: its own configured - // timeout or the remaining auction budget (A_deadline). The old - // comment here claimed origin drain could exhaust the budget before - // collection, but SSP backends are given first-byte and between-bytes - // timeouts equal to effective_timeout (capped at their provider - // timeout) at dispatch time, so they cannot run past A_deadline - // independently. Giving the mediator an uncapped timeout lets it run - // past A_deadline, violating the bounded hold invariant. - let remaining = remaining_budget_ms(auction_start, timeout_ms); - if remaining == 0 { - log::warn!( - "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", - mediator.provider_name(), - responses.len(), - ); - let winning = self.select_winning_bids(&responses, &floor_prices); - return OrchestrationResult { - provider_responses: responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: auction_start.elapsed().as_millis() as u64, - metadata: HashMap::new(), - }; - } - // The platform canonicalizes the value for backend-name - // stability (see - // `PlatformBackend::canonicalize_transport_timeout_ms`). + let normalized = self.normalize_provider_responses(&request, &mut responses); + let mut mediation_failed = false; + let mut mediator_response = None; + let mut mediated_winners = None; + + if let Some(mediator_name) = &self.config.mediator { + if let Some(mediator) = self.providers.get(mediator_name.as_str()) { + let remaining = remaining_budget_ms(auction_start, timeout_ms); + if remaining == 0 { + log::warn!( + "A_deadline exhausted before mediator '{}' — using direct fallback", + mediator.provider_name(), + ); + mediation_failed = true; + } else { let mediator_timeout = services .backend() .canonicalize_transport_timeout_ms(remaining, mediator.timeout_ms()); let mediator_start = Instant::now(); - log::info!( - "Running mediator '{}' with {}ms budget (A_deadline remaining: {}ms, configured: {}ms)", - mediator.provider_name(), - mediator_timeout, - remaining, - mediator.timeout_ms(), - ); - // The mediator runs on the collect path. See the doc-comment on - // `AuctionContext::request`: the real client request was already - // consumed by `send_async` during dispatch, so we substitute a - // canonical placeholder URL. Any future mediator that needs real - // client headers must snapshot them at dispatch time onto - // `DispatchedAuction` rather than reading `context.request` here. let placeholder = http::Request::builder() .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) .body(edgezero_core::body::Body::empty()) @@ -1375,93 +1749,84 @@ impl AuctionOrchestrator { provider_responses: Some(&responses), services: context.services, }; - let mediator_response = + let raw_response = match mediator.request_bids(&request, &mediator_context).await { Ok(ProviderRequestOutcome::Immediate(response)) => Some(response), Ok(ProviderRequestOutcome::Pending { request: pending, parse_state, - }) => match services.http_client().wait(pending).await.change_context( - TrustedServerError::Auction { - message: format!( - "Mediator {} request failed", - mediator.provider_name() - ), - }, - ) { - Ok(platform_resp) => match mediator + }) => match services.http_client().wait(pending).await { + Ok(platform_response) => mediator .parse_response_with_context_and_state( - platform_resp, + platform_response, mediator_start.elapsed().as_millis() as u64, &request, &mediator_context, parse_state.as_deref(), ) .await - { - Ok(response) => Some(response), - Err(error) => { + .inspect_err(|error| { log::warn!( - "Mediator '{}' parse failed: {:?}", - mediator.provider_name(), - error + "Mediator '{}' parse failed: {error:?}", + mediator.provider_name() ); - None - } - }, + }) + .ok(), Err(error) => { - log::warn!("Mediator request failed: {:?}", error); + log::warn!("Mediator request failed: {error:?}"); None } }, Err(error) => { log::warn!( - "Mediator '{}' failed to dispatch: {:?}", - mediator.provider_name(), - error + "Mediator '{}' failed to dispatch: {error:?}", + mediator.provider_name() ); None } }; - - if let Some(mediator_response) = mediator_response { - let winning = mediator_response - .bids - .iter() - .filter_map(|bid| { - if bid.price.is_none() { - log::warn!( - "Mediator '{}' returned bid for slot '{}' without decoded price - skipping", - mediator.provider_name(), - bid.slot_id - ); - None - } else { - Some((bid.slot_id.clone(), bid.clone())) - } - }) - .collect(); - let winning = self.apply_floor_prices(winning, &floor_prices); - (Some(mediator_response), winning) + if let Some(raw_response) = raw_response { + mediator_response = Some(raw_response.clone()); + match Self::resolve_mediator_candidates( + raw_response, + &normalized.candidates, + ) { + Ok(resolved) => { + let selected = resolved + .bids + .iter() + .map(|bid| (bid.slot_id.clone(), bid.clone())) + .collect(); + mediated_winners = + Some(self.apply_floor_prices(selected, &floor_prices)); + mediator_response = Some(resolved); + } + Err(()) => mediation_failed = true, + } } else { - (None, self.select_winning_bids(&responses, &floor_prices)) + mediation_failed = true; } } - None => { - // lgtm[rust/cleartext-logging] - // The mediator name is a static config identifier, not a secret. - log::warn!("Mediator '{}' not registered", mediator_name); - (None, self.select_winning_bids(&responses, &floor_prices)) - } + } else { + log::warn!("Mediator '{}' not registered", mediator_name); + mediation_failed = true; } - } else { - (None, self.select_winning_bids(&responses, &floor_prices)) - }; + } + + let winning_bids = + mediated_winners.unwrap_or_else(|| self.select_winning_bids(&responses, &floor_prices)); + let decision_set = self.build_decision_set( + &request, + &normalized.outcomes, + &winning_bids, + mediation_failed, + ); OrchestrationResult { provider_responses: responses, mediator_response, winning_bids, + decision_set, total_time_ms: auction_start.elapsed().as_millis() as u64, metadata: HashMap::new(), } @@ -1483,6 +1848,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 @@ -1520,11 +1887,14 @@ mod tests { use crate::auction::config::AuctionConfig; use crate::auction::orchestrator::DispatchAuctionOutcome; - use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; + use crate::auction::provider::{ + AuctionProvider, ProviderRequestOutcome, ProviderSlotDisposition, + }; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest, - AuctionResponse, Bid, BidRenderer, BidStatus, MediaType, PublisherInfo, UserInfo, + AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionDropReason, + AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, BidRenderSourceV1, + BidStatus, MediaType, PublisherInfo, SlotAuctionDecisionV1, UserInfo, }; use crate::error::TrustedServerError; use crate::platform::test_support::{ @@ -1538,9 +1908,10 @@ mod tests { use crate::test_support::tests::crate_test_settings_str; use error_stack::{Report, ResultExt}; use std::collections::{HashMap, HashSet}; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; - use super::AuctionOrchestrator; + use super::{AuctionIdentityGenerator, AuctionOrchestrator}; // --------------------------------------------------------------------------- // Minimal test double for AuctionProvider @@ -1671,16 +2042,6 @@ mod tests { resolved: &'static str, } - impl DivergentBackendProvider { - fn new(name: &'static str, predicted: &'static str, resolved: &'static str) -> Self { - Self { - name, - predicted, - resolved, - } - } - } - #[async_trait::async_trait(?Send)] impl AuctionProvider for DivergentBackendProvider { fn provider_name(&self) -> &'static str { @@ -1697,7 +2058,7 @@ mod tests { .method("POST") .uri("https://example.com/bid") .body(edgezero_core::body::Body::empty()) - .expect("should build stub bid request"), + .expect("should build divergent request"), self.resolved, ); context @@ -1706,7 +2067,7 @@ mod tests { .send_async(req) .await .change_context(TrustedServerError::Auction { - message: "stub launch failed".to_string(), + message: "divergent launch failed".to_string(), }) .map(ProviderRequestOutcome::pending) } @@ -1732,6 +2093,48 @@ mod tests { } } + struct CanonicalTimeoutBackend { + canonical_ms: u32, + calls: Arc>>, + } + + impl PlatformBackend for CanonicalTimeoutBackend { + fn predict_name( + &self, + _spec: &PlatformBackendSpec, + ) -> Result> { + Ok("stub-backend".to_string()) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Ok("stub-backend".to_string()) + } + + fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { + self.calls + .lock() + .expect("should lock canonicalization calls") + .push((remaining_ms, configured_ms)); + self.canonical_ms + } + } + + fn recording_provider( + name: &'static str, + backend: &'static str, + configured_timeout_ms: u32, + predicted: &Arc>>, + requested: &Arc>>, + ) -> StubAuctionProvider { + StubAuctionProvider::recording( + name, + backend, + configured_timeout_ms, + Arc::clone(predicted), + Arc::clone(requested), + ) + } + /// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring /// `adserver_mock`), while its context-free parse does not. Lets a test prove /// the synchronous mediation path calls `parse_response_with_context`. @@ -1739,7 +2142,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(), @@ -1753,6 +2156,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 @@ -1775,9 +2181,54 @@ 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(()) + } + } + 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()), @@ -1798,6 +2249,64 @@ mod tests { } } + 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()) + } + } + #[async_trait::async_trait(?Send)] impl AuctionProvider for CacheRestoringMediator { fn provider_name(&self) -> &'static str { @@ -1846,12 +2355,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, )) } @@ -1876,13 +2391,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, ))) } @@ -1921,10 +2441,9 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::new( - "bidder", - "bidder-backend", - ))); + orchestrator.register_provider(Arc::new(SourceBidProvider { + nurl: "https://nurl.example/win", + })); orchestrator.register_provider(Arc::new(CacheRestoringMediator)); let request = create_test_auction_request(); @@ -1977,10 +2496,9 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::new( - "bidder", - "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(); let settings = create_test_settings(); @@ -2068,6 +2586,362 @@ mod tests { } } + fn one_slot_request() -> AuctionRequest { + let mut request = create_test_auction_request(); + request.slots = vec![AdSlot { + id: "slot-1".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }]; + request + } + + fn enabled_config(providers: &[&str]) -> AuctionConfig { + AuctionConfig { + enabled: true, + providers: providers + .iter() + .map(|provider| (*provider).to_string()) + .collect(), + ..AuctionConfig::default() + } + } + + #[test] + fn normalized_provider_outcomes_cover_every_dispatched_slot() { + let generator = Arc::new(CounterIdentityGenerator::new()); + let mut orchestrator = + AuctionOrchestrator::with_identity_generator(enabled_config(&["alpha"]), generator); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + let request = one_slot_request(); + let mut candidate = auction_bid("aps", 2.0); + candidate.slot_id = "slot-1".to_string(); + let mut responses = vec![AuctionResponse::success("alpha", vec![candidate], 10)]; + + let normalized = orchestrator.normalize_provider_responses(&request, &mut responses); + + assert_eq!(normalized.outcomes.len(), 1); + assert_eq!(normalized.outcomes[0].provider, "alpha"); + assert_eq!(normalized.outcomes[0].slot, "slot-1"); + assert!(matches!( + &normalized.outcomes[0].disposition, + ProviderSlotDisposition::Candidates(candidates) + if candidates.len() == 1 + && candidates[0].candidate_id.as_deref().is_some_and(|id| id.len() == 12) + )); + + let mut no_bid = vec![AuctionResponse::no_bid("alpha", 10)]; + let normalized = orchestrator.normalize_provider_responses(&request, &mut no_bid); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::NoBid + )); + + let mut timeout = vec![super::provider_timeout_response("alpha", 10)]; + let normalized = orchestrator.normalize_provider_responses(&request, &mut timeout); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Failed(AuctionSlotFailureReason::ProviderTimeout) + )); + } + + #[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") @@ -2179,6 +3053,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] @@ -2209,6 +3094,17 @@ mod tests { assert_eq!(result.provider_responses.len(), 1); assert_eq!(result.provider_responses[0].status, BidStatus::NoBid); assert!(result.winning_bids.is_empty()); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::NoBid { + slot: "header-banner".to_string(), + }, + SlotAuctionDecisionV1::NoBid { + slot: "sidebar".to_string(), + }, + ] + ); } #[tokio::test] @@ -2369,6 +3265,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()), @@ -2392,6 +3291,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()), @@ -2462,14 +3364,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, @@ -2490,36 +3403,35 @@ mod tests { let context = create_test_auction_context(&settings, &req, 2000); let result = orchestrator.run_auction(&request, &context).await; - - let err = result.expect_err("should fail when every provider launch fails"); - assert!( - err.to_string() - .contains("All 1 configured provider(s) skipped or failed to launch"), - "should explain that no configured provider request launched" + 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, + }, + ] ); }); } #[test] fn rejects_duplicate_configured_providers() { - // A provider listed twice canonicalizes to one backend name, so the - // duplicate would only be caught after its second outbound request had - // already fired. Startup validation must reject it up front. let config = AuctionConfig { enabled: true, providers: vec!["prebid".to_string(), "prebid".to_string()], timeout_ms: 2000, ..Default::default() }; - let orchestrator = AuctionOrchestrator::new(config); - - let err = orchestrator + let err = AuctionOrchestrator::new(config) .validate_configured_provider_names() .expect_err("should reject a provider listed more than once"); - assert!( - err.to_string().contains("listed more than once"), - "should explain the duplicate provider, got: {err}" - ); + assert!(err.to_string().contains("listed more than once")); } #[test] @@ -2531,15 +3443,61 @@ mod tests { timeout_ms: 2000, ..Default::default() }; - let orchestrator = AuctionOrchestrator::new(config); - - let err = orchestrator + let err = AuctionOrchestrator::new(config) .validate_configured_provider_names() - .expect_err("should reject a mediator that is also a provider"); - assert!( - err.to_string().contains("may not mediate its own auction"), - "should explain the mediator/provider overlap, got: {err}" - ); + .expect_err("should reject a mediator also configured as a provider"); + assert!(err.to_string().contains("may not mediate its own auction")); + } + + #[tokio::test] + async fn duplicate_backend_name_fails_second_provider_attributably_in_both_paths() { + for split in [false, true] { + let config = AuctionConfig { + enabled: true, + providers: vec!["provider-a".to_string(), "provider-b".to_string()], + timeout_ms: 2000, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::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); + 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 = if split { + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("should dispatch the first provider"); + }; + orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await + } else { + orchestrator + .run_auction(&request, &context) + .await + .expect("should complete auction despite the collision") + }; + + 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 + })); + } } #[test] @@ -2594,135 +3552,49 @@ mod tests { ); } - /// Test backend whose [`PlatformBackend::canonicalize_transport_timeout_ms`] - /// returns a fixed value regardless of the wall-clock budget, so the - /// orchestrator's transport-timeout wiring can be asserted without timing - /// flakiness. Records every `(remaining_ms, configured_ms)` pair it sees. - /// - /// The exact quantization arithmetic lives in the Fastly adapter (the only - /// platform that overrides `canonicalize_transport_timeout_ms`); these core - /// tests only prove the orchestrator applies whatever the platform returns - /// and applies it identically to the predicted name and the launched - /// request. - struct CanonicalTimeoutBackend { - canonical_ms: u32, - calls: Arc>>, - } - - impl CanonicalTimeoutBackend { - fn new(canonical_ms: u32, calls: Arc>>) -> Self { - Self { - canonical_ms, - calls, - } - } - } - - impl PlatformBackend for CanonicalTimeoutBackend { - fn predict_name( - &self, - _spec: &PlatformBackendSpec, - ) -> Result> { - Ok("stub-backend".to_owned()) - } - - fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { - Ok("stub-backend".to_owned()) - } - - fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { - self.calls - .lock() - .expect("should lock canonicalize calls") - .push((remaining_ms, configured_ms)); - self.canonical_ms - } - } - #[test] fn parallel_launch_applies_canonical_timeout_to_name_and_request() { futures::executor::block_on(async { - // The orchestrator must hand the platform-canonicalized value to - // BOTH `backend_name` (which derives the correlation key) and - // `request_bids` (via `context.timeout_ms`). Recording them - // separately and asserting exact equality catches a regression that - // predicts one bucket but registers another — which would drop the - // response into the "unknown backend" branch. let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); let calls = Arc::new(Mutex::new(Vec::new())); - let backend = Arc::new(CanonicalTimeoutBackend::new(750, Arc::clone(&calls))); - let services = build_services_with_backend_and_http_client(backend, stub); - // SAFETY: `Box::leak` creates a `'static` reference for test use only. - // The leaked allocation is bounded to the test process lifetime. - let services: &'static RuntimeServices = Box::leak(Box::new(services)); - + let services = build_services_with_backend_and_http_client( + Arc::new(CanonicalTimeoutBackend { + canonical_ms: 750, + calls: Arc::clone(&calls), + }), + stub, + ); let predicted = Arc::new(Mutex::new(Vec::new())); let requested = Arc::new(Mutex::new(Vec::new())); - let config = AuctionConfig { + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, providers: vec!["bidder".to_string()], timeout_ms: 2000, - mediator: None, ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + }); + orchestrator.register_provider(Arc::new(recording_provider( "bidder", "bidder-backend", 1000, - Arc::clone(&predicted), - Arc::clone(&requested), + &predicted, + &requested, ))); - - let request = create_test_auction_request(); let settings = create_test_settings(); - let req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/test") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let context = AuctionContext { - settings: &settings, - request: &req, - timeout_ms: 2000, - provider_responses: None, - services, - }; + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = immediate_test_context(&settings, &downstream, &services); orchestrator - .run_auction(&request, &context) + .run_auction(&create_test_auction_request(), &context) .await .expect("should complete auction"); - let predicted = predicted.lock().expect("should lock predicted"); - let requested = requested.lock().expect("should lock requested"); - assert_eq!( - *predicted, - vec![750], - "backend_name should receive the canonicalized value" - ); - assert_eq!( - *requested, - vec![750], - "request_bids should receive the same canonicalized value" - ); - assert_eq!( - *predicted, *requested, - "predicted and registered transport timeouts must be identical" - ); - + assert_eq!(*predicted.lock().expect("should lock predicted"), vec![750]); + assert_eq!(*requested.lock().expect("should lock requested"), vec![750]); let calls = calls.lock().expect("should lock calls"); - assert_eq!(calls.len(), 1, "should canonicalize once for the launch"); - let (remaining_ms, configured_ms) = calls[0]; - assert_eq!( - configured_ms, 1000, - "should pass the provider's configured timeout as the configured bound" - ); - assert!( - remaining_ms > 0 && remaining_ms <= 2000, - "should pass the live remaining budget, got {remaining_ms}ms" - ); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].1, 1000); + assert!(calls[0].0 > 0 && calls[0].0 <= 2000); }); } @@ -2730,48 +3602,53 @@ mod tests { 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. With the only provider - // skipped, no requests launch and the auction errors. + // 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 backend = Arc::new(CanonicalTimeoutBackend::new(0, Arc::clone(&calls))); - let services = build_services_with_backend_and_http_client(backend, stub); - // SAFETY: `Box::leak` creates a `'static` reference for test use only. - // The leaked allocation is bounded to the test process lifetime. - let services: &'static RuntimeServices = Box::leak(Box::new(services)); - - let config = AuctionConfig { + let services = build_services_with_backend_and_http_client( + Arc::new(CanonicalTimeoutBackend { + canonical_ms: 0, + calls, + }), + stub, + ); + let predicted = Arc::new(Mutex::new(Vec::new())); + let requested = Arc::new(Mutex::new(Vec::new())); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, providers: vec!["bidder".to_string()], timeout_ms: 2000, - mediator: None, ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + }); + orchestrator.register_provider(Arc::new(recording_provider( "bidder", "bidder-backend", + 1000, + &predicted, + &requested, ))); - - let request = create_test_auction_request(); let settings = create_test_settings(); - let req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/test") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let context = AuctionContext { - settings: &settings, - request: &req, - timeout_ms: 2000, - provider_responses: None, - services, - }; + 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(&request, &context).await; - assert!( - result.is_err(), - "should error when the only provider is skipped for an exhausted budget" + let result = orchestrator + .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, + }, + ] ); }); } @@ -2779,329 +3656,121 @@ mod tests { #[test] fn synchronous_mediation_applies_canonical_timeout_to_mediator() { futures::executor::block_on(async { - // The mediator runs after the bidding phase and has no select-loop - // backstop; it must still receive the platform-canonicalized value - // for both prediction and request. let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); // bidder send_async - stub.push_response(200, b"{}".to_vec()); // mediator send_async + stub.push_response(200, b"{}".to_vec()); + stub.push_response(200, b"{}".to_vec()); let calls = Arc::new(Mutex::new(Vec::new())); - let backend = Arc::new(CanonicalTimeoutBackend::new(500, Arc::clone(&calls))); - let services = build_services_with_backend_and_http_client(backend, stub); - // SAFETY: `Box::leak` creates a `'static` reference for test use only. - // The leaked allocation is bounded to the test process lifetime. - let services: &'static RuntimeServices = Box::leak(Box::new(services)); - + let services = build_services_with_backend_and_http_client( + Arc::new(CanonicalTimeoutBackend { + canonical_ms: 500, + calls, + }), + stub, + ); let predicted = Arc::new(Mutex::new(Vec::new())); let requested = Arc::new(Mutex::new(Vec::new())); - let config = AuctionConfig { + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, providers: vec!["bidder".to_string()], mediator: Some("mediator".to_string()), timeout_ms: 2000, ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); + }); orchestrator.register_provider(Arc::new(StubAuctionProvider::new( "bidder", "bidder-backend", ))); - orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + orchestrator.register_provider(Arc::new(recording_provider( "mediator", "mediator-backend", 2000, - Arc::clone(&predicted), - Arc::clone(&requested), + &predicted, + &requested, ))); - - let request = create_test_auction_request(); let settings = create_test_settings(); - let req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/test") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let context = AuctionContext { - settings: &settings, - request: &req, - timeout_ms: 2000, - provider_responses: None, - services, - }; + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = immediate_test_context(&settings, &downstream, &services); orchestrator - .run_auction(&request, &context) + .run_auction(&create_test_auction_request(), &context) .await .expect("should complete mediated auction"); - let predicted = predicted.lock().expect("should lock predicted"); - let requested = requested.lock().expect("should lock requested"); - // The orchestrator hands the mediator its budget through - // `context.timeout_ms` and calls `request_bids` directly; it does not - // call the mediator's `backend_name` (the mediator self-registers its - // backend), so only the request side is observed here. - assert!( - predicted.is_empty(), - "orchestrator should not separately predict a backend name for the mediator" - ); - assert_eq!( - *requested, - vec![500], - "mediator request should use the canonical value" - ); + assert!(predicted.lock().expect("should lock predicted").is_empty()); + assert_eq!(*requested.lock().expect("should lock requested"), vec![500]); }); } #[test] fn dispatched_collect_applies_canonical_timeout_to_both_paths() { futures::executor::block_on(async { - // Same wiring invariant on the split dispatch/collect path used by - // publisher page rendering: the dispatched bidder and the collected - // mediator both receive the canonicalized value for prediction and - // request. let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); // bidder send_async - stub.push_response(200, b"{}".to_vec()); // mediator send_async + stub.push_response(200, b"{}".to_vec()); + stub.push_response(200, b"{}".to_vec()); let calls = Arc::new(Mutex::new(Vec::new())); - let backend = Arc::new(CanonicalTimeoutBackend::new(500, Arc::clone(&calls))); - let services = build_services_with_backend_and_http_client(backend, stub); - // SAFETY: `Box::leak` creates a `'static` reference for test use only. - // The leaked allocation is bounded to the test process lifetime. - let services: &'static RuntimeServices = Box::leak(Box::new(services)); - + let services = build_services_with_backend_and_http_client( + Arc::new(CanonicalTimeoutBackend { + canonical_ms: 500, + calls, + }), + stub, + ); let bidder_predicted = Arc::new(Mutex::new(Vec::new())); let bidder_requested = Arc::new(Mutex::new(Vec::new())); let mediator_predicted = Arc::new(Mutex::new(Vec::new())); let mediator_requested = Arc::new(Mutex::new(Vec::new())); - let config = AuctionConfig { + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, providers: vec!["bidder".to_string()], mediator: Some("mediator".to_string()), timeout_ms: 2000, ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + }); + orchestrator.register_provider(Arc::new(recording_provider( "bidder", "bidder-backend", 2000, - Arc::clone(&bidder_predicted), - Arc::clone(&bidder_requested), + &bidder_predicted, + &bidder_requested, ))); - orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + orchestrator.register_provider(Arc::new(recording_provider( "mediator", "mediator-backend", 2000, - Arc::clone(&mediator_predicted), - Arc::clone(&mediator_requested), + &mediator_predicted, + &mediator_requested, ))); - - let request = create_test_auction_request(); let settings = create_test_settings(); - let req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/test") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let context = AuctionContext { - settings: &settings, - request: &req, - timeout_ms: 2000, - provider_responses: None, - services, - }; + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = immediate_test_context(&settings, &downstream, &services); + let request = create_test_auction_request(); - let dispatched = match orchestrator.dispatch_auction(&request, &context).await { - DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, - _ => panic!("should dispatch the bidder request"), + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("should dispatch bidder request"); }; orchestrator - .collect_dispatched_auction(dispatched, services, &context) + .collect_dispatched_auction(dispatched, &services, &context) .await; - let bidder_predicted = bidder_predicted - .lock() - .expect("should lock bidder predicted"); - let bidder_requested = bidder_requested - .lock() - .expect("should lock bidder requested"); - assert_eq!( - *bidder_predicted, - vec![500], - "dispatched bidder name should use canonical value" - ); assert_eq!( - *bidder_requested, - vec![500], - "dispatched bidder request should use canonical value" + *bidder_predicted.lock().expect("should lock predicted"), + vec![500] ); assert_eq!( - *bidder_predicted, *bidder_requested, - "dispatched bidder predicted and registered timeouts must be identical" + *bidder_requested.lock().expect("should lock requested"), + vec![500] ); - - let mediator_predicted = mediator_predicted - .lock() - .expect("should lock mediator predicted"); - let mediator_requested = mediator_requested - .lock() - .expect("should lock mediator requested"); - // As on the synchronous path, the orchestrator calls the mediator's - // `request_bids` directly without predicting a backend name for it. assert!( - mediator_predicted.is_empty(), - "orchestrator should not separately predict a backend name for the mediator" - ); - assert_eq!( - *mediator_requested, - vec![500], - "mediator request should use the canonical value" - ); - }); - } - - #[test] - fn parallel_duplicate_backend_name_fails_second_provider_attributably() { - futures::executor::block_on(async { - // Two providers that canonicalize to the SAME backend name (e.g. two - // auction providers behind one gateway origin). The correlation map - // keys on backend name, so the second must not silently overwrite - // the first — it must fail attributably so no bid is misparsed or - // lost. - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); // provider-a send_async - stub.push_response(200, b"{}".to_vec()); // provider-b send_async (dropped after guard) - let services = build_services_with_http_client(stub); - // SAFETY: `Box::leak` creates a `'static` reference for test use only. - // The leaked allocation is bounded to the test process lifetime. - let services: &'static RuntimeServices = Box::leak(Box::new(services)); - - let config = AuctionConfig { - enabled: true, - providers: vec!["provider-a".to_string(), "provider-b".to_string()], - timeout_ms: 2000, - mediator: None, - ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::new( - "provider-a", - "shared-backend", - ))); - orchestrator.register_provider(Arc::new(StubAuctionProvider::new( - "provider-b", - "shared-backend", - ))); - - let request = create_test_auction_request(); - let settings = create_test_settings(); - let req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/test") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let context = AuctionContext { - settings: &settings, - request: &req, - timeout_ms: 2000, - provider_responses: None, - services, - }; - - let result = orchestrator - .run_auction(&request, &context) - .await - .expect("should complete auction despite the name collision"); - - assert_eq!( - result.provider_responses.len(), - 2, - "should account for both providers" - ); - let provider_a = result - .provider_responses - .iter() - .find(|r| r.provider == "provider-a") - .expect("should have provider-a response"); - let provider_b = result - .provider_responses - .iter() - .find(|r| r.provider == "provider-b") - .expect("should have provider-b response"); - assert_eq!( - provider_a.status, - BidStatus::Success, - "the first provider on the shared name should launch and succeed" + mediator_predicted + .lock() + .expect("should lock predicted") + .is_empty() ); assert_eq!( - provider_b.status, - BidStatus::Error, - "the second provider on the shared name should fail attributably, not be dropped" - ); - }); - } - - #[test] - fn dispatched_duplicate_backend_name_fails_second_provider_attributably() { - futures::executor::block_on(async { - // Same collision defense on the dispatch/collect path. - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); // provider-a send_async - stub.push_response(200, b"{}".to_vec()); // provider-b send_async (dropped after guard) - let services = build_services_with_http_client(stub); - // SAFETY: `Box::leak` creates a `'static` reference for test use only. - // The leaked allocation is bounded to the test process lifetime. - let services: &'static RuntimeServices = Box::leak(Box::new(services)); - - let config = AuctionConfig { - enabled: true, - providers: vec!["provider-a".to_string(), "provider-b".to_string()], - timeout_ms: 2000, - mediator: None, - ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::new( - "provider-a", - "shared-backend", - ))); - orchestrator.register_provider(Arc::new(StubAuctionProvider::new( - "provider-b", - "shared-backend", - ))); - - let request = create_test_auction_request(); - let settings = create_test_settings(); - let req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/test") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let context = AuctionContext { - settings: &settings, - request: &req, - timeout_ms: 2000, - provider_responses: None, - services, - }; - - let dispatched = match orchestrator.dispatch_auction(&request, &context).await { - DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, - _ => panic!("should dispatch the first provider despite the name collision"), - }; - let result = orchestrator - .collect_dispatched_auction(dispatched, services, &context) - .await; - - let provider_b = result - .provider_responses - .iter() - .find(|r| r.provider == "provider-b") - .expect("should have provider-b response"); - assert_eq!( - provider_b.status, - BidStatus::Error, - "the second provider on the shared name should fail attributably, not be dropped" + *mediator_requested.lock().expect("should lock requested"), + vec![500] ); }); } @@ -3112,45 +3781,29 @@ mod tests { let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); let services = build_services_with_http_client(stub); - // SAFETY: `Box::leak` creates a `'static` reference for test use only. - // The leaked allocation is bounded to the test process lifetime. - let services: &'static RuntimeServices = Box::leak(Box::new(services)); - - let config = AuctionConfig { + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, providers: vec!["provider-a".to_string()], timeout_ms: 2000, - mediator: None, ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(DivergentBackendProvider::new( - "provider-a", - "predicted-backend", - "resolved-backend", - ))); - - let request = create_test_auction_request(); + }); + orchestrator.register_provider(Arc::new(DivergentBackendProvider { + name: "provider-a", + predicted: "predicted-backend", + resolved: "resolved-backend", + })); let settings = create_test_settings(); - let req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/test") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let context = AuctionContext { - settings: &settings, - request: &req, - timeout_ms: 2000, - provider_responses: None, - services, - }; + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = immediate_test_context(&settings, &downstream, &services); + let request = create_test_auction_request(); - let dispatched = match orchestrator.dispatch_auction(&request, &context).await { - DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, - _ => panic!("should dispatch the diverging provider"), + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("should dispatch provider"); }; let result = orchestrator - .collect_dispatched_auction(dispatched, services, &context) + .collect_dispatched_auction(dispatched, &services, &context) .await; let provider_a = result @@ -3173,52 +3826,34 @@ mod tests { stub.push_response(200, b"{}".to_vec()); stub.push_response(200, b"{}".to_vec()); let services = build_services_with_http_client(stub); - // SAFETY: `Box::leak` creates a `'static` reference for test use only. - // The leaked allocation is bounded to the test process lifetime. - let services: &'static RuntimeServices = Box::leak(Box::new(services)); - - let config = AuctionConfig { + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, providers: vec!["provider-a".to_string(), "provider-b".to_string()], timeout_ms: 2000, - mediator: None, ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(DivergentBackendProvider::new( - "provider-a", - "predicted-a", - "shared-resolved", - ))); - orchestrator.register_provider(Arc::new(DivergentBackendProvider::new( - "provider-b", - "predicted-b", - "shared-resolved", - ))); - - let request = create_test_auction_request(); + }); + orchestrator.register_provider(Arc::new(DivergentBackendProvider { + name: "provider-a", + predicted: "predicted-a", + resolved: "shared-resolved", + })); + orchestrator.register_provider(Arc::new(DivergentBackendProvider { + name: "provider-b", + predicted: "predicted-b", + resolved: "shared-resolved", + })); let settings = create_test_settings(); - let req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/test") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let context = AuctionContext { - settings: &settings, - request: &req, - timeout_ms: 2000, - provider_responses: None, - services, - }; + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = immediate_test_context(&settings, &downstream, &services); + let request = create_test_auction_request(); - let dispatched = match orchestrator.dispatch_auction(&request, &context).await { - DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, - _ => { - panic!("should dispatch the first provider despite the resolved-name collision") - } + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("should dispatch first provider"); }; let result = orchestrator - .collect_dispatched_auction(dispatched, services, &context) + .collect_dispatched_auction(dispatched, &services, &context) .await; let provider_a = result @@ -3439,11 +4074,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(), @@ -3562,6 +4207,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()), @@ -3606,6 +4254,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()), @@ -3645,6 +4296,9 @@ mod tests { "atf".to_string(), Bid { slot_id: "atf".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(0.75), // decoded APS price — above floor currency: "USD".to_string(), creative: Some("
APS Ad
".to_string()), diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index 766bd7a08..a3c6f012f 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -8,7 +8,31 @@ use error_stack::Report; use crate::error::TrustedServerError; use crate::platform::{PlatformPendingRequest, PlatformResponse, RuntimeServices}; -use super::types::{AuctionContext, AuctionRequest, AuctionResponse}; +use super::types::{ + AuctionContext, AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, +}; + +/// Exactly one normalized outcome for a slot dispatched to one provider. +#[derive(Debug, Clone)] +pub struct ProviderSlotOutcome { + /// Provider integration that received the slot. + pub provider: String, + /// Exact dispatched slot identifier. + pub slot: String, + /// Candidate, successful no-bid, or typed failure. + pub disposition: ProviderSlotDisposition, +} + +/// Closed normalized provider result for one dispatched slot. +#[derive(Debug, Clone)] +pub enum ProviderSlotDisposition { + /// One or more independently validated candidates returned for the slot. + Candidates(Vec), + /// Provider completed successfully without a candidate for this slot. + NoBid, + /// Provider failed for this slot. + Failed(AuctionSlotFailureReason), +} /// Provider-local state carried from request dispatch to response parsing. pub type ProviderParseState = Box; diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index b3e049eaf..1ba73a655 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -933,7 +933,7 @@ mod tests { use serde_json::json; - use crate::auction::types::{AdFormat, AdSlot, PublisherInfo, UserInfo}; + use crate::auction::types::{AdFormat, AdSlot, AuctionDecisionSetV1, PublisherInfo, UserInfo}; use super::*; @@ -969,6 +969,9 @@ mod tests { fn bid(slot_id: &str, bidder: &str, ad_id: Option<&str>, price: Option) -> Bid { Bid { slot_id: slot_id.to_owned(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price, currency: "USD".to_owned(), creative: None, @@ -1049,6 +1052,11 @@ mod tests { provider_responses: vec![provider_success, provider_no_bid, provider_error], mediator_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), winning)]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 99, metadata: HashMap::new(), }; @@ -1112,6 +1120,11 @@ mod tests { provider_responses: vec![provider_success.clone()], mediator_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), provider_success.bids[0].clone())]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 42, metadata: HashMap::new(), }; @@ -1153,6 +1166,11 @@ mod tests { provider_responses: vec![provider_http_error], mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 12, metadata: HashMap::new(), }; @@ -1191,6 +1209,11 @@ mod tests { provider_responses: vec![provider_success], mediator_response: Some(mediator_response), winning_bids: HashMap::from([("slot-1".to_owned(), mediator_bid)]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 80, metadata: HashMap::new(), }; @@ -1231,6 +1254,11 @@ mod tests { provider_responses: Vec::new(), mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 1, metadata: HashMap::new(), }; diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 2768adb61..5a59fb9a1 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 { @@ -23,7 +65,7 @@ pub struct AuctionRequest { pub slots: Vec, /// Publisher information pub publisher: PublisherInfo, - /// User information (consent-aware) + /// User information (privacy-preserving) pub user: UserInfo, /// Device information pub device: Option, @@ -78,7 +120,7 @@ pub struct PublisherInfo { pub page_url: Option, } -/// Consent-aware user information. +/// Privacy-preserving user information. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserInfo { /// Stable EC ID (from cookie or freshly generated). @@ -154,6 +196,349 @@ 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 { + /// 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::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. + pub renderer_reservation_id: String, + /// Sole tagged render authority for the winner. + pub render_source: BidRenderSourceV1, +} + +/// Exact GAM placement metadata required to publish one server-projected slot. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BrowserAuctionSlotV1 { + /// Exact server slot identity joined to one auction decision. + pub slot: String, + /// Fully rendered GAM ad-unit path for this navigation. + pub gam_unit_path: String, + /// Stable configured DOM id/prefix for responsive resolution. + pub div_id: String, + /// Accepted banner dimensions in configured order. + pub formats: Vec<[u32; 2]>, + /// Static publisher targeting applied before winner targeting. + pub targeting: BTreeMap, +} + +/// Complete browser-facing version-1 auction projection. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserAuctionProjectionV1 { + /// Contract version. + pub version: u8, + /// Ordered decision set for every requested slot. + pub auction: AuctionDecisionSetV1, + /// Ordered GAM placement definitions; empty only for direct `/auction` serialization. + pub slots: Vec, + /// Winner bids in matching decision order. + pub bids: Vec, +} + /// URL used by the orchestrator when invoking a mediator from the collect /// path. Providers can `debug_assert` against this value to catch a mediator /// that has accidentally started depending on `context.request` carrying real @@ -187,7 +572,7 @@ pub enum ApsTagType { /// Version 1 APS renderer descriptor shared with browser clients. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ApsRendererV1 { /// Renderer contract version. pub version: u8, @@ -210,36 +595,339 @@ 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, +} + +/// Version 1 trusted cache render source shared with browser clients. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CacheRenderSourceV1 { + /// Render-source contract version. + pub version: u8, + /// Exact validated PBS Cache UUID. + pub cache_id: String, + /// Server-constructed trusted cache fetch URL. + pub fetch_url: String, + /// Creative width. + pub width: u32, + /// Creative height. + pub height: u32, +} + +/// Immutable trusted base used to construct and admit PBS Cache fetches. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CacheFetchPolicyV1 { + /// Cache-policy contract version. + pub version: u8, + /// Canonical configured HTTPS cache endpoint without query or fragment. + pub base_url: String, +} + +/// Typed browser render source carried by a bid. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] -pub enum BidRenderer { +pub enum BidRenderSourceV1 { /// APS renderer version 1. Aps(ApsRendererV1), + /// Inline ADM version 1. + Adm(AdmRenderSourceV1), + /// Trusted cache fetch version 1. + Cache(CacheRenderSourceV1), } -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::Cache(_) => 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. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Bid { /// Slot this bid is for pub slot_id: String, + /// Server-minted opaque identifier used only for this auction response. + #[serde(skip)] + pub candidate_id: Option, + /// Provider integration name paired with the upstream bid ID for provenance. + #[serde(skip)] + pub candidate_provider: Option, + /// Server-minted renderer capability identifier (populated during projection). + #[serde(skip)] + pub renderer_reservation_id: Option, /// Bid price in CPM. pub price: Option, /// Currency code (e.g., "USD") pub currency: String, /// Creative markup (HTML/VAST). /// - /// `None` when the bid uses a typed [`BidRenderer`] instead. + /// `None` when the bid uses a typed [`BidRenderSourceV1`] instead. pub creative: Option, /// Advertiser domain pub adomain: Option>, @@ -267,7 +955,7 @@ pub struct Bid { pub creative_id: Option, /// Typed browser renderer capability. #[serde(skip_serializing_if = "Option::is_none")] - pub renderer: Option, + pub renderer: Option, /// Prebid Cache UUID for this bid. /// /// Populated from `ext.prebid.cache.bids.cacheId` in the PBS response. @@ -360,7 +1048,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. @@ -416,6 +1104,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)] @@ -423,9 +1135,58 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn typed_drop_reasons_use_exact_literals_in_provider_summary_metadata() { + let reasons = [ + 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, @@ -558,6 +1319,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, @@ -597,7 +1361,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(), @@ -631,7 +1395,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(), @@ -660,10 +1424,40 @@ mod tests { ); } + #[test] + fn slot_failure_priority_matches_the_closed_contract() { + let ordered = [ + AuctionSlotFailureReason::InternalError, + AuctionSlotFailureReason::MediationFailed, + AuctionSlotFailureReason::InvalidProviderResponse, + AuctionSlotFailureReason::ProviderError, + AuctionSlotFailureReason::ProviderTimeout, + AuctionSlotFailureReason::ConsentDenied, + AuctionSlotFailureReason::AuctionDisabled, + AuctionSlotFailureReason::SlotNotEligible, + ]; + + assert_eq!( + ordered.map(AuctionSlotFailureReason::priority), + [0, 1, 2, 3, 4, 5, 6, 7] + ); + assert_eq!( + AuctionSlotFailureReason::WinnerNotRenderable.priority(), + u8::MAX + ); + assert_eq!( + AuctionSlotFailureReason::IdentityGenerationFailed.priority(), + u8::MAX + ); + } + #[test] fn bid_has_ad_id_field() { let bid = Bid { slot_id: "s".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(1.0), currency: "USD".to_string(), creative: None, diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index 8e70aa020..a58cf5561 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -269,9 +269,8 @@ mod tests { /// handler covers is the operator's decision, and silently carving holes in /// it would be worse than a documented constraint. Operators must scope /// handler patterns to the paths they mean (`^/_ts/admin`) — see the - /// configuration guide. The tsjs client's `/__ts/page-bids` fallback keeps - /// affected deployments serving SPA ads until they do, but it disappears - /// with the alias in IABTechLab/trusted-server#970. + /// configuration guide. A broad pattern will block the canonical page-bids + /// endpoint; the hard-cutover client does not retry a compatibility alias. #[test] fn broad_handler_regex_also_covers_browser_facing_endpoints() { let config = crate_test_settings_str().replace(r#"path = "^/secure""#, r#"path = "^/_ts""#); diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 7e357dcf5..b6950a6eb 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -386,6 +386,12 @@ impl CreativeOpportunitiesConfig { slot.id )); } + if slot.providers.aps.is_some() { + log::warn!( + "creative opportunity slot '{}': providers.aps is retained only for configuration compatibility and is ignored by APS OpenRTB", + slot.id + ); + } } Ok(()) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 4c827ace0..ff0ef921e 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -5,13 +5,8 @@ use std::cell::Cell; use std::io; use std::rc::Rc; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use lol_html::{ - EndTagHandler, Settings as RewriterSettings, element, - html_content::{ContentType, EndTag}, - text, -}; +use lol_html::{Settings as RewriterSettings, element, html_content::ContentType, text}; use crate::integrations::datadome::{DATADOME_INTEGRATION_ID, DataDomeClientTagSuppressed}; use crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision; @@ -20,11 +15,12 @@ use crate::integrations::{ IntegrationHtmlContext, IntegrationHtmlPostProcessor, IntegrationRegistry, IntegrationScriptContext, ScriptRewriteAction, }; -use crate::publisher::build_empty_bids_script; use crate::settings::Settings; use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; use crate::tsjs; +const EMPTY_AUCTION_PROJECTION_JSON: &str = r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"slots":[],"bids":[]}"#; + /// Wraps [`HtmlRewriterAdapter`] with optional post-processing. /// /// When `post_processors` is empty (the common streaming path), chunks pass @@ -176,6 +172,8 @@ pub struct HtmlProcessorConfig { pub max_buffered_body_bytes: usize, /// Request-scoped conditional diagnostics delivery decision. pub gpt_diagnostics: Option, + /// Server-owned request-scoped render-trace overlay decision. + pub render_trace_overlay: bool, /// Whether to omit Trusted Server's automatic `DataDome` client-side tag. pub suppress_datadome_client_side_tag: bool, } @@ -199,6 +197,7 @@ impl HtmlProcessorConfig { ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: settings.publisher.max_buffered_body_bytes, gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, } } @@ -228,6 +227,13 @@ impl HtmlProcessorConfig { self } + /// Attach the server-owned request-scoped render-trace overlay decision. + #[must_use] + pub fn with_render_trace_overlay(mut self, active: bool) -> Self { + self.render_trace_overlay = active; + self + } + /// Attach the request-scoped `DataDome` client-tag suppression decision. #[must_use] pub fn with_datadome_client_tag_suppression(mut self, suppress: bool) -> Self { @@ -314,12 +320,11 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso }); let injected_tsjs = Rc::new(Cell::new(false)); - let injected_bids = Arc::new(AtomicBool::new(false)); let integration_registry = config.integrations.clone(); let script_rewriters = integration_registry.script_rewriters(); - let ad_slots_script = config.ad_slots_script.clone(); let ad_bids_state = config.ad_bids_state.clone(); let gpt_diagnostics = config.gpt_diagnostics.clone(); + let render_trace_overlay = config.render_trace_overlay; let mut element_content_handlers = vec![ // Inject unified tsjs bundle once at the start of @@ -328,14 +333,19 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let integrations = integration_registry.clone(); let patterns = patterns.clone(); let document_state = document_state.clone(); - let ad_slots_script = ad_slots_script.clone(); + let ad_bids_state = ad_bids_state.clone(); let gpt_diagnostics = gpt_diagnostics.clone(); move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); - // Inject ad slots script first so it appears before tsjs bundle. - if let Some(ref slots_script) = ad_slots_script { - snippet.push_str(slots_script); + // The server has already interpreted and removed the reserved + // directive. Its request-scoped cleanup program only updates the + // browser-visible URL and must run before publisher/core code. + if let Some(cleanup_tag) = gpt_diagnostics + .as_ref() + .and_then(GptDiagnosticsRequestDecision::url_cleanup_script_tag) + { + snippet.push_str(&cleanup_tag); } let ctx = IntegrationHtmlContext { request_host: &patterns.request_host, @@ -343,31 +353,71 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso origin_host: &patterns.origin_host, document_state: &document_state, }; - // First inject integration-specific config (e.g., window.__tsjs_prebid) - // so it's available when the bundle's auto-init code reads it. + let immediate_ids = integrations.js_module_ids_immediate(); + let deferred_ids = integrations.js_module_ids_deferred(); + let diagnostics_active = gpt_diagnostics + .as_ref() + .is_some_and(GptDiagnosticsRequestDecision::active); + let mut manifest_ids = immediate_ids.clone(); + if diagnostics_active && !manifest_ids.contains(&"gpt_diagnostics") { + manifest_ids.push("gpt_diagnostics"); + } + manifest_ids.extend(deferred_ids.iter().copied()); + let state = ad_bids_state + .lock() + .expect("should lock boot projection state"); + let state_value = state.as_deref(); + let (debug_comment, projection_json) = match state_value { + Some(value) if value.starts_with("` as the only surviving terminator and drop `--!>`. - for creative in [ - "
evil-->break
", - "--!>", - "", - "", - ] { - let comment = dump_comment_for_creative(creative); - assert_eq!( - comment.matches("-->").count(), - 1, - "exactly one `-->` (the terminator) must survive for {creative:?}: {comment}" - ); - assert!( - !comment.contains("--!>"), - "the `--!>` nested terminator must not survive for {creative:?}: {comment}" - ); + fn tagged_adm_bid(slot: &str, candidate_id: &str, cpm: f64) -> Bid { + Bid { + slot_id: slot.to_string(), + candidate_id: Some(candidate_id.to_string()), + candidate_provider: Some("prebid".to_string()), + renderer_reservation_id: None, + price: Some(cpm), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "example_bidder".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + bid_id: Some(format!("upstream-{slot}")), + ad_id: None, + creative_id: None, + renderer: Some(BidRenderSourceV1::Adm(AdmRenderSourceV1 { + version: 1, + adm: format!("
{slot}
"), + width: 300, + height: 250, + })), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + } } - } - struct ChunkedReader { - chunks: std::collections::VecDeque>, - read_count: Arc, - } + fn tagged_aps_bid(slot: &str, candidate_id: &str, cpm: f64) -> Bid { + let envelope = + include_str!("../../trusted-server-js/lib/test/fixtures/aps-renderer-v1.json"); + let mut bid = tagged_adm_bid(slot, candidate_id, cpm); + bid.candidate_provider = Some("aps".to_string()); + bid.bidder = "aps".to_string(); + bid.bid_id = Some("fictional-selected-bid-id".to_string()); + bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { + version: 1, + account_id: "example-account-id".to_string(), + bid_id: "fictional-selected-bid-id".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: base64::engine::general_purpose::STANDARD.encode(envelope), + width: 300, + height: 250, + })); + bid + } - impl ChunkedReader { - fn new(chunks: &[&[u8]], read_count: Arc) -> Self { - Self { - chunks: chunks.iter().map(|chunk| chunk.to_vec()).collect(), - read_count, + fn result_with_winners(bids: Vec) -> OrchestrationResult { + let results = bids + .iter() + .map(|bid| SlotAuctionDecisionV1::Winner { + slot: bid.slot_id.clone(), + candidate_id: bid + .candidate_id + .clone() + .expect("test winner should have candidate id"), + }) + .collect(); + OrchestrationResult { + provider_responses: Vec::new(), + mediator_response: None, + winning_bids: bids + .into_iter() + .map(|bid| (bid.slot_id.clone(), bid)) + .collect(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-projection".to_string(), + results, + }, + total_time_ms: 1, + metadata: HashMap::new(), } } - } - impl io::Read for ChunkedReader { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - let Some(chunk) = self.chunks.pop_front() else { - return Ok(0); - }; - self.read_count.fetch_add(1, Ordering::SeqCst); - let len = chunk.len().min(buf.len()); - buf[..len].copy_from_slice(&chunk[..len]); - Ok(len) - } - } + #[test] + fn projection_preserves_tagged_source_and_uses_one_reservation_on_both_wires() { + let source = BidRenderSourceV1::Adm(AdmRenderSourceV1 { + version: 1, + adm: "
slot-1
".to_string(), + width: 300, + height: 250, + }); + let result = result_with_winners(vec![tagged_adm_bid("slot-1", "AAAAAAAAAAAA", 2.75)]); + let generator = ScriptedIdentityGenerator::new([vec![7; 16]]); - struct RecordingProcessor { - read_count: Arc, - body_close_processed_at: Arc, - } + let canonical = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + None, + None, + &generator, + ) + .expect("valid winner should project"); - impl StreamProcessor for RecordingProcessor { - fn process_chunk(&mut self, chunk: &[u8], _is_last: bool) -> Result, io::Error> { - if find_ascii_case_insensitive(chunk, BODY_CLOSE_PREFIX).is_some() { - self.body_close_processed_at - .store(self.read_count.load(Ordering::SeqCst), Ordering::SeqCst); - } - Ok(chunk.to_vec()) + let bid = &canonical.projection.bids[0]; + assert_eq!(bid.candidate_id, "AAAAAAAAAAAA"); + assert_eq!(bid.cpm, 2.75); + assert_eq!(bid.render_source, source); + assert_eq!(bid.renderer_reservation_id, "r1_BwcHBwcHBwcHBwcHBwcHBw"); + assert!( + !serde_json::to_value(&bid.render_source) + .expect("render source should serialize") + .to_string() + .contains("2.75"), + "selected CPM must not enter the render capability" + ); + + let direct: serde_json::Value = serde_json::from_slice( + &crate::auction::formats::coordinated_cutover_v1::serialize_trusted_server_auction_response_v1( + &canonical, + ) + .expect("direct wire should serialize"), + ) + .expect("direct wire should be JSON"); + assert_eq!( + direct["seatbid"][0]["bid"][0]["id"], + bid.renderer_reservation_id + ); } - } - fn gzip_encode(input: &[u8]) -> Vec { - let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default()); - encoder - .write_all(input) - .expect("should write gzip test input"); - encoder.finish().expect("should finish gzip encoding") - } + #[test] + fn initial_html_state_uses_the_exact_projection_json_without_a_legacy_script() { + let result = result_with_winners(vec![tagged_adm_bid("slot-1", "AAAAAAAAAAAA", 2.75)]); + let state: Arc>> = Arc::new(Mutex::new(None)); + let slots = serde_json::to_string(&vec![BrowserAuctionSlotV1 { + slot: "slot-1".to_string(), + gam_unit_path: "/123/slot-1".to_string(), + div_id: "div-slot-1".to_string(), + formats: vec![[300, 250]], + targeting: BTreeMap::from([("pos".to_string(), "atf".to_string())]), + }]) + .expect("slot projection should serialize"); + + let delivered = write_projection_to_state( + &result, + PriceGranularity::Dense, + &state, + &Settings::default(), + "https://publisher.example", + Some(&slots), + ); - fn gzip_decode(input: &[u8]) -> Vec { - let mut decoder = GzDecoder::new(input); - let mut output = Vec::new(); - decoder - .read_to_end(&mut output) - .expect("should decode gzip test output"); - output - } + assert_eq!(delivered, HashSet::from(["slot-1".to_owned()])); + let stored = state + .lock() + .expect("should lock projection state") + .clone() + .expect("should store projection JSON"); + let projection: serde_json::Value = + serde_json::from_str(&stored).expect("should store the exact projection shape"); + let browser_auction_id = projection["auction"]["auctionId"] + .as_str() + .expect("browser projection should carry an auction identity"); + assert!(browser_auction_id.starts_with("a1_")); + assert_ne!( + browser_auction_id, result.decision_set.auction_id, + "browser-visible identity must not expose an EC-derived upstream request id" + ); + assert_eq!(projection["slots"][0]["slot"], "slot-1"); + assert_eq!(projection["slots"][0]["gamUnitPath"], "/123/slot-1"); + assert_eq!(projection["bids"].as_array().map(Vec::len), Some(1)); + assert!(!stored.contains(" Vec { - let mut encoder = - flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - encoder - .write_all(input) - .expect("should write deflate test input"); - encoder.finish().expect("should finish deflate encoding") - } + #[test] + fn reservation_collision_exhaustion_fails_only_the_affected_winner() { + let repeated = vec![9; 16]; + let generator = ScriptedIdentityGenerator::new( + std::iter::once(repeated.clone()).chain(std::iter::repeat_n(repeated, 9)), + ); + let result = result_with_winners(vec![ + tagged_adm_bid("slot-1", "AAAAAAAAAAAA", 2.0), + tagged_adm_bid("slot-2", "BBBBBBBBBBBB", 1.0), + ]); + + let canonical = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + None, + None, + &generator, + ) + .expect("collision exhaustion should remain a per-slot decision"); - fn deflate_decode(input: &[u8]) -> Vec { - let mut decoder = flate2::read::ZlibDecoder::new(input); - let mut output = Vec::new(); - decoder - .read_to_end(&mut output) - .expect("should decode deflate test output"); - output - } + assert_eq!(generator.count.load(Ordering::SeqCst), 10); + assert_eq!(canonical.projection.bids.len(), 1); + assert!(matches!( + &canonical.projection.auction.results[0], + SlotAuctionDecisionV1::Winner { slot, .. } if slot == "slot-1" + )); + assert_eq!( + canonical.projection.auction.results[1], + SlotAuctionDecisionV1::Failed { + slot: "slot-2".to_string(), + reason: AuctionSlotFailureReason::IdentityGenerationFailed, + } + ); + } - fn brotli_encode(input: &[u8]) -> Vec { - let mut encoder = CompressorWriter::new(Vec::new(), 4096, 5, 22); - encoder - .write_all(input) - .expect("should write brotli test input"); - encoder.into_inner() - } + #[test] + fn aps_projection_preserves_the_validated_descriptor_without_cpm() { + let result = result_with_winners(vec![tagged_aps_bid("slot-1", "AAAAAAAAAAAA", 4.25)]); + let source = result.winning_bids["slot-1"] + .renderer + .clone() + .expect("APS source should exist"); + let generator = ScriptedIdentityGenerator::new([vec![3; 16]]); - fn brotli_decode(input: &[u8]) -> Vec { - let mut decoder = Decompressor::new(input, 4096); - let mut output = Vec::new(); - decoder - .read_to_end(&mut output) - .expect("should decode brotli test output"); - output - } + let canonical = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + None, + None, + &generator, + ) + .expect("valid APS winner should project"); - fn make_stream_params( - settings: &Settings, - content_encoding: &str, - ) -> OwnedProcessResponseParams { - OwnedProcessResponseParams { - content_encoding: content_encoding.to_owned(), - origin_host: settings.publisher.origin_host(), - origin_url: settings.publisher.origin_url.clone(), - request_host: settings.publisher.domain.clone(), - request_scheme: "https".to_owned(), - content_type: "application/json".to_owned(), - ad_slots_script: None, - ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), - auction_observation: None, - auction_request: None, - dispatched_auction: None, - price_granularity: Default::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, + assert_eq!(canonical.projection.bids[0].render_source, source); + assert_eq!(canonical.projection.bids[0].cpm, 4.25); + assert!( + !serde_json::to_string(&canonical.projection.bids[0].render_source) + .expect("APS source should serialize") + .contains("4.25") + ); } - } - fn test_auction_request() -> AuctionRequest { - AuctionRequest { - id: "test-auction".to_string(), - slots: vec![AdSlot { - id: "atf".to_string(), - formats: vec![AdFormat { - media_type: MediaType::Banner, + #[test] + fn cache_projection_uses_only_the_frozen_policy_and_preserves_the_uuid() { + let mut bid = tagged_adm_bid("slot-1", "AAAAAAAAAAAA", 1.5); + bid.renderer = None; + bid.cache_id = Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()); + bid.cache_host = Some("cache.example".to_string()); + bid.cache_path = Some("/pbc/v1/cache".to_string()); + let result = result_with_winners(vec![bid]); + let policy = CacheFetchPolicyV1 { + version: 1, + base_url: "https://cache.example/pbc/v1/cache".to_string(), + }; + let generator = ScriptedIdentityGenerator::new([vec![4; 16]]); + + let canonical = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + Some(&policy), + None, + &generator, + ) + .expect("valid cache winner should project"); + + assert_eq!( + canonical.projection.bids[0].render_source, + BidRenderSourceV1::Cache(CacheRenderSourceV1 { + version: 1, + cache_id: "f47447a0-b759-4f2f-9887-af458b79b570".to_string(), + fetch_url: "https://cache.example/pbc/v1/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570".to_string(), width: 300, height: 250, - }], - floor_price: None, - targeting: Default::default(), - bidders: Default::default(), - }], - publisher: PublisherInfo { - domain: "test-publisher.com".to_string(), - page_url: Some("https://test-publisher.com/article".to_string()), - }, - user: UserInfo { - id: None, - consent: None, - eids: None, - }, - device: None, - site: None, - context: Default::default(), + }) + ); + + let without_policy = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + None, + None, + &ScriptedIdentityGenerator::new([]), + ) + .expect("missing policy should remain an explicit winner failure"); + assert!(without_policy.projection.bids.is_empty()); + assert_eq!( + without_policy.projection.auction.results[0], + SlotAuctionDecisionV1::Failed { + slot: "slot-1".to_string(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + } + ); } - } - fn build_request(method: Method, uri: &str) -> HttpRequest { - HttpRequest::builder() - .method(method) - .uri(uri) - .body(EdgeBody::empty()) - .expect("should build test request") - } + #[test] + fn projection_rejects_an_adm_with_a_coexisting_cache_pointer() { + let mut bid = tagged_adm_bid("slot-1", "AAAAAAAAAAAA", 1.5); + bid.renderer = None; + bid.creative = Some("
creative
".to_string()); + bid.cache_id = Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()); + bid.cache_host = Some("cache.example".to_string()); + bid.cache_path = Some("/pbc/v1/cache".to_string()); + let result = result_with_winners(vec![bid]); + let policy = CacheFetchPolicyV1 { + version: 1, + base_url: "https://cache.example/pbc/v1/cache".to_string(), + }; - #[test] - fn stream_publisher_body_injects_active_diagnostics_for_materialized_html() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - let mut request = HttpRequest::builder() - .method(Method::GET) - .uri("https://publisher.example/article?ts_console=1") - .header("sec-fetch-dest", "document") - .body(EdgeBody::empty()) - .expect("should build activation request"); - let decision = - crate::integrations::gpt_diagnostics::prepare_request(&settings, &mut request) - .expect("should prepare diagnostics request"); - let mut params = make_stream_params(&settings, ""); - params.content_type = "text/html".to_owned(); - params.gpt_diagnostics = Some(decision); - let mut output = Vec::new(); + let canonical = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + Some(&policy), + None, + &ScriptedIdentityGenerator::new([vec![8; 16]]), + ) + .expect("ambiguous source should remain an explicit winner failure"); - stream_publisher_body( - EdgeBody::from("Example"), - &mut output, - ¶ms, - &settings, - &integration_registry, - ) - .expect("should process materialized HTML"); + assert!(canonical.projection.bids.is_empty()); + assert_eq!( + canonical.projection.auction.results[0], + SlotAuctionDecisionV1::Failed { + slot: "slot-1".to_string(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + } + ); + } - let html = String::from_utf8(output).expect("should produce UTF-8 HTML"); + #[test] + fn invalid_targeting_is_rejected_without_truncation() { + let mut bid = tagged_adm_bid("slot-1", "AAAAAAAAAAAA", 1.5); + bid.bidder = "x".repeat(41); + let result = result_with_winners(vec![bid]); + let canonical = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + None, + None, + &ScriptedIdentityGenerator::new([vec![5; 16]]), + ) + .expect("invalid winner targeting should remain an explicit slot result"); + + assert!(canonical.projection.bids.is_empty()); + assert_eq!( + canonical.projection.auction.results[0], + SlotAuctionDecisionV1::Failed { + slot: "slot-1".to_string(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + } + ); + assert!( + !String::from_utf8(canonical.json) + .expect("canonical projection should be UTF-8") + .contains(&"x".repeat(40)) + ); + } + + #[test] + fn blank_upstream_bid_id_fails_only_the_affected_winner() { + let mut bid = tagged_adm_bid("slot-1", "AAAAAAAAAAAA", 1.5); + bid.bid_id = Some(String::new()); + let result = result_with_winners(vec![bid]); + + let canonical = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + None, + None, + &ScriptedIdentityGenerator::new([]), + ) + .expect("blank upstream identity should remain an explicit slot failure"); + + assert!(canonical.projection.bids.is_empty()); + assert_eq!( + canonical.projection.auction.results[0], + SlotAuctionDecisionV1::Failed { + slot: "slot-1".to_string(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + } + ); + } + + #[test] + fn unavailable_reservation_randomness_is_identity_generation_failed() { + let result = result_with_winners(vec![tagged_adm_bid("slot-1", "AAAAAAAAAAAA", 1.5)]); + let canonical = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + None, + None, + &ScriptedIdentityGenerator::new([]), + ) + .expect("CSPRNG failure should remain a per-slot decision"); + + assert!(canonical.projection.bids.is_empty()); + assert_eq!( + canonical.projection.auction.results[0], + SlotAuctionDecisionV1::Failed { + slot: "slot-1".to_string(), + reason: AuctionSlotFailureReason::IdentityGenerationFailed, + } + ); + } + } + + /// Build the ts-debug comment for a one-bid auction whose creative is + /// `creative`, so tests can assert on the rendered dump. + fn dump_comment_for_creative(creative: &str) -> String { + let mut bid = make_test_bid_with_creative(creative); + bid.slot_id = "ad-header-0".to_string(); + let result = OrchestrationResult { + provider_responses: vec![ + AuctionResponse::no_bid("prebid", 665), + AuctionResponse::success("aps", vec![bid], 42), + ], + mediator_response: None, + winning_bids: std::collections::HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "debug-auction".to_string(), + results: Vec::new(), + }, + total_time_ms: 665, + metadata: std::collections::HashMap::new(), + }; + let state = Arc::new(Mutex::new(Some("BIDS_SCRIPT".to_string()))); + prepend_auction_debug_comment("stream", &result, &state); + let comment = state + .lock() + .expect("should lock state") + .clone() + .expect("should have comment"); + drop(state); + comment + } + + #[test] + fn auction_debug_comment_dumps_provider_status() { + let comment = dump_comment_for_creative("
plain
"); + // Compact (non-pretty) JSON: `"status":"nobid"` with no spaces. + assert!( + comment.contains("\"status\":\"nobid\""), + "should surface the no-bid provider status: {comment}" + ); assert!( - html.contains("__tsjs_gpt_diagnostics_active"), - "should inject the activation flag" + comment.contains("dump={\"provider_responses\":"), + "should dump the provider_responses payload: {comment}" ); + // No mediator ran, so it is omitted (mediator=none already says so). assert!( - html.contains("tsjs-gpt_diagnostics.min.js"), - "should inject the standalone diagnostics module" + !comment.contains("mediator_response"), + "should omit mediator_response when no mediator ran: {comment}" ); } #[test] - fn stream_publisher_body_round_trips_gzip() { - let settings = create_test_settings(); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - let input = b"{\"asset\":\"https://origin.test-publisher.com/path/file.js\"}"; - let compressed = gzip_encode(input); - let params = make_stream_params(&settings, "gzip"); - let mut output = Vec::new(); + fn auction_debug_comment_projects_typed_drop_reasons() { + let response = AuctionResponse::no_bid("aps", 12) + .with_drop_reason(AuctionDropReason::DuplicateUpstreamBidId); + let result = OrchestrationResult { + provider_responses: vec![response], + mediator_response: None, + winning_bids: std::collections::HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "debug-auction".to_string(), + results: Vec::new(), + }, + total_time_ms: 12, + metadata: std::collections::HashMap::new(), + }; + let state = Arc::new(Mutex::new(Some("BIDS_SCRIPT".to_string()))); - stream_publisher_body( - EdgeBody::from(compressed), - &mut output, - ¶ms, - &settings, - &integration_registry, - ) - .expect("should stream gzip response through rewrite pipeline"); + prepend_auction_debug_comment("stream", &result, &state); - let decoded = gzip_decode(&output); - let decoded = String::from_utf8(decoded).expect("should decode rewritten gzip payload"); - assert!( - decoded.contains("https://test-publisher.com/path/file.js"), - "should rewrite origin URLs to the request host" - ); + let comment = state + .lock() + .expect("should lock state") + .clone() + .expect("should have comment"); assert!( - !decoded.contains("origin.test-publisher.com"), - "should remove the origin hostname from the rewritten payload" + comment.contains("\"drop_reasons\":{\"duplicate_upstream_bid_id\":1}"), + "typed fixed reason/count metadata should remain visible: {comment}" ); } #[test] - fn stream_publisher_body_round_trips_brotli() { - let settings = create_test_settings(); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - let input = b"{\"asset\":\"https://origin.test-publisher.com/path/file.css\"}"; - let compressed = brotli_encode(input); - let params = make_stream_params(&settings, "br"); - let mut output = Vec::new(); + fn auction_debug_comment_never_leaks_provider_debug_metadata() { + // A provider response whose `debug` metadata mirrors the shape prebid + // stores verbatim when `[integration.prebid].debug` is on: the resolved + // OpenRTB request carrying the visitor's identity graph. The dump must + // drop it — only allowlisted keys may reach the DOM. + let response = AuctionResponse::error("prebid", 12) + .with_metadata( + "debug", + serde_json::json!({ + "resolvedrequest": { + "user": { + "id": "EC-ID-abc123", + "consent": "CPtc-TCSTRING-xyz", + "ext": { "eids": [{ "source": "example.com", + "uids": [{ "id": "EID-USER-999" }] }] } + }, + "device": { "ip": "203.0.113.77", + "geo": { "lat": 37.7749, "lon": -122.4194 } } + } + }), + ) + // An allowlisted key must still survive. + .with_metadata("error_type", serde_json::json!("http_status")); + let result = OrchestrationResult { + provider_responses: vec![response], + mediator_response: None, + winning_bids: std::collections::HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "debug-auction".to_string(), + results: Vec::new(), + }, + total_time_ms: 12, + metadata: std::collections::HashMap::new(), + }; + let state = Arc::new(Mutex::new(Some("BIDS_SCRIPT".to_string()))); + prepend_auction_debug_comment("stream", &result, &state); + let comment = state + .lock() + .expect("should lock state") + .clone() + .expect("should have comment"); - stream_publisher_body( - EdgeBody::from(compressed), - &mut output, - ¶ms, - &settings, - &integration_registry, - ) - .expect("should stream brotli response through rewrite pipeline"); + for needle in [ + "EC-ID-abc123", + "EID-USER-999", + "CPtc-TCSTRING-xyz", + "203.0.113.77", + "37.7749", + "resolvedrequest", + ] { + assert!( + !comment.contains(needle), + "identity/debug value {needle:?} must not reach the page HTML: {comment}" + ); + } + assert!( + comment.contains("\"error_type\":\"http_status\""), + "allowlisted metadata must still surface: {comment}" + ); + } - let decoded = brotli_decode(&output); - let decoded = String::from_utf8(decoded).expect("should decode rewritten brotli payload"); + #[test] + fn auction_debug_comment_truncates_oversized_creative() { + // A creative larger than the per-bid preview cap must be truncated with a + // marker rather than copied verbatim into the page. + let oversized = "x".repeat(MAX_BID_CREATIVE_DUMP_BYTES * 4); + let comment = dump_comment_for_creative(&oversized); assert!( - decoded.contains("https://test-publisher.com/path/file.css"), - "should rewrite origin URLs to the request host" + comment.contains("(truncated"), + "oversized creative should carry a truncation marker: {}", + &comment[..comment.len().min(200)] ); assert!( - !decoded.contains("origin.test-publisher.com"), - "should remove the origin hostname from the rewritten payload" + !comment.contains(&oversized), + "the full oversized creative must not appear in the comment" ); } #[test] - fn request_ec_uses_cookie_not_header() { - let settings = create_test_settings(); - let header_ec = format!("{}.HdrId1", "a".repeat(64)); - let cookie_ec = format!("{}.CkId01", "b".repeat(64)); - let req = Request::builder() - .method(Method::GET) - .uri("https://test.example.com/page") - .header("x-ts-ec", &header_ec) - .header("cookie", format!("ts-ec={cookie_ec}; other=value")) - .body(EdgeBody::empty()) - .expect("should build test request"); - - let ec_context = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); - - assert_eq!( - ec_context.ec_value(), + fn auction_debug_comment_neutralises_every_comment_terminator_vector() { + // Each vector reaches HTML5 comment-end state via a distinct tokenizer + // path. A single `replace("--", …)` would re-form a terminator on the + // odd-dash-run cases; the targeted two-replace must leave the comment's + // own trailing `-->` as the only surviving terminator and drop `--!>`. + for creative in [ + "
evil-->break
", + "--!>", + "", + "", + ] { + let comment = dump_comment_for_creative(creative); + assert_eq!( + comment.matches("-->").count(), + 1, + "exactly one `-->` (the terminator) must survive for {creative:?}: {comment}" + ); + assert!( + !comment.contains("--!>"), + "the `--!>` nested terminator must not survive for {creative:?}: {comment}" + ); + } + } + + fn gzip_encode(input: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(input) + .expect("should write gzip test input"); + encoder.finish().expect("should finish gzip encoding") + } + + fn gzip_decode(input: &[u8]) -> Vec { + let mut decoder = GzDecoder::new(input); + let mut output = Vec::new(); + decoder + .read_to_end(&mut output) + .expect("should decode gzip test output"); + output + } + + fn deflate_encode(input: &[u8]) -> Vec { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(input) + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + } + + fn deflate_decode(input: &[u8]) -> Vec { + let mut decoder = flate2::read::ZlibDecoder::new(input); + let mut output = Vec::new(); + decoder + .read_to_end(&mut output) + .expect("should decode deflate test output"); + output + } + + fn brotli_encode(input: &[u8]) -> Vec { + let mut encoder = CompressorWriter::new(Vec::new(), 4096, 5, 22); + encoder + .write_all(input) + .expect("should write brotli test input"); + encoder.into_inner() + } + + fn brotli_decode(input: &[u8]) -> Vec { + let mut decoder = Decompressor::new(input, 4096); + let mut output = Vec::new(); + decoder + .read_to_end(&mut output) + .expect("should decode brotli test output"); + output + } + + fn make_stream_params( + settings: &Settings, + content_encoding: &str, + ) -> OwnedProcessResponseParams { + OwnedProcessResponseParams { + content_encoding: content_encoding.to_owned(), + origin_host: settings.publisher.origin_host(), + origin_url: settings.publisher.origin_url.clone(), + request_host: settings.publisher.domain.clone(), + request_scheme: "https".to_owned(), + content_type: "application/json".to_owned(), + ad_slots_script: None, + ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: Default::default(), + gpt_diagnostics: None, + render_trace_overlay: false, + suppress_datadome_client_side_tag: false, + } + } + + fn test_auction_request() -> AuctionRequest { + AuctionRequest { + id: "test-auction".to_string(), + slots: vec![AdSlot { + id: "atf".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: Default::default(), + bidders: Default::default(), + }], + publisher: PublisherInfo { + domain: "test-publisher.com".to_string(), + page_url: Some("https://test-publisher.com/article".to_string()), + }, + user: UserInfo { + id: None, + consent: None, + eids: None, + }, + device: None, + site: None, + context: Default::default(), + } + } + + fn build_request(method: Method, uri: &str) -> HttpRequest { + HttpRequest::builder() + .method(method) + .uri(uri) + .body(EdgeBody::empty()) + .expect("should build test request") + } + + #[test] + fn ts_console_stream_publisher_body_injects_active_diagnostics_for_materialized_html() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + settings + .integrations + .insert_config("gpt", &serde_json::json!({})) + .expect("should enable the GPT event provider"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let mut request = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/article?ts_console=1") + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build activation request"); + let decision = + crate::integrations::gpt_diagnostics::prepare_request(&settings, &mut request) + .expect("should prepare diagnostics request"); + let mut params = make_stream_params(&settings, ""); + params.content_type = "text/html".to_owned(); + params.gpt_diagnostics = Some(decision); + let mut output = Vec::new(); + + stream_publisher_body( + EdgeBody::from("Example"), + &mut output, + ¶ms, + &settings, + &integration_registry, + ) + .expect("should process materialized HTML"); + + let html = String::from_utf8(output).expect("should produce UTF-8 HTML"); + assert!( + !html.contains("__tsjs_gpt_diagnostics_active"), + "should not inject the removed activation flag" + ); + assert!( + html.contains(r#""gpt":{"active":true}"#), + "should activate diagnostics through immutable boot data" + ); + assert!(html.contains("tsjs-unified.min.js?v=")); + assert!(!html.contains("tsjs-gpt_diagnostics.min.js")); + assert_eq!(html.matches("history.replaceState").count(), 1); + } + + #[test] + fn stream_publisher_body_round_trips_gzip() { + let settings = create_test_settings(); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let input = b"{\"asset\":\"https://origin.test-publisher.com/path/file.js\"}"; + let compressed = gzip_encode(input); + let params = make_stream_params(&settings, "gzip"); + let mut output = Vec::new(); + + stream_publisher_body( + EdgeBody::from(compressed), + &mut output, + ¶ms, + &settings, + &integration_registry, + ) + .expect("should stream gzip response through rewrite pipeline"); + + let decoded = gzip_decode(&output); + let decoded = String::from_utf8(decoded).expect("should decode rewritten gzip payload"); + assert!( + decoded.contains("https://test-publisher.com/path/file.js"), + "should rewrite origin URLs to the request host" + ); + assert!( + !decoded.contains("origin.test-publisher.com"), + "should remove the origin hostname from the rewritten payload" + ); + } + + #[test] + fn stream_publisher_body_round_trips_brotli() { + let settings = create_test_settings(); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let input = b"{\"asset\":\"https://origin.test-publisher.com/path/file.css\"}"; + let compressed = brotli_encode(input); + let params = make_stream_params(&settings, "br"); + let mut output = Vec::new(); + + stream_publisher_body( + EdgeBody::from(compressed), + &mut output, + ¶ms, + &settings, + &integration_registry, + ) + .expect("should stream brotli response through rewrite pipeline"); + + let decoded = brotli_decode(&output); + let decoded = String::from_utf8(decoded).expect("should decode rewritten brotli payload"); + assert!( + decoded.contains("https://test-publisher.com/path/file.css"), + "should rewrite origin URLs to the request host" + ); + assert!( + !decoded.contains("origin.test-publisher.com"), + "should remove the origin hostname from the rewritten payload" + ); + } + + #[test] + fn request_ec_uses_cookie_not_header() { + let settings = create_test_settings(); + let header_ec = format!("{}.HdrId1", "a".repeat(64)); + let cookie_ec = format!("{}.CkId01", "b".repeat(64)); + let req = Request::builder() + .method(Method::GET) + .uri("https://test.example.com/page") + .header("x-ts-ec", &header_ec) + .header("cookie", format!("ts-ec={cookie_ec}; other=value")) + .body(EdgeBody::empty()) + .expect("should build test request"); + + let ec_context = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + + assert_eq!( + ec_context.ec_value(), Some(cookie_ec.as_str()), "should resolve request EC ID from cookie" ); @@ -4767,6 +4397,77 @@ mod tests { .expect("should proxy publisher request") } + struct TsConsolePipelineResult { + response: Response, + origin_uri: String, + outbound_cookie: Option, + } + + async fn run_ts_console_pipeline( + method: Method, + destination: &str, + uri: &str, + cookie: Option<&str>, + ) -> TsConsolePipelineResult { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + settings + .integrations + .insert_config("gpt", &serde_json::json!({})) + .expect("should enable the GPT event provider"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![("content-type", "text/html; charset=utf-8")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let mut request = Request::builder() + .method(method.clone()) + .uri(uri) + .header(header::HOST, "publisher.example") + .header("sec-fetch-dest", destination); + if let Some(cookie) = cookie { + request = request.header(header::COOKIE, cookie); + } + let request = request + .body(EdgeBody::empty()) + .expect("should build diagnostics pipeline request"); + let publisher_response = run_publisher_proxy(&settings, &services, request).await; + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let response = buffer_publisher_response_async( + publisher_response, + &method, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should buffer diagnostics pipeline response"); + let outbound_cookie = stub.recorded_request_headers().first().and_then(|headers| { + headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(header::COOKIE.as_str())) + .map(|(_, value)| value.clone()) + }); + TsConsolePipelineResult { + response, + origin_uri: stub + .recorded_request_uris() + .into_iter() + .next() + .expect("should forward one origin request"), + outbound_cookie, + } + } + mod ssat_cache_policy_tests { use super::*; use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; @@ -4921,15 +4622,6 @@ mod tests { .expect("should parse settings with auction and creative opportunities enabled") } - fn settings_with_disabled_ad_templates() -> Settings { - let toml = format!( - "{}\n[auction]\nenabled = true\n\n\ - [creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", - crate_test_settings_str() - ); - Settings::from_toml(&toml).expect("should parse settings with disabled ad templates") - } - fn settings_with_dispatching_provider() -> Settings { let toml = format!( "{}\n[auction]\nenabled = true\nproviders = [\"{UNEXPECTED_304_PROVIDER}\"]\n\n\ @@ -4988,16 +4680,13 @@ mod tests { .expect("should build conditional navigation request") } - fn queue_html_response_with_cache_control( - stub: &StubHttpClient, - cache_control: &'static str, - ) { + fn queue_cacheable_html_response(stub: &StubHttpClient) { stub.push_response_with_headers( 200, b"origin".to_vec(), vec![ ("content-type", "text/html; charset=utf-8"), - ("cache-control", cache_control), + ("cache-control", "public, max-age=300"), ("etag", ORIGIN_ETAG), ("last-modified", ORIGIN_LAST_MODIFIED), ("surrogate-control", "max-age=300"), @@ -5067,7 +4756,7 @@ mod tests { // Arrange let settings = settings_with_enabled_auction_and_creative_opportunities(); let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_cache_control(&stub, "public, max-age=300"); + queue_cacheable_html_response(&stub); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); @@ -5164,7 +4853,7 @@ mod tests { // Arrange let settings = settings_with_enabled_auction_and_creative_opportunities(); let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_cache_control(&stub, "public, max-age=300"); + queue_cacheable_html_response(&stub); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); @@ -5241,102 +4930,6 @@ mod tests { } } - #[tokio::test] - async fn disabled_ad_templates_use_short_browser_cache_policy() { - // Arrange - let settings = settings_with_disabled_ad_templates(); - let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_cache_control(&stub, "public, max-age=300"); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let slots = [article_slot()]; - - // Act - let response = run_with_slots( - &settings, - &services, - &slots, - conditional_navigation_request(), - ) - .await; - let response_head = response_head(response); - - // Assert - assert_eq!( - stub.recorded_cache_bypass_flags(), - vec![false], - "disabled server-side ad templates should not bypass the origin cache" - ); - assert_eq!( - response_head - .headers - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("max-age=60"), - "disabled server-side ad templates should use the short browser cache policy" - ); - for (header_name, expected) in [ - (header::ETAG, ORIGIN_ETAG), - (header::LAST_MODIFIED, ORIGIN_LAST_MODIFIED), - ( - header::HeaderName::from_static("surrogate-control"), - "max-age=300", - ), - ( - header::HeaderName::from_static("fastly-surrogate-control"), - "max-age=300", - ), - ( - header::HeaderName::from_static("cdn-cache-control"), - "max-age=300", - ), - ( - header::HeaderName::from_static("cloudflare-cdn-cache-control"), - "max-age=300", - ), - ] { - assert_eq!( - response_head - .headers - .get(&header_name) - .and_then(|value| value.to_str().ok()), - Some(expected), - "disabled server-side ad templates should preserve {header_name}" - ); - } - } - - #[tokio::test] - async fn navigation_without_matched_slots_preserves_private_origin_cache_policy() { - let settings = settings_with_enabled_auction_and_creative_opportunities(); - - for cache_control in ["private, max-age=0", "No-Store"] { - // Arrange - let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_cache_control(&stub, cache_control); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - - // Act - let response = - run_with_slots(&settings, &services, &[], conditional_navigation_request()) - .await; - let response_head = response_head(response); - - // Assert - assert_eq!( - response_head - .headers - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some(cache_control), - "origin {cache_control} policy should not be weakened" - ); - } - } - #[tokio::test] async fn eligible_navigation_rejects_unexpected_origin_304() { for content_type in [None, Some("text/html; charset=utf-8")] { @@ -5436,6 +5029,54 @@ mod tests { } } + #[tokio::test] + async fn ts_console_finalizes_session_on_replaced_origin_304_response() { + let mut settings = settings_with_enabled_auction_and_creative_opportunities(); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 304, + Vec::new(), + vec![ + ("cache-control", "public, max-age=300"), + ("etag", ORIGIN_ETAG), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let slots = [article_slot()]; + let mut req = conditional_navigation_request(); + *req.uri_mut() = "https://ts.example.com/article?keep=1&ts_console=1" + .parse() + .expect("should parse activation URI"); + + let response = run_with_slots(&settings, &services, &slots, req).await; + let response = match response { + PublisherResponse::Buffered(response) => response, + PublisherResponse::PassThrough { .. } | PublisherResponse::Stream { .. } => { + panic!("unexpected origin 304 should return a buffered response") + } + }; + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!( + response.headers()[header::SET_COOKIE], + "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + assert_eq!( + stub.recorded_request_uris(), + vec!["https://origin.test-publisher.com/article?keep=1"] + ); + } + #[tokio::test] async fn noneligible_origin_304_preserves_conditional_response_metadata() { // Arrange @@ -5549,6 +5190,177 @@ mod tests { ); } + #[tokio::test] + async fn ts_console_publisher_pipeline_strips_reserved_input_and_finalizes_session() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("cache-control", "public, max-age=300"), + ("surrogate-control", "max-age=300"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/article?keep=%2F&ts_console=true") + .header(header::HOST, "publisher.example") + .header("sec-fetch-dest", "document") + .header( + header::COOKIE, + "other=value; __Host-ts-console=1; second=two", + ) + .body(EdgeBody::empty()) + .expect("should build diagnostics navigation"); + + let response = run_publisher_proxy(&settings, &services, req).await; + let headers = match response { + PublisherResponse::Buffered(response) + | PublisherResponse::PassThrough { response, .. } + | PublisherResponse::Stream { response, .. } => response.into_parts().0.headers, + }; + + let origin_uri = stub + .recorded_request_uris() + .into_iter() + .next() + .expect("should forward one publisher request"); + assert!(origin_uri.contains("keep=%2F")); + assert!(!origin_uri.contains("ts_console")); + let outbound_headers = stub.recorded_request_headers(); + let outbound_cookies = outbound_headers + .first() + .expect("should record publisher request headers") + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case(header::COOKIE.as_str())) + .map(|(_, value)| value.as_str()) + .collect::>(); + assert_eq!(outbound_cookies, vec!["other=value; second=two"]); + assert_eq!( + headers[header::SET_COOKIE], + "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax" + ); + assert_eq!(headers[header::CACHE_CONTROL], "private, no-store"); + assert!(!headers.contains_key("surrogate-control")); + } + + #[tokio::test] + async fn ts_console_publisher_pipeline_duplicate_and_invalid_fail_closed_but_clean_url() { + for uri in [ + "https://publisher.example/article?keep=a%2Fb&ts_console=1&ts_console=true", + "https://publisher.example/article?ts_console=True&keep=a%2Fb", + ] { + let result = run_ts_console_pipeline( + Method::GET, + "document", + uri, + Some("__Host-ts-console=1; publisher=value"), + ) + .await; + let body = response_body_string(result.response); + + assert_eq!( + result.origin_uri, + "https://origin.test-publisher.com/article?keep=a%2Fb" + ); + assert_eq!(result.outbound_cookie.as_deref(), Some("publisher=value")); + assert!(body.contains(r#""gpt":{"active":false}"#)); + assert!(!body.contains("tsjs-gpt_diagnostics.min.js")); + assert!(!body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); + assert_eq!(body.matches("history.replaceState").count(), 1); + } + } + + #[tokio::test] + async fn ts_console_publisher_pipeline_cookie_session_and_disable_are_exact() { + let active = run_ts_console_pipeline( + Method::GET, + "document", + "https://publisher.example/article?keep=%2F", + Some("publisher=value; __Host-ts-console=1"), + ) + .await; + let active_body = response_body_string(active.response); + assert_eq!(active.outbound_cookie.as_deref(), Some("publisher=value")); + assert!(active_body.contains(r#""gpt":{"active":true}"#)); + assert!(active_body.contains("tsjs-unified.min.js?v=")); + assert!(!active_body.contains("tsjs-gpt_diagnostics.min.js")); + assert!(!active_body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); + assert!(!active_body.contains("history.replaceState")); + + let disabled = run_ts_console_pipeline( + Method::GET, + "document", + "https://publisher.example/article?ts_console=false&keep=%2F", + Some("publisher=value; __Host-ts-console=1"), + ) + .await; + assert_eq!( + disabled.response.headers()[header::SET_COOKIE], + "__Host-ts-console=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0" + ); + assert_eq!( + disabled.origin_uri, + "https://origin.test-publisher.com/article?keep=%2F" + ); + let disabled_body = response_body_string(disabled.response); + assert!(disabled_body.contains(r#""gpt":{"active":false}"#)); + assert!(!disabled_body.contains("tsjs-gpt_diagnostics.min.js")); + assert!(!disabled_body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); + assert_eq!(disabled_body.matches("history.replaceState").count(), 1); + } + + #[tokio::test] + async fn render_trace_cookie_populates_the_immutable_html_boot() { + let result = run_ts_console_pipeline( + Method::GET, + "document", + "https://publisher.example/article", + Some("publisher=value; ts-trace=1"), + ) + .await; + let body = response_body_string(result.response); + + assert!( + body.contains(r#""renderTraceOverlay":true"#), + "the exact server-owned trace cookie must populate DiagnosticsBootV1: {body}" + ); + assert_eq!(body.matches(r#""renderTraceOverlay""#).count(), 1); + } + + #[tokio::test] + async fn ts_console_publisher_pipeline_method_and_document_ineligibility_stay_inert() { + for (method, destination) in [(Method::POST, "document"), (Method::GET, "script")] { + let result = run_ts_console_pipeline( + method, + destination, + "https://publisher.example/article?keep=%2F&ts_console=1", + Some("publisher=value; __Host-ts-console=1"), + ) + .await; + assert_eq!( + result.origin_uri, + "https://origin.test-publisher.com/article?keep=%2F" + ); + assert_eq!(result.outbound_cookie.as_deref(), Some("publisher=value")); + assert!(!result.response.headers().contains_key(header::SET_COOKIE)); + let body = response_body_string(result.response); + assert!(body.contains(r#""gpt":{"active":false}"#)); + assert!(!body.contains("tsjs-gpt_diagnostics.min.js")); + assert!(!body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); + assert!(!body.contains("history.replaceState")); + } + } + #[tokio::test] async fn publisher_origin_fetch_leaves_stream_response_disabled_when_unsupported() { let settings = create_test_settings(); @@ -5606,6 +5418,45 @@ mod tests { ); } + #[tokio::test] + async fn suppressed_datadome_request_drops_origin_validators() { + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![("content-type", "text/html; charset=utf-8")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let mut req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/page") + .header(header::HOST, "publisher.example") + .header(header::IF_NONE_MATCH, "\"cached-page\"") + .header(header::IF_MODIFIED_SINCE, "Wed, 21 Oct 2015 07:28:00 GMT") + .body(EdgeBody::empty()) + .expect("should build conditional request"); + req.extensions_mut() + .insert(crate::integrations::datadome::DataDomeClientTagSuppressed); + + let _response = run_publisher_proxy(&settings, &services, req).await; + + let headers = stub + .recorded_request_headers() + .into_iter() + .next() + .expect("should record one outbound request"); + assert!( + headers.iter().all(|(name, _)| { + !name.eq_ignore_ascii_case(header::IF_NONE_MATCH.as_str()) + && !name.eq_ignore_ascii_case(header::IF_MODIFIED_SINCE.as_str()) + }), + "tag-suppressed origin requests must not revalidate a shared representation" + ); + } + #[tokio::test] async fn handle_publisher_request_does_not_self_generate_ec() { // EC generation is the adapter's real-browser-gated responsibility. This @@ -5711,8 +5562,8 @@ mod tests { .header(header::CACHE_CONTROL, "public, max-age=600") .header("surrogate-control", "max-age=600") .header("fastly-surrogate-control", "max-age=600") - .header("cloudflare-cdn-cache-control", "max-age=600") .header("cdn-cache-control", "max-age=600") + .header("cloudflare-cdn-cache-control", "max-age=600") .body(EdgeBody::empty()) .expect("should build cacheable HTML response"); @@ -5739,16 +5590,12 @@ mod tests { response.headers().get("fastly-surrogate-control").is_none(), "suppressed HTML should not retain Fastly-Surrogate-Control" ); + assert!(response.headers().get("cdn-cache-control").is_none()); assert!( response .headers() .get("cloudflare-cdn-cache-control") - .is_none(), - "suppressed HTML should not retain Cloudflare-CDN-Cache-Control" - ); - assert!( - response.headers().get("cdn-cache-control").is_none(), - "suppressed HTML should not retain CDN-Cache-Control" + .is_none() ); let mut no_store_response = Response::builder() @@ -5763,12 +5610,8 @@ mod tests { "text/html; charset=utf-8", ); assert_eq!( - no_store_response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("no-store"), - "suppressed HTML should preserve an existing no-store policy" + no_store_response.headers()[header::CACHE_CONTROL], + "no-store" ); } @@ -5939,37 +5782,37 @@ mod tests { #[test] fn server_side_ad_stack_runs_only_when_all_auction_gates_pass() { - let enabled_config = ServerSideAdStackConfig { + let enabled = ServerSideAdStackConfig { ad_templates_enabled: true, auction_enabled: true, }; assert!( - should_run_server_side_ad_stack(true, true, false, false, true, true, enabled_config,), - "GET, real navigation, enabled templates, matched slots, and consent should run TS ad stack" + should_run_server_side_ad_stack(true, true, false, false, true, true, enabled), + "GET, real navigation, matched slots, and consent should run TS ad stack" ); assert!( - !should_run_server_side_ad_stack(false, true, false, false, true, true, enabled_config,), + !should_run_server_side_ad_stack(false, true, false, false, true, true, enabled), "non-GET requests should skip TS ad stack" ); assert!( - !should_run_server_side_ad_stack(true, false, false, false, true, true, enabled_config,), + !should_run_server_side_ad_stack(true, false, false, false, true, true, enabled), "non-document requests should skip TS ad stack" ); assert!( - !should_run_server_side_ad_stack(true, true, true, false, true, true, enabled_config,), + !should_run_server_side_ad_stack(true, true, true, false, true, true, enabled), "prefetch requests should skip TS ad stack and injection" ); assert!( - !should_run_server_side_ad_stack(true, true, false, true, true, true, enabled_config,), + !should_run_server_side_ad_stack(true, true, false, true, true, true, enabled), "bot requests should skip TS ad stack and injection" ); assert!( - !should_run_server_side_ad_stack(true, true, false, false, false, true, enabled_config,), + !should_run_server_side_ad_stack(true, true, false, false, false, true, enabled), "requests with no matching slots should skip TS ad stack" ); assert!( - !should_run_server_side_ad_stack(true, true, false, false, true, false, enabled_config,), + !should_run_server_side_ad_stack(true, true, false, false, true, false, enabled), "requests without required consent should skip TS ad stack and injection" ); assert!( @@ -6000,185 +5843,7 @@ mod tests { auction_enabled: true, }, ), - "disabled [creative_opportunities].enabled switch should skip TS ad stack and injection" - ); - } - - #[tokio::test] - async fn body_close_hold_loop_processes_close_tail_before_reading_post_body_chunks() { - let settings = create_test_settings(); - let services = noop_services(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - let dispatched = DispatchedAuction::empty_for_test(test_auction_request(), 500); - let read_count = Arc::new(AtomicUsize::new(0)); - let body_close_processed_at = Arc::new(AtomicUsize::new(0)); - let reader = ChunkedReader::new( - &[ - b"painted", - b"", - b"", - ], - Arc::clone(&read_count), - ); - let mut processor = RecordingProcessor { - read_count: Arc::clone(&read_count), - body_close_processed_at: Arc::clone(&body_close_processed_at), - }; - let ad_bids_state = Arc::new(Mutex::new(None)); - let ctx = AuctionCollectCtx { - dispatched, - telemetry: AuctionTelemetryCarry { - observation: None, - auction_request: None, - }, - deps: AuctionCollectDeps { - price_granularity: PriceGranularity::default(), - ad_bids_state: &ad_bids_state, - orchestrator: &orchestrator, - services: &services, - settings: &settings, - request_origin: String::new(), - }, - }; - let mut output = Vec::new(); - - body_close_hold_loop(reader, &mut output, &mut processor, ctx) - .await - .expect("should stream body with auction hold"); - - assert_eq!( - body_close_processed_at.load(Ordering::SeqCst), - 1, - "close-body tail should be processed as soon as it is found, before later chunks are read" - ); - assert_eq!( - std::str::from_utf8(&output).expect("should be utf8"), - "painted", - "post-body chunks should still stream in order" - ); - } - - #[tokio::test] - async fn hold_step_yields_ready_prefix_before_collecting_auction() { - // A small page whose `` lands in the first source chunk must - // still stream its document prefix immediately. `hold_step_decoded_chunk` - // reports the ready prefix and `close_found` without collecting; only - // `hold_collect_close_tail` awaits collection. Regression guard for the - // #849 FCP objective: the prefix must become ready while collection - // remains pending. - let settings = create_test_settings(); - let services = noop_services(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - let ad_bids_state = Arc::new(Mutex::new(None)); - let mut state = AuctionHoldState::new( - DispatchedAuctionGuard::new(DispatchedAuction::empty_for_test( - test_auction_request(), - 500, - )), - AuctionTelemetryCarry { - observation: None, - auction_request: None, - }, - ); - let collect_refs = AuctionCollectDeps { - price_granularity: PriceGranularity::default(), - ad_bids_state: &ad_bids_state, - orchestrator: &orchestrator, - services: &services, - settings: &settings, - request_origin: String::new(), - }; - // Passthrough processor: the ordering contract is about collection, not - // HTML rewriting, so keep the emitted bytes verbatim. - let mut processor = RecordingProcessor { - read_count: Arc::new(AtomicUsize::new(0)), - body_close_processed_at: Arc::new(AtomicUsize::new(0)), - }; - let mut encoder = BodyStreamEncoder::new(Compression::None); - - let step = hold_step_decoded_chunk( - &mut processor, - &mut encoder, - b"painted", - &mut state, - &collect_refs, - ) - .await - .expect("hold step should succeed"); - - assert!( - step.close_found, - " in the first chunk must be detected" - ); - let ready: Vec = step.ready.iter().flat_map(|b| b.to_vec()).collect(); - assert_eq!( - std::str::from_utf8(&ready).expect("ready prefix should be utf8"), - "painted", - "the prefix up to must be ready before collection" - ); - assert!( - ad_bids_state - .lock() - .expect("should lock bid state") - .is_none(), - "auction must not be collected while the ready prefix is emitted" - ); - - let tail = hold_collect_close_tail(&mut processor, &mut encoder, &mut state, &collect_refs) - .await - .expect("collect should succeed"); - let tail_bytes: Vec = tail.iter().flat_map(|b| b.to_vec()).collect(); - assert_eq!( - std::str::from_utf8(&tail_bytes).expect("held tail should be utf8"), - "", - "the held close tail must be emitted after collection" - ); - assert!( - ad_bids_state - .lock() - .expect("should lock bid state") - .is_some(), - "collection must run when the held tail is emitted" - ); - } - - #[test] - fn body_close_hold_buffer_holds_close_body_tail_in_single_chunk() { - let mut hold = BodyCloseHoldBuffer::new(); - - let ready = hold.push(b"painted"); - let held = hold.finish(); - - assert_eq!( - std::str::from_utf8(&ready).expect("should be utf8"), - "painted", - "content before should stream before auction collection" - ); - assert_eq!( - std::str::from_utf8(&held).expect("should be utf8"), - "", - "the close-body tag and trailing bytes should be held" - ); - } - - #[test] - fn body_close_hold_buffer_holds_close_body_tail_across_chunks() { - let mut hold = BodyCloseHoldBuffer::new(); - - let first = hold.push(b"painted"); - let held = hold.finish(); - - let streamed = [first, second].concat(); - assert_eq!( - std::str::from_utf8(&streamed).expect("should be utf8"), - "painted", - "split bytes must not leak before auction collection" - ); - assert_eq!( - std::str::from_utf8(&held).expect("should be utf8"), - "", - "split close-body tag should be held intact" + "disabled [creative_opportunities].enabled switch should skip TS ad stack" ); } @@ -6585,17 +6250,128 @@ mod tests { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); - let req = build_request( - Method::GET, - "https://publisher.example/static/tsjs=tsjs-unified.min.js", - ); + let selection = registry + .tsjs_static_transport_selections(false) + .pop() + .expect("should expose one transport selection"); + let module_ids = registry.tsjs_critical_module_ids(selection); + let src = crate::tsjs::tsjs_script_src(&module_ids); + let req = build_request(Method::GET, &format!("https://publisher.example{src}")); let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static( + "application/javascript; charset=utf-8" + )) + ); + assert_eq!( + response.headers().get(header::X_CONTENT_TYPE_OPTIONS), + Some(&HeaderValue::from_static("nosniff")) + ); + assert!(response.headers().contains_key(header::ETAG)); + } + + #[test] + fn tsjs_dynamic_preserves_strong_etag_conditional_304() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let selection = registry + .tsjs_static_transport_selections(false) + .pop() + .expect("should expose one transport selection"); + let ids = registry.tsjs_critical_module_ids(selection); + let src = crate::tsjs::tsjs_script_src(&ids); + let first = build_request(Method::GET, &format!("https://publisher.example{src}")); + let first_response = + handle_tsjs_dynamic(&first, ®istry).expect("should serve current release"); + let etag = first_response + .headers() + .get(header::ETAG) + .cloned() + .expect("should emit an ETag"); + let mut conditional = + build_request(Method::GET, &format!("https://publisher.example{src}")); + conditional + .headers_mut() + .insert(header::IF_NONE_MATCH, etag.clone()); + + let response = handle_tsjs_dynamic(&conditional, ®istry) + .expect("should handle conditional request"); + + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + assert_eq!(response.headers().get(header::ETAG), Some(&etag)); + assert_eq!( + response.headers().get(header::X_CONTENT_TYPE_OPTIONS), + Some(&HeaderValue::from_static("nosniff")) + ); + } + + #[test] + fn tsjs_dynamic_rejects_noncanonical_transport_locally_without_fallthrough() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let selection = registry + .tsjs_static_transport_selections(false) + .pop() + .expect("should expose one transport selection"); + let ids = registry.tsjs_critical_module_ids(selection); + let hash = trusted_server_js::concatenated_hash(&ids); + let cases = [ + (Method::HEAD, format!("tsjs-unified.min.js?v={hash}")), + (Method::OPTIONS, format!("tsjs-unified.min.js?v={hash}")), + (Method::POST, format!("tsjs-unified.min.js?v={hash}")), + (Method::GET, "tsjs-unified.min.js".to_owned()), + (Method::GET, format!("tsjs-unified.js?v={hash}")), + (Method::GET, format!("tsjs-unified.min.js?v={hash}&x=1")), + (Method::GET, format!("tsjs-unified.min.js?x=1&v={hash}")), + (Method::GET, "tsjs-unified.min.js?v=stale".to_owned()), + ( + Method::GET, + format!("tsjs-unified.min.js?v={}", "0".repeat(64)), + ), + ( + Method::GET, + format!("tsjs-unified.min.js?v={}", hash.to_uppercase()), + ), + ( + Method::GET, + format!("tsjs-unknown.min.js?v={}", "0".repeat(64)), + ), + ( + Method::GET, + format!( + "tsjs-creative.min.js?v={}", + trusted_server_js::single_module_hash("creative") + .expect("should hash critical creative") + ), + ), + ]; + + for (method, suffix) in cases { + let req = build_request( + method, + &format!("https://publisher.example/static/tsjs={suffix}"), + ); + let response = handle_tsjs_dynamic(&req, ®istry).expect("should reject locally"); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "case {suffix}"); + assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&HeaderValue::from_static("no-store")), + "case {suffix}" + ); + assert!( + !response.headers().contains_key(header::LOCATION), + "case {suffix}" + ); + } } #[test] - fn tsjs_dynamic_serves_diagnostics_standalone_without_cookie_variance() { + fn tsjs_dynamic_rejects_critical_diagnostics_as_a_standalone_alias() { let mut settings = create_test_settings(); settings .integrations @@ -6605,7 +6381,11 @@ mod tests { IntegrationRegistry::new(&settings).expect("should create integration registry"); let mut req = build_request( Method::GET, - "https://publisher.example/static/tsjs=tsjs-gpt_diagnostics.min.js", + &format!( + "https://publisher.example/static/tsjs=tsjs-gpt_diagnostics.min.js?v={}", + trusted_server_js::single_module_hash("gpt_diagnostics") + .expect("should hash diagnostics") + ), ); req.headers_mut().insert( header::COOKIE, @@ -6614,29 +6394,36 @@ mod tests { let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::NOT_FOUND); assert!(!response.headers().contains_key(header::SET_COOKIE)); - assert!( - !response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.contains("private") || value.contains("no-store")), - "standalone module should remain cookie-independent and publicly cacheable" - ); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + } + + #[test] + fn ts_console_legacy_cleanup_asset_is_not_a_tsjs_release_route() { + let settings = create_test_settings(); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let req = Request::builder() + .uri("https://publisher.example/static/tsjs=tsjs-gpt_diagnostics-bootstrap.min.js") + .body(EdgeBody::empty()) + .expect("should build cleanup asset request"); + + let response = handle_tsjs_dynamic(&req, ®istry).expect("should serve cleanup asset"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); } #[test] fn parse_single_module_filename_extracts_known_id() { assert_eq!( - parse_single_module_filename("tsjs-sourcepoint.min.js"), - Some("sourcepoint"), - "should extract sourcepoint from minified filename" + parse_single_module_filename("tsjs-sourcepoint_lifecycle.min.js"), + Some("sourcepoint_lifecycle"), + "should extract a catalogued deferred module from the exact filename" ); assert_eq!( - parse_single_module_filename("tsjs-sourcepoint.js"), - Some("sourcepoint"), - "should extract sourcepoint from unminified filename" + parse_single_module_filename("tsjs-sourcepoint_lifecycle.js"), + None, + "should reject unminified aliases" ); } @@ -6649,8 +6436,8 @@ mod tests { ); assert_eq!( parse_single_module_filename("tsjs-core.min.js"), - Some("core"), - "should accept any known module ID (deferred check happens in caller)" + None, + "should reject reserved core as a standalone module" ); assert_eq!( parse_single_module_filename("prebid.min.js"), @@ -6665,20 +6452,31 @@ mod tests { } #[test] - fn tsjs_dynamic_serves_prebid_shim_when_enabled() { - let settings = create_test_settings(); + fn tsjs_dynamic_serves_one_enabled_deferred_module_with_exact_hash() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("osano", &serde_json::json!({ "enabled": true })) + .expect("should enable Osano lifecycle"); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); - let req = build_request( - Method::GET, - "https://publisher.example/static/tsjs=tsjs-prebid.min.js", - ); + let selection = registry + .tsjs_static_transport_selections(false) + .pop() + .expect("should expose one transport selection"); + let deferred = registry.tsjs_deferred_module_ids(selection); + let module_id = deferred + .iter() + .find(|module_id| **module_id != "diagnostics_presentation") + .expect("should enable one non-diagnostics deferred module"); + let src = crate::tsjs::tsjs_single_module_script_src(module_id); + let req = build_request(Method::GET, &format!("https://publisher.example{src}")); let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); assert_eq!( response.status(), StatusCode::OK, - "should serve the deferred prebid shim module when prebid is enabled" + "should serve an enabled deferred catalog module" ); } @@ -6797,6 +6595,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; @@ -6846,6 +6645,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; @@ -6884,6 +6684,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let body = EdgeBody::from_stream(futures::stream::iter(vec![Ok::<_, io::Error>( @@ -7000,6 +6801,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let body = EdgeBody::stream(futures::stream::iter(vec![ @@ -7054,6 +6856,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let compressed = @@ -7111,6 +6914,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let compressed = @@ -7168,6 +6972,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let compressed = @@ -7225,6 +7030,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let compressed = @@ -7270,6 +7076,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, } } @@ -7437,7 +7244,7 @@ mod tests { } #[test] - fn stream_publisher_body_async_processes_stream_with_auction_hold() { + fn stream_publisher_body_async_collects_exact_projection_before_head_boot() { futures::executor::block_on(async { let settings = create_test_settings(); let registry = @@ -7445,6 +7252,7 @@ mod tests { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let state = Arc::new(Mutex::new(None)); + let auction_request = test_auction_request(); let mut params = OwnedProcessResponseParams { content_encoding: String::new(), origin_host: "origin.example.com".to_string(), @@ -7458,13 +7266,14 @@ mod tests { ), ad_bids_state: state, auction_observation: None, - auction_request: Some(test_auction_request()), + auction_request: Some(auction_request.clone()), dispatched_auction: Some(DispatchedAuction::empty_for_test( - test_auction_request(), + auction_request.clone(), 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let body = EdgeBody::stream(futures::stream::iter(vec![ @@ -7491,13 +7300,19 @@ mod tests { "should preserve streamed HTML content. Got: {html}" ); assert!( - html.contains(".adSlots=JSON.parse"), - "should still inject ad slots. Got: {html}" + html.contains(r#""auctionId":"a1_"#), + "the immutable pre-core boot must contain the collected auction projection with a browser-only auction id. Got: {html}" ); assert!( - html.contains("var b=JSON.parse("), - "should collect auction and inject bids before body close. Got: {html}" + !html.contains(&format!(r#""auctionId":"{}""#, auction_request.id)), + "the immutable pre-core boot must not expose the upstream auction id. Got: {html}" ); + assert!( + !html.contains(r#""auctionId":"initial""#), + "the safe empty projection must not replace a dispatched auction. Got: {html}" + ); + assert!(!html.contains(".adSlots")); + assert!(!html.contains(".bids=")); }); } @@ -7530,12 +7345,12 @@ mod tests { )), price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; - // The `` that triggers bid injection lives in the SECOND gzip - // member. `flate2::read::GzDecoder` decodes only the first member, so - // this buffered `Body::Once` body (the non-stream auction arm) proves - // the multi-member decoder now reads every member. + // The document tail lives in the SECOND gzip member. This buffered + // `Body::Once` body proves the hard-cutover pipeline preserves every + // gzip member after collecting the projection before ``. let mut compressed = gzip_encode(b"hello"); compressed.extend(gzip_encode(b"")); let body = EdgeBody::from(compressed); @@ -7563,9 +7378,14 @@ mod tests { "should decode the second gzip member that a single-member decoder drops. Got: {html}" ); assert!( - html.contains("var b=JSON.parse("), - "should inject bids before the carried in the second member. Got: {html}" + html.contains(r#""auctionId":"a1_"#), + "should emit the collected projection with a browser-only auction id before the head bundle. Got: {html}" + ); + assert!( + !html.contains(r#""auctionId":"test-auction""#), + "should not expose the upstream auction id to the browser. Got: {html}" ); + assert!(!html.contains(".bids=")); }); } @@ -7594,6 +7414,7 @@ mod tests { )), price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( @@ -7651,6 +7472,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let publisher_response = PublisherResponse::Stream { @@ -7788,6 +7610,7 @@ mod tests { dispatched_auction, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, } } @@ -7833,11 +7656,11 @@ mod tests { } #[test] - fn streaming_finalize_auction_hold_emits_prefix_before_origin_eof() { - // The auction-hold path must stream the document prefix (up to the held - // `` tail) before the origin finishes and before the auction is - // collected — otherwise the hold reintroduces the FCP regression. The - // origin sends the head/body prefix (no ``) then stays Pending. + fn streaming_finalize_collects_exact_projection_before_lazy_html_head() { + // Fastly must complete the dispatched auction before it constructs the + // HTML processor. The origin can still stream lazily after that barrier, + // but its first `` must carry the exact projection, never the safe + // empty placeholder or a legacy body-close transport. let page = b"

hello

more streamed content here

"; let params = html_stream_params( "", @@ -7855,16 +7678,22 @@ mod tests { let html = String::from_utf8(first.to_vec()).expect("should be valid UTF-8"); assert!( html.contains("hello"), - "auction-hold path must stream the prefix before EOF. Got: {html}" + "the origin body should remain lazy after the pre-head collect. Got: {html}" ); assert!( - html.contains(".adSlots=JSON.parse"), - "prefix must carry the injected (rewritten) head before EOF. Got: {html}" + html.contains(r#""auctionId":"a1_"#), + "the first head chunk must contain the exact collected projection with a browser-only auction id. Got: {html}" ); assert!( - !html.contains("var b=JSON.parse("), - "bids inject only at after collection, which the first poll must not wait for. Got: {html}" + !html.contains(r#""auctionId":"test-auction""#), + "the first head chunk must not expose the upstream auction id. Got: {html}" ); + assert!( + !html.contains(r#""auctionId":"initial""#), + "a dispatched auction must not boot with the safe empty projection. Got: {html}" + ); + assert!(!html.contains(".adSlots")); + assert!(!html.contains(".bids=")); } #[test] @@ -8140,6 +7969,7 @@ mod tests { )), price_granularity: PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, } }; @@ -8276,7 +8106,7 @@ mod tests { } #[test] - fn publisher_response_streaming_finalize_holds_auction_and_keeps_gzip_tail() { + fn publisher_response_streaming_finalize_boots_projection_and_keeps_gzip_tail() { let settings = Arc::new(create_test_settings()); let registry = Arc::new( IntegrationRegistry::new(&settings).expect("should create integration registry"), @@ -8289,10 +8119,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build response"); // The trailing content after `` must exceed the flate2 write - // decoder's 32 KiB internal output buffer: the close-body tag then - // surfaces (and releases the auction hold) mid-stream, while the - // trailing markup only surfaces at decoder finalization. This guards - // against the EOF decoded tail being dropped once the hold is gone. + // decoder's 32 KiB internal output buffer. This guards against the EOF + // decoded tail being dropped after the pre-head projection barrier. let trailing_comment = format!("", "trailing-content ".repeat(3 * 1024)); let page = format!("hello{trailing_comment}"); let compressed = gzip_encode(page.as_bytes()); @@ -8320,6 +8148,7 @@ mod tests { )), price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let publisher_response = PublisherResponse::Stream { @@ -8348,10 +8177,16 @@ mod tests { let html = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); assert!( - html.contains("var b=JSON.parse("), - "should collect the held auction and inject bids. Got tail: {}", - &html[html.len().saturating_sub(200)..] + html.contains(r#""auctionId":"a1_"#), + "should collect the exact projection with a browser-only auction id before the compressed head. Got head: {}", + &html[..html.len().min(500)] + ); + assert!( + !html.contains(r#""auctionId":"test-auction""#), + "should not expose the upstream auction id in the compressed head. Got head: {}", + &html[..html.len().min(500)] ); + assert!(!html.contains(".bids=")); assert!( html.contains("trailing-content"), "should preserve content after the close-body tag" @@ -8364,7 +8199,7 @@ mod tests { } #[test] - fn stream_publisher_body_treats_mixed_case_html_as_html() { + fn stream_publisher_body_treats_mixed_case_html_as_hard_cutover_html() { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -8388,6 +8223,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let mut output = Vec::new(); @@ -8403,12 +8239,12 @@ mod tests { let html = String::from_utf8(output).expect("should be valid UTF-8"); assert!( - html.contains(".adSlots=JSON.parse"), - "mixed-case HTML must use the HTML processor and inject ad slots. Got: {html}" + html.contains(r#""auctionId":"initial","results":[]},"slots":[],"bids":[]"#), + "mixed-case HTML must use the HTML processor and inject the canonical boot projection. Got: {html}" ); assert!( - html.contains(".bids=JSON.parse"), - "mixed-case HTML must use the HTML processor and inject bids. Got: {html}" + !html.contains(".adSlots=JSON.parse") && !html.contains(".bids=JSON.parse"), + "mixed-case HTML must not restore legacy TSJS data globals. Got: {html}" ); } @@ -8439,6 +8275,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; @@ -8548,1735 +8385,189 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, - }; - let mut output = Vec::new(); - stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) - .expect("should process streaming HTML"); - - assert!( - !output.is_empty(), - "streaming processed output must not be empty" - ); - let as_str = std::str::from_utf8(&output).expect("output should be valid UTF-8"); - assert!( - as_str.contains("proxy.example.com"), - "origin must be rewritten. Got: {as_str}" - ); - assert!( - !as_str.contains("origin.example.com"), - "origin host must not leak. Got: {as_str}" - ); - } - - /// Document-state survives from the streaming pass into the post-processor. - /// `NextJsRscPlaceholderRewriter` writes into `IntegrationDocumentState` - /// during streaming; `NextJsHtmlPostProcessor` reads it and substitutes. - /// Regression test: with post-processors registered, placeholders must - /// be inserted during streaming and substituted out of the final output. - #[test] - fn document_state_placeholders_substitute_through_accumulating_path() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "nextjs", - &serde_json::json!({ - "enabled": true, - "rewrite_attributes": ["href", "link", "url"], - }), - ) - .expect("should update nextjs config"); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - // Small, single-fragment RSC script — placeholder path (not fallback). - let html = br#""#; - let params = OwnedProcessResponseParams { - content_encoding: String::new(), - origin_host: "origin.example.com".to_string(), - origin_url: "https://origin.example.com".to_string(), - request_host: "proxy.example.com".to_string(), - request_scheme: "https".to_string(), - content_type: "text/html".to_string(), - ad_slots_script: None, - ad_bids_state: Arc::new(Mutex::new(None)), - auction_observation: None, - auction_request: None, - dispatched_auction: None, - price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, - suppress_datadome_client_side_tag: false, - }; - - let mut output = Vec::new(); - stream_publisher_body( - EdgeBody::from(html.to_vec()), - &mut output, - ¶ms, - &settings, - ®istry, - ) - .expect("should process RSC push"); - - let processed = String::from_utf8(output).expect("valid UTF-8"); - assert!( - !processed.contains("__ts_rsc_payload_"), - "placeholder must be substituted before reaching output. Got: {processed}" - ); - assert!( - processed.contains("proxy.example.com/page"), - "origin URL must be rewritten in the substituted payload. Got: {processed}" - ); - assert!( - !processed.contains("origin.example.com"), - "origin host must not leak. Got: {processed}" - ); - } - - #[cfg(test)] - mod creative_opportunities_tests { - use super::super::{ - MatchedSlotsContext, build_ad_slots_script, build_auction_request, build_bid_map, - build_bids_script, diagnostics_auction_id, html_escape_for_script, write_bids_to_state, - }; - use crate::auction::types::{ApsRendererV1, ApsTagType, Bid, BidRenderer, MediaType}; - use crate::consent::ConsentContext; - use crate::creative_opportunities::{ - CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, - }; - use crate::http_util::RequestInfo; - use crate::price_bucket::PriceGranularity; - use crate::settings::Settings; - use std::collections::HashMap; - - // Rewriting is enabled by default; tests disable it when they need to - // inspect sanitizer-accepted URLs directly. - fn test_settings() -> Settings { - Settings::default() - } - - fn make_config() -> CreativeOpportunitiesConfig { - CreativeOpportunitiesConfig { - enabled: true, - gam_network_id: "21765378893".to_string(), - auction_timeout_ms: Some(500), - price_granularity: PriceGranularity::Dense, - section_root: None, - section_segment: None, - slot: Vec::new(), - } - } - - fn make_slot() -> CreativeOpportunitySlot { - CreativeOpportunitySlot { - id: "atf_sidebar_ad".to_string(), - gam_unit_path: Some("/21765378893/publisher/atf-sidebar".to_string()), - div_id: Some("div-atf-sidebar".to_string()), - page_patterns: vec!["/20**".to_string()], - formats: vec![CreativeOpportunityFormat { - width: 300, - height: 250, - media_type: MediaType::Banner, - }], - floor_price: Some(0.50), - targeting: [("pos".to_string(), "atf".to_string())] - .into_iter() - .collect(), - providers: Default::default(), - compiled_patterns: Vec::new(), - compiled_unit: None, - } - } - - fn make_bid( - slot_id: &str, - price: f64, - bidder: &str, - ad_id: &str, - nurl: &str, - burl: &str, - ) -> Bid { - Bid { - slot_id: slot_id.to_string(), - price: Some(price), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: bidder.to_string(), - width: 300, - height: 250, - nurl: Some(nurl.to_string()), - burl: Some(burl.to_string()), - bid_id: None, - ad_id: Some(ad_id.to_string()), - creative_id: None, - renderer: None, - cache_id: None, - cache_host: None, - cache_path: None, - metadata: Default::default(), - } - } - - #[test] - fn ad_slots_script_contains_slot_data() { - let slots = vec![make_slot()]; - let config = make_config(); - let script = build_ad_slots_script(&slots, &config, "/"); - assert!( - script.contains("window.tsjs=window.tsjs||{}"), - "should initialise tsjs namespace" - ); - assert!( - script.contains(".adSlots=JSON.parse"), - "should use JSON.parse for adSlots" - ); - assert!(script.contains("atf_sidebar_ad"), "should include slot id"); - assert!(!script.contains("adInit"), "must NOT contain adInit"); - assert!( - !script.contains("__ts_request_id"), - "must NOT contain request_id" - ); - } - - #[test] - fn ad_slots_script_is_xss_safe() { - let slots = vec![make_slot()]; - let config = make_config(); - let script = build_ad_slots_script(&slots, &config, "/"); - let inner = script - .trim_start_matches(""); - assert!(!inner.contains('<'), "no unescaped < in script content"); - assert!(!inner.contains('>'), "no unescaped > in script content"); - } - - #[test] - fn ad_slots_script_omits_only_over_limit_dynamic_slot() { - let mut over_limit = make_slot(); - over_limit.id = "over_limit_dynamic".to_string(); - over_limit.gam_unit_path = Some("/{section}/{section}".to_string()); - over_limit - .compile_unit_template() - .expect("template should compile"); - let mut valid_static = make_slot(); - valid_static.id = "valid_static_sibling".to_string(); - valid_static.gam_unit_path = Some("/12345/example/static".to_string()); - let slots = vec![over_limit, valid_static]; - let config = make_config(); - let request_path = format!("/{}", "a".repeat(60)); - - let script = build_ad_slots_script(&slots, &config, &request_path); - - assert!( - !script.contains("over_limit_dynamic"), - "should omit the over-limit dynamic slot" - ); - assert!( - script.contains("valid_static_sibling"), - "should retain the valid static sibling" - ); - } - - #[test] - fn build_slot_json_renders_section_from_request_path() { - let mut config = make_config(); - config.gam_network_id = "99999".to_string(); - config.section_root = Some("homepage".to_string()); - let mut slot = make_slot(); - slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); - slot.compile_unit_template() - .expect("template should compile"); - - let news_section = config.section_for_path("/news/article-123"); - let news = crate::publisher::build_slot_json(&slot, &config, &news_section) - .expect("should render slot"); - assert_eq!( - news["gam_unit_path"], "/99999/example/news", - "section should derive from the first path segment" - ); - - let home_section = config.section_for_path("/"); - let home = crate::publisher::build_slot_json(&slot, &config, &home_section) - .expect("should render slot"); - assert_eq!( - home["gam_unit_path"], "/99999/example/homepage", - "root path should use section_root" - ); - } - - #[test] - fn build_slot_json_honours_configured_section_segment() { - // Locale-prefixed publisher: `/en/news/article` must resolve to the - // `news` unit, not `en`. - let mut config = make_config(); - config.gam_network_id = "99999".to_string(); - config.section_root = Some("homepage".to_string()); - config.section_segment = Some(1); - let mut slot = make_slot(); - slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); - slot.compile_unit_template() - .expect("template should compile"); - - let news_section = config.section_for_path("/en/news/article-123"); - let news = crate::publisher::build_slot_json(&slot, &config, &news_section) - .expect("should render slot"); - assert_eq!( - news["gam_unit_path"], "/99999/example/news", - "section should derive from the configured segment index" - ); - - let locale_root_section = config.section_for_path("/en"); - let locale_root = - crate::publisher::build_slot_json(&slot, &config, &locale_root_section) - .expect("should render slot"); - assert_eq!( - locale_root["gam_unit_path"], "/99999/example/homepage", - "a path with no segment at the configured index should use section_root" - ); - } - - #[test] - fn bid_map_includes_nurl_and_burl() { - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ), - ); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); - let obj = entry.as_object().expect("should be object"); - assert_eq!( - obj.get("hb_pb").and_then(|v| v.as_str()), - Some("1.50"), - "should bucket price with dense granularity" - ); - assert_eq!( - obj.get("hb_bidder").and_then(|v| v.as_str()), - Some("kargo"), - "should include bidder" - ); - assert_eq!( - obj.get("hb_adid").and_then(|v| v.as_str()), - Some("abc123"), - "should fall back to ad_id when no cache_id present" - ); - assert_eq!( - obj.get("nurl").and_then(|v| v.as_str()), - Some("https://ssp/win"), - "should include nurl" - ); - assert_eq!( - obj.get("burl").and_then(|v| v.as_str()), - Some("https://ssp/bill"), - "should include burl" - ); - } - - /// Guards the browser-visible token every auction path shares: it must - /// be fresh per auction and absent unless diagnostics can consume it. - #[test] - fn diagnostics_auction_id_is_fresh_and_gated() { - let mut settings = test_settings(); - assert_eq!( - diagnostics_auction_id(&settings), - None, - "no token should be minted without the diagnostics integration" - ); - - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); - let first = - diagnostics_auction_id(&settings).expect("enabled diagnostics should mint a token"); - let second = - diagnostics_auction_id(&settings).expect("enabled diagnostics should mint a token"); - - assert!( - first.starts_with("ts-auc-"), - "token should use the diagnostics prefix, got `{first}`" - ); - assert_ne!(first, second, "each auction should mint its own token"); - } - - #[test] - fn bid_map_exposes_aps_renderer_and_selected_bid_id_without_debug_adm() { - let mut bid = make_bid("atf_sidebar_ad", 1.50, "aps", "fallback-ad", "", ""); - bid.bid_id = Some("selected-bid".to_string()); - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "selected-bid".to_string(), - creative_id: None, - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 300, - height: 250, - })); - bid.nurl = None; - bid.burl = None; - let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map["atf_sidebar_ad"] - .as_object() - .expect("should include APS bid"); - - assert_eq!(obj["hb_bidder"], "aps"); - assert_eq!(obj["hb_adid"], "selected-bid"); - assert_eq!(obj["renderer"]["type"], "aps"); - assert_eq!(obj["renderer"]["bidId"], "selected-bid"); - assert!(obj.get("adm").is_none()); - assert!(obj.get("nurl").is_none()); - assert!(obj.get("burl").is_none()); - assert!(obj.get("metadata").is_none()); - - let script = build_bids_script(&map); - assert!(!script.contains("")); - assert!(script.contains("\\u003C/script\\u003E")); - } - - #[test] - fn initial_document_bids_script_includes_auction_id_only_for_winning_bids() { - let slot = make_slot(); - let slots = [slot]; - let slots_ctx = MatchedSlotsContext { - matched_slots: &slots, - request_path_and_query: "/2024/01/my-article/", - }; - let request_info = RequestInfo { - host: "publisher.example.com".to_string(), - scheme: "https".to_string(), - }; - let mut auction_request = build_auction_request( - &slots_ctx, - None, - &ConsentContext::default(), - &request_info, - "publisher.example.com", - Some("Mozilla/5.0"), - ); - auction_request.id = "initial-auction-example-123".to_string(); - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - make_bid( - "atf_sidebar_ad", - 1.50, - "example_bidder", - "abc123", - "https://example.com/win", - "https://example.com/bill", - ), - ); - - let state = std::sync::Arc::new(std::sync::Mutex::new(None)); - write_bids_to_state( - &winning_bids, - PriceGranularity::Dense, - &state, - &test_settings(), - "", - false, - Some(&auction_request.id), - ); - let script = state - .lock() - .expect("should lock initial bid state") - .clone() - .expect("should generate initial-document bids script"); - let bid_json = script - .strip_prefix( - "", - ) - }) - .expect("should emit the initial-document tsjs.bids script shape"); - let bid_json: String = serde_json::from_str(&format!("\"{bid_json}\"")) - .expect("should decode initial-document JSON.parse input"); - let bids: serde_json::Value = serde_json::from_str(&bid_json) - .expect("should serialize initial-document bids as JSON"); - - assert_eq!( - bids["atf_sidebar_ad"]["hb_auction_id"], auction_request.id, - "initial-document bids should expose the current request ID only on the winner" - ); - - write_bids_to_state( - &HashMap::new(), - PriceGranularity::Dense, - &state, - &test_settings(), - "", - false, - Some(&auction_request.id), - ); - let empty_script = state - .lock() - .expect("should lock empty initial bid state") - .clone() - .expect("should generate empty initial-document bids script"); - let empty_bid_json = empty_script - .strip_prefix( - "", - ) - }) - .expect("should emit the empty initial-document tsjs.bids script shape"); - let empty_bid_json: String = serde_json::from_str(&format!("\"{empty_bid_json}\"")) - .expect("should decode empty initial-document JSON.parse input"); - let empty_bids: serde_json::Value = serde_json::from_str(&empty_bid_json) - .expect("should serialize empty initial-document bids as JSON"); - assert!( - empty_bids - .as_object() - .expect("initial-document bids should be an object") - .is_empty(), - "initial-document bids should not fabricate metadata without a winner" - ); - } - - #[test] - fn bid_map_omits_zero_creative_dimensions() { - // Missing OpenRTB w/h parse to 0. Emitting w:0/h:0 would make the - // bridge (which nullish-coalesces) size the frame to 0 instead of - // falling back to the slot format, so a zero dimension must be omitted. - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.width = 0; - bid.height = 0; - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - assert!(obj.get("w").is_none(), "should omit zero width"); - assert!(obj.get("h").is_none(), "should omit zero height"); - } - - #[test] - fn bid_map_includes_winning_creative_dimensions() { - // The bridge sizes the inline render from these dimensions; without - // them it falls back to the first configured slot format, which - // mis-sizes a multi-size slot whose winner is not the first format. - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.width = 300; - bid.height = 600; - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - assert_eq!( - obj.get("w").and_then(serde_json::Value::as_u64), - Some(300), - "should include winning creative width" - ); - assert_eq!( - obj.get("h").and_then(serde_json::Value::as_u64), - Some(600), - "should include winning creative height" - ); - } - - #[test] - fn client_bid_map_includes_adm_and_omits_debug_bid_by_default() { - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.creative = Some("
Creative
".to_string()); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - // Production path (include_debug_bid = false): the creative is always - // included so the bridge can render it locally, but the verbose - // debug_bid blob is not. - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - - assert_eq!( - obj.get("adm").and_then(|v| v.as_str()), - Some("
Creative
"), - "should include creative markup for local rendering by default" - ); - assert!( - obj.get("debug_bid").is_none(), - "should omit the debug_bid blob when debug injection is disabled" - ); - } - - #[test] - fn build_bid_map_sanitizes_hostile_adm() { - // The inline-adm path must run the same opt-in creative-processing - // boundary as the `/auction` path (sanitize → rewrite) before the - // creative reaches window.tsjs.bids, so with sanitization enabled - // hostile executable markup never lands in the client-facing `adm` - // for the Prebid Universal Creative to run. - let mut settings = test_settings(); - settings.auction.sanitize_creatives = true; - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.creative = Some( - "
\ - x
" - .to_string(), - ); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); - let adm = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .and_then(|o| o.get("adm")) - .and_then(|v| v.as_str()) - .expect("should include a sanitized adm"); - - assert!( - !adm.contains(" elements from the inline adm" - ); - assert!( - !adm.contains("alert(1)"), - "should strip inline script bodies from the inline adm" - ); - assert!( - !adm.contains("onclick"), - "should strip on* event-handler attributes from the inline adm" - ); - assert!( - !adm.contains("javascript:"), - "should strip javascript: URIs from the inline adm" - ); - } - - #[test] - fn build_bid_map_can_skip_rewriting_while_sanitizing() { - let mut settings = test_settings(); - settings.auction.sanitize_creatives = true; - settings.auction.rewrite_creatives = false; - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.creative = Some( - "
\ - x\ -
" - .to_string(), - ); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &settings, - "https://publisher.example", - false, - ); - let adm = map - .get("atf_sidebar_ad") - .and_then(|value| value.as_object()) - .and_then(|object| object.get("adm")) - .and_then(|value| value.as_str()) - .expect("should include a sanitized adm"); - - assert!( - adm.contains(r#"href="https://click.example/landing""#), - "should keep accepted click URLs direct: {adm}" - ); - assert!( - adm.contains(r#"src="https://cdn.example/ad.png""#), - "should keep accepted resource URLs direct: {adm}" - ); - assert!( - !adm.contains("/first-party/"), - "should skip first-party URL rewriting: {adm}" - ); - assert!( - !adm.contains("data-tsclick"), - "should skip click-guard attributes: {adm}" - ); - assert!( - !adm.contains("marker") && !adm.contains("onclick"), - "should still sanitize executable markup: {adm}" - ); - } - - #[test] - fn build_bid_map_omits_oversized_adm() { - // Creatives larger than the 1 MiB cap are rejected (empty result) - // in every processing mode, so the inline `adm` is omitted rather - // than shipping an unbounded creative to the client. Runs with - // default settings to cover the shipped configuration. - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.creative = Some(format!("
{}
", "a".repeat(1024 * 1024 + 1))); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .expect("should have a bid entry"); - assert!( - obj.get("adm").is_none(), - "should omit the inline adm when the creative exceeds the 1 MiB cap" - ); - } - - #[test] - fn build_bid_map_omits_oversized_adm_when_sanitizing() { - // Creatives larger than the sanitize pass's 1 MiB cap are rejected - // (empty result), so the inline `adm` is omitted and the pbRender - // bridge falls back to the PBS Cache coordinates instead of shipping - // an unbounded creative to the client. - let settings = test_settings(); - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.creative = Some(format!("
{}
", "a".repeat(1024 * 1024 + 1))); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); - let obj = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .expect("should have a bid entry"); - assert!( - obj.get("adm").is_none(), - "should omit the inline adm when the creative exceeds the 1 MiB cap" - ); - } - - // A supplied creative that processing rejects must not fall back to the - // PBS Cache coordinates: the GPT bridge fetches the cached bid's ORIGINAL - // adm, which would undo sanitization and the size cap entirely. - fn cached_bid_with_creative(creative: &str) -> Bid { - Bid { - slot_id: "atf_sidebar_ad".to_string(), - price: Some(1.50), - currency: "USD".to_string(), - creative: Some(creative.to_string()), - adomain: None, - bidder: "prebid".to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - ad_id: Some("bid-impression-id".to_string()), - cache_id: Some("cache-uuid".to_string()), - cache_host: Some("prebid-cache.example.com".to_string()), - cache_path: Some("/cache".to_string()), - bid_id: None, - creative_id: None, - renderer: None, - metadata: Default::default(), - } - } - - fn assert_no_render_source(settings: &Settings, creative: String, case: &str) { - let mut winning_bids = HashMap::new(); - let mut bid = cached_bid_with_creative(""); - bid.creative = Some(creative); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, settings, "", false); - let obj = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .expect("should have a bid entry"); - - assert!( - obj.get("adm").is_none(), - "{case}: rejected creative should not emit adm" - ); - assert!( - obj.get("hb_cache_host").is_none(), - "{case}: rejected creative should suppress hb_cache_host" - ); - assert!( - obj.get("hb_cache_path").is_none(), - "{case}: rejected creative should suppress hb_cache_path" - ); - } - - #[test] - fn build_bid_map_suppresses_cache_fallback_for_rejected_creatives() { - let mut sanitizing = test_settings(); - sanitizing.auction.sanitize_creatives = true; - - // Script-only creative: sanitization strips everything. - assert_no_render_source( - &sanitizing, - "".to_string(), - "script-only", - ); - // Oversized creative: rejected by the cap in every mode. - assert_no_render_source( - &test_settings(), - format!("
{}
", "a".repeat(1024 * 1024 + 1)), - "oversized", - ); - // An explicit empty `adm` is a supplied creative, not an absent one: - // classifying it as absent would re-enable the raw cache fallback. - assert_no_render_source(&test_settings(), String::new(), "explicit-empty"); - } - - #[test] - fn build_bid_map_keeps_cache_fallback_for_absent_creatives() { - // A bid with no supplied creative is the legitimate PBS Cache case: - // the coordinates are the only render source. - let mut winning_bids = HashMap::new(); - let mut bid = cached_bid_with_creative(""); - bid.creative = None; - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .expect("should have a bid entry"); - - assert_eq!( - obj.get("hb_cache_host").and_then(|v| v.as_str()), - Some("prebid-cache.example.com"), - "absent creative should keep hb_cache_host" - ); - assert_eq!( - obj.get("hb_cache_path").and_then(|v| v.as_str()), - Some("/cache"), - "absent creative should keep hb_cache_path" - ); - } - - #[test] - fn build_bid_map_rewrites_inline_adm_to_absolute_first_party_urls() { - // The inline `adm` is rendered by the Prebid Universal Creative inside - // GAM's iframe (`f.srcdoc = d.ad`), a foreign origin. Proxied URLs must - // therefore be emitted **absolute** against the publisher domain — a - // root-relative `/first-party/proxy` would resolve against GAM and 404. - // The tsjs bundle must NOT be injected into that foreign-origin iframe. - let mut settings = test_settings(); - settings.auction.rewrite_creatives = true; - settings.publisher.domain = "example.com".to_string(); - settings.auction.rewrite_creatives = true; - - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "examplessp", - "abc123", - "https://ssp.example.com/win", - "https://ssp.example.com/bill", - ); - bid.creative = Some( - "" - .to_string(), - ); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); - let adm = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .and_then(|o| o.get("adm")) - .and_then(|v| v.as_str()) - .expect("should include a rewritten adm"); - - assert!( - adm.contains("https://example.com/first-party/proxy?tsurl="), - "should emit an absolute first-party proxy URL for the foreign-origin render context, got: {adm}" - ); - assert!( - !adm.contains("src=\"/first-party/proxy"), - "should not emit a root-relative proxy URL that 404s under GAM's origin, got: {adm}" - ); - assert!( - !adm.contains("https://cdn.example.com/pixel.png"), - "should proxy the original absolute CDN URL, got: {adm}" - ); - assert!( - !adm.contains("/static/tsjs="), - "should not inject the tsjs bundle into a foreign-origin creative iframe, got: {adm}" - ); - } - - #[test] - fn build_bid_map_uses_request_origin_for_inline_urls() { - // The inline adm's absolute first-party URLs must resolve against the - // origin the visitor is on (here an HTTP dev host with a port), not the - // configured publisher domain. - let mut settings = test_settings(); - settings.auction.rewrite_creatives = true; - settings.publisher.domain = "example.com".to_string(); - settings.auction.rewrite_creatives = true; - - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "examplessp", - "abc123", - "https://ssp.example.com/win", - "https://ssp.example.com/bill", - ); - bid.creative = Some( - "" - .to_string(), - ); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &settings, - "http://localhost:7676", - false, - ); - let adm = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .and_then(|o| o.get("adm")) - .and_then(|v| v.as_str()) - .expect("should include a rewritten adm"); - - assert!( - adm.contains("http://localhost:7676/first-party/proxy?tsurl="), - "should emit URLs against the request origin, got: {adm}" - ); - assert!( - !adm.contains("https://example.com/first-party/proxy"), - "must not fall back to the configured publisher domain, got: {adm}" - ); - } - - #[test] - fn build_bid_map_expands_auction_price_macro_before_rewrite() { - // ${AUCTION_PRICE} must be resolved to the clearing price before the - // creative is rewritten and signed. Otherwise URL rewriting encodes the - // literal macro (`%24%7BAUCTION_PRICE%7D`) into the signed proxy/click - // URL, so trackers receive an encoded macro instead of the price and the - // signature locks the wrong value. - let mut settings = test_settings(); - settings.publisher.domain = "example.com".to_string(); - settings.auction.rewrite_creatives = true; - - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "examplessp", - "abc123", - "https://ssp.example.com/win", - "https://ssp.example.com/bill", - ); - bid.creative = Some( - "\ - go\ - " - .to_string(), - ); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); - let adm = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .and_then(|o| o.get("adm")) - .and_then(|v| v.as_str()) - .expect("should include a rewritten adm"); - - assert!( - !adm.to_uppercase().contains("AUCTION_PRICE"), - "no literal or encoded ${{AUCTION_PRICE}} macro should survive: {adm}" - ); - assert!( - adm.contains("p=1.5"), - "the exact winning CPM should be substituted into the signed URL: {adm}" - ); - } - - #[test] - fn build_bid_map_expands_auction_price_macro_in_notification_urls() { - // Per OpenRTB the win/billing notices are the primary carriers of - // ${AUCTION_PRICE}, and the bridge fires them verbatim. An unexpanded - // macro would report an unresolved clearing price to the SSP, and - // would disagree with the price already substituted into the adm. - let mut winning_bids = HashMap::new(); - let bid = make_bid( - "atf_sidebar_ad", - 1.50, - "examplessp", - "abc123", - "https://ssp.example.com/win?p=${AUCTION_PRICE}", - "https://ssp.example.com/bill?p=${AUCTION_PRICE}", - ); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .expect("should have bid entry"); - - for field in ["nurl", "burl"] { - let url = obj - .get(field) - .and_then(|v| v.as_str()) - .unwrap_or_else(|| panic!("should include {field}")); - assert!( - !url.to_uppercase().contains("AUCTION_PRICE"), - "no literal or encoded ${{AUCTION_PRICE}} macro should survive in {field}: {url}" - ); - assert!( - url.ends_with("?p=1.5"), - "the exact winning CPM should be substituted into {field}: {url}" - ); - } - } - - #[test] - fn build_bids_script_escapes_line_separators_in_adm() { - // U+2028/U+2029 are valid JSON string content but terminate inline - // ".to_string(), - width: 300, - height: 250, - })); - let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); + /// Document-state survives from the streaming pass into the post-processor. + /// `NextJsRscPlaceholderRewriter` writes into `IntegrationDocumentState` + /// during streaming; `NextJsHtmlPostProcessor` reads it and substitutes. + /// Regression test: with post-processors registered, placeholders must + /// be inserted during streaming and substituted out of the final output. + #[test] + fn document_state_placeholders_substitute_through_accumulating_path() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "nextjs", + &serde_json::json!({ + "enabled": true, + "rewrite_attributes": ["href", "link", "url"], + }), + ) + .expect("should update nextjs config"); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map["atf_sidebar_ad"] - .as_object() - .expect("should include APS bid"); + // Small, single-fragment RSC script — placeholder path (not fallback). + let html = br#""#; + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, + render_trace_overlay: false, + suppress_datadome_client_side_tag: false, + }; - assert_eq!(obj["hb_bidder"], "aps"); - assert_eq!(obj["hb_adid"], "selected-bid"); - assert_eq!(obj["renderer"]["type"], "aps"); - assert_eq!(obj["renderer"]["bidId"], "selected-bid"); - assert!(obj.get("adm").is_none()); + let mut output = Vec::new(); + stream_publisher_body( + EdgeBody::from(html.to_vec()), + &mut output, + ¶ms, + &settings, + ®istry, + ) + .expect("should process RSC push"); - let script = build_bids_script(&map); - assert!(!script.contains("")); - assert!(script.contains("\\u003C/script\\u003E")); - } + let processed = String::from_utf8(output).expect("valid UTF-8"); + assert!( + !processed.contains("__ts_rsc_payload_"), + "placeholder must be substituted before reaching output. Got: {processed}" + ); + assert!( + processed.contains("proxy.example.com/page"), + "origin URL must be rewritten in the substituted payload. Got: {processed}" + ); + assert!( + !processed.contains("origin.example.com"), + "origin host must not leak. Got: {processed}" + ); + } - #[test] - fn bid_map_falls_back_to_bid_id_when_cache_id_and_ad_id_absent() { - // Real shape for bidders that return neither a Prebid Cache UUID nor - // `adid` in the OpenRTB response, but always carry `id` (the bid's own - // identifier) per spec. Without this fallback the bid reaches the page - // with no hb_adid, so no targeting key is set and the render bridge - // never receives a matching `Prebid Request`. - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - Bid { - slot_id: "atf_sidebar_ad".to_string(), - price: Some(1.00), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: "example-bidder".to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: Some("019f7e2a-b45b-70b0-a2d1-b651c430700b".to_string()), - ad_id: None, - creative_id: None, - renderer: None, - cache_id: None, - cache_host: None, - cache_path: None, - metadata: Default::default(), - }, - ); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - assert_eq!( - obj.get("hb_adid").and_then(|v| v.as_str()), - Some("019f7e2a-b45b-70b0-a2d1-b651c430700b"), - "should fall back to bid_id when cache_id and ad_id are both absent" - ); - } + #[cfg(test)] + mod creative_opportunities_tests { + use super::super::{MatchedSlotsContext, build_auction_request, build_browser_slots_v1}; + use crate::auction::types::MediaType; + use crate::consent::ConsentContext; + use crate::creative_opportunities::{ + CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, + }; + use crate::http_util::RequestInfo; + use crate::price_bucket::PriceGranularity; - #[test] - fn bid_map_skips_blank_cache_id_and_ad_id_for_hb_adid() { - // A bidder that emits `cacheId`/`adid` as empty strings must not win - // the precedence: an empty hb_adid is falsey on the page, so GPT skips - // the targeting key and the render bridge has nothing to match — the - // same failure as omitting hb_adid entirely. - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - Bid { - slot_id: "atf_sidebar_ad".to_string(), - price: Some(1.00), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: "example-bidder".to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: Some("019f7e2a-b45b-70b0-a2d1-b651c430700b".to_string()), - ad_id: Some(String::new()), - creative_id: None, - renderer: None, - cache_id: Some(String::new()), - cache_host: None, - cache_path: None, - metadata: Default::default(), - }, - ); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - assert_eq!( - obj.get("hb_adid").and_then(|v| v.as_str()), - Some("019f7e2a-b45b-70b0-a2d1-b651c430700b"), - "should treat blank cache_id and ad_id as absent and use bid_id" - ); + fn make_config() -> CreativeOpportunitiesConfig { + CreativeOpportunitiesConfig { + enabled: true, + gam_network_id: "21765378893".to_string(), + auction_timeout_ms: Some(500), + price_granularity: PriceGranularity::Dense, + section_root: None, + section_segment: None, + slot: Vec::new(), + } } - #[test] - fn bid_map_omits_cache_coordinates_without_a_cache_id() { - // PBS reports the cache `url` and `cacheId` independently. With - // coordinates but no UUID, hb_adid holds a non-cache identifier, so - // emitting them would send the Universal Creative to - // `?uuid=` — a guaranteed miss — instead of the inline adm. - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - Bid { - slot_id: "atf_sidebar_ad".to_string(), - price: Some(1.00), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: "example-bidder".to_string(), + fn make_slot() -> CreativeOpportunitySlot { + CreativeOpportunitySlot { + id: "atf_sidebar_ad".to_string(), + gam_unit_path: Some("/21765378893/publisher/atf-sidebar".to_string()), + div_id: Some("div-atf-sidebar".to_string()), + page_patterns: vec!["/20**".to_string()], + formats: vec![CreativeOpportunityFormat { width: 300, height: 250, - nurl: None, - burl: None, - bid_id: Some("019f7e2a-b45b-70b0-a2d1-b651c430700b".to_string()), - ad_id: None, - creative_id: None, - renderer: None, - cache_id: None, - cache_host: Some("cache.example.com".to_string()), - cache_path: Some("/cache".to_string()), - metadata: Default::default(), - }, - ); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - assert!( - obj.get("hb_cache_host").is_none(), - "should omit hb_cache_host when there is no cache UUID to look up" - ); - assert!( - obj.get("hb_cache_path").is_none(), - "should omit hb_cache_path when there is no cache UUID to look up" - ); + media_type: MediaType::Banner, + }], + floor_price: Some(0.50), + targeting: [("pos".to_string(), "atf".to_string())] + .into_iter() + .collect(), + providers: Default::default(), + compiled_patterns: Vec::new(), + compiled_unit: None, + } } #[test] - fn bid_map_omits_cache_coordinates_for_a_blank_cache_id() { - // A blank `cacheId` loses the hb_adid precedence to `adid`/the bid - // id, so the cache gate must treat it as absent too. Otherwise the - // coordinates ship alongside a non-cache hb_adid and the Universal - // Creative fetches `?uuid=` — a guaranteed miss — rather than - // falling through to the inline adm. - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - Bid { - slot_id: "atf_sidebar_ad".to_string(), - price: Some(1.00), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: "example-bidder".to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: Some("019f7e2a-b45b-70b0-a2d1-b651c430700b".to_string()), - ad_id: Some("creative-123".to_string()), - creative_id: None, - renderer: None, - cache_id: Some(String::new()), - cache_host: Some("cache.example.com".to_string()), - cache_path: Some("/cache".to_string()), - metadata: Default::default(), - }, - ); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - assert_eq!( - obj.get("hb_adid").and_then(|v| v.as_str()), - Some("creative-123"), - "should fall back to ad_id when cache_id is blank" - ); - assert!( - obj.get("hb_cache_host").is_none(), - "should omit hb_cache_host when the cache UUID is blank" - ); - assert!( - obj.get("hb_cache_path").is_none(), - "should omit hb_cache_path when the cache UUID is blank" - ); - } + fn browser_slots_render_section_from_request_path() { + let mut config = make_config(); + config.gam_network_id = "99999".to_string(); + config.section_root = Some("homepage".to_string()); + let mut slot = make_slot(); + slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); + slot.compile_unit_template() + .expect("template should compile"); - #[test] - fn bid_map_omits_hb_adid_when_cache_id_ad_id_and_bid_id_all_absent() { - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - Bid { - slot_id: "atf_sidebar_ad".to_string(), - price: Some(0.50), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: "ordinary".to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: None, - ad_id: None, - creative_id: None, - renderer: None, - cache_id: None, - cache_host: None, - cache_path: None, - metadata: Default::default(), - }, - ); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - assert!( - obj.get("hb_adid").is_none(), - "should omit hb_adid when no cache_id, ad_id, or bid_id" + let news = + build_browser_slots_v1(std::slice::from_ref(&slot), &config, "/news/article-123"); + assert_eq!( + news[0].gam_unit_path, "/99999/example/news", + "section should derive from the first path segment" ); - } - #[test] - fn bid_map_excludes_slot_when_price_is_none() { - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "no-price-slot".to_string(), - Bid { - slot_id: "no-price-slot".to_string(), - price: None, - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: "kargo".to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: None, - ad_id: None, - creative_id: None, - renderer: None, - cache_id: None, - cache_host: None, - cache_path: None, - metadata: Default::default(), - }, - ); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - assert!( - map.is_empty(), - "slot with no price should be excluded from bid map" + let home = build_browser_slots_v1(std::slice::from_ref(&slot), &config, "/"); + assert_eq!( + home[0].gam_unit_path, "/99999/example/homepage", + "root path should use section_root" ); } #[test] - fn bids_script_is_xss_safe() { - let mut map = serde_json::Map::new(); - map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); - let script = build_bids_script(&map); - let inner = script - .trim_start_matches(""); - assert!(!inner.contains('<'), "no unescaped < in bids script"); - assert!(!inner.contains('>'), "no unescaped > in bids script"); - } - - #[test] - fn bids_script_schedules_ad_init_without_retry_timer() { - let mut map = serde_json::Map::new(); - map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); - - let script = build_bids_script(&map); - - assert!( - script.contains("t.scheduleInitialAdInit"), - "should hand off bids to the deferred adInit scheduler" - ); - assert!( - !script.contains("setTimeout"), - "should not retry adInit on a timer" - ); - assert!( - !script.contains("prevGptSlots"), - "should not use TS-owned slots as adInit success signal" - ); - } + fn browser_slots_honour_configured_section_segment() { + // Locale-prefixed publisher: `/en/news/article` must resolve to the + // `news` unit, not `en`. + let mut config = make_config(); + config.gam_network_id = "99999".to_string(); + config.section_root = Some("homepage".to_string()); + config.section_segment = Some(1); + let mut slot = make_slot(); + slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); + slot.compile_unit_template() + .expect("template should compile"); - #[test] - fn bids_script_defers_ad_init_until_after_hydration() { - let mut map = serde_json::Map::new(); - map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); - - let script = build_bids_script(&map); - - // adInit() mutates ad-slot subtrees (GPT defineSlot on the - // `-container` wrapper). Running it synchronously at body-parse time - // lands those mutations inside React's hydration window and trips a - // #418 hydration mismatch. The deferral lifecycle (window `load`, - // double `requestAnimationFrame`, generation-0 pinning via - // `tsjs.navGeneration`) lives in the GPT bundle module (with a - // head-injected fallback in gpt_bootstrap.js) where it is executable - // under Vitest (schedule_initial_ad_init.test.ts); this inline - // script must only delegate to that scheduler. - assert!( - script.contains("var s=t.scheduleInitialAdInit"), - "should delegate deferral to the installed scheduler" - ); - // The bids payload is handed to the scheduler (which applies it only - // while the page is still on navigation generation 0) instead of - // being assigned unconditionally, so a faster SPA navigation's live - // bids cannot be clobbered by the stale SSR payload. - assert!( - script.contains("if(typeof s===\"function\")s(b)"), - "should pass the SSR bids payload to the scheduler" - ); - assert!( - script.contains("else t.bids=b"), - "should fall back to a plain bids assignment without a scheduler" - ); - assert!( - !script.contains(".bids=JSON.parse"), - "should not assign the SSR payload unconditionally" + let news = build_browser_slots_v1( + std::slice::from_ref(&slot), + &config, + "/en/news/article-123", ); - // The one hydration-unsafe thing this script could do is invoke - // adInit synchronously at body-parse time — it must not. - assert!( - !script.contains("adInit()"), - "should not invoke adInit synchronously at parse time" + assert_eq!( + news[0].gam_unit_path, "/99999/example/news", + "section should derive from the configured segment index" ); - assert!( - !script.contains("setTimeout"), - "should not retry adInit on a timer" + + let locale_root = build_browser_slots_v1(std::slice::from_ref(&slot), &config, "/en"); + assert_eq!( + locale_root[0].gam_unit_path, "/99999/example/homepage", + "a path with no segment at the configured index should use section_root" ); } @@ -10393,156 +8684,15 @@ mod tests { "should preserve existing EC-derived request id when present" ); } - - #[test] - fn html_escape_encodes_special_chars() { - assert_eq!( - html_escape_for_script("text\\with\\backslash"), - "text\\\\with\\\\backslash", - "should escape backslashes" - ); - assert_eq!( - html_escape_for_script("string\"with\"quotes"), - "string\\\"with\\\"quotes", - "should escape quotes" - ); - assert_eq!( - html_escape_for_script("simple"), - "simple", - "should not change simple text" - ); - assert_eq!( - html_escape_for_script("both\\\"mixed"), - "both\\\\\\\"mixed", - "should escape both backslashes and quotes" - ); - assert_eq!( - html_escape_for_script(""), - "\\u003Cscript\\u003Ealert(1)\\u003C/script\\u003E", - "should unicode-escape angle brackets to prevent script injection" - ); - assert_eq!( - html_escape_for_script("a&b"), - "a\\u0026b", - "should unicode-escape ampersand" - ); - assert_eq!( - html_escape_for_script("line\u{2028}sep"), - "line\\u2028sep", - "should unicode-escape U+2028 line separator" - ); - assert_eq!( - html_escape_for_script("para\u{2029}sep"), - "para\\u2029sep", - "should unicode-escape U+2029 paragraph separator" - ); - } } mod page_bids_no_match_tests { use super::super::*; - use super::build_services_with_http_client; use crate::auction::AuctionOrchestrator; - use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; - use crate::auction::types::{AuctionRequest, AuctionResponse, Bid}; use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; - use crate::platform::test_support::{StubHttpClient, noop_services}; - use crate::platform::{PlatformHttpRequest, PlatformResponse}; + use crate::platform::test_support::noop_services; use crate::test_support::tests::crate_test_settings_str; - use error_stack::{Report, ResultExt}; use http::Method; - use std::sync::{Arc, Mutex}; - - const AUCTION_ID_TEST_PROVIDER: &str = "auction_id_test_provider"; - const AUCTION_ID_TEST_BACKEND: &str = "auction-id-test-backend"; - - struct AuctionIdTestProvider { - captured_request: Arc>>, - winning_bid: bool, - } - - #[async_trait::async_trait(?Send)] - impl AuctionProvider for AuctionIdTestProvider { - fn provider_name(&self) -> &'static str { - AUCTION_ID_TEST_PROVIDER - } - - async fn request_bids( - &self, - request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - *self - .captured_request - .lock() - .expect("should lock captured auction request") = Some(request.clone()); - let request = PlatformHttpRequest::new( - Request::builder() - .method(Method::POST) - .uri("https://bidder.example.test/bids") - .body(EdgeBody::empty()) - .expect("should build test bidder request"), - AUCTION_ID_TEST_BACKEND, - ); - context - .services - .http_client() - .send_async(request) - .await - .map(ProviderRequestOutcome::pending) - .change_context(TrustedServerError::Auction { - message: "test bidder launch failed".to_string(), - }) - } - - async fn parse_response( - &self, - _response: PlatformResponse, - response_time_ms: u64, - ) -> Result> { - let bids = if self.winning_bid { - vec![Bid { - slot_id: "atf".to_string(), - price: Some(1.50), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: AUCTION_ID_TEST_PROVIDER.to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: None, - ad_id: Some("winner-123".to_string()), - creative_id: None, - renderer: None, - cache_id: None, - cache_host: None, - cache_path: None, - metadata: Default::default(), - }] - } else { - Vec::new() - }; - Ok(AuctionResponse::success( - AUCTION_ID_TEST_PROVIDER, - bids, - response_time_ms, - )) - } - - fn timeout_ms(&self) -> u32 { - 100 - } - - fn backend_name( - &self, - _services: &RuntimeServices, - _timeout_ms: u32, - ) -> Option { - Some(AUCTION_ID_TEST_BACKEND.to_string()) - } - } fn settings_with_co() -> Settings { let toml = format!( @@ -10573,7 +8723,8 @@ mod tests { "{}\n[auction]\nenabled = true\n\n[creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", crate_test_settings_str() ); - Settings::from_toml(&toml).expect("should parse settings with disabled templates") + Settings::from_toml(&toml) + .expect("should parse settings with server-side ad templates disabled") } async fn run_page_bids( @@ -10636,15 +8787,11 @@ mod tests { } fn make_page_bids_request(path: &str) -> Request { - make_page_bids_request_on(PAGE_BIDS_PATH, path) - } - - /// Builds a page-bids request against an explicit endpoint path, so the - /// canonical route and its deprecated alias can be compared directly. - fn make_page_bids_request_on(endpoint: &str, path: &str) -> Request { let mut req = Request::builder() .method(Method::GET) - .uri(format!("https://test-publisher.com{endpoint}?path={path}")) + .uri(format!( + "https://test-publisher.com{PAGE_BIDS_PATH}?path={path}" + )) .body(EdgeBody::empty()) .expect("should build test request"); // Pass the same-origin gate the way a browser fetch from the @@ -10695,319 +8842,6 @@ mod tests { .expect("should return ok response") } - fn auction_id_test_orchestrator( - settings: &Settings, - captured_request: Arc>>, - winning_bid: bool, - ) -> AuctionOrchestrator { - let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - orchestrator.register_provider(Arc::new(AuctionIdTestProvider { - captured_request, - winning_bid, - })); - orchestrator - } - - #[tokio::test] - async fn page_bids_response_includes_auction_id_only_for_winning_bids() { - let mut settings = settings_with_co(); - settings.auction.providers = vec![AUCTION_ID_TEST_PROVIDER.to_string()]; - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); - let slots = article_slot(); - let winning_stub = Arc::new(StubHttpClient::new()); - winning_stub.push_response(200, b"winner".to_vec()); - let winning_services = build_services_with_http_client( - Arc::clone(&winning_stub) as Arc - ); - let winning_request = Arc::new(Mutex::new(None)); - let winning_orchestrator = - auction_id_test_orchestrator(&settings, Arc::clone(&winning_request), true); - let ec_context = EcContext::new_for_test( - Some("page-auction-example-123".to_string()), - crate::consent::ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, - ..Default::default() - }, - ); - - let winning_response = handle_page_bids( - &settings, - &winning_services, - None, - AuctionDispatch { - orchestrator: &winning_orchestrator, - slots: &slots, - registry: None, - }, - &ec_context, - make_page_bids_request("/2024/01/my-article/"), - ) - .await - .expect("should return winning page-bids response"); - let winning_body: serde_json::Value = serde_json::from_slice( - &winning_response - .into_body() - .into_bytes() - .expect("should read winning page-bids response body"), - ) - .expect("should serialize winning page-bids response as JSON"); - let auction_request = winning_request - .lock() - .expect("should lock captured winning request") - .clone() - .expect("should dispatch a winning auction request"); - - assert_eq!( - auction_request.id, "ts-page-auction-example-123", - "test EC ID should produce a deterministic auction request ID" - ); - let winning_auction_id = winning_body["bids"]["atf"]["hb_auction_id"] - .as_str() - .expect("page-bids should expose an auction ID on the winner") - .to_string(); - assert!( - winning_auction_id.starts_with("ts-auc-"), - "page-bids should expose a freshly minted diagnostics token, got `{winning_auction_id}`" - ); - assert_ne!( - winning_auction_id, auction_request.id, - "browser-visible auction ID must not be the EC-derived request ID" - ); - assert!( - !winning_auction_id.contains("page-auction-example-123"), - "browser-visible auction ID must not embed the EC ID" - ); - - let no_winner_stub = Arc::new(StubHttpClient::new()); - no_winner_stub.push_response(200, b"no-bid".to_vec()); - let no_winner_services = build_services_with_http_client( - Arc::clone(&no_winner_stub) as Arc - ); - let no_winner_orchestrator = - auction_id_test_orchestrator(&settings, Arc::new(Mutex::new(None)), false); - let no_winner_response = handle_page_bids( - &settings, - &no_winner_services, - None, - AuctionDispatch { - orchestrator: &no_winner_orchestrator, - slots: &slots, - registry: None, - }, - &ec_context, - make_page_bids_request("/2024/01/my-article/"), - ) - .await - .expect("should return no-winner page-bids response"); - let no_winner_body: serde_json::Value = serde_json::from_slice( - &no_winner_response - .into_body() - .into_bytes() - .expect("should read no-winner page-bids response body"), - ) - .expect("should serialize no-winner page-bids response as JSON"); - - assert!( - no_winner_body["bids"] - .as_object() - .expect("page-bids should return a bids object") - .is_empty(), - "page-bids should not fabricate auction metadata without a winner" - ); - } - - /// The browser-visible auction ID is minted per auction and only for - /// deployments that run the diagnostics integration, so it can neither - /// carry EC identity across auctions nor reach pages that ignore it. - #[tokio::test] - async fn page_bids_auction_id_is_per_auction_and_gated_on_diagnostics() { - async fn winning_auction_id(settings: &Settings) -> Option { - let slots = article_slot(); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"winner".to_vec()); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let orchestrator = - auction_id_test_orchestrator(settings, Arc::new(Mutex::new(None)), true); - let ec_context = EcContext::new_for_test( - Some("page-auction-example-123".to_string()), - crate::consent::ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, - ..Default::default() - }, - ); - let response = handle_page_bids( - settings, - &services, - None, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &slots, - registry: None, - }, - &ec_context, - make_page_bids_request("/2024/01/my-article/"), - ) - .await - .expect("should return page-bids response"); - let body: serde_json::Value = serde_json::from_slice( - &response - .into_body() - .into_bytes() - .expect("should read page-bids response body"), - ) - .expect("should serialize page-bids response as JSON"); - body["bids"]["atf"]["hb_auction_id"] - .as_str() - .map(str::to_string) - } - - let mut settings = settings_with_co(); - settings.auction.providers = vec![AUCTION_ID_TEST_PROVIDER.to_string()]; - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); - - let first = winning_auction_id(&settings) - .await - .expect("first auction should expose a diagnostics token"); - let second = winning_auction_id(&settings) - .await - .expect("second auction should expose a diagnostics token"); - assert_ne!( - first, second, - "each auction for the same visitor should mint its own token" - ); - - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": false })) - .expect("should disable diagnostics"); - assert_eq!( - winning_auction_id(&settings).await, - None, - "no auction metadata should reach the page without the diagnostics integration" - ); - } - - /// The deprecated `/__ts/page-bids` alias must be handled identically to - /// the canonical path — same status, same JSON body. - /// - /// The alias exists so pre-rename tsjs bundles keep getting ads on SPA - /// navigations. If the handler ever varied its output by request path - /// (slot matching reads the `path` *query parameter*, not the endpoint - /// path), those clients would silently get different results from the - /// ones on the canonical route. - #[tokio::test] - async fn deprecated_alias_response_matches_canonical_path() { - let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - - let canonical = run_page_bids_response( - &settings, - &orchestrator, - &article_slot(), - make_page_bids_request_on(PAGE_BIDS_PATH, "/2024/01/my-article/"), - ) - .await; - let alias = run_page_bids_response( - &settings, - &orchestrator, - &article_slot(), - make_page_bids_request_on(PAGE_BIDS_LEGACY_PATH, "/2024/01/my-article/"), - ) - .await; - - assert_eq!( - canonical.status(), - alias.status(), - "alias must return the same status as the canonical path" - ); - assert_eq!( - canonical.into_body().into_bytes(), - alias.into_body().into_bytes(), - "alias must return the same body as the canonical path" - ); - } - - /// Traffic on the deprecated alias must be measurable from edge access - /// logs, not just application logs: the removal precondition in - /// IABTechLab/trusted-server#970 is "no remaining traffic on the legacy - /// path", and operators who cannot read app logs need a response-side - /// marker to count. - #[tokio::test] - async fn deprecated_alias_response_is_marked_deprecated() { - let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - - let canonical = run_page_bids_response( - &settings, - &orchestrator, - &article_slot(), - make_page_bids_request_on(PAGE_BIDS_PATH, "/2024/01/my-article/"), - ) - .await; - let alias = run_page_bids_response( - &settings, - &orchestrator, - &article_slot(), - make_page_bids_request_on(PAGE_BIDS_LEGACY_PATH, "/2024/01/my-article/"), - ) - .await; - - assert_eq!( - alias - .headers() - .get(header::LINK) - .and_then(|value| value.to_str().ok()), - Some( - "; rel=\"deprecation\"" - ), - "alias response should carry the RFC 9745 deprecation link relation" - ); - assert!( - !canonical.headers().contains_key(header::LINK), - "canonical path should not be marked deprecated" - ); - } - - /// A deployment without creative opportunities answers page-bids with a - /// 404, but its alias traffic still has to be counted — otherwise a - /// silent legacy signal on such a config reads as "no remaining - /// traffic" when evaluating IABTechLab/trusted-server#970. - #[tokio::test] - async fn deprecated_alias_is_marked_without_creative_opportunities() { - let settings = settings_without_co(); - assert!( - settings.creative_opportunities.is_none(), - "test settings should have no creative opportunities configured" - ); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - - let response = run_page_bids_response( - &settings, - &orchestrator, - &[], - make_page_bids_request_on(PAGE_BIDS_LEGACY_PATH, "/2024/01/my-article/"), - ) - .await; - - assert_eq!( - response.status(), - StatusCode::NOT_FOUND, - "should 404 when creative opportunities are not configured" - ); - assert!( - response.headers().contains_key(header::LINK), - "alias 404 should still be marked deprecated so it is countable" - ); - } - /// The cross-site gate runs before the not-configured 404, so a /// cross-site caller cannot probe whether a deployment has creative /// opportunities configured. @@ -11103,7 +8937,7 @@ mod tests { } #[tokio::test] - async fn empty_slots_file_returns_empty_slots_and_bids() { + async fn empty_slots_file_returns_an_exact_empty_projection() { // Spec §8 kill-switch: creative-opportunities.toml with zero slots disables // all server-side auction activity and injection. let settings = settings_with_co(); @@ -11112,26 +8946,19 @@ mod tests { let body = run_page_bids(&settings, &orchestrator, &[], req).await; + assert_eq!(body["version"], 1); + assert_eq!(body["auction"]["version"], 1); + assert_eq!(body["auction"]["results"], serde_json::json!([])); assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 0, - "empty slots should produce zero injected slots" - ); - assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), + body["bids"].as_array().expect("bids should be array").len(), 0, "empty slots should produce zero bids" ); + assert_eq!(body["slots"], serde_json::json!([])); } #[tokio::test] - async fn bot_user_agent_returns_slots_but_no_bids() { + async fn bot_user_agent_returns_a_terminal_projection_without_bids() { // Crawlers should get slot definitions (so HTML structure is unchanged) // but the server must not burn SSP request quota running a real auction // for them. Same gate the publisher path applies. @@ -11148,25 +8975,22 @@ mod tests { let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 1, - "bot request should still get slot definitions" + body["auction"]["results"][0], + serde_json::json!({ + "slot": "atf", + "outcome": "failed", + "reason": "slot_not_eligible" + }) ); assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), + body["bids"].as_array().expect("bids should be array").len(), 0, "bot request must not run an auction (no SSP cost burned for crawlers)" ); } #[tokio::test] - async fn prefetch_request_returns_slots_but_no_bids() { + async fn prefetch_request_returns_a_terminal_projection_without_bids() { // Navigations triggered by Sec-Purpose=prefetch should not fire real // SSP auctions — the user has not yet visited the page. let settings = settings_with_co(); @@ -11177,19 +9001,9 @@ mod tests { let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; + assert_eq!(body["auction"]["results"][0]["reason"], "slot_not_eligible"); assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 1, - "prefetch request should still get slot definitions" - ); - assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), + body["bids"].as_array().expect("bids should be array").len(), 0, "prefetch request must not run an auction" ); @@ -11222,7 +9036,9 @@ mod tests { set_test_header(&mut req, "sec-purpose", "prefetch"); let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; - let returned_slots = body["slots"].as_array().expect("slots should be array"); + let returned_slots = body["auction"]["results"] + .as_array() + .expect("results should be array"); assert_eq!( returned_slots.len(), @@ -11230,7 +9046,7 @@ mod tests { "should omit only the over-limit dynamic slot" ); assert_eq!( - returned_slots[0]["id"], "valid_static_sibling", + returned_slots[0]["slot"], "valid_static_sibling", "should retain the valid static sibling" ); } @@ -11245,19 +9061,9 @@ mod tests { let body = run_page_bids(&settings, &orchestrator, &slots, req).await; + assert_eq!(body["auction"]["results"], serde_json::json!([])); assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 0, - "non-matching URL should produce zero injected slots" - ); - assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), + body["bids"].as_array().expect("bids should be array").len(), 0, "non-matching URL should produce zero bids" ); @@ -11307,13 +9113,12 @@ mod tests { } #[tokio::test] - async fn disabled_auction_returns_no_slots_or_bids() { + async fn disabled_auction_returns_exact_failed_decisions() { // [auction].enabled = false is a global kill switch: it must disable // the entire server-side ad stack, not just SSP calls. Returning slot - // definitions would let the SPA hook assign `ts.adSlots` and call - // `adInit()`, creating/refreshing GPT slots client-side even though - // the auction is off. Consent is allowed here so the test isolates - // the kill switch. + // definitions would let the hard-cutover browser runtime create or + // refresh GPT slots even though the auction is off. Consent is + // allowed here so the test isolates the kill switch. let settings = settings_with_co_auction_disabled(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let slots = article_slot(); @@ -11321,28 +9126,16 @@ mod tests { let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; + assert_eq!(body["auction"]["results"][0]["reason"], "auction_disabled"); assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 0, - "disabled auction must not return slot definitions (kill switch stops the ad stack)" - ); - assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), + body["bids"].as_array().expect("bids should be array").len(), 0, "disabled auction must not produce bids" ); } #[tokio::test] - async fn disabled_server_side_ad_templates_return_no_slots_or_bids() { - // The dedicated template switch must suppress publisher/page-bids - // delivery without using the global auction switch. + async fn disabled_server_side_ad_templates_return_an_empty_projection() { let settings = settings_with_co_templates_disabled(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let slots = article_slot(); @@ -11350,26 +9143,13 @@ mod tests { let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; - assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 0, - "disabled server-side ad templates must not return slot definitions" - ); - assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), - 0, - "disabled server-side ad templates must not produce bids" - ); + assert_eq!(body["auction"]["results"], serde_json::json!([])); + assert_eq!(body["slots"], serde_json::json!([])); + assert_eq!(body["bids"], serde_json::json!([])); } #[tokio::test] - async fn consent_denied_returns_no_slots_or_bids() { + async fn consent_denied_returns_exact_failed_decisions() { // When consent denies the server-side auction (here: Jurisdiction // Unknown fails closed), the endpoint must return no slots so the SPA // hook does not create GPT slots client-side — matching the publisher @@ -11383,19 +9163,9 @@ mod tests { // Jurisdiction::Unknown (consent denied). let body = run_page_bids(&settings, &orchestrator, &slots, req).await; + assert_eq!(body["auction"]["results"][0]["reason"], "consent_denied"); assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 0, - "consent denial must suppress slot definitions" - ); - assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), + body["bids"].as_array().expect("bids should be array").len(), 0, "consent denial must produce no bids" ); @@ -11569,9 +9339,6 @@ mod tests { over_limit.id = "over_limit_dynamic".to_string(); over_limit.page_patterns = vec!["/*".to_string()]; over_limit.gam_unit_path = Some("/{section}/{section}".to_string()); - over_limit - .compile_unit_template() - .expect("should compile dynamic GAM unit template"); let mut valid_static = article_slot() .into_iter() @@ -11593,11 +9360,7 @@ mod tests { .clone() .expect("should dispatch an auction request"); let slot_ids: Vec<_> = request.slots.iter().map(|slot| slot.id.as_str()).collect(); - assert_eq!( - slot_ids, - ["valid_static_sibling"], - "auction request should exclude the over-limit dynamic slot" - ); + assert_eq!(slot_ids, vec!["valid_static_sibling"]); } /// [`EcContext`] whose consent context permits the server-side auction. diff --git a/crates/trusted-server-core/src/trace_cookie.rs b/crates/trusted-server-core/src/trace_cookie.rs index 583a96238..fdbc60c4c 100644 --- a/crates/trusted-server-core/src/trace_cookie.rs +++ b/crates/trusted-server-core/src/trace_cookie.rs @@ -13,7 +13,7 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use http::{HeaderValue, Response, StatusCode, header}; +use http::{HeaderValue, Request, Response, StatusCode, header}; use crate::constants::COOKIE_TS_TRACE; use crate::error::TrustedServerError; @@ -25,6 +25,32 @@ use crate::settings::Settings; /// navigations, short enough that a forgotten toggle expires on its own. const TRACE_COOKIE_MAX_AGE_SECS: u32 = 3600; +/// Resolve the server-owned render-trace overlay bit for `DiagnosticsBootV1`. +/// +/// Only the exact cookie emitted by [`handle_trace_mode`] activates the overlay. +/// Duplicate reserved cookies fail closed so request header ordering cannot +/// choose the browser-visible diagnostics state. +#[must_use] +pub fn render_trace_overlay_active(request: &Request) -> bool { + let mut occurrences = 0_usize; + let mut active = false; + for value in request.headers().get_all(header::COOKIE) { + let Ok(value) = value.to_str() else { + return false; + }; + for cookie in value.split(';').map(str::trim) { + let Some((name, value)) = cookie.split_once('=') else { + continue; + }; + if name == COOKIE_TS_TRACE { + occurrences += 1; + active = value == "1"; + } + } + } + occurrences == 1 && active +} + /// Formats the trace cookie `Set-Cookie` header value. /// /// Deliberately host-only (no `Domain` attribute): a `Domain` scoped to @@ -109,6 +135,7 @@ pub fn handle_trace_mode( mod tests { use super::*; use crate::test_support::tests::create_test_settings; + use http::{Request, header}; fn trace_enabled_settings() -> Settings { let mut settings = create_test_settings(); @@ -215,4 +242,34 @@ mod tests { "disabled trace route should not set a cookie" ); } + + #[test] + fn trace_cookie_boot_resolver_accepts_only_one_exact_server_cookie() { + for (cookie, expected) in [ + (None, false), + (Some("ts-trace=1"), true), + (Some("other=value; ts-trace=1"), true), + (Some("ts-trace=0"), false), + (Some("ts-trace=true"), false), + (Some("ts-trace =1"), false), + (Some("ts-trace= 1"), false), + (Some("ts-trace=1; ts-trace=1"), false), + ] { + let mut builder = Request::builder() + .method("GET") + .uri("https://publisher.example/article"); + if let Some(cookie) = cookie { + builder = builder.header(header::COOKIE, cookie); + } + let request = builder + .body(EdgeBody::empty()) + .expect("should build trace-cookie request"); + + assert_eq!( + render_trace_overlay_active(&request), + expected, + "unexpected boot resolution for {cookie:?}" + ); + } + } } diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 133e6d011..68a1bbd66 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -1,4 +1,334 @@ -use trusted_server_js::{all_module_ids, concatenated_hash, single_module_hash}; +use std::collections::HashSet; + +use error_stack::Report; +use trusted_server_js::{ + TsjsModulePhase, all_integration_metadata, all_module_ids, concatenated_hash, release_id, + single_module_hash, +}; + +use crate::error::TrustedServerError; + +/// Serialize one exact `BootManifestV1` without publishing it into HTML. +/// +/// `module_ids` contains enabled integration bundles in actual injection order; +/// core is implicit and therefore rejected here. Unknown, duplicate, malformed, +/// or over-capacity inventories fail closed. +/// +/// # Errors +/// +/// Returns an error when the integration inventory exceeds the bounded capacity, +/// contains an invalid module ID, or cannot be serialized. +pub fn tsjs_boot_manifest_v1(module_ids: &[&str]) -> Result> { + if module_ids.len() > 16 { + return Err(boot_manifest_error("more than 16 integration modules")); + } + let known = all_module_ids().into_iter().collect::>(); + let mut seen = HashSet::new(); + let mut integrations = Vec::with_capacity(module_ids.len()); + for id in module_ids { + if *id == "core" || !valid_integration_id(id) || !known.contains(id) || !seen.insert(*id) { + return Err(boot_manifest_error("invalid integration inventory")); + } + let encoded = serde_json::to_string(id) + .map_err(|_| boot_manifest_error("integration id serialization failed"))?; + integrations.push(format!(r#"{{"id":{encoded},"required":true}}"#)); + } + Ok(format!( + r#"{{"version":1,"releaseId":"{}","integrations":[{}]}}"#, + release_id(), + integrations.join(",") + )) +} + +/// Serialize a dormant phase-aware `BootManifestV1` without publishing it. +/// +/// This remains separate from the legacy production manifest serializer. It lets +/// test-only prospective routes use generated release metadata before cutover. +/// +/// # Errors +/// +/// Returns an error when the requested inventory is invalid, omits an earlier +/// required capability provider, or cannot be serialized. +pub fn prospective_tsjs_boot_manifest_v1( + module_ids: &[&str], +) -> Result> { + let selected = prospective_selected_metadata(module_ids)?; + let critical_ids = selected + .iter() + .filter_map(|metadata| { + (metadata.phase == Some(TsjsModulePhase::Critical)).then_some(metadata.id) + }) + .collect::>(); + let mut provided = HashSet::from(["runtime.v1"]); + let mut integrations = Vec::with_capacity(selected.len()); + + for metadata in selected { + for dependency in metadata.inputs { + if dependency.contains('?') { + continue; + } + if !provided.contains(*dependency) { + return Err(boot_manifest_error( + "selected catalog inventory omits a required capability provider", + )); + } + } + + let id = serde_json::to_string(metadata.id) + .map_err(|_| boot_manifest_error("integration id serialization failed"))?; + match metadata.phase { + Some(TsjsModulePhase::Critical) => { + integrations.push(format!(r#"{{"id":{id},"phase":"critical"}}"#)); + } + Some(TsjsModulePhase::Deferred) => { + let trigger = metadata.trigger.ok_or_else(|| { + boot_manifest_error("deferred catalog trigger is unavailable") + })?; + let hash = single_module_hash(metadata.id) + .ok_or_else(|| boot_manifest_error("deferred module hash is unavailable"))?; + let trigger = serde_json::to_string(trigger) + .map_err(|_| boot_manifest_error("deferred trigger serialization failed"))?; + let src = serde_json::to_string(&format!( + "/static/tsjs=tsjs-{}.min.js?v={hash}", + metadata.id + )) + .map_err(|_| boot_manifest_error("deferred source serialization failed"))?; + integrations.push(format!( + r#"{{"id":{id},"phase":"deferred","trigger":{trigger},"src":{src}}}"# + )); + } + None => { + return Err(boot_manifest_error( + "catalog integration phase is unavailable", + )); + } + } + + for capability in metadata.outputs { + provided.insert(*capability); + } + } + + Ok(format!( + r#"{{"version":1,"releaseId":"{}","criticalSrc":"/static/tsjs=tsjs-unified.min.js?v={}","integrations":[{}]}}"#, + release_id(), + concatenated_hash(&critical_ids), + integrations.join(",") + )) +} + +/// Serialize a dormant phase-aware controller fragment and one critical script tag. +/// +/// HTML processing and production routes intentionally do not call this helper. +/// Browser fixtures use it to exercise the prospective controller contract. +/// +/// # Errors +/// +/// Returns an error for an invalid manifest, projection, or boot bits that +/// disagree with prospective catalog membership. +pub fn prospective_tsjs_boot_controller_fragment_v1( + config: TsjsBootScriptConfigV1<'_>, + publisher_origin: &str, +) -> Result> { + let manifest = prospective_tsjs_boot_manifest_v1(config.module_ids)?; + let projection = crate::auction::formats::coordinated_cutover_v1::canonicalize_browser_auction_projection_json_v1( + config.auction_projection_json, + publisher_origin, + ) + .map_err(|_| boot_manifest_error("auction projection violates the version-1 contract"))?; + + let selected = prospective_selected_metadata(config.module_ids)?; + let contains = |id: &str| selected.iter().any(|metadata| metadata.id == id); + let creative_required = + config.creative.enabled && (config.creative.click_guard || config.creative.render_guard); + if contains("creative") != creative_required + || (!config.creative.enabled + && (config.creative.click_guard || config.creative.render_guard)) + { + return Err(boot_manifest_error( + "creative boot bits disagree with prospective manifest membership", + )); + } + if contains("gpt_diagnostics") != config.gpt_diagnostics_active { + return Err(boot_manifest_error( + "GPT diagnostics boot bit disagrees with prospective manifest membership", + )); + } + if contains("diagnostics_presentation") + != (config.render_trace_overlay || config.gpt_diagnostics_active) + { + return Err(boot_manifest_error( + "diagnostics presentation membership disagrees with prospective boot bits", + )); + } + + let critical_ids = selected + .iter() + .filter_map(|metadata| { + (metadata.phase == Some(TsjsModulePhase::Critical)).then_some(metadata.id) + }) + .collect::>(); + let critical_src = tsjs_script_src(&critical_ids); + let manifest = escape_json_for_inline_script(&manifest); + let projection = escape_json_for_inline_script(&projection); + let controller = format!( + "", + release_id(), + manifest, + projection, + config.creative.enabled, + config.creative.click_guard, + config.creative.render_guard, + config.render_trace_overlay, + config.gpt_diagnostics_active, + ); + Ok(format!( + r#"{controller}"# + )) +} + +fn prospective_selected_metadata( + module_ids: &[&str], +) -> Result, Report> { + if module_ids.len() > trusted_server_js::MAX_MANIFEST_MODULES { + return Err(boot_manifest_error("more than 20 integration modules")); + } + let requested = module_ids.iter().copied().collect::>(); + if requested.len() != module_ids.len() + || requested + .iter() + .any(|id| *id == "core" || !valid_integration_id(id)) + { + return Err(boot_manifest_error( + "invalid prospective integration inventory", + )); + } + + let metadata = all_integration_metadata(); + if requested + .iter() + .any(|id| !metadata.iter().any(|entry| entry.id == *id)) + { + return Err(boot_manifest_error( + "invalid prospective integration inventory", + )); + } + if metadata + .iter() + .filter(|entry| entry.include == Some("always")) + .any(|entry| !requested.contains(entry.id)) + { + return Err(boot_manifest_error( + "prospective integration inventory omits an always-on catalog member", + )); + } + Ok(metadata + .into_iter() + .filter(|entry| requested.contains(entry.id)) + .collect()) +} + +/// Exact immutable creative boot bits emitted for one document generation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CreativeBootConfigV1 { + /// Whether the creative integration is a required manifest member. + pub enabled: bool, + /// Whether automatic click interception activates after kernel commit. + pub click_guard: bool, + /// Whether automatic render interception activates after kernel commit. + pub render_guard: bool, +} + +/// Inputs for the one hard-cutover browser boot transport. +#[derive(Clone, Copy, Debug)] +pub struct TsjsBootScriptConfigV1<'a> { + /// Enabled integration bundles in their actual injection order. + pub module_ids: &'a [&'a str], + /// Canonical exact [`BrowserAuctionProjectionV1`](crate::auction::types::BrowserAuctionProjectionV1) + /// JSON produced by the auction projection boundary. + pub auction_projection_json: &'a str, + /// Exact creative integration boot configuration. + pub creative: CreativeBootConfigV1, + /// Whether the local render-trace overlay is active for this document. + pub render_trace_overlay: bool, + /// Whether request/session-scoped GPT diagnostics is active. + pub gpt_diagnostics_active: bool, +} + +/// Serialize the sole pre-core `TsjsBootV1` assignment and bids-ready mark. +/// +/// The returned inline script keeps the publisher-created `window.tsjs` object, +/// writes only the exact boot transport, and escapes every HTML-significant JSON +/// character before insertion into a script element. +/// +/// # Errors +/// +/// Returns an error for an invalid manifest, non-object projection JSON, or a +/// creative/diagnostics enabled bit that disagrees with manifest membership. +pub fn tsjs_boot_script_v1( + config: TsjsBootScriptConfigV1<'_>, +) -> Result> { + let manifest = tsjs_boot_manifest_v1(config.module_ids)?; + let projection = serde_json::from_str::(config.auction_projection_json) + .map_err(|_| boot_manifest_error("auction projection is not valid JSON"))?; + if !projection.is_object() { + return Err(boot_manifest_error("auction projection must be an object")); + } + + let creative_in_manifest = config.module_ids.contains(&"creative"); + if creative_in_manifest != config.creative.enabled + || (!config.creative.enabled + && (config.creative.click_guard || config.creative.render_guard)) + { + return Err(boot_manifest_error( + "creative boot bits disagree with manifest membership", + )); + } + let diagnostics_in_manifest = config.module_ids.contains(&"gpt_diagnostics"); + if diagnostics_in_manifest != config.gpt_diagnostics_active { + return Err(boot_manifest_error( + "GPT diagnostics boot bit disagrees with manifest membership", + )); + } + + let manifest = escape_json_for_inline_script(&manifest); + let projection = escape_json_for_inline_script(config.auction_projection_json); + Ok(format!( + "", + release_id(), + manifest, + projection, + config.creative.enabled, + config.creative.click_guard, + config.creative.render_guard, + config.render_trace_overlay, + config.gpt_diagnostics_active, + )) +} + +fn escape_json_for_inline_script(json: &str) -> String { + json.replace('&', "\\u0026") + .replace('<', "\\u003c") + .replace('>', "\\u003e") + .replace('\u{2028}', "\\u2028") + .replace('\u{2029}', "\\u2029") +} + +fn valid_integration_id(id: &str) -> bool { + let bytes = id.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 64 + && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) + && bytes.iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_' || *byte == b'-' + }) +} + +fn boot_manifest_error(message: &str) -> Report { + Report::new(TrustedServerError::Configuration { + message: format!("TSJS boot manifest: {message}"), + }) +} /// `/static` URL for the tsjs bundle with cache-busting hash based on /// the concatenated content of the given module set. @@ -73,8 +403,65 @@ pub fn tsjs_deferred_script_tags(module_ids: &[&str]) -> String { #[cfg(test)] mod tests { + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; + use super::*; + const VALID_BROWSER_AUCTION_PROJECTION_JSON: &str = r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"slots":[],"bids":[]}"#; + const PERFORMANCE_ORIGIN: &str = "https://performance.example"; + + fn prospective_aps_projection(creative_url: &str) -> String { + use crate::auction::types::{ + ApsRendererV1, ApsTagType, AuctionDecisionSetV1, BidRenderSourceV1, + BrowserAuctionBidV1, BrowserAuctionProjectionV1, BrowserAuctionSlotV1, + SlotAuctionDecisionV1, + }; + + let envelope = BASE64_STANDARD.encode(include_str!( + "../../trusted-server-js/lib/test/fixtures/aps-renderer-v1.json" + )); + serde_json::to_string(&BrowserAuctionProjectionV1 { + version: 1, + auction: AuctionDecisionSetV1 { + version: 1, + auction_id: "performance-initial".to_string(), + results: vec![SlotAuctionDecisionV1::Winner { + slot: "perf-slot".to_string(), + candidate_id: "AAAAAAAAAAAA".to_string(), + }], + }, + slots: vec![BrowserAuctionSlotV1 { + slot: "perf-slot".to_string(), + gam_unit_path: "/123/performance".to_string(), + div_id: "perf-slot".to_string(), + formats: vec![[300, 250]], + targeting: Default::default(), + }], + bids: vec![BrowserAuctionBidV1 { + candidate_id: "AAAAAAAAAAAA".to_string(), + slot: "perf-slot".to_string(), + provider: "aps".to_string(), + upstream_bid_id: "fictional-selected-bid-id".to_string(), + cpm: 1.23, + currency: "USD".to_string(), + targeting: Default::default(), + renderer_reservation_id: "r1_aaaaaaaaaaaaaaaaaaaaaa".to_string(), + render_source: BidRenderSourceV1::Aps(ApsRendererV1 { + version: 1, + account_id: "example-account-id".to_string(), + bid_id: "fictional-selected-bid-id".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: creative_url.to_string(), + aax_response: envelope, + width: 300, + height: 250, + }), + }], + }) + .expect("should serialize a canonical APS projection") + } + fn hash_query_value(src: &str) -> &str { src.split_once("?v=") .map(|(_, hash)| hash) @@ -89,6 +476,309 @@ mod tests { ); } + #[test] + fn release_id_is_shared_by_generated_metadata_and_every_bundle() { + let release = release_id(); + + assert_eq!(release.len(), 64, "should be one SHA-256 release id"); + assert!( + release + .chars() + .all(|character| character.is_ascii_digit() || ('a'..='f').contains(&character)), + "should use lowercase hexadecimal" + ); + for id in all_module_ids() { + let bundle = trusted_server_js::module_bundle(id).expect("should include known module"); + assert_eq!( + bundle.matches(release).count(), + 1, + "module {id} should carry the shared release id exactly once" + ); + } + } + + #[test] + fn boot_manifest_serializer_preserves_enabled_injection_order() { + let value = tsjs_boot_manifest_v1(&["prebid", "creative"]) + .expect("should serialize known unique integrations"); + + assert_eq!( + value, + format!( + "{{\"version\":1,\"releaseId\":\"{}\",\"integrations\":[{{\"id\":\"prebid\",\"required\":true}},{{\"id\":\"creative\",\"required\":true}}]}}", + release_id() + ), + "should emit the exact BootManifestV1 field and integration order" + ); + } + + #[test] + fn boot_manifest_serializer_rejects_duplicate_unknown_and_core_ids() { + for ids in [ + &["creative", "creative"][..], + &["unknown"] as &[&str], + &["core"] as &[&str], + ] { + assert!(tsjs_boot_manifest_v1(ids).is_err(), "should reject {ids:?}"); + } + } + + #[test] + fn prospective_manifest_serializes_generated_phase_order_and_deferred_sources() { + let manifest = prospective_tsjs_boot_manifest_v1(&[ + "gpt_later", + "gpt", + "diagnostics_presentation", + "render_runtime", + ]) + .expect("should serialize a dependency-complete catalog selection"); + + assert_eq!( + manifest, + format!( + concat!( + r#"{{"version":1,"releaseId":"{}","criticalSrc":"/static/tsjs=tsjs-unified.min.js?v={}","integrations":["#, + r#"{{"id":"render_runtime","phase":"critical"}},"#, + r#"{{"id":"gpt","phase":"critical"}},"#, + r#"{{"id":"diagnostics_presentation","phase":"deferred","trigger":"first_display_or_idle","src":"/static/tsjs=tsjs-diagnostics_presentation.min.js?v={}"}},"#, + r#"{{"id":"gpt_later","phase":"deferred","trigger":"first_display_or_idle","src":"/static/tsjs=tsjs-gpt_later.min.js?v={}"}}]}}"# + ), + release_id(), + concatenated_hash(&["render_runtime", "gpt"]), + single_module_hash("diagnostics_presentation") + .expect("should hash generated diagnostics presentation"), + single_module_hash("gpt_later").expect("should hash generated GPT lifecycle") + ), + "should canonicalize the requested selection to generated catalog order" + ); + } + + #[test] + fn prospective_manifest_rejects_missing_required_catalog_capability() { + assert!( + prospective_tsjs_boot_manifest_v1(&["render_runtime", "gpt_later"]).is_err(), + "gpt_later requires the earlier GPT provider" + ); + } + + #[test] + fn prospective_manifest_requires_generated_always_catalog_members() { + assert!( + prospective_tsjs_boot_manifest_v1(&["creative"]).is_err(), + "the generated always-on render runtime cannot be omitted" + ); + } + + #[test] + fn prospective_controller_rejects_noncanonical_or_oversized_browser_projections() { + let noncanonical = r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"slots":[],"bids":[],"unexpected":true}"#; + let padding = " ".repeat(crate::auction::types::MAX_BROWSER_AUCTION_PROJECTION_BYTES + 1); + let oversized = format!("{VALID_BROWSER_AUCTION_PROJECTION_JSON}{padding}"); + + for projection in [noncanonical, oversized.as_str()] { + assert!( + prospective_tsjs_boot_controller_fragment_v1( + TsjsBootScriptConfigV1 { + module_ids: &["render_runtime"], + auction_projection_json: projection, + creative: CreativeBootConfigV1 { + enabled: false, + click_guard: false, + render_guard: false, + }, + render_trace_overlay: false, + gpt_diagnostics_active: false, + }, + PERFORMANCE_ORIGIN + ) + .is_err(), + "the prospective controller must reject {projection:?} before emission" + ); + } + } + + #[test] + fn prospective_controller_validates_aps_creative_urls_against_its_publisher_origin() { + let same_origin = prospective_aps_projection("https://performance.example/creative"); + let foreign_origin = prospective_aps_projection("https://creative.example/render"); + let config = |auction_projection_json| TsjsBootScriptConfigV1 { + module_ids: &["render_runtime"], + auction_projection_json, + creative: CreativeBootConfigV1 { + enabled: false, + click_guard: false, + render_guard: false, + }, + render_trace_overlay: false, + gpt_diagnostics_active: false, + }; + + assert!( + prospective_tsjs_boot_controller_fragment_v1(config(&same_origin), PERFORMANCE_ORIGIN) + .is_err(), + "the publisher origin must reject an APS creative URL on the same origin" + ); + assert!( + prospective_tsjs_boot_controller_fragment_v1( + config(&foreign_origin), + PERFORMANCE_ORIGIN + ) + .is_ok(), + "a valid foreign HTTPS APS creative URL should remain accepted" + ); + } + + #[test] + fn prospective_controller_requires_creative_membership_to_match_enabled_guards() { + for (module_ids, creative) in [ + ( + &["render_runtime", "creative"][..], + CreativeBootConfigV1 { + enabled: true, + click_guard: false, + render_guard: false, + }, + ), + ( + &["render_runtime"][..], + CreativeBootConfigV1 { + enabled: false, + click_guard: true, + render_guard: false, + }, + ), + ] { + assert!( + prospective_tsjs_boot_controller_fragment_v1( + TsjsBootScriptConfigV1 { + module_ids, + auction_projection_json: VALID_BROWSER_AUCTION_PROJECTION_JSON, + creative, + render_trace_overlay: false, + gpt_diagnostics_active: false, + }, + PERFORMANCE_ORIGIN + ) + .is_err(), + "creative membership must match enabled and at least one guard" + ); + } + } + + #[test] + fn prospective_controller_keeps_deferred_modules_out_of_html_script_tags() { + let controller = prospective_tsjs_boot_controller_fragment_v1( + TsjsBootScriptConfigV1 { + module_ids: &[ + "render_runtime", + "gpt", + "diagnostics_presentation", + "gpt_later", + ], + auction_projection_json: VALID_BROWSER_AUCTION_PROJECTION_JSON, + creative: CreativeBootConfigV1 { + enabled: false, + click_guard: false, + render_guard: false, + }, + render_trace_overlay: true, + gpt_diagnostics_active: false, + }, + PERFORMANCE_ORIGIN, + ) + .expect("should serialize the dormant phase-aware controller"); + + assert!( + controller.contains(&format!( + r#""criticalSrc":"/static/tsjs=tsjs-unified.min.js?v={}""#, + concatenated_hash(&["render_runtime", "gpt"]) + )), + "should carry the exact critical artifact source in BootManifestV1" + ); + assert!( + controller.ends_with(&format!( + r#""#, + concatenated_hash(&["render_runtime", "gpt"]) + )), + "should emit exactly one critical script tag" + ); + assert!( + controller.matches("(function(){var t=window.tsjs=window.tsjs||{};")); + assert!(script.contains(&format!( + r#"t.boot={{"abi":1,"releaseId":"{}","manifest":{{"version":1,"releaseId":"{}","integrations":[{{"id":"creative","required":true}},{{"id":"gpt","required":true}},{{"id":"gpt_diagnostics","required":true}}]}},"auctionProjection":{{"version":1,"auction":{{"version":1,"auctionId":"initial","results":[]}},"slots":[],"bids":[]}},"creative":{{"version":1,"enabled":true,"clickGuard":true,"renderGuard":false}},"diagnostics":{{"version":1,"renderTraceOverlay":true,"gpt":{{"active":true}}}}}};"#, + release_id(), + release_id() + ))); + assert_eq!(script.matches("tsjs:bids-script").count(), 1); + assert!(!script.contains("__tsjs")); + assert!(script.ends_with("})();")); + } + + #[test] + fn boot_script_rejects_manifest_diagnostics_mismatch_and_escapes_projection_markup() { + let mismatched = tsjs_boot_script_v1(TsjsBootScriptConfigV1 { + module_ids: &["creative"], + auction_projection_json: r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"slots":[],"bids":[]}"#, + creative: CreativeBootConfigV1 { + enabled: true, + click_guard: true, + render_guard: false, + }, + render_trace_overlay: false, + gpt_diagnostics_active: true, + }); + assert!( + mismatched.is_err(), + "should reject an active diagnostics bit without its module" + ); + + let script = tsjs_boot_script_v1(TsjsBootScriptConfigV1 { + module_ids: &["creative"], + auction_projection_json: + r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"slots":[],"bids":[],"probe":""); + + assert!(!inner.contains('<')); + assert!(!inner.contains('>')); + assert!(!inner.contains('&')); + assert!(inner.contains(r#"\u003c/ScRiPt\u003e\u003cscript\u003e\u0026\u2028"#)); + } + #[test] fn tsjs_script_src_formats_unified_bundle_url_with_hash() { let src = tsjs_script_src(&["creative"]); @@ -147,7 +837,7 @@ mod tests { #[test] fn tsjs_script_src_is_stable_for_identical_module_ids() { - let module_ids = ["core", "lockr", "permutive"]; + let module_ids = ["core", "lockr", "permutive_context"]; let src = tsjs_script_src(&module_ids); assert_sha256_hex_hash(hash_query_value(&src)); @@ -265,7 +955,7 @@ mod tests { fn tsjs_script_src_differs_for_different_module_sets() { assert_ne!( tsjs_script_src(&["lockr"]), - tsjs_script_src(&["lockr", "permutive"]), + tsjs_script_src(&["lockr", "permutive_context"]), "should bust the cache when the module set content changes" ); } diff --git a/crates/trusted-server-integration-tests/Cargo.toml b/crates/trusted-server-integration-tests/Cargo.toml index f2319fec8..773b55bdf 100644 --- a/crates/trusted-server-integration-tests/Cargo.toml +++ b/crates/trusted-server-integration-tests/Cargo.toml @@ -17,6 +17,15 @@ name = "parity" path = "tests/parity.rs" harness = true +[[test]] +name = "aps_runner_proxy" +path = "tests/aps_runner_proxy.rs" +harness = true +required-features = ["aps-runner-proxy"] + +[features] +aps-runner-proxy = [] + [lints] workspace = true @@ -39,10 +48,11 @@ log = { workspace = true } reqwest = { workspace = true, features = ["blocking", "cookies"] } scraper = { workspace = true } testcontainers = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } toml = { workspace = true } tower = { workspace = true, features = ["util"] } -trusted-server-adapter-axum = { path = "../trusted-server-adapter-axum" } -trusted-server-adapter-cloudflare = { path = "../trusted-server-adapter-cloudflare" } -trusted-server-adapter-spin = { path = "../trusted-server-adapter-spin" } +trusted-server-adapter-axum = { path = "../trusted-server-adapter-axum", features = ["aps-runner-proxy-integration-test"] } +trusted-server-adapter-cloudflare = { path = "../trusted-server-adapter-cloudflare", features = ["aps-runner-proxy-integration-test"] } +trusted-server-adapter-spin = { path = "../trusted-server-adapter-spin", features = ["aps-runner-proxy-integration-test"] } urlencoding = { workspace = true } diff --git a/crates/trusted-server-integration-tests/README.md b/crates/trusted-server-integration-tests/README.md index e82cb8837..e5b6cc9fd 100644 --- a/crates/trusted-server-integration-tests/README.md +++ b/crates/trusted-server-integration-tests/README.md @@ -7,7 +7,7 @@ containers using [Testcontainers](https://testcontainers.com/) and ## Prerequisites - **Docker** — running and accessible -- **Viceroy** — Fastly local simulator (`cargo install viceroy --version 0.17.0 --locked --force`) +- **Viceroy** — Fastly local simulator (`cargo install viceroy --version 0.19.0 --locked --force`) - **wasm32-wasip1 target** — `rustup target add wasm32-wasip1` - **Node.js** — version pinned in `.tool-versions`, for browser tests only diff --git a/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js b/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js new file mode 100644 index 000000000..900aee7eb --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js @@ -0,0 +1,57 @@ +// Fictional hermetic APS runner fixture. +// This implements only Trusted Server's documented queue/callback test shape. +// It is not copied, transformed, or derived from APS runner bytes. +(function () { + "use strict"; + + if (!(window._aps instanceof Map)) return; + window._aps.forEach(function (account) { + if (!account || !Array.isArray(account.queue)) return; + account.queue.splice(0).forEach(function (event) { + var detail = event && event.detail; + var keys = detail && Object.getOwnPropertyNames(detail).sort(); + if ( + !detail || + JSON.stringify(keys) !== + JSON.stringify([ + "aaxResponse", + "reject", + "resolve", + "seatBidId", + "source", + ]) || + detail.source !== "internal" || + typeof detail.resolve !== "function" || + typeof detail.reject !== "function" + ) { + if (detail && typeof detail.reject === "function") { + detail.reject(new Error("fictional_detail_invalid")); + } + return; + } + + var bidId = detail.seatBidId; + if (bidId.indexOf("silent-") === 0) return; + if (bidId.indexOf("reject-") === 0) { + detail.reject(new Error("fictional_rejection")); + return; + } + if (bidId.indexOf("nested-") === 0) { + var frame = document.createElement("iframe"); + frame.setAttribute("sandbox", "allow-scripts"); + frame.srcdoc = + "`; -} - -const FAKE_RUNNER = `(function(){ - var runnerRead = false; - var runnerWrite = false; - try { void top.document.body; runnerRead = true; } catch (_error) {} - try { top.document.body.dataset.apsCompromised = 'runner'; runnerWrite = true; } catch (_error) {} - parent.postMessage({ - message: 'fictional-runner-security', - runnerRead: runnerRead, - runnerWrite: runnerWrite, - accountMap: window._aps instanceof Map - }, '*'); - - addEventListener('message', function(event) { - if (event.data && event.data.message === 'fictional-creative-security') { - parent.postMessage(event.data, '*'); - } - }); - - window._aps.forEach(function(account) { - var events = account.queue.splice(0); - events.forEach(function(event) { - var response = JSON.parse(atob(event.detail.aaxResponse)); - var bid = response.seatbid[0].bid[0]; - if (bid.ext.tagtype === 'iframe') { - var frame = document.createElement('iframe'); - frame.width = String(bid.w); - frame.height = String(bid.h); - frame.style.border = '0'; - frame.style.display = 'block'; - frame.setAttribute('sandbox', 'allow-scripts allow-same-origin'); - frame.src = bid.ext.creativeurl; - document.body.appendChild(frame); - } else { - var script = document.createElement('script'); - script.src = bid.ext.creativeurl; - document.head.appendChild(script); - } - }); - }); -})();`; - -const IFRAME_CREATIVE = ` - -`, - }), - ); - await page.route(RUNNER_URL, async (route) => { - runnerRequests += 1; - await route.fulfill({ - status: 200, - contentType: "application/javascript", - body: FAKE_RUNNER, - }); - }); - await page.route(IFRAME_CREATIVE_URL, (route) => { - creativeRequests += 1; - return route.fulfill({ - status: 200, - contentType: "text/html", - body: IFRAME_CREATIVE, - }); - }); - - await page.goto(runtimeUrl("/aps-puc-topology-test")); - await page.addScriptTag({ path: clientAuctionBundlePaths().gpt }); - await page.evaluate( - ({ apsRenderer: renderer, outerUrl }) => { - const typedWindow = window as unknown as { - tsjs: Record; - pucEvents: Array>; - }; - typedWindow.tsjs = { - bids: { - "aps-slot": { - hb_adid: renderer.bidId, - hb_bidder: "aps", - hb_pb: "1.23", - renderer, - }, - }, - 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 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"; - // Loading only the GPT bridge keeps `pbjs` absent, forcing PUC's - // dynamic-renderer branch and its hidden sibling frame topology. - frame.src = outerUrl; - slot.appendChild(frame); - - 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); - }, - { apsRenderer, outerUrl: outerCreativeUrl }, - ); - - await expect.poll(() => runnerRequests).toBe(1); - 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); - const outerPucFrame = page - .frames() - .find((frame) => frame.url() === outerCreativeUrl); - expect(outerPucFrame).toBeDefined(); - await expect - .poll(() => - outerPucFrame!.evaluate(() => ({ - clientWidth: document.documentElement.clientWidth, - clientHeight: document.documentElement.clientHeight, - scrollWidth: document.documentElement.scrollWidth, - scrollHeight: document.documentElement.scrollHeight, - })), - ) - .toEqual({ - clientWidth: 300, - clientHeight: 250, - scrollWidth: 300, - scrollHeight: 250, - }); - const rendererFrame = page - .frames() - .find( - (frame) => - new URL(frame.url()).pathname === - "/integrations/aps/renderer", - ); - expect(rendererFrame).toBeDefined(); - await expect - .poll(() => - rendererFrame!.evaluate(() => ({ - bodyMargin: getComputedStyle(document.body).margin, - bodyPadding: getComputedStyle(document.body).padding, - bodyOverflow: getComputedStyle(document.body).overflow, - rootOverflow: getComputedStyle(document.documentElement) - .overflow, - clientWidth: document.documentElement.clientWidth, - clientHeight: document.documentElement.clientHeight, - scrollWidth: document.documentElement.scrollWidth, - scrollHeight: document.documentElement.scrollHeight, - })), - ) - .toEqual({ - bodyMargin: "0px", - bodyPadding: "0px", - bodyOverflow: "hidden", - rootOverflow: "hidden", - clientWidth: 300, - clientHeight: 250, - scrollWidth: 300, - scrollHeight: 250, - }); - const creativeFrame = page - .frames() - .find((frame) => frame.url() === IFRAME_CREATIVE_URL); - expect(creativeFrame).toBeDefined(); - await expect - .poll(() => - creativeFrame!.evaluate(() => ({ - backgroundColor: getComputedStyle(document.body) - .backgroundColor, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - scrollWidth: Math.max( - document.body.scrollWidth, - document.documentElement.scrollWidth, - ), - scrollHeight: Math.max( - document.body.scrollHeight, - document.documentElement.scrollHeight, - ), - })), - ) - .toEqual({ - backgroundColor: "rgb(25, 135, 84)", - viewportWidth: 300, - viewportHeight: 250, - scrollWidth: 300, - scrollHeight: 250, - }); - 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("#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: [ - { - 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 = ` + @@ -43,14 +76,19 @@ test.describe("Sandboxed creative iframe", () => { // Prefer 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 runtime = await page.evaluate(() => { const script = Array.from(document.querySelectorAll("script[src]")).find( - (element) => (element as HTMLScriptElement).src.includes("/static/tsjs="), + (element) => + (element as HTMLScriptElement).src.includes("/static/tsjs="), ); - return script ? (script as HTMLScriptElement).src : null; + return { + bundleUrl: script ? (script as HTMLScriptElement).src : null, + releaseId: (window as any).tsjs?.releaseId as string | undefined, + }; }); const bundleUrl = - injectedBundle ?? runtimeUrl("/static/tsjs=tsjs-unified.min.js"); + runtime.bundleUrl ?? runtimeUrl("/static/tsjs=tsjs-unified.min.js"); + expect(runtime.releaseId).toMatch(/^[a-f0-9]{64}$/); const rebuildRequest = page.waitForRequest( (request) => request.url().includes("/first-party/proxy-rebuild"), @@ -68,13 +106,47 @@ test.describe("Sandboxed creative iframe", () => { }, { sandbox: CREATIVE_SANDBOX_TOKENS, - html: creativeDocument(new URL(runtimeUrl("/")).origin, bundleUrl), + html: creativeDocument( + new URL(runtimeUrl("/")).origin, + bundleUrl, + runtime.releaseId!, + ), }, ); 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..cc2c0b1c3 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts @@ -0,0 +1,218 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { test, expect, type Browser, type Page } from "@playwright/test"; + +const REPO_ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", +}).trim(); +const TSJS_CRATE = resolve(REPO_ROOT, "crates/trusted-server-js"); +const CORE_BUNDLE = resolve(TSJS_CRATE, "dist/tsjs-core.js"); +const BUILD_METRICS = resolve(TSJS_CRATE, "dist/tsjs-build-metrics-v1.json"); +const WARMUPS = 5; +const SAMPLES = 50; +const PERCENTILE = 90; + +type HeapCheckpoint = + | "afterBoot" + | "afterFirstRender" + | "afterRefresh" + | "afterSpaNavigation"; + +interface PerfApi { + requestAds(options?: { slots?: readonly string[] }): Promise; +} + +function fixtureDocument(): string { + return ` + +TSJS deterministic performance fixture v1 +
+`; +} + +async function openFixture( + browser: Browser, +): Promise<{ page: Page; close(): Promise }> { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.setContent(fixtureDocument(), { waitUntil: "load" }); + await page.addScriptTag({ path: CORE_BUNDLE }); + return { page, close: () => context.close() }; +} + +async function render(page: Page): Promise { + return page.evaluate(async () => { + const perfWindow = window as unknown as { tsjs: PerfApi }; + await perfWindow.tsjs.requestAds({ slots: ["perf-slot"] }); + const boot = performance.getEntriesByName("tsjs:boot")[0]?.startTime ?? performance.now(); + const firstDisplay = + performance.getEntriesByName("tsjs:first-display")[0]?.startTime ?? performance.now(); + return firstDisplay - boot; + }); +} + +async function collectHeap(page: Page): Promise { + const session = await page.context().newCDPSession(page); + try { + await session.send("HeapProfiler.collectGarbage"); + const usage = await session.send("Runtime.getHeapUsage"); + return usage.usedSize as number; + } finally { + await session.detach(); + } +} + +function p90(values: number[]): number { + const ordered = [...values].sort((left, right) => left - right); + return ordered[Math.ceil((PERCENTILE / 100) * ordered.length) - 1]!; +} + +function packageVersion(packagePath: string): string { + const packageJson = JSON.parse(readFileSync(packagePath, "utf8")) as { + version: string; + }; + return packageJson.version; +} + +test.describe("TSJS deterministic performance evidence", () => { + test("records bundle, p90 display, and forced-GC heap baselines", async ({ + browser, + browserName, + }) => { + test.setTimeout(180_000); + const mode = process.env.TSJS_PERF_MODE; + test.skip( + mode !== "baseline" && mode !== "gate", + "performance evidence run only", + ); + expect(browserName).toBe("chromium"); + + for (let index = 0; index < WARMUPS; index += 1) { + const fixture = await openFixture(browser); + try { + await render(fixture.page); + } finally { + await fixture.close(); + } + } + + const displaySamplesMs: number[] = []; + for (let index = 0; index < SAMPLES; index += 1) { + const fixture = await openFixture(browser); + try { + displaySamplesMs.push(await render(fixture.page)); + } finally { + await fixture.close(); + } + } + + const heapFixture = await openFixture(browser); + const retainedHeapBytes = {} as Record; + try { + retainedHeapBytes.afterBoot = await collectHeap(heapFixture.page); + await render(heapFixture.page); + retainedHeapBytes.afterFirstRender = await collectHeap(heapFixture.page); + await heapFixture.page.evaluate(async () => { + const perfWindow = window as unknown as { tsjs: PerfApi }; + await perfWindow.tsjs.requestAds({ slots: ["perf-slot"] }); + }); + retainedHeapBytes.afterRefresh = await collectHeap(heapFixture.page); + await heapFixture.page.evaluate(async () => { + location.hash = "performance-fixture-navigation"; + const oldSlot = document.getElementById("perf-slot"); + const replacement = document.createElement("div"); + replacement.id = "perf-slot"; + oldSlot?.replaceWith(replacement); + const perfWindow = window as unknown as { tsjs: PerfApi }; + await perfWindow.tsjs.requestAds({ slots: ["perf-slot"] }); + }); + retainedHeapBytes.afterSpaNavigation = await collectHeap( + heapFixture.page, + ); + } finally { + await heapFixture.close(); + } + + const outputArgument = process.env.TSJS_PERF_OUTPUT; + expect(outputArgument, "TSJS_PERF_OUTPUT is required").toBeTruthy(); + const outputPath = isAbsolute(outputArgument!) + ? outputArgument! + : resolve(REPO_ROOT, outputArgument!); + const buildMetrics = JSON.parse(readFileSync(BUILD_METRICS, "utf8")) as { + schemaVersion: number; + sets: Record; + }; + expect(buildMetrics.schemaVersion).toBe(1); + + const npmVersion = execFileSync("npm", ["--version"], { + encoding: "utf8", + }).trim(); + const artifact = { + schemaVersion: 1, + mode, + source: { + ref: execFileSync("git", ["branch", "--show-current"], { + cwd: REPO_ROOT, + encoding: "utf8", + }).trim(), + sha: execFileSync("git", ["rev-parse", "HEAD"], { + cwd: REPO_ROOT, + encoding: "utf8", + }).trim(), + }, + environment: { + node: process.version, + npm: npmVersion, + typescript: packageVersion( + resolve( + REPO_ROOT, + "crates/trusted-server-js/lib/node_modules/typescript/package.json", + ), + ), + chromium: browser.version(), + ciMachineClass: + process.env.TSJS_PERF_MACHINE_CLASS ?? + (process.env.CI + ? "github-hosted:ubuntu-latest" + : `local:${process.platform}-${process.arch}`), + fixture: "tsjs-core-placeholder-v1", + }, + sampling: { + warmups: WARMUPS, + samples: SAMPLES, + percentile: PERCENTILE, + }, + bundles: buildMetrics.sets, + performance: { + bootToFirstDisplayMs: { + samples: displaySamplesMs, + p90: p90(displaySamplesMs), + }, + retainedHeapBytes, + }, + evidence: { + evidenceId: process.env.TSJS_EVIDENCE_ID ?? null, + workflowRunId: process.env.GITHUB_RUN_ID + ? Number(process.env.GITHUB_RUN_ID) + : null, + }, + }; + + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(artifact, null, 2)}\n`); + expect(displaySamplesMs).toHaveLength(SAMPLES); + expect(Object.values(retainedHeapBytes)).toHaveLength(4); + }); +}); 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..90ab19809 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts @@ -0,0 +1,244 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { expect, test, type Page } from "@playwright/test"; + +const TSJS_CRATE = resolve(__dirname, "../../../../trusted-server-js"); +const CORE_BUNDLE = resolve(TSJS_CRATE, "dist/tsjs-core.js"); +const GPT_BUNDLE = resolve(TSJS_CRATE, "dist/tsjs-gpt.js"); +const RELEASE = JSON.parse( + readFileSync(resolve(TSJS_CRATE, "dist/tsjs-release-v1.json"), "utf8"), +) as { releaseId: string }; + +function boot(releaseId: string) { + return { + abi: 1, + releaseId, + manifest: { version: 1, releaseId, integrations: [] }, + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: "browser-initial", results: [] }, + slots: [], + bids: [], + }, + 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"); +} + +test.describe("TSJS hard-cutover runtime", () => { + 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, + _integrationConfig: {}, + bids: { legacy: true }, + renderAdUnit() {}, + renderAllAdUnits() {}, + setConfig() {}, + getConfig() {}, + }; + }, boot(RELEASE.releaseId)); + + await page.addScriptTag({ path: CORE_BUNDLE }); + 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(RELEASE.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: [], + _integrationConfig: Object.create({ hostile: true }), + }; + }, boot(RELEASE.releaseId)); + + await page.addScriptTag({ path: CORE_BUNDLE }); + 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({ path: GPT_BUNDLE }); + 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(2); + 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..6fc25764e --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml @@ -0,0 +1,22 @@ +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 } + +[[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 }}" diff --git a/crates/trusted-server-integration-tests/src/bin/generate-tsjs-prospective-fixture.rs b/crates/trusted-server-integration-tests/src/bin/generate-tsjs-prospective-fixture.rs new file mode 100644 index 000000000..95ee8ee2f --- /dev/null +++ b/crates/trusted-server-integration-tests/src/bin/generate-tsjs-prospective-fixture.rs @@ -0,0 +1,187 @@ +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, TsjsBootScriptConfigV1, prospective_tsjs_boot_controller_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 prospective projection '{}': {error}", + args.projection.display() + )) + })?; + let ids = args.ids.iter().map(String::as_str).collect::>(); + let controller = prospective_tsjs_boot_controller_fragment_v1( + TsjsBootScriptConfigV1 { + module_ids: &ids, + auction_projection_json: &projection, + creative: CreativeBootConfigV1 { + enabled: false, + click_guard: false, + render_guard: false, + }, + render_trace_overlay: true, + gpt_diagnostics_active: false, + }, + PERFORMANCE_ORIGIN, + ) + .map_err(|error| { + error_box(format!( + "failed to build prospective 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-prospective-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_prospective_controller_without_parser_time_deferred_tags() { + 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(), + "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 E7 prospective fixture"); + + assert!(html.contains(r#"id="perf-slot""#)); + assert!(html.contains(r#""renderTraceOverlay":true"#)); + assert!(html.contains(r#""id":"diagnostics_presentation","phase":"deferred""#)); + assert!(html.contains(r#""id":"gpt_later","phase":"deferred""#)); + assert_eq!(html.matches(">, + 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() -> 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())); + + 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::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::success( + "late bytes cannot contaminate the next request", + fixed( + "200 OK", + &[ + ("Content-Type", "application/javascript"), + ("Content-Length", "4"), + ], + b"next", + ), + b"next".to_vec(), + ), + ] +} + +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" + ); +} + +#[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"); + + let response = client + .request( + reqwest::Method::from_bytes(b"PROPFIND").expect("PROPFIND should be valid"), + format!("{}{}", process.base_url, "/integrations/aps/renderer/v1"), + ) + .header("authorization", "Bearer must-not-reach-publisher") + .send() + .expect("PROPFIND reserved request should complete"); + assert_eq!(response.status().as_u16(), 405); + assert_eq!(response.headers()["allow"], "GET"); + assert_eq!(response.headers()["cache-control"], "no-store"); + 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(["allow", "cache-control"])); + assert!( + response + .bytes() + .expect("405 body should be readable") + .is_empty() + ); + fixture.assert_no_proxy_observation(0, Duration::from_millis(150)); + + for (observation_index, case) in corpus().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/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 235af413f..9e36d05f6 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -4,6 +4,8 @@ use crate::common::runtime::{ }; 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}; @@ -29,17 +31,41 @@ 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); 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, - ) + let mut command = Command::new(&binary); + command.env("PORT", port.to_string()).env( + "TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG", + app_config, + ); + 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() @@ -48,6 +74,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 || { @@ -72,13 +99,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. @@ -124,6 +144,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..e4be0df6e 100644 --- a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs +++ b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs @@ -3,9 +3,12 @@ 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 +29,14 @@ 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_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 +72,87 @@ 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_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)?, + )) +} + impl RuntimeEnvironment for CloudflareWorkers { fn id(&self) -> &'static str { "cloudflare" @@ -127,6 +219,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 +231,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 +246,103 @@ 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}"); + super::wait_for_http_ready( + &base_url, + trusted_server_core::integrations::aps::APS_RENDERER_V1_ROUTE, + 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 +364,8 @@ impl CloudflareWorkers { struct CloudflareHandle { child: Child, + _configs: Vec, + _state_directory: Option, } impl RuntimeProcessHandle for CloudflareHandle {} @@ -233,4 +429,33 @@ 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_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" + ); + } } diff --git a/crates/trusted-server-integration-tests/tests/environments/fastly.rs b/crates/trusted-server-integration-tests/tests/environments/fastly.rs index 124a380f6..ec936c77b 100644 --- a/crates/trusted-server-integration-tests/tests/environments/fastly.rs +++ b/crates/trusted-server-integration-tests/tests/environments/fastly.rs @@ -2,9 +2,19 @@ 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 +29,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 +103,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 +132,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 +147,69 @@ 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")?; + 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 +236,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..1a7138e47 --- /dev/null +++ b/crates/trusted-server-integration-tests/tests/environments/spin.rs @@ -0,0 +1,175 @@ +use crate::common::config::integration_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__"; + +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 = integration_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()); + #[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")); + assert!(!APS_RUNNER_PROXY_MANIFEST.contains("https://*:*")); + } +} diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index acf7f5f4b..610be70b7 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_V1_ROUTE; use trusted_server_core::settings::Settings; /// Shared test settings for all adapters. @@ -44,6 +46,11 @@ fn test_settings() -> Settings { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [integrations.aps] + enabled = true + account_id = "parity-test-aps-account" + allow_script_creatives = true "#, ) .expect("should parse parity test settings") @@ -703,27 +710,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 +760,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_V1_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_v1_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 f3af9bfcf..9ab21aacc 100644 --- a/crates/trusted-server-js/Cargo.toml +++ b/crates/trusted-server-js/Cargo.toml @@ -19,6 +19,9 @@ test = false [build-dependencies] build-print = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } which = { workspace = true } [dependencies] diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index 6d6bdde9f..b1578a4d1 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,38 +11,70 @@ 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)] +struct CatalogManifest { + version: u8, + modules: Vec, +} + +#[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() @@ -56,8 +87,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() @@ -68,11 +97,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) @@ -83,110 +109,260 @@ 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" + ); + for (module, artifact) in catalog.modules.iter().zip(release.artifacts.iter().skip(2)) { + 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!( - !modules.is_empty(), - "tsjs: no tsjs-*.js files found in {}. Ensure `npm run build` succeeds.", - dist_dir.display() + valid_hash(&manifest.release_id), + "tsjs: generated manifest has invalid release id" ); - - info!( - "tsjs: Discovered {} module files: {:?}", - modules.len(), - modules - .iter() - .map(|(id, _)| id.as_str()) - .collect::>() + assert_eq!( + manifest.artifacts.len(), + 22, + "tsjs: release must contain bootstrap, core, and twenty integrations" ); + assert_eq!(manifest.artifacts[0].id, "bootstrap"); + assert_eq!(manifest.artifacts[0].role, "bootstrap"); + assert_eq!(manifest.artifacts[1].id, "core"); + assert_eq!(manifest.artifacts[1].role, "core"); - // Copy each module file to OUT_DIR - for (_, filename) in &modules { - copy_bundle(filename, true, &crate_dir, &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(); + for (index, artifact) in manifest.artifacts.iter().enumerate() { + assert!(ids.insert(&artifact.id), "tsjs: duplicate artifact id"); + if index >= 2 { + assert_eq!(artifact.role, "integration"); + if index < 16 { + assert_eq!(artifact.phase.as_deref(), Some("critical")); + 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" + ); + } + } else { + assert!(artifact.phase.is_none() && artifact.trigger.is_none()); + } - // 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 critical = manifest + .artifacts + .iter() + .filter(|artifact| artifact.phase.as_deref() == Some("critical")) + .count(); writeln!( - codegen, - "pub(crate) const TSJS_MODULES: [TsjsModuleMeta; {}] = [", - modules.len() + code, + "pub(crate) const TSJS_RELEASE_ID: &str = {:?};", + manifest.release_id ) - .expect("should write generated module header"); - for (id, filename) in &modules { + .expect("should write release id"); + writeln!( + code, + "pub(crate) const GENERATED_MAX_CRITICAL_MODULES: usize = {critical};\npub(crate) const GENERATED_MAX_MANIFEST_MODULES: usize = {integrations};" + ) + .expect("should write generated capacities"); + code.push_str( + "pub(crate) const GPT_BOOTSTRAP_FALLBACK: &str = include_str!(concat!(env!(\"OUT_DIR\"), \"/gpt-bootstrap-fallback.js\"));\n\n", + ); + writeln!( + code, + "pub(crate) const TSJS_ARTIFACTS: [TsjsGeneratedArtifactMeta; {}] = [", + manifest.artifacts.len() + ) + .expect("should write generated artifact header"); + for (index, artifact) in manifest.artifacts.iter().enumerate() { + let inputs = rust_string_slice(&artifact.inputs); + let outputs = rust_string_slice(&artifact.outputs); + let include = index + .checked_sub(2) + .and_then(|catalog_index| catalog.modules.get(catalog_index)) + .map(|module| module.include.as_str()); writeln!( - codegen, - " TsjsModuleMeta {{\n bundle: include_str!(concat!(env!(\"OUT_DIR\"), \"/{filename}\")),\n id: \"{id}\",\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("}\n"); - - 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() - ); - }); + 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"); +} + +fn rust_string_slice(values: &[String]) -> String { + format!( + "&[{}]", + values + .iter() + .map(|value| format!("{value:?}")) + .collect::>() + .join(", ") + ) +} + +fn rust_option(value: Option<&str>) -> String { + value.map_or_else(|| "None".to_owned(), |value| format!("Some({value:?})")) +} + +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()); } -fn copy_bundle(filename: &str, required: bool, crate_dir: &Path, dist_dir: &Path, out_dir: &Path) { +fn push_frame(target: &mut Vec, bytes: &[u8]) { + push_u64(target, bytes.len()); + target.extend_from_slice(bytes); +} + +fn valid_hash(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +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() { - if let Err(err) = fs::copy(source, &target) { - assert!( - !required, - "tsjs: failed to copy {} to {}: {err}", + fs::copy(source, &target).unwrap_or_else(|error| { + panic!( + "tsjs: failed to copy {} to {}: {error}", source.display(), target.display() - ); - } + ) + }); return; } } - - assert!( - !required, - "tsjs: bundle {filename} not found: {} (and fallback {}). Ensure Node is installed and `npm run build` succeeds, or commit dist/{filename}.", - primary.display(), - fallback.display() - ); - - fs::write(&target, "").expect("should write optional empty bundle placeholder"); + panic!("tsjs: bundle {filename} was not generated"); } fn watch_dir_recursively(root: &Path) { @@ -194,15 +370,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..f018b0604 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -1,59 +1,154 @@ -/** - * 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() - : []; - -console.log('[build-all] Discovered integrations:', integrationModules); - -/** 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)}`); - - await build({ +import { + deriveInventorySetFiles, + measureBundleSet, + measureBytes, + 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 = 'gpt-bootstrap-fallback.js'; + +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; +catalogModule.validateReleaseCatalog(releaseCatalog); +if (releaseCatalog.length !== 20) throw new Error('[build-all] Catalog must contain 20 rows'); +const runtimeCatalog = releaseCatalog.map(({ id, phase, trigger, consumes, provides }) => ({ + id, + phase, + trigger, + consumes, + provides, +})); +fs.rmSync(path.join(distributionDirectory, catalogModuleFile)); + +const sourceById = Object.freeze({ + render_runtime: 'integrations/render_runtime/index.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 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'); +} + +const artifacts = [ + { + id: 'bootstrap', + role: 'bootstrap', + phase: '', + trigger: '', + inputs: [], + outputs: [], + file: bootstrapFile, + entry: 'integrations/gpt/bootstrap_fallback.ts', + }, + { + id: 'core', + role: 'core', + phase: '', + trigger: '', + inputs: [], + outputs: ['runtime.v1'], + file: 'tsjs-core.js', + entry: 'composition/index.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], + })), +]; + +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); +} + +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, + 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), + }, build: { emptyOutDir: false, - outDir: distDir, + outDir: distributionDirectory, assetsDir: '.', sourcemap: false, minify: 'esbuild', @@ -61,33 +156,160 @@ async function buildModule(name, entryPath) { 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}`, }, }, }, 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 buildArtifact(artifacts[1]); +await Promise.all(artifacts.slice(2).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 !== 'critical') 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 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` +); +fs.writeFileSync( + path.join(distributionDirectory, catalogFile), + `${JSON.stringify({ + version: 1, + modules: releaseCatalog.map(({ id, phase, trigger, include }) => ({ + id, + phase, + trigger, + include, + })), + })}\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 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, + }, + 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..f8a370d18 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -34,6 +34,17 @@ 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'); + } +} export function parseArgs(argv) { const options = new Map(); @@ -66,10 +77,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 +117,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 +153,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 +180,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 +243,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 +266,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 +294,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..69afb8f1b --- /dev/null +++ b/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js @@ -0,0 +1,530 @@ +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`); adapter boundaries and +// restricted imports remain defense in depth. + +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 src/adapters; 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/'); + + 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..f27068f59 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.1", + "eslint": "^10.8.0", "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.9.0", + "jsdom": "^29.1.1", + "prettier": "^3.9.6", + "typescript": "~6.0.3", + "typescript-eslint": "^8.66.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,30 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "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", + "@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 +145,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 +161,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 +207,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 +237,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 +263,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 +279,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 +331,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 +369,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 +386,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 +468,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 +484,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 +499,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 +530,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 +547,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 +575,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 +590,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 +605,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 +620,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 +651,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 +666,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 +683,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 +700,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 +715,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 +730,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 +746,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 +762,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 +782,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 +798,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 +814,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 +830,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 +845,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 +861,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 +876,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 +892,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 +907,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 +922,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 +938,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 +955,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 +970,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 +985,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 +1000,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 +1015,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 +1031,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 +1047,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 +1065,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 +1081,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 +1097,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 +1112,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 +1127,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 +1142,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 +1161,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 +1177,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 +1192,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 +1208,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 +1223,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 +1239,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 +1256,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 +1271,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 +1286,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 +1302,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 +1317,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 +1332,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 +1348,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 +1363,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 +1378,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 +1393,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 +1412,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 +1427,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 +1443,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 +1459,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 +1475,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 +1583,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 +1602,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 +1643,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 +1669,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 +1689,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 +1713,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 +1729,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" @@ -1739,9 +1765,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 +1779,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", @@ -1776,18 +1810,54 @@ "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, + "peer": 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, + "peer": 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 +1874,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -1821,9 +1891,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -1838,9 +1908,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -1855,9 +1925,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -1872,9 +1942,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -1889,9 +1959,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -1906,9 +1976,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -1923,9 +1993,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -1940,9 +2010,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -1957,9 +2027,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -1974,9 +2044,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -1991,9 +2061,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -2008,9 +2078,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -2025,9 +2095,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -2042,9 +2112,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -2059,9 +2129,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -2076,9 +2146,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -2093,9 +2163,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -2110,9 +2180,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -2127,9 +2197,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -2144,9 +2214,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -2161,9 +2231,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -2178,9 +2248,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -2195,9 +2265,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -2212,9 +2282,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -2229,9 +2299,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.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -2246,9 +2316,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 +2334,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 +2358,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 +2476,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 +2586,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/@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/@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/@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 +2630,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 +2647,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,26 +2664,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "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==", - "cpu": [ - "arm64" ], - "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-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": [ "x64" ], @@ -2644,12 +2681,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "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-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": [ "arm" ], @@ -2658,40 +2698,32 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "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-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 +2732,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 +2749,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 +2766,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 +2783,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 +2800,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 +2817,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 +2834,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 +2851,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 +2883,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 +2912,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 +2945,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 +2970,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.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", "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.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.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 +2993,33 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.1", + "@typescript-eslint/parser": "^8.66.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.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", "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.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3" }, "engines": { @@ -3058,18 +3031,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.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.1", - "@typescript-eslint/types": "^8.56.1", + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", "debug": "^4.4.3" }, "engines": { @@ -3080,18 +3053,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.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1" + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3102,9 +3075,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.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", "dev": true, "license": "MIT", "engines": { @@ -3115,21 +3088,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.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", "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.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.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 +3113,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.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", "dev": true, "license": "MIT", "engines": { @@ -3158,21 +3131,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.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", "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.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.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 +3155,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/utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", - "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", "dev": true, "license": "MIT", + "peer": true, "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.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3206,17 +3180,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.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3227,125 +3201,427 @@ "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==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "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-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": "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-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": { - "@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": [ + "android" + ] }, - "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-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": { - "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-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": { - "@vitest/utils": "4.0.18", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "optional": true, + "os": [ + "darwin" + ] }, - "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-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/pretty-format": "4.0.18", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "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==", + "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", - "funding": { - "url": "https://opencollective.com/vitest" + "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.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "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": { + "url": "https://opencollective.com/vitest" } }, "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,9 +3641,9 @@ } }, "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, @@ -3388,20 +3664,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": { @@ -3433,9 +3699,9 @@ } }, "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", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3466,22 +3732,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,13 +3752,15 @@ } }, "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" - }, - "node_modules/arr-diff": { + "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", "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", @@ -3526,134 +3778,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 +3803,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 +3827,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 +3840,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 +3890,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 +3903,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 +3929,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", @@ -3865,11 +3962,11 @@ "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 +3986,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 +4005,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 +4026,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 +4055,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 +4085,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 +4108,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 +4152,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 +4187,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 +4241,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 +4279,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,42 +4310,6 @@ "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, - "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_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, - "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_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4413,25 +4329,35 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "license": "MIT" + "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": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "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", - "dependencies": { - "esutils": "^2.0.2" - }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, "node_modules/dset": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", @@ -4462,9 +4388,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 +4403,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 +4415,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 +4434,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 +4452,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,12 +4459,13 @@ "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.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4662,32 +4473,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.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -4719,34 +4530,34 @@ } }, "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.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "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 +4567,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 +4575,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,362 +4605,245 @@ "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==", + "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": { - "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==", - "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==", - "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==", + "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", + "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", + "peer": true, "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==", + "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": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "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==", + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "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==", - "dev": true, - "license": "Apache-2.0", + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, "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", + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, "engines": { - "node": ">= 4" + "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==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "ISC", + "license": "BSD-3-Clause", "dependencies": { - "brace-expansion": "^1.1.7" + "estraverse": "^5.1.0" }, "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.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" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" + "node": ">=0.10" } }, "node_modules/esrecurse": { @@ -5205,9 +4898,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 +4908,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 +4934,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 +5002,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", @@ -5433,28 +5126,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 +5191,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 +5250,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 +5277,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.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz", + "integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==", "dev": true, "license": "MIT", "engines": { @@ -5645,23 +5289,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 +5308,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 +5346,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 +5355,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 +5367,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 +5429,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 +5439,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 +5471,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 +5526,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,148 +5544,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" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "semver": "^7.7.1" } }, - "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-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": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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==", - "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_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": { - "builtin-modules": "^5.0.0" - }, - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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==", - "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" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -6233,42 +5607,6 @@ "node": ">=0.10.0" } }, - "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, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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==", - "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_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6282,47 +5620,21 @@ "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" - }, - "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" - }, - "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==", + "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": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "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-plain-object": { @@ -6344,333 +5656,436 @@ "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/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": "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", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, "engines": { - "node": ">= 0.4" + "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": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "license": "MIT", + "dependencies": { + "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-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/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", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20.0.0" } }, - "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==", + "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", + "peer": true, "dependencies": { - "call-bound": "^1.0.3" + "@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" }, "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "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==", - "dev": true, + "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": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "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==", + "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/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" + }, + "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" + }, + "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.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "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/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": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "json-buffer": "3.0.1" } }, - "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, + "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", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 8" } }, - "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/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": { - "call-bound": "^1.0.3" + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8.0" } }, - "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==", + "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", - "get-intrinsic": "^1.2.6" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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==", + "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": "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": [ + "android" + ], "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" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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==", + "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": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=20.0.0" + "node": ">= 12.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 +6127,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 +6138,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 +6158,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 +6186,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 +6253,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 +6268,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 +6275,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.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, "funding": [ { @@ -6877,6 +6293,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 +6326,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, - "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" + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "license": "MIT", + "engines": { + "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 +6367,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 +6410,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 +6458,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 +6489,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 +6550,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,9 +6569,9 @@ "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, @@ -7242,20 +6607,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 +6628,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7326,9 +6681,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 +6696,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 +6720,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 +6735,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 +6773,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 +6804,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 +6828,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 +6871,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 +6891,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 +6954,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", @@ -7763,9 +6993,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": { @@ -7798,9 +7028,9 @@ "license": "MIT" }, "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 +7094,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 +7124,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 +7143,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 +7229,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 +7240,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" - } + "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/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 +7307,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 +7345,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 +7384,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 +7394,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 +7411,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 +7421,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 +7467,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 +7499,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 +7511,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==", - "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==", + "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": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } + "license": "0BSD", + "optional": true }, "node_modules/type-check": { "version": "0.4.0", @@ -8440,6 +7532,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,88 +7558,10 @@ "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, @@ -8556,16 +7583,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.66.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", + "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", "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.66.0", + "@typescript-eslint/parser": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8576,7 +7603,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 +7621,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 +7632,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 +7687,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 +7807,18 @@ } }, "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 +7834,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 +7850,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -8837,31 +7886,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 +7926,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 +7955,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -8911,6 +7969,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -8927,6 +7988,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 +8046,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 +8090,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..3cb3821a0 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -6,35 +6,44 @@ "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-architecture.mjs", + "check:bundle": "node scripts/check-bundle-budgets.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.1", + "eslint": "^10.8.0", "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.9.0", + "jsdom": "^29.1.1", + "prettier": "^3.9.6", + "typescript": "~6.0.3", + "typescript-eslint": "^8.66.0", + "vite": "^8.2.1", + "vitest": "^4.1.10" } } 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..fe09c31d6 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs @@ -0,0 +1,124 @@ +/** Pure deterministic measurement primitives for frozen TSJS transfer budgets. */ + +import { createHash } from 'node:crypto'; +import { brotliCompressSync, constants as zlibConstants, 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'); + +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' && + ['critical', '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 critical = modules.filter(({ phase }) => phase === 'critical'); + const reference = REFERENCE_INCLUDE_ORDER.map((include) => + critical.filter((module) => module.include === include) + ); + if (reference.some((matches) => matches.length !== 1)) { + fail('catalog must define every reference predicate exactly once'); + } + return { + minimal: [ + 'core', + ...critical.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, { level: 9, mtime: 0 }).byteLength, + brotliBytes: brotliCompressSync(bytes, { + params: { + [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT, + [zlibConstants.BROTLI_PARAM_QUALITY]: 11, + [zlibConstants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength, + }, + }).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)) }; +} diff --git a/crates/trusted-server-js/lib/scripts/check-architecture.mjs b/crates/trusted-server-js/lib/scripts/check-architecture.mjs new file mode 100644 index 000000000..d19a147d5 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/check-architecture.mjs @@ -0,0 +1,266 @@ +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', + '.js', + '.json', + '.md', + '.mjs', + '.rs', + '.sh', + '.toml', + '.ts', + '.tsx', + '.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 generatedTsjsFiles = collect(path.join(packageRoot, 'dist')); +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 oldRuntimePrefix = token('__', 'tsjs', '_'); +const oldCreativeGlobal = token('ts', 'creative'); +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') +); +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 criticalTraceFile = path.join(packageRoot, 'src/core/trace.ts'); +forbidSource( + criticalTraceFile, + fs.readFileSync(criticalTraceFile, 'utf8'), + 'critical 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(?!\/v1(?![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(?!\/v1(?![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 +); + +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-js/lib/src/core/context.ts', + 'crates/trusted-server-js/lib/src/core/request.ts', + 'crates/trusted-server-js/lib/test/core/context.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/integrations/mod.rs', '_integrationConfig'], + ['crates/trusted-server-js/lib/src/core/index.ts', '_integrationConfig'], + ['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}`); + } +} + +if (violations.length > 0) { + console.error(`Architecture hard-cutover check failed (${violations.length} violations):`); + for (const violation of violations.sort()) console.error(`- ${violation}`); + process.exitCode = 1; +} else { + console.log('Architecture hard-cutover check passed.'); +} 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..338e99e6d --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -0,0 +1,714 @@ +#!/usr/bin/env node + +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'; + +import { + BUNDLE_SET_NAMES, + BUNDLE_SIZE_NAMES, + deriveInventorySetFiles, + deriveSemanticBundleSetIds, + 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 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 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 = + 'fd8b549ed2c87037fb07d751a96058da86c2ec48231f6bd69add78d55a4c915f'; +const BOOTSTRAP_BASELINE = Object.freeze({ + rawBytes: 19_101, + gzipBytes: 5_468, + brotliBytes: 4_632, +}); +const PRODUCTION_SEAM_PATTERN = + /(?:^|\/)(?:tests?|fixtures?|fakes?|no-?op)(?:\/|$)|(?:^|[/_.-])(?:test|fake|no-?op)(?=[/_.-]|$)|ForTest/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'); +} + +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 (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 !== 'gpt-bootstrap-fallback.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', '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: 'gpt-bootstrap-fallback.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 || artifact.role === 'bootstrap') { + fail(`buildMetrics.sets.${setName} contains an unknown or bootstrap 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 validateSourceOwners(sourceOwners, release) { + if (!sourceOwners || typeof sourceOwners !== 'object' || Array.isArray(sourceOwners)) { + fail('captured sourceOwners must be an object'); + } + const artifactIds = new Set(release.artifacts.map(({ id }) => id)); + const ownersBySource = new Map(); + for (const [source, owners] of Object.entries(sourceOwners)) { + if ( + source !== canonicalSourcePath(source) || + !source.startsWith('src/') || + !Array.isArray(owners) || + owners.length === 0 || + new Set(owners).size !== owners.length || + owners.some((owner) => !artifactIds.has(owner)) + ) { + fail(`captured source ownership is invalid: ${source}`); + } + ownersBySource.set(source, new Set(owners)); + } + return ownersBySource; +} + +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(); + 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); + } + if (artifact.role !== 'bootstrap' && (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), + }; +} + +function findCriticalDeferredViolations(graphEntries, ownersBySource, release) { + const artifactsById = new Map(release.artifacts.map((artifact) => [artifact.id, artifact])); + const violations = []; + for (const { artifact, sources } of graphEntries) { + if (artifact.role !== 'core' && artifact.phase !== 'critical') continue; + for (const source of sources) { + const owners = ownersBySource.get(source); + if (owners && [...owners].every((owner) => artifactsById.get(owner)?.phase === 'deferred')) { + violations.push(`${artifact.id} reaches deferred-owned source ${source}`); + } + } + } + return violations; +} + +/** Return critical artifacts that transitively bundle deferred-owned source. */ +export function findCriticalDeferredSourceViolations(metrics, release, sourceOwners) { + const { graphEntries } = readCurrentSourceGraph(metrics, release); + const ownersBySource = validateSourceOwners(sourceOwners, release); + return findCriticalDeferredViolations(graphEntries, ownersBySource, release); +} + +/** Return all frozen production graph ownership and seam violations. */ +export function findProductionGraphViolations(metrics, release, sourceOwners) { + const currentGraph = readCurrentSourceGraph(metrics, release); + const ownersBySource = validateSourceOwners(sourceOwners, release); + const violations = findCriticalDeferredViolations( + currentGraph.graphEntries, + ownersBySource, + release + ); + if (canonicalJson(currentGraph.sourceOwners) !== canonicalJson(sourceOwners)) { + violations.push('current source ownership differs from immutable capture'); + } + const providersByCapability = new Map(); + for (const { artifact } of currentGraph.graphEntries) { + for (const capability of artifact.outputs) { + if (providersByCapability.has(capability)) { + fail(`capability ${capability} has multiple release providers`); + } + providersByCapability.set(capability, { artifact }); + } + } + for (const { artifact, sources } of currentGraph.graphEntries) { + const requiredProviders = new Map(); + for (const input of artifact.inputs) { + const capability = input.split('?', 1)[0]; + const provider = providersByCapability.get(capability); + if (provider && provider.artifact.id !== artifact.id) { + requiredProviders.set(provider.artifact.id, provider); + } + } + for (const source of sources) { + const canonicalOwners = ownersBySource.get(source); + let hasSpecificOwnerViolation = false; + for (const { artifact: providerArtifact } of requiredProviders.values()) { + if (canonicalOwners?.has(providerArtifact.id) && !canonicalOwners.has(artifact.id)) { + violations.push( + `${artifact.id} inlines provider ${providerArtifact.id} implementation ${source}` + ); + hasSpecificOwnerViolation = true; + } + } + if (PRODUCTION_SEAM_PATTERN.test(source)) { + violations.push(`${artifact.id} reaches production test/fake/no-op seam ${source}`); + } + if (!canonicalOwners) { + violations.push(`${artifact.id} reaches source without a captured owner ${source}`); + } else if (!canonicalOwners.has(artifact.id) && !hasSpecificOwnerViolation) { + violations.push( + `${artifact.id} includes source owned by ${[...canonicalOwners].join(',')} from ${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 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('gpt-bootstrap-fallback.js'); + if (!(bootstrapBytes instanceof Uint8Array)) { + fail('current artifact bytes are missing: gpt-bootstrap-fallback.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[index + 1]; + 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) { + 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 === 'integration' && + (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' || + !/^(?:gpt-bootstrap-fallback|tsjs-[a-z0-9_]+)\.js$/u.test(artifact.file) || + 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 validateCurrentReleaseMetadata(current, captured) { + if ( + current.version !== captured.version || + current.artifacts.length !== captured.artifacts.length + ) { + fail('current release does not match canonical capture metadata'); + } + const metadataFields = ['id', 'role', 'phase', 'trigger', 'inputs', 'outputs', 'file']; + for (const [index, artifact] of current.artifacts.entries()) { + const currentMetadata = Object.fromEntries( + metadataFields.map((field) => [field, artifact[field]]) + ); + const capturedMetadata = Object.fromEntries( + metadataFields.map((field) => [field, captured.artifacts[index]?.[field]]) + ); + if (canonicalJson(currentMetadata) !== canonicalJson(capturedMetadata)) { + fail(`current release artifact ${index} does not match canonical capture metadata`); + } + } +} + +function validateCapturedMembership(capture, catalog) { + const expectedFiles = deriveInventorySetFiles(capture.release.artifacts, catalog.modules); + const idsByFile = new Map(capture.release.artifacts.map(({ id, file }) => [file, id])); + const expected = { + bootstrap: { artifactIds: ['bootstrap'], files: ['gpt-bootstrap-fallback.js'] }, + ...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 || + JSON.stringify(set.artifactIds) !== JSON.stringify(expected[setName].artifactIds) || + JSON.stringify(set.files) !== JSON.stringify(expected[setName].files) + ) { + fail(`role-correct ${setName} semantic membership is invalid`); + } + for (const sizeName of SIZE_NAMES) { + assertPositiveInteger(set[sizeName], `roleCorrectTransfer.sets.${setName}.${sizeName}`); + } + if (!/^[0-9a-f]{64}$/.test(set.sha256)) { + fail(`roleCorrectTransfer.sets.${setName}.sha256 is invalid`); + } + } +} + +/** Enforce independent five-percent transfer ceilings with an inclusive ceil boundary. */ +export function enforceTransferCeilings(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}`); + const ceilingBytes = Math.ceil(capturedBytes * 1.05); + if (currentBytes > ceilingBytes) { + fail(`${setName}.${sizeName} is ${currentBytes} bytes; ceiling is ${ceilingBytes}`); + } + reports[setName][sizeName] = { capturedBytes, currentBytes, ceilingBytes }; + } + } + return reports; +} + +/** Validate immutable evidence, exact semantics, current artifacts, and transfer ceilings. */ +export function validateRoleCorrectTransfer({ + baseline, + metrics, + catalog, + release, + currentArtifactContents, + requireExactCapture = false, +}) { + const capture = baseline?.roleCorrectTransfer; + if (!capture || typeof capture !== 'object') fail('role-correct capture is missing'); + const historical = Object.fromEntries( + Object.entries(baseline).filter(([key]) => key !== 'roleCorrectTransfer') + ); + const historicalDigest = canonicalJsonSha256(historical); + if (historicalDigest !== HISTORICAL_EVIDENCE_SHA256) { + fail('historical evidence digest does not match the immutable original top-level fields'); + } + if (canonicalJsonSha256(capture) !== ROLE_CORRECT_CAPTURE_SHA256) { + fail('role-correct capture digest does not match the immutable capture'); + } + if (capture.originalTopLevelSha256 !== HISTORICAL_EVIDENCE_SHA256) { + fail('historical evidence digest linkage is invalid'); + } + + validateReleaseInventoryShape(capture.release, 'captured'); + validateReleaseInventoryShape(release, 'current'); + validateCapturedMembership(capture, catalog); + validateCurrentReleaseMetadata(release, capture.release); + validateSemanticBundleSets(metrics, release, catalog); + const graphViolations = findProductionGraphViolations(metrics, release, capture.sourceOwners); + if (graphViolations.length > 0) + fail(`production graph failed:\n- ${graphViolations.join('\n- ')}`); + if (requireExactCapture && JSON.stringify(release) !== JSON.stringify(capture.release)) { + fail('generated release inventory differs from the clean capture parent'); + } + if (currentArtifactContents !== undefined) { + validateArtifactContents(release, currentArtifactContents); + validateCurrentMeasurements(metrics, release, catalog, currentArtifactContents); + } + + const currentSets = { bootstrap: metrics.bootstrap, ...metrics.sets }; + return { + reports: enforceTransferCeilings(capture.sets, currentSets), + capture, + }; +} + +function parseArgs(argv) { + const options = { baselineOnly: false, baselinePath: defaultBaselinePath }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--baseline-only') { + options.baselineOnly = true; + } else 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({ + baselineOnly = false, + 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 failures = findProductionGraphViolations( + metrics, + release, + baseline.roleCorrectTransfer?.sourceOwners + ); + 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], + }, + ]) + ); + if (baselineOnly) { + for (const sizeName of SIZE_NAMES) { + if (current[sizeName] !== historical[sizeName]) { + failures.push( + `${setName}.${sizeName} is ${current[sizeName]} bytes; exact historical value is ${historical[sizeName]}` + ); + } + } + if ( + setName !== 'bootstrap' && + (JSON.stringify(current.files) !== JSON.stringify(historical.files) || + current.sha256 !== historical.sha256) + ) { + failures.push(`${setName} differs from the exact pre-change artifact`); + } + } + } + + 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, + }); + + if (failures.length > 0) fail(`budget check failed:\n- ${failures.join('\n- ')}`); + + return { + baselineOnly, + baselinePath, + roleCorrectStatus: 'frozen', + transferCeilingsEnforced: true, + historicalDeltas, + roleCorrectTransfer: roleCorrect.reports, + sets: metrics.sets, + }; +} + +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(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-rc-july-adoption.mjs b/crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs new file mode 100644 index 000000000..f80eb1b1d --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs @@ -0,0 +1,166 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const MANIFEST_FENCE = /```json rcjuly-tsjs-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/'; + +function sorted(values) { + return [...values].sort((left, right) => left.localeCompare(right)); +} + +function gitLines(repositoryRoot, args) { + const output = execFileSync('git', args, { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + return output.split('\n').filter(Boolean); +} + +function gitObjectExists(repositoryRoot, objectName) { + try { + execFileSync('git', ['cat-file', '-e', objectName], { + cwd: repositoryRoot, + stdio: 'ignore', + }); + return true; + } catch { + return false; + } +} + +function extractManifest(specSource) { + const matches = [...specSource.matchAll(MANIFEST_FENCE)]; + if (matches.length !== 1 || typeof matches[0]?.[1] !== 'string') { + throw new Error(`expected exactly one rcjuly-tsjs-manifest-v1 block, found ${matches.length}`); + } + + const manifest = JSON.parse(matches[0][1]); + if ( + manifest === null || + typeof manifest !== 'object' || + manifest.version !== 1 || + typeof manifest.baseline !== 'string' || + !Array.isArray(manifest.includeRoots) || + !Array.isArray(manifest.mappings) + ) { + throw new Error('rc/july adoption manifest has an invalid outer shape'); + } + + 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; +} + +export function auditRcJulyAdoption({ repositoryRoot, specPath }) { + const specSource = fs.readFileSync(specPath, 'utf8'); + const manifest = extractManifest(specSource); + const files = new Set(); + + for (const includeRoot of manifest.includeRoots) { + for (const file of gitLines(repositoryRoot, [ + 'ls-tree', + '-r', + '--name-only', + manifest.baseline, + '--', + includeRoot, + ])) { + files.add(file); + } + } + + for (const mapping of manifest.mappings) { + for (const file of mapping.exact ?? []) { + if (gitObjectExists(repositoryRoot, `${manifest.baseline}:${file}`)) files.add(file); + } + } + + const orderedFiles = sorted(files); + 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))); + + return { + baseline: manifest.baseline, + fileCount: orderedFiles.length, + mappingCount: manifest.mappings.length, + manifestIdCount: manifestIds.size, + ledgerIdCount: ledgerIds.size, + unmappedFiles, + qualityOnlySourceFiles, + deadMappings, + manifestOnlyIds, + ledgerOnlyIds, + }; +} + +export function assertRcJulyAdoption(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])}`); + } + 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 result = auditRcJulyAdoption({ repositoryRoot, specPath }); + assertRcJulyAdoption(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..3c07888f2 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/print-release-id.mjs @@ -0,0 +1,73 @@ +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 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 !== 22 +) { + 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', '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')) || + (index === 1 && (artifact.id !== 'core' || artifact.role !== 'core')) || + (index >= 2 && artifact.role !== 'integration') + ) { + 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..ed0a2fa21 --- /dev/null +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -0,0 +1,2922 @@ +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; + 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; + 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 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 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]), + 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 pendingReservations = 0; + let disposed = false; + let firstDisplayObserved = 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; + 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 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 }), + ...(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, + 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..18f3734a5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -0,0 +1,1442 @@ +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; + } +} + +/** Every protocol literal shared by the §4.2–§4.5 message channels. */ +export const TSJS_MESSAGE_PROTOCOL_V1 = Object.freeze({ + version: 1 as const, + rendererVersion: '3' as const, + message: Object.freeze({ + prebidRequest: 'Prebid Request' as const, + prebidResponse: 'Prebid Response' as const, + ownerRegister: 'TS Render Owner Register' as const, + ownerRegistered: 'TS Render Owner Registered' as const, + ownerRefused: 'TS Render Owner Refused' as const, + apsStart: 'TS APS Start' as const, + admStart: 'TS ADM Start' as const, + ownerInserted: 'TS Owner Inserted' as const, + ownerSettled: 'TS Owner Settled' as const, + admLoaded: 'TS ADM Loaded' as const, + admFailed: 'TS ADM Failed' as const, + apsDocumentAccepted: 'TS APS Document Accepted' as const, + apsRunnerLoaded: 'TS APS Runner Loaded' as const, + apsRenderCompleted: 'TS APS Render Completed' as const, + apsRenderFailed: 'TS APS Render Failed' as const, + }), + status: Object.freeze({ ready: 'ready' as const, refused: 'refused' as const }), + kind: Object.freeze({ aps: 'aps' as const, adm: 'adm' as const }), + outcome: Object.freeze({ + accepted: 'accepted' as const, + failed: 'failed' as const, + cancelled: 'cancelled' as const, + }), + runnerFailure: Object.freeze({ + descriptorInvalid: 'descriptor_invalid' as const, + runnerNoLoad: 'runner_no_load' as const, + runnerFailed: 'runner_failed' as const, + }), + cancellation: Object.freeze({ + callerAborted: 'caller_aborted' as const, + superseded: 'superseded' as const, + navigationDisposed: 'navigation_disposed' as const, + }), +}); + +interface ProtocolMessageSchema { + readonly transport: 'global-json' | 'structured'; + readonly keys: readonly string[]; + readonly literals: Readonly>; +} + +function schema( + transport: ProtocolMessageSchema['transport'], + keys: readonly string[], + literals: Readonly> +): ProtocolMessageSchema { + return Object.freeze({ + transport, + keys: Object.freeze([...keys]), + literals: Object.freeze({ ...literals }), + }); +} + +/** 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: '3' } + ), + prebidResponseRefused: schema('structured', ['message', 'adId', 'rendererVersion', 'tsOwner'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse, + rendererVersion: '3', + }), + 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, + }), + apsStart: schema( + 'structured', + ['message', 'version', 'lifecycleTicket', 'rendererUrl', 'envelope'], + { message: TSJS_MESSAGE_PROTOCOL_V1.message.apsStart, 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, + } + ), + 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}$/, + 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', + 'cache_network_error', + 'cache_http_error', + 'cache_invalid_response', + '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 rendererUrl(value: unknown, expected?: string): value is string { + const valid = (candidate: unknown): candidate is string => { + if (!boundedString(candidate, 2_048)) return false; + try { + const parsed = new URL(candidate); + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + parsed.hostname !== '' && + parsed.username === '' && + parsed.password === '' && + parsed.pathname === '/integrations/aps/renderer/v1' && + parsed.search === '' && + parsed.hash === '' + ); + } catch { + return false; + } + }; + if (!valid(value)) return false; + if (expected !== undefined) return valid(expected) && value === expected; + return true; +} + +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): unknown { + if ( + typeof candidate !== 'string' || + new TextEncoder().encode(candidate).byteLength > MAX_GLOBAL_MESSAGE_BYTES + ) { + 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 'apsStart': + return ( + ticket() && + options.expectedRendererUrl !== undefined && + rendererUrl(record['rendererUrl'], options.expectedRendererUrl) + ); + 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 '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 === 'apsStart' || kind === 'apsEnvelope') { + if ( + kind === 'apsStart' && + (!capability(record['lifecycleTicket'], 'ticket') || + options.expectedRendererUrl === undefined || + !rendererUrl(record['rendererUrl'], options.expectedRendererUrl)) + ) { + return undefined; + } + const candidate = kind === 'apsStart' ? record['envelope'] : record; + const canonicalEnvelope = canonicalApsEnvelope(candidate, options); + if (!canonicalEnvelope) return undefined; + return kind === 'apsStart' + ? replaceNested(record, keys, { envelope: canonicalEnvelope }) + : canonicalEnvelope; + } + 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) : 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..6a61db96c --- /dev/null +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -0,0 +1,1691 @@ +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' || value === null) && 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 (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') { + 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.ts b/crates/trusted-server-js/lib/src/composition/browser.ts new file mode 100644 index 000000000..dc78c5150 --- /dev/null +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -0,0 +1,19 @@ +import { EMBEDDED_RUNTIME_CATALOG } from '../core/release'; +import { createRuntime, type Runtime, type RuntimeOptions } from '../kernel/runtime'; + +export interface BrowserRuntimeComposition { + readonly runtime: Runtime; +} + +/** Minimal production core: concrete product behavior belongs to provider IIFEs. */ +export function createBrowserRuntimeComposition( + runtimeOptions: RuntimeOptions, + _compositionOptions: Readonly> = Object.freeze({}) +): BrowserRuntimeComposition { + return Object.freeze({ + runtime: createRuntime({ + ...runtimeOptions, + catalog: EMBEDDED_RUNTIME_CATALOG, + }), + }); +} 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..138eb272b --- /dev/null +++ b/crates/trusted-server-js/lib/src/composition/browser_test.ts @@ -0,0 +1,1941 @@ +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 { parseCacheFetchPolicyV1 } from '../core/config'; +import { parseTrustedServerAuctionResponseV1 } from '../core/auction'; +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_V1_PATH, renderDirectApsAttempt } 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 { + createPrebidRefreshPolicy, + createPrebidSelectionCoordinator, + createPrebidSyntheticRefreshRunner, + preparePrebidRegisteredRefreshAuction, + publishPrebidBid, + type PrebidSelectionCoordinator, +} from '../integrations/prebid/module'; +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 } 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, + IntegrationRegistration, +} from '../kernel/integration_registry'; +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 { + createCommittedArtifactStore, + createRenderAttempt, + createRendererNonceRegistry, + createSlotOperation, + resolveCacheAdmAttempt, + renderDirectCacheAttempt, + resizeCollapsedPucShell, + renderDirectAdmAttempt, + type RenderAttempt, + type CommittedArtifactStore, + type RendererNonceRegistry, + type SlotOperationCreationResult, +} from '../services/render'; +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_CRITICAL_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + +function installBrowserTestCriticalScript(runtimeDocument: Document): void { + if (runtimeDocument.currentScript) return; + const script = runtimeDocument.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL(TRUSTED_BROWSER_TEST_CRITICAL_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 auctionBatches: AuctionBatchService; + readonly pucBridge: PucBridge; + readonly reservations: ReservationService; + readonly rendererNonces: RendererNonceRegistry; + readonly renderDirectAdm: (attempt: RenderAttempt, container: HTMLElement) => boolean; + readonly renderDirectAps: (attempt: RenderAttempt, container: HTMLElement) => boolean; + readonly renderDirectCache: (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 prospective GPT attempt through the runtime-owned services in tests. */ + readonly startGptSlotOperationForTest: ( + input: Omit + ) => SlotOperationCreationResult; + /** Publish one prospective 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']; +} + +interface AcceptedBrowserBoot { + readonly auctionProjection: object; + readonly cachePolicy?: unknown; + 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 resolveCacheAdm: NonNullable; + 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/v1', 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( + runtimeOptions: RuntimeOptions, + compositionOptions: TestBrowserRuntimeCompositionOptions +): BrowserRuntimeComposition { + const runtimeDocument = + runtimeOptions.document ?? (typeof document === 'undefined' ? undefined : document); + if (runtimeDocument) installBrowserTestCriticalScript(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; + 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 cachePolicy = + boot.cachePolicy === undefined ? undefined : parseCacheFetchPolicyV1(boot.cachePolicy); + const parseProjection = (candidate: unknown): object | undefined => + parseBrowserAuctionProjectionV1(candidate, boot.cachePolicy); + 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 slotService = createSlotService({ + disposeCommittedArtifact: (navigationGeneration, registeredSlotId) => { + const artifact = artifacts.current(registeredSlotId); + if (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) => parseBidRenderSourceV1(candidate, cachePolicy), + }); + 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 fetchCache = globalThis.fetch; + const rendererUrl = new URL(APS_RENDERER_V1_PATH, publisherOrigin).href; + const renderDirectAdm = Object.freeze( + (attempt: RenderAttempt, container: HTMLElement): boolean => { + try { + return renderDirectAdmAttempt({ + attempt, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin, + }); + } catch { + return false; + } + } + ); + const renderDirectCache = Object.freeze( + (attempt: RenderAttempt, container: HTMLElement): boolean => { + if (!cachePolicy) { + try { + attempt.fail('descriptor_invalid'); + } catch { + // The admitted attempt remains the only terminal authority. + } + return false; + } + if (typeof fetchCache !== 'function') { + try { + attempt.fail('cache_network_error'); + } catch { + // The admitted attempt remains the only terminal authority. + } + return false; + } + try { + return renderDirectCacheAttempt({ + attempt, + cachePolicy, + container, + fetcher: (input, init) => fetchCache(input, init), + prepareIframe: prepareAdmIframe, + publisherOrigin, + }); + } catch { + return false; + } + } + ); + const renderDirectAps = Object.freeze( + (attempt: RenderAttempt, container: HTMLElement): boolean => { + try { + return renderDirectApsAttempt({ + attempt, + container, + messaging: composition.adapters.messaging, + nonces: rendererNonces, + publisherOrigin, + }); + } catch { + return false; + } + } + ); + const resolveCacheAdm: NonNullable = ( + attempt, + onResolved + ): boolean => { + if (!cachePolicy) { + try { + attempt.fail('descriptor_invalid'); + } catch { + // The admitted attempt remains the only terminal authority. + } + return false; + } + if (typeof fetchCache !== 'function') { + try { + attempt.fail('cache_network_error'); + } catch { + // The admitted attempt remains the only terminal authority. + } + return false; + } + try { + return resolveCacheAdmAttempt({ + attempt: attempt as RenderAttempt, + cachePolicy, + fetcher: (input, init) => fetchCache(input, init), + onResolved, + }); + } 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, cachePolicy); + return source ? 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); + if (attempt.renderSource?.type === 'cache') return renderDirectCache(attempt, container); + attempt.fail('winner_not_renderable'); + return false; + }; + const batchCoordinator = createAuctionBatchService({ + ...(cachePolicy ? { cachePolicy } : {}), + 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, + reservations: reservationService, + rendererNonces, + renderDirectAdm, + renderDirectAps, + renderDirectCache, + slots: slotService, + targeting: targetingService, + }); + preparedBrowserServices = Object.freeze({ + createAttempt: createOwnedAttempt, + publisherOrigin, + renderProjectedFallback, + rendererUrl, + resolveCacheAdm, + 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(); + 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, + publisherOrigin: prepared.publisherOrigin, + ...(compositionOptions.pucSchedulerForTest + ? { scheduler: compositionOptions.pucSchedulerForTest } + : {}), + rendererNonces: prepared.services.rendererNonces, + rendererUrl: prepared.rendererUrl, + reservations: prepared.services.reservations, + resizeCollapsedShell: resizeCollapsedPucShell, + resolveCacheAdm: prepared.resolveCacheAdm, + }); + 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: () => + Object.freeze({ + abi: 1, + id: BROWSER_TEST_DIAGNOSTICS_PROVIDER_ID, + phase: 'critical', + releaseId: runtimeOptions.releaseId, + 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, + }), + }), + }); + }, + }), + createTraceCapabilityProviderRegistrationForTest: () => + Object.freeze({ + abi: 1, + id: BROWSER_TEST_TRACE_PROVIDER_ID, + phase: 'critical', + releaseId: runtimeOptions.releaseId, + 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, + }), + }), + }); + }, + }), + 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/index.ts b/crates/trusted-server-js/lib/src/composition/index.ts new file mode 100644 index 000000000..4e079851d --- /dev/null +++ b/crates/trusted-server-js/lib/src/composition/index.ts @@ -0,0 +1,7 @@ +import { startProductionRuntime } from '../core/index'; + +import { createBrowserRuntimeComposition } from './browser'; + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + startProductionRuntime(createBrowserRuntimeComposition); +} diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index 489d66f4d..2fc162f35 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -2,10 +2,36 @@ // 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 { parseCacheFetchPolicyV1 } from './config'; +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 +71,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. */ @@ -61,11 +87,172 @@ export interface AuctionBid { /** Advertiser domains. */ adomain: string[]; /** Server-side auction ID used for render tracing. */ - auctionId?: string; + auctionId?: string | undefined; /** Upstream OpenRTB bid ID used for render tracing. */ - bidId?: string; + bidId?: string | undefined; /** Trace hash of the delivered creative markup. */ - admHash?: string; + admHash?: string | undefined; +} + +export interface TrustedServerAuctionBidV1 { + candidateId: string; + rendererReservationId: string; + impid: string; + provider: string; + price: number; + width: number; + height: number; + renderSource: BidRenderSourceV1; + adm?: string | undefined; +} + +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, + cachePolicyValue?: unknown +): TrustedServerAuctionResponseV1 | undefined { + const cachePolicy = + cachePolicyValue === undefined ? undefined : parseCacheFetchPolicyV1(cachePolicyValue); + if (cachePolicyValue !== undefined && !cachePolicy) return 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 || + !isRendererReservationIdV1(bid.id) || + !validBoundedString(bid.impid, 256) || + !isAuctionCandidateIdV1(trusted.candidate_id) || + trusted.slot_id !== bid.impid || + typeof bid.price !== 'number' || + !Number.isFinite(bid.price) || + bid.price < 0 || + !validDimension(bid.w) || + !validDimension(bid.h) + ) { + return undefined; + } + const renderSource = parseRenderSource(trusted.render_source, cachePolicy); + if (!renderSource || renderSource.width !== bid.w || renderSource.height !== 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; + } + bids.push({ + candidateId: trusted.candidate_id, + rendererReservationId: bid.id, + impid: bid.impid, + provider: seat.seat, + price: bid.price, + width: bid.w, + height: bid.h, + 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) || reservations.has(bid.rendererReservationId)) { + return true; + } + candidates.add(bid.candidateId); + 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; + canonicalBids.push({ + candidateId: bid.candidateId, + slot: bid.impid, + provider: bid.provider, + upstreamBidId: + bid.renderSource.type === 'aps' ? bid.renderSource.bidId : bid.rendererReservationId, + cpm: bid.price, + currency: 'USD', + targeting: {}, + 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 }; } // --------------------------------------------------------------------------- @@ -75,7 +262,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/config.ts b/crates/trusted-server-js/lib/src/core/config.ts index c0bbe7428..81edc7341 100644 --- a/crates/trusted-server-js/lib/src/core/config.ts +++ b/crates/trusted-server-js/lib/src/core/config.ts @@ -1,25 +1,77 @@ -// Global configuration storage for the tsjs runtime (logging, debug, etc.). -import { log, LogLevel } from './log'; +import type { CacheFetchPolicyV1 } from './types'; -export interface Config { - debug?: boolean; - logLevel?: 'silent' | 'error' | 'warn' | 'info' | 'debug'; - [key: string]: unknown; +function exactOwnDataObject( + value: unknown, + expectedKeys: readonly string[] +): Record | undefined { + 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 = Object.getOwnPropertyNames(value); + if (names.length !== expectedKeys.length || expectedKeys.some((key) => !names.includes(key))) { + return undefined; + } + for (const name of names) { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + } + return value as Record; } -let CONFIG: Config = {}; +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; +} -// 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); +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; } -// Return a defensive copy so callers can't mutate shared state. -export function getConfig(): Config { - return { ...CONFIG }; +/** Validate, snapshot, and freeze the immutable cache-fetch boot policy. */ +export function parseCacheFetchPolicyV1(value: unknown): Readonly | undefined { + const policy = exactOwnDataObject(value, ['version', 'baseUrl']); + if ( + !policy || + policy.version !== 1 || + typeof policy.baseUrl !== 'string' || + policy.baseUrl.length === 0 || + !validUnicodeScalars(policy.baseUrl) || + new TextEncoder().encode(policy.baseUrl).length > 4096 || + hasAsciiControl(policy.baseUrl) + ) { + return undefined; + } + + let base: URL; + try { + base = new URL(policy.baseUrl); + } catch { + return undefined; + } + if ( + base.protocol !== 'https:' || + base.hostname === '' || + base.username !== '' || + base.password !== '' || + base.search !== '' || + base.hash !== '' || + base.pathname === '/' + ) { + return undefined; + } + + return Object.freeze({ version: 1, baseUrl: policy.baseUrl }); } 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..7d71dc3a7 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/aps_renderer.ts @@ -0,0 +1,53 @@ +import type { ApsRendererV1 } from '../types'; + +import { + classifyApsRendererDescriptorV1, + classifyApsRendererV1, +} from './generated/renderer_validator_v1'; + +type ValidatedRendererCacheEntry = { + publisherOrigin: string; + renderer: ApsRendererV1; +}; + +const validatedRendererCache = new WeakMap(); + +function isRecord(value: unknown): value is Record { + try { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } catch { + return false; + } +} + +/** 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; + } +} 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..8e0254fab --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts @@ -0,0 +1,670 @@ +import { parseCacheFetchPolicyV1 } from '../config'; +import type { + AdmRenderSourceV1, + AuctionDecisionSetV1, + AuctionSlotFailureReason, + BidRenderSourceV1, + BrowserAuctionBidV1, + BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, + CacheFetchPolicyV1, + CacheRenderSourceV1, + SlotAuctionDecisionV1, +} from '../types'; + +import { validateApsRenderer } from './aps_renderer'; + +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 textEncoder = new TextEncoder(); +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; +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 cacheIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +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 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; +} + +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) + ); +} + +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, + cachePolicy?: Readonly +): 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 === 'cache') { + const source = ownDataObject(value, [ + 'type', + 'version', + 'cacheId', + 'fetchUrl', + 'width', + 'height', + ]); + if ( + !source || + source.version !== 1 || + typeof source.cacheId !== 'string' || + !matches(cacheIdPattern, source.cacheId) || + !validBoundedString(source.fetchUrl, MAX_URL_BYTES) || + !validDimension(source.width) || + !validDimension(source.height) || + !cachePolicy + ) { + return undefined; + } + let fetchUrl: URL; + try { + fetchUrl = new URL(source.fetchUrl); + } catch { + return undefined; + } + if ( + fetchUrl.protocol !== 'https:' || + fetchUrl.username !== '' || + fetchUrl.password !== '' || + fetchUrl.hash !== '' || + [...fetchUrl.searchParams.keys()].length !== 1 || + fetchUrl.searchParams.get('uuid') !== source.cacheId || + fetchUrl.search !== `?uuid=${encodeURIComponent(source.cacheId)}` + ) { + return undefined; + } + let policyBase: URL; + try { + policyBase = new URL(cachePolicy.baseUrl); + } catch { + return undefined; + } + const expected = new URL(policyBase.href); + expected.search = `?uuid=${encodeURIComponent(source.cacheId)}`; + if ( + fetchUrl.origin !== policyBase.origin || + fetchUrl.port !== policyBase.port || + fetchUrl.pathname !== policyBase.pathname || + fetchUrl.href !== expected.href + ) { + return undefined; + } + return { + type: 'cache', + version: 1, + cacheId: source.cacheId, + fetchUrl: fetchUrl.href, + width: source.width, + height: source.height, + } satisfies CacheRenderSourceV1; + } + + 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, + cachePolicy?: Readonly +): BrowserAuctionBidV1 | undefined { + const bid = ownDataObject(value, [ + 'candidateId', + 'slot', + 'provider', + 'upstreamBidId', + 'cpm', + 'currency', + 'targeting', + 'rendererReservationId', + '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' || + !isRendererReservationIdV1(bid.rendererReservationId) + ) { + return undefined; + } + const targeting = parseTargeting(bid.targeting); + const renderSource = parseBidRenderSourceV1(bid.renderSource, cachePolicy); + if (!targeting || !renderSource) return undefined; + return { + candidateId: bid.candidateId, + slot: bid.slot, + provider: bid.provider, + upstreamBidId: bid.upstreamBidId, + cpm: bid.cpm, + currency: 'USD', + targeting, + 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, + cachePolicyValue?: unknown +): BrowserAuctionProjectionV1 | undefined { + try { + const cachePolicy = + cachePolicyValue === undefined ? undefined : parseCacheFetchPolicyV1(cachePolicyValue); + if (cachePolicyValue !== undefined && !cachePolicy) return undefined; + 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, cachePolicy); + if ( + !bid || + candidateIds.has(bid.candidateId) || + reservationIds.has(bid.rendererReservationId) + ) { + return undefined; + } + candidateIds.add(bid.candidateId); + 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/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/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/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..f27804f9a 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -1,72 +1,219 @@ -// Public tsjs core bundle: sets up the global API, queue, and default methods. +// Sole production bootstrap for the resilient TSJS runtime. 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'; -const VERSION = '0.1.0'; +import type { Runtime, RuntimeOptions } from '../kernel/runtime'; -const w: Window & { tsjs?: TsjsApi } = - ((globalThis as unknown as { window?: Window }).window as Window & { - tsjs?: TsjsApi; - }) || ({} as Window & { tsjs?: TsjsApi }); +import { EMBEDDED_INTEGRATION_IDS, EMBEDDED_RELEASE_ID } from './release'; -// Collect existing tsjs queued fns before we overwrite -const pending: Array<() => void> = Array.isArray(w.tsjs?.que) ? [...w.tsjs.que] : []; +const KNOWN_INTEGRATIONS = new Set(EMBEDDED_INTEGRATION_IDS); +const MAX_CONFIG_DEPTH = 16; +const MAX_CONFIG_NODES = 512; +const MAX_CONFIG_MEMBERS = 256; +const INVALID_CONFIG = Symbol('invalid-config'); -// 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; +type BootstrapTarget = object & { + boot?: unknown; + que?: unknown; + _integrationConfig?: unknown; +}; -// Single shared queue -installQueue(api, w); +export type BrowserRuntimeCompositionFactory = ( + runtimeOptions: RuntimeOptions, + compositionOptions: Readonly> +) => Readonly<{ runtime: Runtime }>; -// Flush prior queued callbacks -for (const fn of pending) { +function bootstrapTarget(): BootstrapTarget | undefined { try { - if (typeof fn === 'function') { - fn.call(api); - log.debug('queue: flushed callback'); + const current = (window as unknown as { tsjs?: unknown }).tsjs; + if ((typeof current === 'object' || typeof current === 'function') && current !== null) { + return current as BootstrapTarget; } + const target: BootstrapTarget = {}; + (window as unknown as { tsjs?: unknown }).tsjs = target; + return target; } catch { - /* ignore queued callback error */ + return undefined; } } -log.info('tsjs initialized', { - methods: [ - 'setConfig', - 'getConfig', - 'requestAds', - 'addAdUnits', - 'renderAdUnit', - 'renderAllAdUnits', - ], -}); +function snapshotConfigValue( + candidate: unknown, + seen: Set, + state: { nodes: number }, + depth = 0 +): unknown | typeof INVALID_CONFIG { + if (candidate === null || typeof candidate === 'string' || typeof candidate === 'boolean') { + return candidate; + } + if (typeof candidate === 'number') { + return Number.isFinite(candidate) ? candidate : INVALID_CONFIG; + } + if (typeof candidate !== 'object' || depth > MAX_CONFIG_DEPTH || seen.has(candidate)) { + return INVALID_CONFIG; + } + if (state.nodes >= MAX_CONFIG_NODES) return INVALID_CONFIG; + seen.add(candidate); + state.nodes += 1; + try { + const isArray = Array.isArray(candidate); + const prototype = Object.getPrototypeOf(candidate) as unknown; + if ( + (isArray && prototype !== Array.prototype) || + (!isArray && prototype !== Object.prototype && prototype !== null) || + Object.getOwnPropertySymbols(candidate).length !== 0 + ) { + return INVALID_CONFIG; + } + const names = Object.getOwnPropertyNames(candidate); + if (names.length > MAX_CONFIG_MEMBERS + (isArray ? 1 : 0)) return INVALID_CONFIG; + if (isArray) { + const length = Object.getOwnPropertyDescriptor(candidate, 'length'); + if (!length || !('value' in length) || names.length !== length.value + 1) { + return INVALID_CONFIG; + } + const values: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + return INVALID_CONFIG; + } + const value = snapshotConfigValue(descriptor.value, seen, state, depth + 1); + if (value === INVALID_CONFIG) return INVALID_CONFIG; + values.push(value); + } + return Object.freeze(values); + } + const copy: Record = {}; + for (const name of names) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, name); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + return INVALID_CONFIG; + } + const value = snapshotConfigValue(descriptor.value, seen, state, depth + 1); + if (value === INVALID_CONFIG) return INVALID_CONFIG; + copy[name] = value; + } + return Object.freeze(copy); + } catch { + return INVALID_CONFIG; + } +} + +function snapshotIntegrationConfig( + candidate: unknown +): Readonly> | undefined { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + (Object.getPrototypeOf(candidate) !== Object.prototype && + Object.getPrototypeOf(candidate) !== null) || + Object.getOwnPropertySymbols(candidate).length !== 0 + ) { + return undefined; + } + const names = Object.getOwnPropertyNames(candidate); + if (names.length > EMBEDDED_INTEGRATION_IDS.length) return undefined; + const configs: Record = {}; + const seen = new Set(); + const state = { nodes: 0 }; + for (const name of names) { + if (!KNOWN_INTEGRATIONS.has(name)) return undefined; + const configDescriptor = Object.getOwnPropertyDescriptor(candidate, name); + if (!configDescriptor || !configDescriptor.enumerable || !('value' in configDescriptor)) { + return undefined; + } + const value = snapshotConfigValue(configDescriptor.value, seen, state); + if (value === INVALID_CONFIG) return undefined; + configs[name] = value; + } + return Object.freeze(configs); + } catch { + return undefined; + } +} + +function consumeIntegrationConfig( + target: BootstrapTarget +): Readonly> | undefined { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(target, '_integrationConfig'); + } catch { + return undefined; + } + if (!descriptor) return Object.freeze({}); + if (!('value' in descriptor) || !descriptor.configurable) return undefined; + + const configs = snapshotIntegrationConfig(descriptor.value); + try { + if (!Reflect.deleteProperty(target, '_integrationConfig')) return undefined; + } catch { + return undefined; + } + return configs; +} + +function bootManifest(target: BootstrapTarget): unknown { + try { + const boot = Object.getOwnPropertyDescriptor(target, 'boot'); + if (!boot || !('value' in boot) || typeof boot.value !== 'object' || boot.value === null) { + return undefined; + } + const manifest = Object.getOwnPropertyDescriptor(boot.value, 'manifest'); + return manifest && 'value' in manifest ? manifest.value : undefined; + } catch { + return undefined; + } +} + +/** Claim the browser namespace and start the injected sole composition root. */ +export function startProductionRuntime(createComposition: BrowserRuntimeCompositionFactory): void { + const target = bootstrapTarget(); + if (!target) return; + const configs = consumeIntegrationConfig(target); + // A malformed, accessor-backed, or undeletable transport must not survive + // beneath either terminal namespace. Leave the namespace unclaimed. + if (!configs) return; + const composition = createComposition( + { + target, + releaseId: EMBEDDED_RELEASE_ID, + manifest: bootManifest(target), + knownIntegrationIds: EMBEDDED_INTEGRATION_IDS, + getBindings: (id) => + Object.freeze({ + config: configs?.[id], + interfaces: Object.freeze({}), + }), + kernel: { + addAdUnits: () => Object.freeze({ registered: Object.freeze([]) }), + diagnostics: Object.freeze({}), + requestAds: async () => Object.freeze({ slots: Object.freeze([]) }), + }, + }, + {} + ); + if (!composition.runtime.start()) return; + + let requested = false; + const install = (): void => { + if (requested) return; + requested = true; + void composition.runtime.install(); + }; + queueMicrotask(install); +} 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/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..732f9ef8f 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -1,31 +1,667 @@ -// 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/auction_projection'; -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; + } + return bytes; +} + +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; +} + +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, + }); } - log.info('addAdUnits:', { count: toArray(units).length }); + return undefined; } -// 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 snapshotKnownSlots(knownSlots: ReadonlySet): ReadonlySet { + try { + return new Set(knownSlots); + } catch { + throw new AdUnitRegistrationError('slot_collision'); + } } -// Return a snapshot array of all registered ad units. -export function getAllUnits(): AdUnit[] { - return Array.from(registry.values()); +/** + * 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. + */ +export function prepareProgrammaticAdUnits( + value: unknown, + knownSlots: ReadonlySet +): readonly ProgrammaticAdUnit[] { + 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'); + } + + 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)) }); } -// Look up a unit by its code. -export function getUnit(code: string): AdUnit | undefined { - return registry.get(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..29b0746ab --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/release.ts @@ -0,0 +1,28 @@ +declare const __TSJS_EMBEDDED_RELEASE_ID_V1__: string; +declare const __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: readonly string[]; +declare const __TSJS_EMBEDDED_RUNTIME_CATALOG_V1__: readonly Readonly<{ + id: string; + phase: 'critical' | 'deferred'; + trigger: 'first_display_or_idle' | null; + consumes: readonly string[]; + provides: readonly string[]; +}>[]; + +/** Build-stamped identity of the exact canonical production bundle set. */ +export const EMBEDDED_RELEASE_ID = __TSJS_EMBEDDED_RELEASE_ID_V1__; + +/** 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, + consumes: Object.freeze([...entry.consumes]), + provides: Object.freeze([...entry.provides]), + }) + ) +); diff --git a/crates/trusted-server-js/lib/src/core/render.ts b/crates/trusted-server-js/lib/src/core/render.ts index 6e2c9ff94..9489d7d4f 100644 --- a/crates/trusted-server-js/lib/src/core/render.ts +++ b/crates/trusted-server-js/lib/src/core/render.ts @@ -1,8 +1,4 @@ -// Rendering utilities for Trusted Server demo placements: find slots, seed placeholders, -// and inject creatives into sandboxed iframes. -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'; @@ -26,6 +22,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 = { @@ -55,11 +142,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 @@ -100,78 +182,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 @@ -197,8 +207,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 @@ -228,10 +237,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 { - const origin = location.origin; - if (/^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(origin)) return origin; + return exactHttpOrigin(location.origin) ?? ''; } catch { // fall through to an empty stamp; the runtime degrades to document.baseURI } @@ -239,8 +260,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 f22c1dbfc..000000000 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ /dev/null @@ -1,195 +0,0 @@ -// Request orchestration for tsjs: unified auction endpoint with iframe-based creative rendering. -import { 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'; -import { isEffectivelyVisible, recordRender, stampCreativeTrace } from './trace'; - -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; - auctionId?: string; - bidId?: string; - admHash?: 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) { - renderApsCreative({ slotId: bid.impid, renderer: bid.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, - auctionId: bid.auctionId, - bidId: bid.bidId, - admHash: bid.admHash, - }); - } - 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, - auctionId, - bidId, - admHash, -}: RenderCreativeInlineOptions): void { - const trace = { - slotId, - path: 'auction' as const, - auctionId, - bidId, - bidder: seat, - creativeId, - admHash, - servedFrom: 'inline' as const, - }; - 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, - }); - const record = recordRender({ - ...trace, - rendered: false, - injected: false, - visible: false, - elementId: container.id || undefined, - }); - stampCreativeTrace(container, record); - 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); - - const record = recordRender({ - ...trace, - rendered: true, - injected: true, - visible: isEffectivelyVisible(container), - elementId: container.id || undefined, - }); - stampCreativeTrace(container, record); - stampCreativeTrace(iframe, record); - - 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/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 2acc50900..10bdbbbe5 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -1,582 +1,826 @@ -// Render-trace registry, DOM markers, and a floating debug panel: joins a -// creative rendered on the page back to the winning server-side auction bid. -// Every render writes a RenderRecord to window.tsjs.renders (keyed by slot ID), -// stamps the slot element with data-ts-* attributes carrying the same trace -// tuple, and fires a 'tsjs:adRendered' CustomEvent. When the ts-trace cookie is -// armed (via GET /_ts/trace), a Google-Publisher-Console-style overlay panel -// summarises every traced slot so an operator can confirm on the page itself -// that creatives came through Trusted Server — on both the SSAT/GAM and -// /auction render paths. -import { log } from './log'; -import type { RenderRecord, TsjsApi } from './types'; - -/** CustomEvent fired on window after each render-trace record is written. */ -export const RENDER_EVENT_NAME = 'tsjs:adRendered'; - -/** - * Cookie armed by `GET /_ts/trace` (server-side, `ts-trace=1`). While present, - * the floating trace panel is shown so an operator can see on the page itself - * that creatives were delivered by Trusted Server. - */ -const TRACE_COOKIE_NAME = 'ts-trace'; - -/** DOM id of the floating trace panel (body-level overlay). */ -export const TRACE_PANEL_ID = 'ts-render-trace-panel'; - -/** - * Upper bound on `window.tsjs.renderLog`. A publisher page that refreshes its - * slots on every render can produce hundreds of entries in a session, so the - * history is trimmed from the front rather than growing without limit. - */ +// Closure-private render diagnostics data for the hard-cutover runtime. +import type { RenderTraceDiagnostics, RenderTraceRecord } from './types'; + const MAX_RENDER_LOG_ENTRIES = 200; -/** - * Fallback for [`nextRenderSeq`] when `window.tsjs` is unreachable (no DOM, or - * a throwing property access). Never the primary counter — see below. - */ -let fallbackRenderSeq = 0; - -/** - * Allocate the next value for [`RenderRecord.seq`]. - * - * The counter lives on the shared `window.tsjs` object, not in module scope: - * `build-all.mjs` emits core, GPT and every integration as separate - * self-contained IIFEs, each with its own inlined copy of this module. A - * module-scoped counter would therefore restart at 1 in each bundle and hand - * two different renders the same number — duplicate `#1` panel rows and - * badges across the SSAT and `/auction` paths. - */ -function nextRenderSeq(): number { - try { - const ts = (window.tsjs ??= {} as TsjsApi); - const next = Math.max(ts.renderSeq ?? 0, fallbackRenderSeq) + 1; - ts.renderSeq = next; - fallbackRenderSeq = next; - return next; - } catch { - return ++fallbackRenderSeq; - } +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; } -/** CSS class of the per-slot confirmation badge (only on honestly-ok slots). */ -export const TRACE_BADGE_CLASS = 'ts-render-badge'; - -/** - * Whether the visible trace overlay is armed (`ts-trace=1` cookie present — - * set via `GET /_ts/trace`, cleared via `GET /_ts/trace?enabled=false`). - */ -export function traceOverlayEnabled(): boolean { - try { - return new RegExp(`(?:^|;\\s*)${TRACE_COOKIE_NAME}=1(?:;|$)`).test(document.cookie); - } catch { - return false; - } +/** 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; } -/** Short-form mechanism suffix — only the bridge mechanisms add information. */ -function mechanismSuffix(record: RenderRecord): string { - return record.servedFrom === 'debug-adm' || record.servedFrom === 'pbs-cache' - ? ` (${record.servedFrom})` - : ''; +export interface RenderTraceRuntimeScheduler { + readonly set: (callback: () => void, milliseconds: number) => unknown; + readonly clear: (handle: unknown) => void; } -/** - * Whether an element is effectively visible: connected, non-zero box, and no - * ancestor hiding it via `display:none`, `visibility:hidden`, or `opacity:0`. - * - * The ancestor walk is what catches a slot the publisher holds at `opacity:0` - * on a wrapper until its own ad code reveals it — the slot's own computed - * opacity is `1`, so only walking up exposes the gate. - */ -export function isEffectivelyVisible(el: Element | null): boolean { - try { - if (!el || !(el instanceof HTMLElement) || !el.isConnected) return false; - const rect = el.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return false; - let node: HTMLElement | null = el; - while (node) { - const cs = getComputedStyle(node); - if ( - cs.display === 'none' || - cs.visibility === 'hidden' || - parseFloat(cs.opacity || '1') === 0 - ) { - return false; - } - node = node.parentElement; - } - return true; - } catch { - return false; - } +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; } -/** - * Honest per-slot status for the panel, derived from the separate signals: - * - `empty` — GAM reported the slot empty, or nothing was placed. - * - `hidden` — a creative rendered but the slot is not visible (reveal gate). - * - `gam-only`— GAM rendered something, but TS did not place it (can't confirm - * it is the TS creative — cross-origin). - * - `ok` — TS placed a creative and the slot is visible. - */ -type PanelStatus = 'ok' | 'hidden' | 'gam-only' | 'empty'; - -function panelStatus(record: RenderRecord): PanelStatus { - if (!record.rendered || record.gamEmpty === true) return 'empty'; - if (record.visible === false) return 'hidden'; - // `ok` requires a *confirmed* TS placement. Anything else — TS applied - // targeting only (injected false, creative is GAM's and cross-origin - // unreadable), or a path that never reported placement (undefined) — must not - // be claimed as a TS render. Defaulting to gam-only keeps the panel honest - // even if a future render path forgets to set `injected`. - if (record.injected !== true) return 'gam-only'; - return 'ok'; +/** 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; } -const STATUS_STYLE: Record = { - ok: { color: '#3fb950', mark: '✓', label: 'ok' }, - hidden: { color: '#d29922', mark: '⚠', label: 'hidden' }, - 'gam-only': { color: '#58a6ff', mark: '◐', label: 'gam-only' }, - empty: { color: '#f85149', mark: '✗', label: 'empty' }, -}; - -/** - * Attach (or replace) the per-slot confirmation badge on a slot element. - * - * Only called for `ok` slots — a TS creative that actually placed and is - * visible — so the green badge on a physical banner is a truthful "this banner - * is the render in the trace panel" marker, not the overclaiming badge the - * first cut shipped. Hidden / gam-only / empty slots deliberately get none. - * - * `pointer-events: none` keeps the badge from intercepting clicks on the ad. - */ -function attachTraceBadge(el: HTMLElement, record: RenderRecord): void { - const style = STATUS_STYLE[panelStatus(record)]; - - const position = getComputedStyle(el).position; - if (position === 'static' || position === '') { - el.style.position = 'relative'; - } - - const badge = document.createElement('div'); - badge.className = TRACE_BADGE_CLASS; - // Lead with the sequence number: it is what ties this badge to a panel row. - badge.textContent = - `TS ${style.mark} #${record.seq}` + - `${record.bidder ? ` · ${record.bidder}` : ''}` + - `${style.label === 'ok' ? '' : ` · ${style.label}`}`; - badge.title = [ - `render: #${record.seq}`, - `slot: ${record.slotId}`, - `auction: ${record.auctionId ?? '—'}`, - `bidder: ${record.bidder ?? '—'}`, - `bid_id: ${record.bidId ?? '—'}`, - `creative: ${record.creativeId ?? '—'}`, - `adm_hash: ${record.admHash ?? '—'}`, - `served: ${record.servedFrom ?? '—'}`, - ].join('\n'); - const s = badge.style; - s.setProperty('position', 'absolute'); - s.setProperty('top', '4px'); - s.setProperty('left', '4px'); - s.setProperty('z-index', '2147483646'); - s.setProperty('pointer-events', 'none'); - s.setProperty('font', '10px/1.5 ui-monospace, Menlo, Consolas, monospace'); - s.setProperty('padding', '1px 5px'); - s.setProperty('color', '#fff'); - s.setProperty('background', style.color); - s.setProperty('border-radius', '3px'); - el.appendChild(badge); +export interface RenderTracePresentationControls { + readonly dispose: () => void; } -/** - * Remove this element's own trace badge, if it has one. - * - * Must run on *every* stamp, not only the ones that go on to attach a new - * badge: a slot that re-renders into `empty` or `hidden` gets no replacement - * badge, so without an unconditional removal it would keep displaying the green - * or blue badge from its previous render — contradicting the status the panel - * shows for the same slot. - */ -function removeTraceBadge(el: HTMLElement): void { - el.querySelectorAll(`:scope > .${TRACE_BADGE_CLASS}`).forEach((n) => n.remove()); +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 dispose: () => void; } -/** Truncate a long id for the compact panel row while keeping the tail. */ -function short(value: string | undefined, keep = 10): string { - if (!value) return '?'; - return value.length > keep ? `…${value.slice(-keep)}` : value; -} +export class DiagnosticsSubscriberLimitError extends Error { + public readonly code = 'subscriber_capacity' as const; + public readonly surface: 'renderTrace' | 'gpt'; -/** - * Create (or return) the floating trace panel appended to `document.body`. - * - * A body-level fixed overlay is used deliberately instead of per-slot badges: - * it survives GAM/APS clearing a slot's `innerHTML`, publisher reveal gates - * that hold a slot wrapper at `opacity: 0`, and cross-origin creative iframes — - * none of which a child-of-slot badge can survive. - */ -function ensureTracePanel(): HTMLElement | null { - if (typeof document === 'undefined' || !document.body) return null; - - const existing = document.getElementById(TRACE_PANEL_ID); - if (existing) return existing; - - const panel = document.createElement('div'); - panel.id = TRACE_PANEL_ID; - const s = panel.style; - s.setProperty('position', 'fixed'); - s.setProperty('bottom', '12px'); - s.setProperty('right', '12px'); - s.setProperty('z-index', '2147483647'); - s.setProperty('max-width', '360px'); - s.setProperty('max-height', '45vh'); - s.setProperty('overflow', 'auto'); - s.setProperty('background', 'rgba(17,17,17,0.94)'); - s.setProperty('color', '#eee'); - s.setProperty('font', '11px/1.5 ui-monospace, Menlo, Consolas, monospace'); - s.setProperty('border', '1px solid #333'); - s.setProperty('border-radius', '6px'); - s.setProperty('box-shadow', '0 4px 16px rgba(0,0,0,0.4)'); - s.setProperty('padding', '0'); - document.body.appendChild(panel); - return panel; + public constructor(surface: 'renderTrace' | 'gpt') { + super('subscriber_capacity'); + this.name = 'DiagnosticsSubscriberLimitError'; + this.surface = surface; + } } -/** - * Whether this record is still the live render for its slot — i.e. the entry - * `window.tsjs.renders` currently holds. Every other row in the log has been - * superseded by a later render of the same slot. - * - * Compares by object identity, not by `seq`: the registry and the history hold - * the same record objects, so identity is exact regardless of how sequence - * numbers were allocated. - */ -function isCurrentRender(record: RenderRecord): boolean { - try { - return window.tsjs?.renders?.[record.slotId] === record; - } catch { - return false; - } +interface RenderTraceSubscription { + readonly id: number; + readonly listener: (record: Readonly) => void; } -/** GAM/injection state summary for the panel's detail line. */ -function stateSummary(record: RenderRecord): string { - const parts: string[] = []; - // GAM's own fill signal, on every render path that has one. Gating this on - // `ssat` would hide it for `gam-refresh`, where "did GAM fill it this time" - // is the whole question. - if (record.gamEmpty !== undefined) { - parts.push(`gam:${record.gamEmpty ? 'empty' : 'filled'}`); - } - if (record.injected !== undefined) { - parts.push(`inj:${record.injected ? 'y' : 'n'}`); - } - parts.push(`vis:${record.visible === false ? 'n' : record.visible ? 'y' : '?'}`); - return parts.join(' · '); +interface PendingRenderTraceNotification { + readonly record: Readonly; + readonly subscriberIds: readonly number[]; } -/** - * Copy a record's full JSON to the clipboard and log it — used by the panel's - * click-to-copy so full (untruncated) auction IDs and hashes are debuggable - * without hovering the title or digging in `window.tsjs.renders`. - */ -function copyRecord(record: RenderRecord): void { - const json = JSON.stringify(record, null, 2); - log.info('trace: render record', record); - try { - void navigator.clipboard?.writeText(json); - } catch { - // Clipboard unavailable (insecure context / permissions) — the console - // log above is the fallback. +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; } -/** Build one slot row for the panel. */ -function buildPanelRow(record: RenderRecord): HTMLElement { - const status = panelStatus(record); - const style = STATUS_STYLE[status]; - - const row = document.createElement('div'); - const rs = row.style; - rs.setProperty('padding', '6px 10px'); - rs.setProperty('border-top', '1px solid #2a2a2a'); - rs.setProperty('border-left', `3px solid ${style.color}`); - rs.setProperty('cursor', 'pointer'); - // Click a row to copy its full record (untruncated IDs/hash) + log it. - row.addEventListener('click', () => copyRecord(record)); - row.title = [ - `render: #${record.seq}`, - `slot: ${record.slotId}`, - `status: ${style.label}`, - `path: ${record.path}`, - `rendered (gam non-empty): ${record.rendered}`, - `gam_empty: ${record.gamEmpty ?? '—'}`, - `injected (ts placed): ${record.injected ?? '—'}`, - `visible: ${record.visible ?? '—'}`, - `auction: ${record.auctionId ?? '—'}`, - `bidder: ${record.bidder ?? '—'}`, - `creative: ${record.creativeId ?? '—'}`, - `ad_id: ${record.adId ?? '—'}`, - `bid_id: ${record.bidId ?? '—'}`, - `adm_hash: ${record.admHash ?? '—'}`, - `served: ${record.servedFrom ?? '—'}`, - `element: ${record.elementId ?? '—'}`, - `renders: ${record.count}`, - ].join('\n'); - - const line1 = document.createElement('div'); - const clock = new Date(record.at).toLocaleTimeString('en-GB', { hour12: false }); - // `current` marks the row still on screen for its slot — the one whose badge, - // if any, is the badge you are looking at. Older rows are history. - const current = isCurrentRender(record) ? ' ◂ current' : ''; - line1.textContent = `#${record.seq} ${clock} ${style.mark} ${record.slotId} · ${style.label}${current}`; - line1.style.setProperty('font-weight', '600'); - line1.style.setProperty('color', style.color); - - const line2 = document.createElement('div'); - line2.style.setProperty('color', '#bbb'); - // An unattributed render (a GAM refresh TS ran no auction for) carries no - // bidder or hash by design. Say that, rather than rendering `? · ?` as if a - // lookup had failed. - const attribution = - record.bidder || record.admHash - ? `${record.bidder ?? '?'} · ${short(record.admHash)}` - : 'no TS attribution'; - line2.textContent = `${record.path}${mechanismSuffix(record)} · ${attribution}`; - - const line3 = document.createElement('div'); - line3.style.setProperty('color', '#777'); - const auction = record.auctionId ? ` · auction ${short(record.auctionId)}` : ''; - // `×N` is this slot's own render count — distinct from the page-global `#seq` - // on line 1, which is what the on-creative badge shows. - line3.textContent = `${stateSummary(record)}${auction} · ×${record.count}`; - - row.append(line1, line2, line3); - return row; +function scheduleRenderTraceTask(callback: () => void): () => void { + const handle = globalThis.setTimeout(callback, 0); + return (): void => globalThis.clearTimeout(handle); } -/** - * Rebuild the floating trace panel from `window.tsjs.renders`. - * - * Reads the whole registry each call so the panel always reflects the current - * state; safe to call on every render event. - */ -export function renderTracePanel(): void { - try { - if (!traceOverlayEnabled()) return; - const panel = ensureTracePanel(); - if (!panel) return; - - const renders = window.tsjs?.renders ?? {}; - const slots = Object.values(renders); - // Count only slots that are honestly OK (TS creative placed and visible), - // not merely "GAM said something rendered" — the whole point of the fix. - const ok = slots.filter((r) => panelStatus(r) === 'ok').length; - // Newest render first: on a page that refreshes its slots this reads as a - // timeline rather than a set of counters. - const history = [...(window.tsjs?.renderLog ?? [])].reverse(); - - panel.replaceChildren(); - - const header = document.createElement('div'); - const hs = header.style; - hs.setProperty('display', 'flex'); - hs.setProperty('justify-content', 'space-between'); - hs.setProperty('align-items', 'center'); - hs.setProperty('gap', '8px'); - hs.setProperty('padding', '6px 10px'); - hs.setProperty('position', 'sticky'); - hs.setProperty('top', '0'); - hs.setProperty('background', '#000'); - hs.setProperty('font-weight', '700'); - - const title = document.createElement('span'); - title.textContent = `TS Render Trace · ${ok}/${slots.length} slots ok · ${history.length} renders`; - - const close = document.createElement('button'); - close.textContent = '×'; - close.setAttribute('aria-label', 'Close trace panel'); - const cs = close.style; - cs.setProperty('background', 'transparent'); - cs.setProperty('color', '#eee'); - cs.setProperty('border', '0'); - cs.setProperty('font-size', '14px'); - cs.setProperty('cursor', 'pointer'); - cs.setProperty('line-height', '1'); - close.addEventListener('click', () => panel.remove()); - - header.append(title, close); - panel.appendChild(header); - - const hint = document.createElement('div'); - hint.style.setProperty('padding', '2px 10px 4px'); - hint.style.setProperty('color', '#777'); - hint.style.setProperty('font-size', '9px'); - hint.textContent = 'newest first · click a row to copy its full record · hover for detail'; - panel.appendChild(hint); - - if (history.length === 0) { - const empty = document.createElement('div'); - empty.style.setProperty('padding', '6px 10px'); - empty.style.setProperty('color', '#bbb'); - empty.textContent = 'No creatives traced yet.'; - panel.appendChild(empty); - return; +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; } - - for (const record of history) { - panel.appendChild(buildPanelRow(record)); + >(); + 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 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); } - } catch (err) { - log.warn('trace: failed to render panel', err); - } -} + return scheduleRenderTraceTask(callback); + }; + + const reportSubscriberError = (error: unknown): void => { + try { + options.onSubscriberError?.(error); + } catch { + // Diagnostics error reporting cannot affect correctness work. + } + }; -/** - * Write a render record into `window.tsjs.renders` and fire the render event. - * - * Repeated records for the same slot (SPA navigation, GPT refresh) overwrite - * the previous entry and increment `count`, so the registry always reflects - * the latest render while preserving how many renders the slot has seen. - * When the trace overlay is armed, the floating panel is refreshed here — the - * single choke point every render passes through. - */ -export function recordRender(record: Omit): RenderRecord { - const full: RenderRecord = { ...record, count: 1, seq: nextRenderSeq(), at: Date.now() }; - try { - const ts = (window.tsjs ??= {} as TsjsApi); - const renders = (ts.renders ??= {}); - const prev = renders[record.slotId]; - if (prev) full.count = prev.count + 1; - renders[record.slotId] = full; - - // Keep each render as its own history entry, trimmed from the front. - const history = (ts.renderLog ??= []); - history.push(full); + 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) { - history.splice(0, history.length - MAX_RENDER_LOG_ENTRIES); + const evicted = history.shift(); + if (evicted && !retained(evicted)) recordsBySequence.delete(evicted.seq); } - } catch (err) { - log.warn('trace: failed to write render record', { slotId: record.slotId, err }); - } - try { - window.dispatchEvent(new CustomEvent(RENDER_EVENT_NAME, { detail: full })); - } catch (err) { - // CustomEvent unavailable — registry entry above is still written. - log.debug('trace: failed to dispatch render event', { slotId: record.slotId, err }); - } - renderTracePanel(); - return full; -} + 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; + }; -/** - * Fields a later signal about an already-recorded render may contribute. - * Identity (`slotId`) and bookkeeping (`seq`, `count`, `at`) are fixed at - * [`recordRender`] time and are never revised. - */ -export type RenderUpdate = Partial>; - -/** Confirmation flags that a later, weaker signal must never clear. */ -const CONFIRMATION_FIELDS = ['rendered', 'injected'] as const; - -/** - * Merge a later signal into an existing render record, **in place**. - * - * One impression can be observed twice: GAM's `slotRenderEnded` and the Prebid - * Universal Creative bridge both describe the same GAM ad request, and a - * deferred ADM placement resolves an animation frame after the render was first - * recorded. Appending a second [`recordRender`] for those would inflate the - * slot's `count`, the history length, the page-global sequence numbers and the - * panel totals — one impression must stay one row. - * - * So the later signal enriches the record instead: `seq`, `count` and `at` are - * left untouched and no new history entry is appended. Because the registry and - * the history hold the *same* object, mutating it updates both. - * - * Confirmations only ever strengthen. A `false` in `patch` cannot clear a - * `true` already on the record, so the weaker GAM-only signal arriving after - * the bridge's confirmed placement does not erase it. - */ -export function updateRender(record: RenderRecord, patch: RenderUpdate): RenderRecord { - try { - const fields = record as unknown as Record; - for (const [key, value] of Object.entries(patch)) { - if (value === undefined) continue; + 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 ( - value === false && - fields[key] === true && - (CONFIRMATION_FIELDS as readonly string[]).includes(key) + impression.navigationGeneration !== navigationGeneration || + impression.state === 'retired' ) { continue; } - fields[key] = value; + 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; } - } catch (err) { - log.warn('trace: failed to update render record', { slotId: record.slotId, err }); - } - try { - window.dispatchEvent(new CustomEvent(RENDER_EVENT_NAME, { detail: record })); - } catch (err) { - // CustomEvent unavailable — the mutated record above still stands. - log.debug('trace: failed to dispatch render update event', { slotId: record.slotId, err }); - } - renderTracePanel(); - return record; -} + 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; + } -/** - * Stamp an element with `data-ts-*` attributes carrying the trace tuple, so - * a creative in the DOM can be joined to the server-side `auction winner:` / - * `auction delivered creative:` log lines by inspection alone. - * - * Attributes whose record field is absent are removed, so a re-render of the - * same element (SPA navigation, GPT refresh) never leaves stale values from a - * previous auction next to the new ones. These attributes live on the element - * itself, so they survive a later `innerHTML = ''` that clears the slot's - * children (e.g. the GAM adm interceptor) — unlike a child badge would. - */ -export function stampCreativeTrace(el: Element, record: RenderRecord): void { - const attrs: Array<[string, string | undefined]> = [ - ['data-ts-slot-id', record.slotId], - ['data-ts-render-path', record.path], - ['data-ts-rendered', String(record.rendered)], - ['data-ts-auction-id', record.auctionId], - ['data-ts-bidder', record.bidder], - ['data-ts-ad-id', record.adId], - ['data-ts-bid-id', record.bidId], - ['data-ts-creative-id', record.creativeId], - ['data-ts-adm-hash', record.admHash], - ['data-ts-served-from', record.servedFrom], - ['data-ts-gam-empty', record.gamEmpty === undefined ? undefined : String(record.gamEmpty)], - ['data-ts-injected', record.injected === undefined ? undefined : String(record.injected)], - ['data-ts-visible', record.visible === undefined ? undefined : String(record.visible)], - ]; - try { - for (const [name, value] of attrs) { - if (value !== undefined && value !== '') { - el.setAttribute(name, value); - } else { - el.removeAttribute(name); + 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. } - // Badge any slot that actually shows something, carrying its honest status - // colour: green ✓ for a confirmed TS render, blue ◐ for `gam-only` (GAM - // rendered, TS cannot confirm it as its own). Slots with nothing on screen - // (`empty`) or nothing visible (`hidden`) stay unbadged — there is no - // creative there to label. Never badge the iframe itself. - // - // Any previous badge is dropped first, unconditionally, so a slot that - // re-renders into `empty` or `hidden` sheds the badge from its last render - // instead of contradicting the panel. - if (el instanceof HTMLElement && el.tagName !== 'IFRAME') { - removeTraceBadge(el); - const status = panelStatus(record); - if (traceOverlayEnabled() && (status === 'ok' || status === 'gam-only')) { - attachTraceBadge(el, record); + }; + + 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); } - } catch (err) { - log.warn('trace: failed to stamp element', { slotId: record.slotId, err }); - } + }; + + 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, + dispose, + }); +} + +/** Data-only critical 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 aaaae4765..cadfdb764 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,96 +17,79 @@ 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; - /** Notify Prebid that GAM selected this bid before replying to Universal Creative. */ - markWinner(): void; - /** Mark Prebid's bid used after response dispatch; PUC terminal events determine creative success. */ - markRendered(): 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; - /** Trace-only OpenRTB bid identifier. */ - hb_bid_id?: string; - /** Opaque server-auction correlation ID used only by GPT diagnostics. */ - hb_auction_id?: string; - /** Trace-only OpenRTB creative identifier. */ - hb_crid?: string; - /** Trace hash of delivered creative markup. */ - hb_adm_hash?: string; - 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 local rendering through the pbRender - * bridge. Present whenever the winning bid carried a creative that passed the - * server-side sanitize/rewrite boundary; absent when there was no creative or - * it was rejected (e.g. over the 1 MiB cap), in which case the bridge falls - * back to the PBS Cache coordinates. This is NOT gated by - * `inject_adm_for_testing`. - */ - adm?: string; - /** Debug-only bid field mirror. Only present when `[debug] inject_adm_for_testing = true`. */ - debug_bid?: AuctionDebugBidData; -} - -/** How a creative reached the page for a [`RenderRecord`]. */ -export type RenderServedFrom = 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid'; - -/** Client-side record joining a rendered creative to its auction winner. */ -export interface RenderRecord { - slotId: string; - path: 'auction' | 'ssat' | 'gam-refresh'; - rendered: boolean; - elementId?: string; - auctionId?: string; - bidder?: string; - adId?: string; - bidId?: string; - creativeId?: string; - admHash?: string; - servedFrom?: RenderServedFrom; - gamEmpty?: boolean; - injected?: boolean; - visible?: boolean; - count: number; - seq: number; - at: number; +export interface AdmRenderSourceV1 { + type: 'adm'; + version: 1; + adm: string; + width: number; + height: number; } -/** - * 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 { +export interface CacheRenderSourceV1 { + type: 'cache'; + version: 1; + cacheId: string; + fetchUrl: string; + width: number; + height: number; +} + +export interface CacheFetchPolicyV1 { + version: 1; + baseUrl: string; +} + +export type BidRenderSourceV1 = ApsRendererV1 | AdmRenderSourceV1 | CacheRenderSourceV1; + +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[]; +} + +export interface BrowserAuctionBidV1 { + candidateId: string; + slot: string; + provider: string; + upstreamBidId: string; + cpm: number; + currency: 'USD'; + targeting: Record; + rendererReservationId: string; + renderSource: BidRenderSourceV1; +} + +/** Exact GAM placement metadata required to publish one server-projected slot. */ +export interface BrowserAuctionSlotV1 { + slot: string; 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; + divId: string; + formats: ReadonlyArray; + targeting: Record; +} + +export interface BrowserAuctionProjectionV1 { + version: 1; + auction: AuctionDecisionSetV1; + slots: BrowserAuctionSlotV1[]; + bids: BrowserAuctionBidV1[]; } export type GptDiagnosticsCallbackKind = @@ -177,15 +111,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; } /** @@ -197,14 +131,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; } /** @@ -212,31 +146,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 = @@ -250,50 +172,50 @@ 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; - size?: Size; - isBackfill?: boolean; - slotContentChanged?: boolean; + isEmpty?: boolean | undefined; + size?: 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; + currentVisibilityPercentage?: number | undefined; + maximumVisibilityPercentage?: number | undefined; requests: GptDiagnosticsRequestCycle[]; } export interface GptDiagnosticsCallbackIssue { kind: GptDiagnosticsCallbackKind; runtimeSlotNumber: number; - slotElementId?: string; + slotElementId?: string | undefined; timestampMs: number; disposition: GptDiagnosticsCallbackDisposition; reason: string; @@ -359,147 +281,228 @@ export interface GptDiagnosticsApi { 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 for an associated GPT slot. */ recordTrustedServerOpportunity( slot: GptDiagnosticsSlotHandle, auctionSlotId: string, opportunity: GptDiagnosticsTrustedServerOpportunity, trustedServerAuctionId?: string ): 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; } -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; - }; +/** Release-internal critical module emitted inside the unified artifact. */ +export interface BootManifestCriticalIntegrationV1 { + readonly id: string; + readonly phase: 'critical'; +} + +/** 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 = + BootManifestCriticalIntegrationV1 | BootManifestDeferredIntegrationV1; + +/** Exact phase-aware bundle set and injection order required by one TSJS release. */ +export interface BootManifestV1 { + readonly version: 1; + readonly releaseId: string; + readonly criticalSrc: 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_network_error' + | 'cache_http_error' + | 'cache_invalid_response' + | '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; +} - // ── 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; - /** Render-trace registry: latest render per slot. */ - renders?: Record; - /** Append-only history of every render. */ - renderLog?: RenderRecord[]; - /** Monotonic render generation for cancelling stale async work. */ - renderGeneration?: number; - /** Page-global render sequence counter. */ - renderSeq?: number; - /** 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; - /** Guards SPA pushState hook installation. */ - spaHookInstalled?: 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: the hook's route identity is pathname plus query (matching - * the page-bids refresh hook), hash-only changes leave the counter - * unchanged, and an `/a → /b → /a` round trip (where the URL compares - * equal again) still 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. - */ - scheduleInitialAdInit?: (initialBids?: Record) => 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; +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 interface TsjsBootV1 { + readonly abi: 1; + readonly releaseId: string; + readonly manifest: Readonly; + readonly auctionProjection: Readonly; + readonly cachePolicy?: 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'; + }>; +} + +export type TsjsApi = TsjsKernelApi | TsjsFallbackApi; 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/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..dd1acadec --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/aps/module.ts @@ -0,0 +1,103 @@ +import type { MessagingAdapter } from '../../adapters/messaging'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../kernel/integration_registry'; +import type { RendererNonceRegistry, RenderAttempt } from '../../services/render'; + +import { renderDirectApsAttempt, resolveApsRendererV1Url, validateApsRenderer } from './render'; + +interface RenderCapability { + 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 { + return Object.freeze({ + abi: 1 as const, + id: 'aps', + phase: 'critical' as const, + releaseId, + prepare: (context: IntegrationPrepareContext) => { + const render = capability(context.interfaces, 'render.v1'); + const messages = capability(context.interfaces, 'messages.v1'); + if ( + typeof render.registerRenderer !== 'function' || + typeof render.publisherOrigin !== 'string' || + typeof messages.messaging !== 'object' || + messages.messaging === null || + typeof messages.registerApsValidation !== 'function' + ) { + throw new TypeError('APS capability graph is malformed'); + } + const rendererUrl = resolveApsRendererV1Url(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, + container, + messaging: messages.messaging, + nonces: render.rendererNonces, + publisherOrigin: render.publisherOrigin, + }); + const apsCapability = Object.freeze({ render: renderer }); + 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'); + 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, + }), + }); + }, + }); +} 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 ddda0f971..bb7712572 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -1,434 +1,689 @@ -import { log } from '../../core/log'; -import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; - -export const APS_RENDERER_PATH = '/integrations/aps/renderer'; +import type { ApsRendererV1 } from '../../core/types'; +import { validateApsRenderer } from '../../core/contracts/aps_renderer'; +import type { MessagingAdapter, MessagingChannel } from '../../adapters/messaging'; +import type { + CommittedRenderArtifact, + RenderAttempt, + RenderFailureReason, + RendererNonceRegistry, +} from '../../services/render'; + +const objectFreezeIntrinsic = Object.freeze; +const objectGetPrototypeOfIntrinsic = Object.getPrototypeOf; +const regexpTestIntrinsic = RegExp.prototype.test; +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 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 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_V1_PATH = '/integrations/aps/renderer/v1'; 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(); - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function hasExactKeys( - 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 - ) { +/** 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; } +} - return value as unknown as ApsRendererV1; +export interface DirectApsAttemptOptions { + readonly attempt: RenderAttempt; + readonly container: HTMLElement; + readonly messaging: MessagingAdapter; + readonly nonces: RendererNonceRegistry; + readonly publisherOrigin: string; } -function decodeStandardBase64(value: string): Uint8Array | undefined { - 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) - ) { - return undefined; - } +function freeze(value: Value): Readonly { + return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; +} +export function resolveApsRendererV1Url(publisherOrigin: string): 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)); + 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; + } + const rendererUrl = new URL(APS_RENDERER_V1_PATH, origin); + if ( + rendererUrl.origin !== origin.origin || + rendererUrl.pathname !== APS_RENDERER_V1_PATH || + rendererUrl.search !== '' || + rendererUrl.hash !== '' + ) { + return undefined; + } + return rendererUrl.href; } catch { return undefined; } } -function validCreativeUrl(value: string, publisherOrigin: string): boolean { - if (new TextEncoder().encode(value).length > MAX_CREATIVE_URL_BYTES) return false; - +function closeChannel(channel: MessagingChannel | undefined): void { try { - const url = new URL(value); - return ( - url.protocol === 'https:' && - url.username === '' && - url.password === '' && - url.origin !== publisherOrigin - ); + channel?.transferred.close(); } catch { - 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; + // The second endpoint must still be attempted when the first close is hostile. } - - const renderer = parseApsRendererDescriptor(value); - if (!renderer || !validCreativeUrl(renderer.creativeUrl, publisherOrigin)) return undefined; - - const bytes = decodeStandardBase64(renderer.aaxResponse); - if (!bytes) return undefined; - - let decoded: unknown; try { - decoded = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + channel?.retained.close(); } catch { - return undefined; - } - - 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; - } - - const bid = seat.bid[0]; - if (!hasExactKeys(bid, ['ext', 'h', 'id', 'price', 'w'])) return undefined; - if (!hasExactKeys(bid.ext, ['creativeurl', 'tagtype'])) return undefined; - - 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; + // Failed construction cleanup remains best-effort. } - - const validated = Object.freeze({ ...renderer }) as ApsRendererV1; - validatedRendererCache.set(value as object, { publisherOrigin, renderer: validated }); - validatedRendererCache.set(validated, { publisherOrigin, renderer: validated }); - return validated; } -function validPrebidIdentity(value: unknown): value is string { - return ( - typeof value === 'string' && - value.length > 0 && - new TextEncoder().encode(value).length <= MAX_PREBID_ID_BYTES - ); +function mapNonceIssueFailure( + reason: 'capability_registry_full' | 'identity_generation_failed' | 'invalid_attempt' +): RenderFailureReason { + return reason === 'invalid_attempt' ? 'internal_error' : reason; } -function validPrebidAdId(value: unknown): value is string { - return validPrebidIdentity(value) && /^[A-Za-z0-9-]+$/.test(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 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]; +function readNonceIssueResult(value: unknown): + | 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, rendererNoncePattern, [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; } - - 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]); } -/** 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?: { markWinner(): void; markRendered(): void } -): boolean { +/** Drive one direct APS attempt through the versioned static renderer document. */ +export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolean { if ( - !validPrebidAdId(adId) || - !validPrebidIdentity(adUnitCode) || - typeof lifecycle?.markWinner !== 'function' || - typeof lifecycle.markRendered !== 'function' + !directRenderDocument || + !directIframePrototype || + typeof documentCreateElementIntrinsic !== '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 htmlCollectionLengthGetter !== 'function' || + typeof iframeContentWindowGetter !== 'function' || + typeof iframeSourceGetter !== 'function' ) { return false; } - 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]]; + let attempt: RenderAttempt; + let messaging: MessagingAdapter; + let nonces: RendererNonceRegistry; + let container: HTMLElement; + let publisherOrigin: string; + let sourceCandidate: unknown; + let attemptId: string; + let attemptSlot: string; + let attemptGeneration: object; + let navigationGeneration: object; + let ownerDocument: Document; + try { + attempt = options.attempt; + messaging = options.messaging; + nonces = options.nonces; + container = options.container; + publisherOrigin = options.publisherOrigin; + sourceCandidate = attempt.renderSource; + attemptId = attempt.id; + attemptSlot = attempt.slot; + attemptGeneration = attempt.generation; + navigationGeneration = attempt.navigationGeneration; + if (typeof nodeOwnerDocumentGetter !== 'function') return false; + ownerDocument = Reflect.apply(nodeOwnerDocumentGetter, container, []) as Document; + } catch { + return false; } - - registry[adId] = { - adUnitCode, - renderer, - registeredAt: now, - expiresAt: now + boundedTtlSeconds * 1000, - markWinner: lifecycle.markWinner, - markRendered: lifecycle.markRendered, - }; - 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.markWinner !== 'function' || - typeof entry.markRendered !== 'function' - ) { - delete registry![adId]; - return undefined; + let exactDocumentOrigin: boolean; + try { + exactDocumentOrigin = + ownerDocument === directRenderDocument && + ownerDocument.defaultView?.location.origin === publisherOrigin; + } catch { + exactDocumentOrigin = false; + } + const renderer = prepareApsRenderSource(sourceCandidate, publisherOrigin); + const rendererUrl = resolveApsRendererV1Url(publisherOrigin); + if (!exactDocumentOrigin || !renderer || !rendererUrl) { + try { + attempt.fail('winner_not_renderable'); + } catch { + // Invalid input remains rejected even when the attempt boundary is hostile. + } + return false; + } + let createChannelMethod: MessagingAdapter['createChannel']; + let postWindowMethod: MessagingAdapter['postWindow']; + let parseMessageMethod: MessagingAdapter['parseProtocolMessage']; + let issueMethod: RendererNonceRegistry['issue']; + let bindSourceMethod: RendererNonceRegistry['bindSource']; + let consumeMethod: 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 { + createChannelMethod = messaging.createChannel; + postWindowMethod = messaging.postWindow; + parseMessageMethod = messaging.parseProtocolMessage; + issueMethod = nonces.issue; + bindSourceMethod = nonces.bindSource; + consumeMethod = nonces.consume; + beginDirectMethod = attempt.beginDirect; + beginDocumentMethod = attempt.beginApsDocument; + documentAcceptedMethod = attempt.apsDocumentAccepted; + acceptMethod = attempt.accept; + failMethod = attempt.fail; + snapshotMethod = attempt.snapshot; + if ( + typeof createChannelMethod !== 'function' || + typeof postWindowMethod !== 'function' || + typeof parseMessageMethod !== 'function' || + typeof issueMethod !== 'function' || + typeof bindSourceMethod !== 'function' || + typeof consumeMethod !== 'function' || + typeof beginDirectMethod !== 'function' || + typeof beginDocumentMethod !== 'function' || + typeof documentAcceptedMethod !== 'function' || + typeof acceptMethod !== 'function' || + typeof failMethod !== 'function' || + typeof snapshotMethod !== 'function' || + Reflect.apply(beginDirectMethod, attempt, []) !== true + ) { + return false; + } + } catch { + return false; } - 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 fail = (reason: RenderFailureReason): false => { + try { + Reflect.apply(failMethod, attempt, [reason]); + } catch { + // The attempt's terminal latch owns failure authority. + } + return false; + }; -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, ''); -} + const attemptState = (): ReturnType['state'] | undefined => { + try { + return Reflect.apply(snapshotMethod, attempt, []).state; + } catch { + return undefined; + } + }; -/** Return the absolute, same-publisher URL used by direct and Universal Creative rendering. */ -export function apsRendererUrl(pageOrigin = window.location.origin): string | undefined { + let iframe: HTMLIFrameElement; try { - const origin = new URL(pageOrigin); - const url = new URL(APS_RENDERER_PATH, origin); if ( - url.origin !== origin.origin || - url.pathname !== APS_RENDERER_PATH || - url.search !== '' || - url.hash !== '' + typeof nodeParentNodeGetter !== 'function' || + typeof nodeIsConnectedGetter !== 'function' || + typeof elementLocalNameGetter !== 'function' || + typeof elementNamespaceGetter !== 'function' || + typeof elementChildrenGetter !== 'function' || + typeof htmlCollectionLengthGetter !== 'function' || + typeof iframeContentWindowGetter !== 'function' || + typeof iframeSourceGetter !== 'function' ) { - return undefined; + return fail('renderer_document_no_load'); + } + 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', + `border: 0; display: block; height: ${renderer.height}px; margin: 0; overflow: hidden; width: ${renderer.width}px`, + ], + ] as const; + for (let index = 0; index < attributes.length; index += 1) { + const attribute = attributes[index]; + if (attribute) { + Reflect.apply(elementSetAttributeIntrinsic, iframe, [attribute[0], attribute[1]]); + } } - return url.href; } catch { - return undefined; + return fail('renderer_document_no_load'); } -} - -export interface RenderApsCreativeOptions { - slotId: string; - renderer: unknown; -} -/** 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; + let channel: MessagingChannel | undefined; + try { + channel = Reflect.apply(createChannelMethod, messaging, []); + } catch { + channel = undefined; } - - const container = document.getElementById(slotId); - if (!container) { - log.warn('APS renderer: slot not found'); - return false; + if (!channel) return fail('internal_error'); + let issueResult: unknown; + try { + issueResult = Reflect.apply(issueMethod, nonces, [{ attempt, port: channel.retained }]); + } catch { + closeChannel(channel); + return fail('identity_generation_failed'); + } + const issued = readNonceIssueResult(issueResult); + if (!issued) { + closeChannel(channel); + return fail('identity_generation_failed'); } + if (!issued.ok) { + closeChannel(channel); + return fail(mapNonceIssueFailure(issued.reason)); + } + const nonce = issued.nonce; + let boundSource: object | undefined; + let documentAccepted = false; + let disposed = false; + let sourceAssigned = false; + let appendInProgress = false; + let insertionCommitted = false; + let loadObserved = false; + let errorObserved = false; + let envelopeTransferred = false; + let artifactOwnedByAttempt = false; + let insertionPredecessors: readonly Element[] = []; + const expectedFrameSource = `${rendererUrl}#tsaps=${nonce}`; + + const removeFrameListeners = (): void => { + try { + Reflect.apply(eventTargetRemoveListenerIntrinsic, iframe, ['load', onLoad]); + } catch { + // Listener removal cannot interrupt terminal resource cleanup. + } + try { + Reflect.apply(eventTargetRemoveListenerIntrinsic, iframe, ['error', onError]); + } catch { + // The second listener is always attempted. + } + }; + + const artifact: CommittedRenderArtifact = freeze({ + kind: 'direct_iframe' as const, + attemptId, + slot: attemptSlot, + navigationGeneration, + dispose: (): void => { + if (disposed) return; + disposed = true; + removeFrameListeners(); + try { + channel?.transferred.close(); + } catch { + // A transferred endpoint is inert; an untransferred endpoint is locally closed. + } + try { + Reflect.apply(elementRemoveIntrinsic, iframe, []); + } catch { + // DOM removal remains best-effort under a hostile page. + } + }, + }); - 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}`; + const startupFailure = (reason: RenderFailureReason): false => { + if (!artifactOwnedByAttempt) artifact.dispose(); + return fail(reason); + }; - // 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); + const nonceExpectation = (source: object) => + freeze({ nonce, attempt, generation: attemptGeneration, source, port: channel!.retained }); - let settled = false; - const cleanup = (): void => { - window.removeEventListener('message', receive); - window.clearTimeout(timeoutId); + const messageData = (event: unknown): unknown => { + try { + return typeof event === 'object' && event !== null ? Reflect.get(event, 'data') : undefined; + } catch { + return undefined; + } }; - 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 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 index = 0; index < length; index += 1) { + const child = Reflect.apply(htmlCollectionItemIntrinsic, children, [ + index, + ]) as Element | null; + if (child && child !== iframe) predecessors[predecessors.length] = child; + } + } catch { + // Failure to inspect publisher siblings cannot expand cleanup authority. + } + return predecessors; }; - const fail = (): void => { - if (settled) return; - cancel(); - log.warn('APS renderer: frame load failed'); + + const commitContainer = (predecessors: readonly Element[]): void => { + try { + for (let index = predecessors.length - 1; index >= 0; index -= 1) { + const child = predecessors[index]; + if ( + child && + child !== iframe && + Reflect.apply(nodeParentNodeGetter!, child, []) === container + ) { + Reflect.apply(nodeRemoveChildIntrinsic, container, [child]); + } + } + } catch { + // The accepted artifact remains authoritative if publisher sibling cleanup is hostile. + } }; - 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 = ''; + + const exactFrameBinding = (): boolean => { + try { + // This binds the native element and browsing context. An opaque Document cannot + // be attested after ancestor-controlled contentWindow.location navigation (§4.4). + return ( + insertionCommitted && + Reflect.apply(nodeParentNodeGetter!, iframe, []) === container && + Reflect.apply(nodeIsConnectedGetter!, iframe, []) === true && + Reflect.apply(iframeContentWindowGetter!, iframe, []) === boundSource && + Reflect.apply(elementGetAttributeIntrinsic, iframe, ['src']) === expectedFrameSource && + Reflect.apply(iframeSourceGetter!, iframe, []) === expectedFrameSource + ); + } catch { + return false; + } }; - function receive(event: MessageEvent): void { - if (event.source !== iframe.contentWindow || !hasExactKeys(event.data, ['message', 'nonce'])) { + + const receive = (event: unknown): void => { + if (disposed || !boundSource || !envelopeTransferred) return; + if (!exactFrameBinding()) { + fail(documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); return; } - if (event.data.nonce !== nonce) return; - if (event.data.message === RENDERER_READY_MESSAGE) commit(); - else if (event.data.message === RENDERER_FAILED_MESSAGE) fail(); + const data = messageData(event); + const accepted = Reflect.apply(parseMessageMethod, messaging, ['apsDocumentAccepted', data]); + if (accepted?.['nonce'] === nonce) { + if (documentAccepted || attemptState() !== 'waiting_for_document') return; + const expectation = nonceExpectation(boundSource); + if ( + Reflect.apply(consumeMethod, nonces, [expectation]) === true && + Reflect.apply(documentAcceptedMethod, attempt, []) === true + ) { + documentAccepted = true; + } + return; + } + const loaded = Reflect.apply(parseMessageMethod, messaging, ['apsRunnerLoaded', data]); + if (loaded?.['nonce'] === nonce) return; + const completed = Reflect.apply(parseMessageMethod, messaging, ['apsRenderCompleted', data]); + if (completed?.['nonce'] === nonce) { + if (documentAccepted && Reflect.apply(acceptMethod, attempt, []) === true) { + if (!disposed && exactFrameBinding()) commitContainer(insertionPredecessors); + } + return; + } + const failed = Reflect.apply(parseMessageMethod, messaging, ['apsRenderFailed', data]); + if (failed?.['nonce'] !== nonce) return; + const reason = mapRunnerFailure(failed['reason']); + if (reason) Reflect.apply(failMethod, attempt, [reason]); + }; + + const receiveError = (): void => { + if (disposed || !envelopeTransferred) return; + fail(documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); + }; + + const transferEnvelope = (): void => { + if (disposed || envelopeTransferred || !loadObserved || !boundSource) return; + if (!exactFrameBinding()) { + fail('renderer_document_no_load'); + return; + } + if (attemptState() !== 'waiting_for_document') { + fail('internal_error'); + return; + } + const envelope = freeze({ version: 1 as const, nonce, publisherOrigin, renderer }); + const posted = Reflect.apply(postWindowMethod, messaging, [ + boundSource, + envelope, + '*', + [channel!.transferred], + ]); + if (posted !== true || !exactFrameBinding()) { + fail('renderer_document_no_load'); + return; + } + envelopeTransferred = true; + removeFrameListeners(); + }; + + function onLoad(): void { + if ( + disposed || + !sourceAssigned || + (!insertionCommitted && + !(appendInProgress && Reflect.apply(nodeParentNodeGetter!, iframe, []) === container)) + ) { + return; + } + loadObserved = true; + transferEnvelope(); } - 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 }); + function onError(): void { + if ( + disposed || + envelopeTransferred || + !sourceAssigned || + (!insertionCommitted && + !(appendInProgress && Reflect.apply(nodeParentNodeGetter!, iframe, []) === container)) + ) { + return; + } + errorObserved = true; + if (artifactOwnedByAttempt) fail('renderer_document_no_load'); + } - const timeoutId = window.setTimeout(fail, RENDERER_READY_TIMEOUT_MS); - pendingFrameCancels.set(container, cancel); - container.appendChild(iframe); - return true; + try { + channel.retained.listen(receive, receiveError); + Reflect.apply(eventTargetAddListenerIntrinsic, iframe, ['load', onLoad, { once: true }]); + Reflect.apply(eventTargetAddListenerIntrinsic, iframe, ['error', onError, { once: true }]); + Reflect.apply(elementSetAttributeIntrinsic, iframe, ['src', expectedFrameSource]); + if ( + Reflect.apply(elementGetAttributeIntrinsic, iframe, ['src']) !== expectedFrameSource || + Reflect.apply(iframeSourceGetter!, iframe, []) !== expectedFrameSource + ) { + return startupFailure('renderer_document_no_load'); + } + sourceAssigned = true; + if ( + disposed || + attemptState() !== 'rendering_direct' || + Reflect.apply(nodeIsConnectedGetter, container, []) !== true + ) { + return startupFailure('renderer_document_no_load'); + } + insertionPredecessors = snapshotContainerPredecessors(); + if ( + disposed || + attemptState() !== 'rendering_direct' || + Reflect.apply(nodeIsConnectedGetter, container, []) !== true + ) { + return startupFailure('renderer_document_no_load'); + } + appendInProgress = true; + try { + Reflect.apply(nodeAppendChildIntrinsic, container, [iframe]); + } finally { + appendInProgress = false; + } + if (Reflect.apply(nodeParentNodeGetter!, iframe, []) !== container) { + return startupFailure('renderer_document_no_load'); + } + insertionCommitted = true; + if (Reflect.apply(beginDocumentMethod, attempt, [artifact]) !== true) { + return startupFailure('internal_error'); + } + artifactOwnedByAttempt = true; + if (disposed) return false; + if (errorObserved) return fail('renderer_document_no_load'); + if (attemptState() !== 'waiting_for_document') return fail('internal_error'); + const source = Reflect.apply(iframeContentWindowGetter!, iframe, []) as Window | null; + if (!source) return fail('renderer_document_no_load'); + boundSource = source; + if (!exactFrameBinding()) return fail('renderer_document_no_load'); + if (Reflect.apply(bindSourceMethod, nonces, [nonceExpectation(source)]) !== true) { + return fail('renderer_document_no_load'); + } + transferEnvelope(); + return true; + } catch { + return startupFailure('renderer_document_no_load'); + } } - -/** - * 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"); -if(typeof MessageChannel!=="function")throw new Error("APS renderer channel 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 { - 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; } @@ -347,17 +372,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; @@ -367,7 +400,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); }); @@ -377,16 +412,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) => { @@ -394,14 +436,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; @@ -416,27 +458,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 2fc216c32..b8152c439 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,5 +1,7 @@ import { log } from '../../core/log'; -import { createMutationScheduler } from '../../shared/scheduler'; +import { createMutationScheduler, type MutationScheduler } from '../../shared/scheduler'; + +import type { CreativeGuardHandle } from './startup'; type ElementWithSrc = Element & { src: string }; @@ -14,6 +16,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; @@ -26,302 +33,412 @@ 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 requestId = ++sequence; - assignments.set(element, { raw, requestId }); + 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 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 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); - log.info(`${options.logPrefix}: signing ${options.resourceName} ${attr}`, { raw }); - void options - .signProxy(raw, element) - .then((signed) => { - const current = assignments.get(element); - if (!current || current.requestId !== requestId) return; + const requestId = ++sequence; + assignments.set(element, { raw, requestId }); + + 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); - const finalUrl = signed || raw; - if (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; + apply(element, raw); + 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((signed) => { + if (!active) return; + const current = assignments.get(element); + if (!current || current.requestId !== requestId) return; + assignments.delete(element); + const finalUrl = signed || raw; + if (signed) { + log.info(`${options.logPrefix}: proxied dynamic ${options.resourceName}`, { + base: raw, + finalUrl, + }); } - continue; - } + 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 handle = Object.freeze({ dispose, scan }); - 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; - - 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..781d3afe2 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/creative/module.ts @@ -0,0 +1,112 @@ +import type { CreativeBootV1 } from '../../core/types'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../kernel/integration_registry'; +import type { RuntimeCapabilityV1 } from '../../kernel/runtime'; + +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 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 { + return Object.freeze({ + abi: 1, + id: CREATIVE_INTEGRATION_ID, + phase: 'critical', + releaseId, + 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: () => undefined }); + } + const runtime = createCreativeStartup({ + document: runtimeDocument, + installClickGuard: () => installClickGuard(false), + installDynamicIframeProxy: () => installDynamicIframeProxy(false), + installDynamicImageProxy: () => installDynamicImageProxy(false), + }); + + return Object.freeze({ + activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { + 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)); + }, + }); + }, + }); +} 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..58bf34b8f --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/datadome/module.ts @@ -0,0 +1,54 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +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(), + validateConfig: (candidate) => candidate === undefined, + }); +} 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..f13f131d5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/didomi/module.ts @@ -0,0 +1,146 @@ +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(), + validateConfig: didomiBootConfig, + }); +} 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)]); } - - 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..223b727d6 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt/later.ts @@ -0,0 +1,211 @@ +import { EMBEDDED_RELEASE_ID } from '../../core/release'; +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 ( + config !== undefined || + !runtime || + !Object.isFrozen(runtime) || + !runtime.document?.defaultView || + !gpt || + !Object.isFrozen(gpt) || + typeof gpt.activateLaterLifecycle !== 'function' + ) { + throw new TypeError('GPT later capability graph is invalid'); + } + 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'); + } + 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..029007752 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -0,0 +1,1316 @@ +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, + PreparedIntegration, +} from '../../kernel/integration_registry'; +import { + createBrowserGoogletagAdapter, + type GoogletagAdapter, + type GoogletagFacade, +} from '../../adapters/googletag'; +import type { MessagingAdapter } from '../../adapters/messaging'; +import { + parseBrowserAuctionProjectionV1, + isAuctionCandidateIdV1, + isRendererReservationIdV1, +} from '../../core/contracts/auction_projection'; +import type { + BrowserAuctionBidV1, + BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, + CacheFetchPolicyV1, +} from '../../core/types'; +import { log } from '../../core/log'; +import { prepareAdmIframe } from '../../core/render'; +import { DisposableStack } from '../../kernel/disposable'; +import type { RuntimeCapabilityV1 } from '../../kernel/runtime'; +import type { NavigationSession, RenderAttemptScope, RuntimeSession } from '../../kernel/sessions'; +import type { + CacheAdmResolutionOptions, + CommittedRenderArtifact, + DirectCacheAttemptOptions, + RenderAttempt, + RenderAttemptCreationResult, + RenderFailureReason, + RendererNonceRegistry, + SlotOperationCreationResult, + SlotOperationOptions, +} from '../../services/render'; +import { + createPucBridge, + type PucBridge, + type PucGamAttemptInput, +} from '../../services/puc_bridge'; +import type { ReservationService } from '../../services/reservations'; +import { createPageBidsController } from '../../services/projections'; +import { + createBrowserSlotReconciliationBoundary, + createSlotService, + type SlotRequestOutcome, + type SlotService, +} from '../../services/slots'; +import type { + TargetingBoundary, + TargetingOwnership, + TargetingService, +} from '../../services/targeting'; +import { createTargetingService } from '../../services/targeting'; + +import { + activateGptDiagnosticsEventListeners, + createGptDiagnosticsFactBuffer, + projectGptTraceFact, +} from './diagnostics_facts'; +import { installGptGuard, resetGuardState } from './script_guard'; +import { createGptStartup } from './startup'; + +export const GPT_INTEGRATION_ID = 'gpt' as const; + +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 MAX_CONFIG_DEPTH = 16; +const MAX_CONFIG_NODES = 512; +const MAX_CONFIG_MEMBERS = 256; +const arrayIsArrayIntrinsic = Array.isArray; +const numberIsFiniteIntrinsic = Number.isFinite; +const objectGetOwnPropertyDescriptorIntrinsic = Object.getOwnPropertyDescriptor; +const objectGetOwnPropertyNamesIntrinsic = Object.getOwnPropertyNames; +const objectGetOwnPropertySymbolsIntrinsic = Object.getOwnPropertySymbols; +const objectGetPrototypeOfIntrinsic = Object.getPrototypeOf; +const objectIsFrozenIntrinsic = Object.isFrozen; +const promiseThenIntrinsic = Promise.prototype.then; +const reflectApplyIntrinsic = Reflect.apply; + +interface ProductionAuctionCapability { + readonly navigation: NavigationSession; + readonly projection: Readonly; + readonly session: RuntimeSession; +} + +interface ProductionSlotsCapability { + readonly attachPhysicalService: (service: SlotService) => () => void; +} + +interface ProductionRenderCapability { + readonly attachPucGamAttemptRegistrar: ( + registrar: (input: PucGamAttemptInput) => boolean + ) => () => void; + readonly artifacts: Readonly<{ + current: (slot: string) => CommittedRenderArtifact | undefined; + release: (artifact: CommittedRenderArtifact) => boolean; + }>; + readonly cachePolicy?: Readonly; + readonly createAttempt: ( + owner: RenderAttemptScope, + parentAttemptId?: string + ) => RenderAttemptCreationResult; + readonly createSlotOperation: (options: SlotOperationOptions) => SlotOperationCreationResult; + readonly publisherOrigin: string; + readonly registerRenderer: ( + type: 'cache', + renderer: (attempt: RenderAttempt, container: HTMLElement) => boolean + ) => () => void; + readonly rendererNonces: RendererNonceRegistry; + readonly renderDirectCacheAttempt: (options: DirectCacheAttemptOptions) => boolean; + readonly renderWinner: (attempt: RenderAttempt) => boolean; + readonly reservations: ReservationService; + readonly resolveCacheAdmAttempt: (options: CacheAdmResolutionOptions) => boolean; +} + +interface ProductionMessagesCapability { + readonly messaging: MessagingAdapter; +} + +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; +} + +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: BrowserAuctionBidV1; + 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): boolean { + try { + const projection = input.navigation.currentAuctionProjection as + BrowserAuctionProjectionV1 | undefined; + 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) || + !isAuctionCandidateIdV1(bid.candidateId) || + !isRendererReservationIdV1(bid.rendererReservationId) || + 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') 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 = [ + Object.freeze(['hb_adid', bid.rendererReservationId]), + ]; + 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. + } + }; + if (!currentProjectedWinner(input)) { + disposeArtifact(); + return failAttempt('winner_not_renderable'); + } + 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, + 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; +} + +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, + }); + } 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; + } +} + +function terminalLatch(attempt: RenderAttempt): Promise { + return new Promise((resolve) => { + if (!attempt.onSettled(resolve)) resolve(attempt.snapshot().outcome); + }); +} + +interface PreparedInitialGptWinner { + readonly attempt: RenderAttempt; + readonly bid: BrowserAuctionBidV1; + 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; +} + +/** 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[] = []; + 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 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: physicalBySlot.get(decision.slot), + decision, + owner: owner.value, + placement, + terminal: terminalLatch(created.value), + }) + ); + } + if (prepared.length === 0) return; + input.protect(Object.freeze(prepared.map(({ terminal }) => terminal))); + + await Promise.all( + prepared.map(async ({ attempt, bid, binding, decision, owner, placement }) => { + 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, + 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); + } + }) + ); +} + +function validFrozenConfig(candidate: unknown): boolean { + const seen = new Set(); + let nodes = 0; + const visit = (value: unknown, depth: number, topLevel: boolean): boolean => { + if (value === undefined) return topLevel; + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; + if (typeof value === 'number') return numberIsFiniteIntrinsic(value); + if (typeof value !== 'object' || depth > MAX_CONFIG_DEPTH || nodes >= MAX_CONFIG_NODES) { + return false; + } + if (seen.has(value) || !objectIsFrozenIntrinsic(value)) return false; + seen.add(value); + nodes += 1; + + const array = arrayIsArrayIntrinsic(value); + const prototype = objectGetPrototypeOfIntrinsic(value); + if ( + (!array && prototype !== Object.prototype && prototype !== null) || + (array && prototype !== Array.prototype) + ) { + return false; + } + if (objectGetOwnPropertySymbolsIntrinsic(value).length !== 0) return false; + const names = objectGetOwnPropertyNamesIntrinsic(value); + if (names.length > MAX_CONFIG_MEMBERS + (array ? 1 : 0)) return false; + if (array) { + const length = objectGetOwnPropertyDescriptorIntrinsic(value, 'length'); + if (!length || !('value' in length) || names.length !== length.value + 1) return false; + for (let index = 0; index < length.value; index += 1) { + const descriptor = objectGetOwnPropertyDescriptorIntrinsic(value, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return false; + if (!visit(descriptor.value, depth + 1, false)) return false; + } + return true; + } + + for (const name of names) { + const descriptor = objectGetOwnPropertyDescriptorIntrinsic(value, name); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return false; + if (!visit(descriptor.value, depth + 1, false)) return false; + } + return true; + }; + + try { + return visit(candidate, 0, true); + } catch { + return false; + } +} + +function prepareProductionGpt(context: IntegrationPrepareContext): PreparedIntegration { + const runtime = exactCapability(context.interfaces, 'runtime.v1'); + if (!runtime) throw new TypeError('GPT requires runtime.v1'); + if (!validFrozenConfig(context.config)) throw new TypeError('GPT integration config is invalid'); + const auction = exactCapability(context.interfaces, 'auction.v1'); + const slotCapability = exactCapability(context.interfaces, 'slots.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.createAttempt !== 'function' || + typeof render.createSlotOperation !== 'function' || + typeof render.registerRenderer !== 'function' || + typeof render.renderDirectCacheAttempt !== 'function' || + typeof render.renderWinner !== 'function' || + typeof render.resolveCacheAdmAttempt !== 'function' || + typeof render.publisherOrigin !== 'string' || + 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 reconciliation = createBrowserSlotReconciliationBoundary( + document, + document.defaultView.MutationObserver + ); + const slots = createSlotService({ + disposeCommittedArtifact: (navigationGeneration, registeredSlotId) => { + const artifact = render.artifacts.current(registeredSlotId); + if (artifact?.navigationGeneration === navigationGeneration) + render.artifacts.release(artifact); + }, + googletag, + ...(reconciliation ? { reconciliation } : {}), + }); + scope.onDispose(() => slots.dispose()); + const targeting = createTargetingService(); + scope.onDispose(() => targeting.dispose()); + const fetchCache = globalThis.fetch; + const cacheRenderer = (attempt: RenderAttempt, container: HTMLElement): boolean => { + if (!active) return false; + const cachePolicy = render.cachePolicy; + if (!cachePolicy) { + attempt.fail('descriptor_invalid'); + return false; + } + if (typeof fetchCache !== 'function') { + attempt.fail('cache_network_error'); + return false; + } + return render.renderDirectCacheAttempt({ + attempt, + cachePolicy, + container, + fetcher: (input, init) => fetchCache(input, init), + prepareIframe: prepareAdmIframe, + publisherOrigin: render.publisherOrigin, + }); + }; + const resolveCacheAdm = ( + attempt: Parameters[0]['resolveCacheAdm']>>[0], + onResolved: Parameters[0]['resolveCacheAdm']>>[1] + ): boolean => { + const cachePolicy = render.cachePolicy; + if (!cachePolicy) { + attempt.fail('descriptor_invalid'); + return false; + } + if (typeof fetchCache !== 'function') { + attempt.fail('cache_network_error'); + return false; + } + return render.resolveCacheAdmAttempt({ + attempt: attempt as RenderAttempt, + cachePolicy, + fetcher: (input, init) => fetchCache(input, init), + onResolved, + }); + }; + const rendererUrl = new URL('/integrations/aps/renderer/v1', render.publisherOrigin).href; + const startup = createGptStartup({ googletag, slots: () => slots }); + 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); + let pucBridge: PucBridge | undefined; + let criticalReconciliationRelease: (() => 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 = criticalReconciliationRelease; + if (!releaseReconciliation) { + throw new TypeError('GPT critical reconciliation owner is unavailable'); + } + criticalReconciliationRelease = 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); + const pageBids = createPageBidsController({ + navigation, + parseProjection: (value) => + parseBrowserAuctionProjectionV1(value, render.cachePolicy), + slotRegistry: slots.projectionRegistry(navigation), + }); + if (pageBids.commit(candidate).status !== 'committed') 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({ render: cacheRenderer }); + + return Object.freeze({ + activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { + if (active) throw new Error('GPT already activated'); + const cacheRelease: { current?: () => void } = {}; + const diagnosticsEventRelease: { current?: () => void } = {}; + const diagnosticsRelease: { current?: () => void } = {}; + const publisherRelease: { current?: () => void } = {}; + const slotServiceRelease: { current?: () => void } = {}; + const bridgeRelease: { current?: () => void } = {}; + const pucRegistrarRelease: { current?: () => void } = {}; + onDispose(resetGuardState); + onDispose(() => cacheRelease.current?.()); + onDispose(() => diagnosticsEventRelease.current?.()); + onDispose(() => diagnosticsRelease.current?.()); + onDispose(() => publisherRelease.current?.()); + onDispose(() => slotServiceRelease.current?.()); + onDispose(() => bridgeRelease.current?.()); + onDispose(() => pucRegistrarRelease.current?.()); + onDispose(() => { + active = false; + const release = laterLifecycleRelease; + laterLifecycleRelease = undefined; + release?.(); + const releaseCriticalReconciliation = criticalReconciliationRelease; + criticalReconciliationRelease = undefined; + releaseCriticalReconciliation?.(); + }); + + slotServiceRelease.current = slotCapability.attachPhysicalService(slots); + slots.start(); + criticalReconciliationRelease = slots.activateReconciliation(); + const bridge = createPucBridge({ + messaging: messages.messaging, + publisherOrigin: render.publisherOrigin, + rendererNonces: render.rendererNonces, + rendererUrl, + reservations: render.reservations, + resolveCacheAdm, + }); + 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; + } + publisherRelease.current = startup.activate(); + cacheRelease.current = render.registerRenderer('cache', cacheRenderer); + installGptGuard(); + active = true; + afterCommit(() => { + startup.start(context.config); + 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 { + return Object.freeze({ + abi: 1, + id: GPT_INTEGRATION_ID, + phase: 'critical', + releaseId, + prepare: (context: IntegrationPrepareContext) => prepareProductionGpt(context), + }); +} 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 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('fails APS start closed without exact generation expectations and semantic validation', () => { + const message = { + message: 'TS APS Start', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + envelope: { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer: Object.freeze(createApsRenderer()), + }, + }; + expect( + createBrowserMessagingAdapter(createTarget()).parseProtocolMessage('apsStart', message) + ).toBeUndefined(); + + const adapter = createBrowserMessagingAdapter(createTarget(), { + expectedPublisherOrigin: 'https://publisher.example', + expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + validateApsRenderer: () => true, + }); + expect(adapter.parseProtocolMessage('apsStart', message)).toBeDefined(); + expect( + adapter.parseProtocolMessage('apsStart', { + ...message, + envelope: { ...message.envelope, publisherOrigin: 'https://wrong.example' }, + }) + ).toBeUndefined(); + const throwing = createBrowserMessagingAdapter(createTarget(), { + expectedPublisherOrigin: 'https://publisher.example', + expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + validateApsRenderer: () => { + throw new Error('validator failed'); + }, + }); + expect(() => throwing.parseProtocolMessage('apsStart', message)).not.toThrow(); + expect(throwing.parseProtocolMessage('apsStart', message)).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/v1', + validateApsRenderer: validator, + }); + const parsed = adapter.parseProtocolMessage('apsStart', { + message: 'TS APS Start', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + envelope: { + 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?.['envelope'] as Readonly> | undefined)?.['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/v1', + 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('validates both renderer URL expectations and candidates before exact equality', () => { + const invalidUrls = [ + '/integrations/aps/renderer/v1', + 'ftp://publisher.example/integrations/aps/renderer/v1', + 'https://user@publisher.example/integrations/aps/renderer/v1', + 'https://publisher.example/integrations/aps/renderer/v1?query=1', + 'https://publisher.example/integrations/aps/renderer/v1#fragment', + 'https://publisher.example/wrong-path', + ]; + for (const invalidUrl of invalidUrls) { + const adapter = createBrowserMessagingAdapter(createTarget(), { + expectedPublisherOrigin: 'https://publisher.example', + expectedRendererUrl: invalidUrl, + validateApsRenderer: () => true, + }); + expect( + adapter.parseProtocolMessage('apsStart', { + message: 'TS APS Start', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + rendererUrl: invalidUrl, + envelope: { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer: createApsRenderer(), + }, + }) + ).toBeUndefined(); + } + }); + + 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: '3', + 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: '3', + 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..501f950dd --- /dev/null +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -0,0 +1,1810 @@ +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.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..0e8a7e89a 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,24 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { + ARTIFACT_RELEASE_SENTINEL, + assertNoLegacyRuntimeFlags, 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('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 +47,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 +84,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 +129,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..3a4e51458 --- /dev/null +++ b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs @@ -0,0 +1,286 @@ +import assert from 'node:assert/strict'; +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, 'gpt-bootstrap-fallback.js'), 'utf8'); +const trustedCriticalSrc = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; + +function documentWithCriticalArtifact() { + return new JSDOM( + ``, + { runScripts: 'outside-only', url: 'https://publisher.example/page' } + ); +} + +const untrustedArtifactDocuments = [ + { + state: 'no critical tag', + create: () => + new JSDOM('', { + runScripts: 'outside-only', + url: 'https://publisher.example/page', + }), + }, + { + state: 'malformed critical tag', + create: () => + new JSDOM( + '', + { runScripts: 'outside-only', url: 'https://publisher.example/page' } + ), + }, +]; + +const namespaceStates = [ + { + state: 'absent namespace', + install: () => undefined, + }, + { + state: 'primitive namespace', + install: (browser) => { + Object.defineProperty(browser, 'tsjs', { + configurable: true, + enumerable: false, + value: 17, + writable: false, + }); + }, + }, + { + state: 'accessor namespace', + install: (browser) => { + const get = () => 'publisher'; + const set = () => undefined; + Object.defineProperty(browser, 'tsjs', { + configurable: true, + enumerable: false, + get, + set, + }); + }, + }, + { + state: 'object namespace descriptor', + install: (browser) => { + const namespace = {}; + Object.defineProperty(namespace, 'publisher', { + configurable: false, + enumerable: false, + value: 'retained', + writable: false, + }); + Object.defineProperty(browser, 'tsjs', { + configurable: true, + enumerable: false, + value: namespace, + writable: false, + }); + }, + }, +]; + +for (const artifact of untrustedArtifactDocuments) { + for (const namespace of namespaceStates) { + test(`generated fallback preserves the exact ${namespace.state} with ${artifact.state}`, () => { + const dom = artifact.create(); + dom.window.TextEncoder = TextEncoder; + dom.window.TextDecoder = TextDecoder; + namespace.install(dom.window); + const before = Object.getOwnPropertyDescriptor(dom.window, 'tsjs'); + const beforeNamespaceDescriptors = + before && 'value' in before && typeof before.value === 'object' && before.value !== null + ? Object.getOwnPropertyDescriptors(before.value) + : undefined; + + dom.window.eval(source); + + assert.deepEqual(Object.getOwnPropertyDescriptor(dom.window, 'tsjs'), before); + if (beforeNamespaceDescriptors) { + assert.deepEqual( + Object.getOwnPropertyDescriptors(before.value), + beforeNamespaceDescriptors + ); + } + dom.window.close(); + }); + } +} + +test('generated fallback uses the independently captured critical source when manifest source is missing', () => { + const dom = documentWithCriticalArtifact(); + dom.window.TextEncoder = TextEncoder; + dom.window.TextDecoder = TextDecoder; + dom.window.tsjs = { + que: [], + boot: { + manifest: { + version: 1, + releaseId: 'b'.repeat(64), + integrations: [], + }, + }, + }; + + dom.window.eval(source); + + assert.deepEqual(JSON.parse(JSON.stringify(dom.window.tsjs.boot.manifest)), { + version: 1, + releaseId: manifest.releaseId, + criticalSrc: trustedCriticalSrc, + integrations: [], + }); + dom.window.close(); +}); + +test('generated fallback uses the independently captured critical source when manifest source is malformed', () => { + const dom = documentWithCriticalArtifact(); + dom.window.TextEncoder = TextEncoder; + dom.window.TextDecoder = TextDecoder; + dom.window.tsjs = { + que: [], + boot: { + manifest: { + version: 1, + releaseId: 'b'.repeat(64), + criticalSrc: `${trustedCriticalSrc}&publisher=1`, + integrations: [], + }, + }, + }; + + dom.window.eval(source); + + assert.deepEqual(JSON.parse(JSON.stringify(dom.window.tsjs.boot.manifest)), { + version: 1, + releaseId: manifest.releaseId, + criticalSrc: trustedCriticalSrc, + integrations: [], + }); + dom.window.close(); +}); + +test('generated fallback leaves the namespace unclaimed without a trusted critical source', () => { + const dom = new JSDOM('', { + runScripts: 'outside-only', + url: 'https://publisher.example/page', + }); + dom.window.TextEncoder = TextEncoder; + dom.window.TextDecoder = TextDecoder; + const boot = { + manifest: { + version: 1, + releaseId: 'b'.repeat(64), + integrations: [], + }, + }; + const namespace = { que: [], boot }; + dom.window.tsjs = namespace; + + dom.window.eval(source); + + assert.equal(dom.window.tsjs, namespace); + assert.equal(dom.window.tsjs.boot, boot); + assert.equal(Object.hasOwn(namespace, 'releaseId'), false); + assert.equal(Object.hasOwn(namespace, '_internal'), false); + dom.window.close(); +}); + +test('generated fallback bytes are stamped, executable, and add no callable global', async () => { + assert.equal(source.includes('__TSJS_RELEASE_ID_SENTINEL_V1__'), false); + assert.equal(source.split(manifest.releaseId).length - 1, 1); + const dom = documentWithCriticalArtifact(); + dom.window.TextEncoder = TextEncoder; + dom.window.TextDecoder = TextDecoder; + const queued = []; + dom.window.tsjs = { + diagnostics: { legacy: true }, + adInit() { + throw new Error('legacy runtime must be removed'); + }, + que: [ + function () { + queued.push(this); + }, + ], + boot: { + abi: 1, + releaseId: 'b'.repeat(64), + manifest: { version: 1, releaseId: 'b'.repeat(64), integrations: [] }, + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'boot', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + }; + + dom.window.eval(source); + + assert.equal(dom.window.tsjs.releaseId, manifest.releaseId); + assert.equal(dom.window.tsjs.boot.releaseId, manifest.releaseId); + assert.equal(dom.window.tsjs.boot.manifest.releaseId, manifest.releaseId); + assert.equal(dom.window.tsjs._internal.reason, 'bundle_partial'); + assert.equal(Object.hasOwn(dom.window.tsjs, 'diagnostics'), false); + assert.equal(Object.hasOwn(dom.window.tsjs, 'adInit'), false); + assert.equal(queued.length, 1); + assert.equal(queued[0], dom.window.tsjs); + assert.equal(dom.window.tsjs_gpt_bootstrap_fallback, undefined); + assert.equal(JSON.stringify(await dom.window.tsjs.requestAds()), '{"slots":[]}'); + dom.window.close(); +}); + +test('generated fallback leaves a conflicting namespace queue untouched', () => { + const dom = documentWithCriticalArtifact(); + dom.window.TextEncoder = TextEncoder; + dom.window.TextDecoder = TextDecoder; + const queued = () => undefined; + const queue = [queued]; + const namespace = { que: queue, boot: {} }; + Object.defineProperty(namespace, 'adInit', { + configurable: false, + enumerable: true, + value: () => undefined, + writable: false, + }); + dom.window.tsjs = namespace; + + dom.window.eval(source); + + assert.equal(dom.window.tsjs, namespace); + assert.equal(dom.window.tsjs.que, queue); + assert.equal(queue.length, 1); + assert.equal(queue[0], queued); + assert.equal(queue.push, Array.prototype.push); + assert.equal(Object.hasOwn(namespace, 'releaseId'), false); + dom.window.close(); +}); + +test('generated fallback initializes fields through a non-configurable namespace root', () => { + const dom = documentWithCriticalArtifact(); + dom.window.TextEncoder = TextEncoder; + dom.window.TextDecoder = TextDecoder; + const namespace = { que: [], boot: {} }; + Object.defineProperty(dom.window, 'tsjs', { + configurable: false, + enumerable: true, + value: namespace, + writable: false, + }); + + dom.window.eval(source); + + assert.equal(dom.window.tsjs, namespace); + assert.equal(dom.window.tsjs.releaseId, manifest.releaseId); + assert.equal(dom.window.tsjs._internal.reason, 'bundle_partial'); + assert.equal(Object.isFrozen(dom.window.tsjs.que), true); + 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..c2c7f31ad --- /dev/null +++ b/crates/trusted-server-js/lib/test/build/release-v1.test.mjs @@ -0,0 +1,1108 @@ +import assert from 'node:assert/strict'; +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, + findCriticalDeferredSourceViolations, + validateSemanticBundleSets, +} from '../../scripts/check-bundle-budgets.mjs'; +import * as bundleBudgets from '../../scripts/check-bundle-budgets.mjs'; +import * as bundleMetrics from '../../scripts/bundle-metrics.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 = 'critical', trigger = '') => ({ + id, + role, + phase, + trigger, + bytes: Buffer.from(`${logical}${RELEASE_SENTINEL}`), +}); + +const EXPECTED_RELEASE_BUNDLE_ORDER = [ + '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', +]; + +const CRITICAL_CONSENT_ARTIFACTS = Object.freeze([ + Object.freeze({ + id: 'osano_consent', + config: undefined, + capability: 'osano_consent.v1', + }), + Object.freeze({ + id: 'permutive_context', + config: undefined, + capability: 'permutive_context.v1', + }), + Object.freeze({ + id: 'sourcepoint_consent', + config: Object.freeze({ rewriteSdk: false }), + 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 executeGeneratedArtifact(window, file, registrations) { + Object.defineProperty(window, 'tsjs', { + configurable: true, + value: Object.freeze({ + _registerIntegration: (registration) => { + registrations.push(registration); + return true; + }, + }), + }); + 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 === '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('generated maximal integration artifacts execute their real 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 { + for (const artifact of release.artifacts.filter(({ role }) => role === 'integration')) { + executeGeneratedArtifact(dom.window, artifact.file, registrations); + } + assert.deepEqual( + registrations.map(({ id }) => id), + EXPECTED_RELEASE_BUNDLE_ORDER.slice(2) + ); + assert.deepEqual( + registrations.map(({ phase }) => phase), + release.artifacts.filter(({ role }) => role === 'integration').map(({ phase }) => phase) + ); + } finally { + dom.window.close(); + } +}); + +test('generated GPT consumes branded render operations without inlining their private store', () => { + 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( + renderRuntime.sources.some(({ file }) => file === 'src/services/render.ts'), + 'render_runtime 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' + ); +}); + +test('independently generated render_runtime and GPT bundles 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 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({ + 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]) + }), + diagnostics: freeze({ + version: 1, + renderTraceOverlay: false, + gpt: freeze({ active: false }) + }), + manifest: freeze({ + version: 1, + releaseId: '${release.releaseId}', + criticalSrc: '/static/tsjs=tsjs-unified.min.js?v=${release.releaseId}', + integrations: freeze([ + freeze({ id: 'render_runtime', phase: 'critical' }), + freeze({ id: 'gpt', phase: 'critical' }) + ]) + }) + }); + })()`); + const runtime = dom.window.Object.freeze({ + attachAuctionContextService: () => () => undefined, + boot: () => boot, + document: dom.window.document, + enqueue: () => true, + generation: dom.window.Object.freeze({}), + protectFirstDisplayAttemptBatch: () => true, + registerAuctionContext: () => () => undefined, + }); + + executeGeneratedArtifact(dom.window, 'tsjs-render_runtime.js', registrations); + executeGeneratedArtifact(dom.window, 'tsjs-gpt.js', registrations); + const renderRegistration = registrations.find(({ id }) => id === 'render_runtime'); + const gptRegistration = registrations.find(({ id }) => id === 'gpt'); + assert.ok(renderRegistration); + assert.ok(gptRegistration); + const renderPrepared = renderRegistration.prepare( + dom.window.Object.freeze({ + config: undefined, + interfaces: dom.window.Object.freeze({ 'runtime.v1': runtime }), + onDispose: (callback) => preparationDisposers.push(callback), + signal: new dom.window.AbortController().signal, + }) + ); + renderPrepared.activate( + dom.window.Object.freeze({ + afterCommit: () => undefined, + onDispose: (callback) => activationDisposers.push(callback), + signal: new dom.window.AbortController().signal, + }) + ); + const gptPrepared = gptRegistration.prepare( + dom.window.Object.freeze({ + config: undefined, + interfaces: dom.window.Object.freeze({ + 'runtime.v1': runtime, + ...renderPrepared.interfaces, + }), + onDispose: (callback) => preparationDisposers.push(callback), + signal: new dom.window.AbortController().signal, + }) + ); + gptPrepared.activate( + dom.window.Object.freeze({ + afterCommit: (callback) => callback(), + onDispose: (callback) => activationDisposers.push(callback), + signal: new dom.window.AbortController().signal, + }) + ); + 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 CRITICAL_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 })') + : fixture.config; + 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 critical 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, 'gpt-bootstrap-fallback.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 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('gpt-bootstrap-fallback.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_CRITICAL_IDS|REFERENCE_CRITICAL_IDS/); +}); + +test('role-correct capture appends provenance without changing historical 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') + ); + + assert.ok(baseline.roleCorrectTransfer, 'role-correct capture must be appended'); + assert.deepEqual(baseline.roleCorrectTransfer.source, { + ref: 'spec/aps-tsjs-resilience-design', + sha: '63bc1a1928e7bb53e0aa4de86a1556b9eee2db3c', + }); + 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') + ); +}); + +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 = 'tsjs-bootstrap.js'; + }, /bootstrap\/bootstrap\/gpt-bootstrap-fallback\.js/); + rejectReleaseMutation((artifacts) => { + artifacts[1].id = 'runtime_core'; + }, /core\/core\/tsjs-core\.js/); + rejectReleaseMutation((artifacts) => { + artifacts[2].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('critical bundle graphs exclude deferred entries and transitive presentation sources', () => { + const { baseline, metrics, release } = readBuildEvidence(); + const sourceOwners = baseline.roleCorrectTransfer.sourceOwners; + const cleanMetrics = structuredClone(metrics); + assert.deepEqual(findCriticalDeferredSourceViolations(cleanMetrics, release, sourceOwners), []); + + const reachesDeferredEntry = structuredClone(cleanMetrics); + reachesDeferredEntry.modules[0].sources.push({ + file: 'src/integrations/gpt/later.ts', + renderedBytes: 1, + }); + assert.deepEqual( + findCriticalDeferredSourceViolations(reachesDeferredEntry, release, sourceOwners), + ['core reaches deferred-owned source src/integrations/gpt/later.ts'] + ); + + const reachesPresentationHelper = structuredClone(cleanMetrics); + reachesPresentationHelper.modules[0].sources.push({ + file: 'src/integrations/gpt_diagnostics/overlay.ts', + renderedBytes: 1, + }); + assert.deepEqual( + findCriticalDeferredSourceViolations(reachesPresentationHelper, release, sourceOwners), + ['core reaches deferred-owned source src/integrations/gpt_diagnostics/overlay.ts'] + ); + + const reachesRenderTracePresentation = structuredClone(cleanMetrics); + reachesRenderTracePresentation.modules[0].sources.push({ + file: 'src/integrations/gpt_diagnostics/presentation.ts', + renderedBytes: 1, + }); + assert.deepEqual( + findCriticalDeferredSourceViolations(reachesRenderTracePresentation, release, sourceOwners), + ['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` + ); + } +}); + +test('capture release and generated bytes exactly match the clean capture parent', () => { + 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, + requireExactCapture: true, + }) + ); + + const changed = structuredClone(evidence); + changed.release.artifacts[0].hash = 'a'.repeat(64); + assert.throws( + () => + bundleBudgets.validateRoleCorrectTransfer({ + ...changed, + currentArtifactContents: contents, + requireExactCapture: true, + }), + /clean capture parent/ + ); + const changedBytes = new Map(contents); + changedBytes.set('gpt-bootstrap-fallback.js', Buffer.from('changed')); + assert.throws( + () => + bundleBudgets.validateRoleCorrectTransfer({ + ...evidence, + currentArtifactContents: changedBytes, + requireExactCapture: true, + }), + /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[2].inputs = []; + assert.throws( + () => bundleBudgets.validateRoleCorrectTransfer(changedMetadata), + /canonical capture metadata/ + ); + + 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('source ownership capture rejects an omitted current non-entry module source', () => { + 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 gptIndex = omittedModuleSource.release.artifacts + .filter(({ role }) => role !== 'bootstrap') + .findIndex(({ id }) => id === 'gpt'); + const gptModule = omittedModuleSource.metrics.modules[gptIndex]; + const sourceIndex = gptModule.sources.findIndex( + ({ file }) => file === 'src/integrations/gpt/startup.ts' + ); + assert.notEqual(sourceIndex, -1); + assert.notEqual(gptModule.sources[sourceIndex].file, gptModule.entry); + gptModule.sources.splice(sourceIndex, 1); + assert.throws( + () => + bundleBudgets.validateRoleCorrectTransfer({ + ...omittedModuleSource, + currentArtifactContents, + }), + /current source ownership differs from immutable capture/ + ); +}); + +test('source ownership capture rejects cleared current 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 }), + /current source ownership differs from immutable capture/ + ); +}); + +test('source ownership capture pins artifact-owner order', () => { + const { baseline, metrics, release } = readBuildEvidence(); + const reorderedOwners = structuredClone(baseline.roleCorrectTransfer.sourceOwners); + reorderedOwners['src/core/release.ts'].reverse(); + assert.match( + bundleBudgets.findProductionGraphViolations(metrics, release, reorderedOwners).join('\n'), + /current source ownership differs from immutable capture/ + ); +}); + +test('source ownership graph rejects duplicate bootstrap sources and captured owners', () => { + const { baseline, metrics, release } = readBuildEvidence(); + const duplicateBootstrapSource = structuredClone(metrics); + duplicateBootstrapSource.bootstrap.sources.push( + structuredClone(duplicateBootstrapSource.bootstrap.sources[0]) + ); + assert.throws( + () => + bundleBudgets.findProductionGraphViolations( + duplicateBootstrapSource, + release, + baseline.roleCorrectTransfer.sourceOwners + ), + /gpt-bootstrap-fallback\.js\.sources\[.*\] is invalid/ + ); + + const duplicateCapturedOwner = structuredClone(baseline.roleCorrectTransfer.sourceOwners); + duplicateCapturedOwner['src/kernel/runtime.ts'].push('core'); + assert.throws( + () => bundleBudgets.findProductionGraphViolations(metrics, release, duplicateCapturedOwner), + /captured source ownership 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 ceilings use ceil at a fractional five-percent boundary', () => { + assert.equal(typeof bundleBudgets.enforceTransferCeilings, 'function'); + const captured = Object.fromEntries( + ['bootstrap', 'minimal', 'reference', 'maximal'].map((setName) => [ + setName, + { rawBytes: 10, gzipBytes: 10, brotliBytes: 10 }, + ]) + ); + const atCeiling = structuredClone(captured); + for (const set of Object.values(atCeiling)) { + set.rawBytes = 11; + set.gzipBytes = 11; + set.brotliBytes = 11; + } + assert.doesNotThrow(() => bundleBudgets.enforceTransferCeilings(captured, atCeiling)); + atCeiling.reference.gzipBytes += 1; + assert.throws( + () => bundleBudgets.enforceTransferCeilings(captured, atCeiling), + /reference\.gzipBytes is 12 bytes; ceiling is 11/ + ); +}); + +test('production bundle graphs reject every frozen forbidden edge', () => { + const { baseline, metrics, release } = readBuildEvidence(); + const sourceOwners = baseline.roleCorrectTransfer.sourceOwners; + assert.equal(typeof bundleBudgets.findProductionGraphViolations, 'function'); + assert.deepEqual(bundleBudgets.findProductionGraphViolations(metrics, release, sourceOwners), []); + 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, sourceOwners).join('\n'), + pattern + ); + }; + + rejectSource('gpt', 'src/integrations/render_runtime/index.ts', /inlines provider/); + rejectSource('aps', 'src/kernel/runtime.ts', /inlines provider core/); + rejectSource('gpt', 'src/adapters/prebid.ts', /owned by prebid/); + rejectSource('aps', 'src/shared/dom_insertion_dispatcher.ts', /owned by .*gpt/); + 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, sourceOwners), + /sources\[.*\] is invalid/ + ); +}); + +test('production bundle graphs scan bootstrap sources for test and fake seams', () => { + const { baseline, metrics, release } = readBuildEvidence(); + metrics.bootstrap.sources.push({ file: 'src/test/fake_adapter.ts', renderedBytes: 1 }); + + assert.match( + bundleBudgets + .findProductionGraphViolations(metrics, release, baseline.roleCorrectTransfer.sourceOwners) + .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 { baseline, 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, baseline.roleCorrectTransfer.sourceOwners) + .join('\n'), + /gpt inlines provider render_runtime.*src\/integrations\/render_runtime\/module\.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 { baseline, 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, baseline.roleCorrectTransfer.sourceOwners) + .join('\n'), + new RegExp( + `${consumerId} inlines provider ${providerId}.*${providerSource.replaceAll('.', '\\.')}` + ) + ); + }); +} + +test('critical bundle graph rejects deferred-presentation-only source ownership', () => { + const { baseline, 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, baseline.roleCorrectTransfer.sourceOwners) + .join('\n'), + /core reaches deferred-owned source src\/integrations\/gpt_diagnostics\/exhaustive\.ts/ + ); +}); + +test('production bundle graphs reject actual underscore-named test seams', () => { + const { baseline, 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, baseline.roleCorrectTransfer.sourceOwners) + .join('\n'), + /aps reaches production test\/fake\/no-op seam src\/composition\/browser_test\.ts/ + ); +}); + +test('role-correct bundle check reports historical deltas and enforces transfer ceilings', () => { + const result = checkBundleBudgets(); + + assert.equal(result.roleCorrectStatus, 'frozen'); + assert.equal(result.transferCeilingsEnforced, true); + 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 + ); + } + } + for (const [setName, report] of Object.entries(result.roleCorrectTransfer)) { + for (const size of ['rawBytes', 'gzipBytes', 'brotliBytes']) { + assert.equal(report[size].ceilingBytes, Math.ceil(report[size].capturedBytes * 1.05)); + assert.ok(report[size].currentBytes <= report[size].ceilingBytes, `${setName}.${size}`); + } + } +}); + +test('critical 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-architecture.mjs'), + 'utf8' + ); + + assert.doesNotMatch( + traceSource, + /\b(?:Document|HTMLElement|MutationObserver)\b|createElement|getElementById|querySelector|clipboard|data-ts-/ + ); + assert.match(architectureSource, /critical 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('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', 'critical', '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..dbcd096f7 --- /dev/null +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -0,0 +1,4165 @@ +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, +} 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, + criticalSrc: `/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: 'critical' 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, + consumes: canonical.consumes, + provides: canonical.provides, + }); + } + return Object.freeze({ + id, + phase: DEFERRED_INTEGRATION_IDS.has(id) ? ('deferred' as const) : ('critical' as const), + trigger: DEFERRED_INTEGRATION_IDS.has(id) ? ('first_display_or_idle' as const) : 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 createLegacyPrebidIntegrationRegistration(releaseId: string): IntegrationRegistration { + return Object.freeze({ + abi: 1, + id: 'prebid', + phase: 'critical', + releaseId, + 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)); + }, + }); + }, + }); +} + +function createLegacyCreativeIntegrationRegistration(releaseId: string): IntegrationRegistration { + return Object.freeze({ + abi: 1, + id: 'creative', + phase: 'critical', + releaseId, + 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)); + }, + }); + }, + }); +} + +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); + }), + 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', + 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 prospective 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' | 'cache'; 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) throw new Error('Expected the parsed 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 Start', + version: 1, + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + rendererUrl: new URL('/integrations/aps/renderer/v1', window.location.origin).href, + envelope: { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: window.location.origin, + renderer, + }, + }; + const validation = { validateApsRenderer: () => true }; + + const browser = createBrowserComposition({ messagingValidation: validation }); + expect(browser.adapters.messaging.parseProtocolMessage('apsStart', message)).toBeDefined(); + + const injected = createBrowserComposition({ + target: createTarget(), + messagingValidation: validation, + }); + expect(injected.adapters.messaging.parseProtocolMessage('apsStart', message)).toBeUndefined(); + }); + + 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, [ + 'critical_lifecycle', + ...deferredIds, + 'later_critical', + ]).integrations; + + expect(rows.map(({ id, phase }) => [id, phase])).toEqual([ + ['critical_lifecycle', 'critical'], + ...deferredIds.map((id) => [id, 'deferred']), + ['later_critical', 'critical'], + ]); + }); + + 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( + Object.freeze({ + abi: 1, + id: 'test', + phase: 'critical', + releaseId: 'a'.repeat(64), + prepare: ({ + 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', + 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( + Object.freeze({ + abi: 1, + id: 'diagnostics_probe', + phase: 'critical', + releaseId, + prepare: ({ 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({ scriptUrl: '/integrations/gpt/script' }); + const prebidConfig = Object.freeze({ clientSideBidders: Object.freeze(['rubicon']) }); + 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).toBe(gptConfig); + expect((target as { version?: unknown }).version).toBe('1.0.0'); + }); + const startPrebid = vi.fn((received: unknown) => { + expect(received).toBe(prebidConfig); + 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: [], + }, + 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({ + 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: [], + }, + 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 criticalMembers = 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([...criticalMembers, ...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', 'critical'], + ['datadome', 'critical'], + ['didomi', 'critical'], + ['google_tag_manager', 'critical'], + ['lockr', 'critical'], + ['osano_consent', 'critical'], + ['permutive_context', 'critical'], + ['sourcepoint_consent', 'critical'], + ['testlight', 'critical'], + ['osano_lifecycle', 'deferred'], + ['permutive_lifecycle', 'deferred'], + ['sourcepoint_lifecycle', 'deferred'], + ]); + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = new URL(manifest.criticalSrc, window.location.origin).href; + document.head.append(criticalScript); + let executingScript: HTMLScriptElement | null = criticalScript; + 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 === criticalScript) 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 = criticalScript; + } + }); + + expect(composition.runtime.start()).toBe(true); + for (const [, createRegistration] of criticalMembers) { + 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(); + criticalScript.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, ['creative']), + knownIntegrationIds: Object.freeze(['creative']), + 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); + expect( + composition.runtime.registerIntegration( + createLegacyCreativeIntegrationRegistration(releaseId) + ) + ).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: false, 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( + Object.freeze({ + abi: 1, + id: 'lifecycle_probe', + phase: 'critical', + releaseId, + prepare: ({ 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']).toBeTypeOf('function'); + 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('fails an admitted cache attempt when owner activation captured no fetch authority', async () => { + const fetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + value: undefined, + writable: true, + }); + 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: [], + }, + cachePolicy: { + version: 1, + baseUrl: 'https://cache.example/pbc/v1/cache', + }, + 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(), + }, + coreActivations: { + correctnessGptListeners: vi.fn(), + }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const renderCache = composition.runtimeSessionForTest()?.interfaces['renderDirectCache'] as + ((attempt: RenderAttempt, container: HTMLElement) => boolean) | undefined; + const fail = vi.fn(() => true); + expect(renderCache).toBeTypeOf('function'); + expect( + renderCache?.(Object.freeze({ fail }) as unknown as RenderAttempt, document.body) + ).toBe(false); + expect(fail).toHaveBeenCalledOnce(); + expect(fail).toHaveBeenCalledWith('cache_network_error'); + } finally { + composition.runtime.dispose(); + if (fetchDescriptor) Object.defineProperty(globalThis, 'fetch', fetchDescriptor); + else Reflect.deleteProperty(globalThis, 'fetch'); + } + }); + + 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: 'critical', + 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( + Object.freeze({ + abi: 1, + id: 'context_test', + phase: 'critical', + releaseId, + prepare: () => 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 criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = new URL(manifest.criticalSrc, window.location.origin).href; + document.head.append(criticalScript); + let executingScript: HTMLScriptElement | null = criticalScript; + 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 === criticalScript) 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 = criticalScript; + } + }); + + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + composition.createTraceCapabilityProviderRegistrationForTest() + ) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + Object.freeze({ + abi: 1, + id: 'context_test', + phase: 'critical', + releaseId, + prepare: () => 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'); + 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(); + criticalScript.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..452be8440 --- /dev/null +++ b/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts @@ -0,0 +1,958 @@ +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_CRITICAL_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 CRITICAL_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, + criticalSrc: CRITICAL_SRC, + integrations: Object.freeze( + RELEASE_CATALOG.map(({ id, phase, trigger }) => { + if (phase === 'critical') 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, consumes, provides }) => + Object.freeze({ id, phase, trigger, consumes, provides }) + ) + ); +} + +function tracedRegistration( + registration: IntegrationRegistration, + events: string[], + failAfterActivation?: string +): IntegrationRegistration { + return Object.freeze({ + abi: registration.abi, + id: registration.id, + phase: registration.phase, + releaseId: registration.releaseId, + prepare: async (context: IntegrationPrepareContext) => { + events.push(`prepare:${registration.id}`); + const prepared = await registration.prepare(context); + 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); + }, + }); +} + +function integrationConfig(id: string): unknown { + if (id === 'didomi') return Object.freeze({ proxyPath: '/integrations/didomi/consent/' }); + if (id === 'gpt') return Object.freeze({}); + if (id === 'prebid') { + return Object.freeze({ + clientSideBidders: Object.freeze([]), + excludedGamAdUnitPathSuffixes: Object.freeze([]), + }); + } + if (id === 'sourcepoint_consent') return Object.freeze({ rewriteSdk: true }); + return undefined; +} + +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 criticalRegistrations = registrations.filter(({ phase }) => phase === 'critical'); + 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: [], + }, + 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 criticalRegistrations) { + expect( + composition.runtime.registerIntegration(registration), + `register ${registration.id}` + ).toBe(true); + events.push(`register:${registration.id}`); + } + const criticalScript = document.querySelector('#trustedserver-js'); + if (!criticalScript) throw new Error('Maximal fixture critical 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 === criticalScript) 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: criticalScript, + }); + } + }); + + 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, + criticalIntegrationIds: criticalRegistrations.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, + criticalSrc: CRITICAL_SRC, + }); + expect(manifest.integrations.map(({ id }) => id)).toEqual(integrationIds); + expect( + manifest.integrations + .slice(0, MAX_CRITICAL_MODULES) + .every(({ phase }) => phase === 'critical') + ).toBe(true); + expect( + manifest.integrations.slice(MAX_CRITICAL_MODULES).every(({ phase }) => phase === 'deferred') + ).toBe(true); + for (const entry of manifest.integrations) { + expect(Object.isFrozen(entry)).toBe(true); + if (entry.phase === 'critical') { + 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: 'critical', + releaseId: TEST_RELEASE_ID, + 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.criticalIntegrationIds.indexOf(failureId); + const preparedIds = + phase === 'activate' + ? harness.criticalIntegrationIds + : harness.criticalIntegrationIds.slice(0, failureIndex + 1); + const activatedIds = + phase === 'activate' ? harness.criticalIntegrationIds.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.criticalIntegrationIds.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..7286a03a7 --- /dev/null +++ b/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs @@ -0,0 +1,165 @@ +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 apsSource = await readFile( + new URL('../../../../trusted-server-core/src/integrations/aps.rs', 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 embedded ES5 validator matches every shared corpus vector', () => { + assert.match( + apsSource, + /include_str!\("generated\/aps_renderer_validator_v1\.js"\)/, + 'Rust should embed the generated validator file directly' + ); + + 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|\?\.|\?\?/); +}); diff --git a/crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs b/crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs new file mode 100644 index 000000000..a3334145e --- /dev/null +++ b/crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { auditRcJulyAdoption } from '../../scripts/check-rc-july-adoption.mjs'; + +const { test } = process.env.VITEST ? await import('vitest') : await import('node:test'); + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(testDirectory, '../../../../..'); +const specPath = path.join( + repositoryRoot, + 'docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md' +); + +test('the pinned rc/july TSJS baseline is completely mapped by the spec ledger', () => { + const result = auditRcJulyAdoption({ repositoryRoot, specPath }); + + assert.equal(result.baseline, '905984e62a0858c53d9f0ff6dd3a1bf190cf311d'); + assert.equal(result.fileCount, 144); + assert.equal(result.mappingCount, 38); + assert.equal(result.manifestIdCount, 23); + assert.equal(result.ledgerIdCount, 23); + assert.deepEqual(result.unmappedFiles, []); + assert.deepEqual(result.qualityOnlySourceFiles, []); + assert.deepEqual(result.deadMappings, []); + assert.deepEqual(result.manifestOnlyIds, []); + assert.deepEqual(result.ledgerOnlyIds, []); +}); diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 55dba7bb6..5f3f2dfd5 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -1,10 +1,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { buildAdRequest, parseAuctionResponse, sendAuction } from '../../src/core/auction'; +import { + buildAdRequest, + MAX_BROWSER_AUCTION_PROJECTION_BYTES, + parseAuctionResponse, + parseBrowserAuctionProjectionV1, + parseTrustedServerAuctionResponseV1, + sendAuction, +} from '../../src/core/auction'; import envelope from '../fixtures/aps-renderer-v1.json'; +import { parseCacheFetchPolicyV1 } from '../../src/core/config'; +import type { BrowserAuctionProjectionV1 } from '../../src/core/types'; function apsRenderer(creativeId?: string) { - const bid = envelope.seatbid[0].bid[0]; + const bid = envelope.seatbid[0]!.bid[0]!; return { type: 'aps' as const, version: 1 as const, @@ -19,8 +28,89 @@ function apsRenderer(creativeId?: string) { }; } +function candidateId(index = 0): string { + return index.toString(36).padStart(12, 'A'); +} + +function reservationId(index = 0): string { + return `r1_${index.toString(36).padStart(22, 'A')}`; +} + +function browserSlot(slot: string) { + return { + slot, + gamUnitPath: `/123/${slot}`, + divId: `div-${slot}`, + formats: [[300, 250]] as Array<[number, number]>, + targeting: { pos: slot } as Record, + }; +} + +function browserProjection() { + const renderer = apsRenderer('fictional-creative-id'); + return { + version: 1, + auction: { + version: 1, + auctionId: 'auction-1', + results: [ + { slot: 'slot-1', outcome: 'winner', candidateId: candidateId() }, + { slot: 'slot-2', outcome: 'no_bid' }, + { slot: 'slot-3', outcome: 'failed', reason: 'provider_timeout' }, + ], + }, + slots: [browserSlot('slot-1'), browserSlot('slot-2'), browserSlot('slot-3')], + bids: [ + { + candidateId: candidateId(), + slot: 'slot-1', + provider: 'aps', + upstreamBidId: renderer.bidId, + cpm: 1.25, + currency: 'USD', + targeting: { hb_bidder: 'aps', hb_pb: '1.25' } as Record, + rendererReservationId: reservationId(), + renderSource: renderer, + }, + ], + }; +} + +function largeAdmProjection(admLengths: number[]): BrowserAuctionProjectionV1 { + return { + version: 1, + auction: { + version: 1, + auctionId: 'auction-large', + results: admLengths.map((_, index) => ({ + slot: `slot-${index}`, + outcome: 'winner' as const, + candidateId: candidateId(index), + })), + }, + slots: admLengths.map((_, index) => browserSlot(`slot-${index}`)), + bids: admLengths.map((length, index) => ({ + candidateId: candidateId(index), + slot: `slot-${index}`, + provider: 'prebid', + upstreamBidId: `upstream-${index}`, + cpm: index, + currency: 'USD', + targeting: {} as Record, + rendererReservationId: reservationId(index), + renderSource: { + type: 'adm' as const, + version: 1 as const, + adm: 'x'.repeat(length), + width: 300, + height: 250, + }, + })), + }; +} + describe('auction/buildAdRequest', () => { - it('builds from tsjs AdUnit objects', () => { + it('builds from direct-auction programmatic units', () => { const units = [ { code: 'div-1', @@ -42,14 +132,17 @@ describe('auction/buildAdRequest', () => { const result = buildAdRequest(units); expect(result.adUnits).toHaveLength(1); - expect(result.adUnits[0].code).toBe('div-1'); - expect(result.adUnits[0].mediaTypes.banner?.sizes).toEqual([ + expect(result.adUnits[0]!.code).toBe('div-1'); + expect(result.adUnits[0]!.mediaTypes.banner?.sizes).toEqual([ [300, 250], [728, 90], ]); - expect(result.adUnits[0].bids).toHaveLength(2); - expect(result.adUnits[0].bids[0]).toEqual({ bidder: 'appnexus', params: { placementId: 123 } }); - expect(result.adUnits[0].bids[1]).toEqual({ bidder: 'rubicon', params: {} }); + expect(result.adUnits[0]!.bids).toHaveLength(2); + expect(result.adUnits[0]!.bids[0]).toEqual({ + bidder: 'appnexus', + params: { placementId: 123 }, + }); + expect(result.adUnits[0]!.bids[1]).toEqual({ bidder: 'rubicon', params: {} }); }); it('builds from Prebid BidRequest objects (adUnitCode + bidder)', () => { @@ -81,13 +174,13 @@ describe('auction/buildAdRequest', () => { const unit1 = result.adUnits.find((u) => u.code === 'div-gpt-1'); expect(unit1).toBeDefined(); expect(unit1!.bids).toHaveLength(2); - expect(unit1!.bids[0].bidder).toBe('appnexus'); - expect(unit1!.bids[1].bidder).toBe('rubicon'); + expect(unit1!.bids[0]!.bidder).toBe('appnexus'); + expect(unit1!.bids[1]!.bidder).toBe('rubicon'); const unit2 = result.adUnits.find((u) => u.code === 'div-gpt-2'); expect(unit2).toBeDefined(); expect(unit2!.bids).toHaveLength(1); - expect(unit2!.bids[0].bidder).toBe('openx'); + expect(unit2!.bids[0]!.bidder).toBe('openx'); }); it('handles empty units array', () => { @@ -139,7 +232,7 @@ describe('auction/buildAdRequest', () => { const result = buildAdRequest(units); expect(result.adUnits).toHaveLength(1); - expect(result.adUnits[0].mediaTypes).toEqual({}); + expect(result.adUnits[0]!.mediaTypes).toEqual({}); }); it('deduplicates by code/adUnitCode', () => { @@ -150,9 +243,9 @@ describe('auction/buildAdRequest', () => { const result = buildAdRequest(units); expect(result.adUnits).toHaveLength(1); - expect(result.adUnits[0].bids).toHaveLength(2); - expect(result.adUnits[0].bids[0].bidder).toBe('a'); - expect(result.adUnits[0].bids[1].bidder).toBe('b'); + expect(result.adUnits[0]!.bids).toHaveLength(2); + expect(result.adUnits[0]!.bids[0]!.bidder).toBe('a'); + expect(result.adUnits[0]!.bids[1]!.bidder).toBe('b'); }); }); @@ -245,8 +338,8 @@ describe('auction/parseAuctionResponse', () => { ], }); - expect(bids[0].renderer).toEqual(renderer); - expect(bids[0].creativeId).toBe('aps-fictional-slot'); + expect(bids[0]!.renderer).toEqual(renderer); + expect(bids[0]!.creativeId).toBe('aps-fictional-slot'); }); it('ignores unrelated or malformed renderer extensions while retaining ordinary adm', () => { @@ -265,8 +358,8 @@ describe('auction/parseAuctionResponse', () => { ], }); - expect(bids[0].renderer).toBeUndefined(); - expect(bids[0].adm).toBe('
ordinary
'); + expect(bids[0]!.renderer).toBeUndefined(); + expect(bids[0]!.adm).toBe('
ordinary
'); }); it('handles multiple seatbids with multiple bids', () => { @@ -307,11 +400,718 @@ describe('auction/parseAuctionResponse', () => { const bids = parseAuctionResponse(body); expect(bids).toHaveLength(1); - expect(bids[0].seat).toBe('unknown'); - expect(bids[0].adm).toBe(''); - expect(bids[0].width).toBe(300); - expect(bids[0].height).toBe(250); - expect(bids[0].adomain).toEqual([]); + expect(bids[0]!.seat).toBe('unknown'); + expect(bids[0]!.adm).toBe(''); + expect(bids[0]!.width).toBe(300); + expect(bids[0]!.height).toBe(250); + expect(bids[0]!.adomain).toEqual([]); + }); +}); + +describe('auction/parseBrowserAuctionProjectionV1', () => { + it('accepts one exact ordered decision per slot and deep-copies the projection', () => { + const input = browserProjection(); + const parsed = parseBrowserAuctionProjectionV1(input); + + expect(parsed).toEqual(input); + expect(parsed).not.toBe(input); + expect(parsed!.auction.results.map((result) => result.slot)).toEqual([ + 'slot-1', + 'slot-2', + 'slot-3', + ]); + expect(Object.keys(parsed!.bids[0]!.targeting)).toEqual(['hb_bidder', 'hb_pb']); + }); + + it('rejects duplicate, missing, extra, and mismatched decision/bid joins', () => { + const cases: unknown[] = []; + + const duplicateResult = browserProjection(); + duplicateResult.auction.results.push({ + slot: 'slot-1', + outcome: 'no_bid', + }); + cases.push(duplicateResult); + + const missingBid = browserProjection(); + missingBid.bids = []; + cases.push(missingBid); + + const extraBid = browserProjection(); + extraBid.bids.push({ ...extraBid.bids[0]!, candidateId: candidateId(1) }); + cases.push(extraBid); + + const mismatchedSlot = browserProjection(); + mismatchedSlot.bids[0]!.slot = 'slot-other'; + cases.push(mismatchedSlot); + + const duplicateCandidate = browserProjection(); + duplicateCandidate.auction.results.push({ + slot: 'slot-4', + outcome: 'winner', + candidateId: candidateId(), + }); + duplicateCandidate.bids.push({ ...duplicateCandidate.bids[0]!, slot: 'slot-4' }); + cases.push(duplicateCandidate); + + for (const value of cases) { + expect(parseBrowserAuctionProjectionV1(value)).toBeUndefined(); + } + }); + + it('enforces exact objects, own data properties, and ordinary prototypes', () => { + const unknownTopLevel = { ...browserProjection(), unknown: true }; + const unknownDecision = browserProjection(); + Object.assign(unknownDecision.auction.results[0]!, { unknown: true }); + const accessor = browserProjection(); + Object.defineProperty(accessor.bids[0]!, 'provider', { + enumerable: true, + get: () => 'aps', + }); + const inherited = browserProjection(); + Object.setPrototypeOf(inherited.bids[0]!, { inherited: true }); + + for (const value of [unknownTopLevel, unknownDecision, accessor, inherited]) { + expect(parseBrowserAuctionProjectionV1(value)).toBeUndefined(); + } + }); + + it('requires exact GAM slot definitions in the canonical projection', () => { + const missingSlots = browserProjection() as Record; + delete missingSlots['slots']; + expect(parseBrowserAuctionProjectionV1(missingSlots)).toBeUndefined(); + + const emptySlots = browserProjection(); + emptySlots.slots = []; + expect(parseBrowserAuctionProjectionV1(emptySlots)).toBeUndefined(); + + const valid = browserProjection(); + expect(parseBrowserAuctionProjectionV1(valid)?.slots).toEqual(valid.slots); + }); + + it('enforces result and bid count boundaries', () => { + expect( + parseBrowserAuctionProjectionV1({ + version: 1, + auction: { version: 1, auctionId: 'auction-empty', results: [] }, + slots: [], + bids: [], + }) + ).toBeDefined(); + + const atLimit = browserProjection(); + atLimit.auction.results = []; + atLimit.slots = []; + atLimit.bids = []; + for (let index = 0; index < 256; index += 1) { + const slot = `slot-${index}`; + const id = candidateId(index); + atLimit.auction.results.push({ slot, outcome: 'winner', candidateId: id }); + atLimit.slots.push(browserSlot(slot)); + atLimit.bids.push({ + ...browserProjection().bids[0]!, + slot, + candidateId: id, + upstreamBidId: `upstream-${index}`, + rendererReservationId: reservationId(index), + }); + } + expect(parseBrowserAuctionProjectionV1(atLimit)).toBeDefined(); + + const tooManyResults = structuredClone(atLimit); + tooManyResults.auction.results.push({ slot: 'overflow', outcome: 'no_bid' }); + expect(parseBrowserAuctionProjectionV1(tooManyResults)).toBeUndefined(); + + const tooManyBids = structuredClone(atLimit); + tooManyBids.bids.push({ + ...tooManyBids.bids[0]!, + slot: 'overflow', + candidateId: candidateId(300), + rendererReservationId: reservationId(300), + }); + expect(parseBrowserAuctionProjectionV1(tooManyBids)).toBeUndefined(); + }); + + it('enforces identity, price, currency, and targeting boundaries', () => { + const valid = browserProjection(); + valid.auction.auctionId = 'A'.repeat(128); + valid.auction.results[0]!.slot = 'é'.repeat(128); + valid.slots[0]!.slot = 'é'.repeat(128); + valid.bids[0]!.slot = 'é'.repeat(128); + valid.bids[0]!.upstreamBidId = 'é'.repeat(32); + valid.bids[0]!.targeting = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [ + `key_${String(index).padStart(2, '0')}`, + index === 0 ? '😀'.repeat(40) : 'v', + ]) + ); + expect(parseBrowserAuctionProjectionV1(valid)).toBeDefined(); + + const mutations: Array<(value: ReturnType) => void> = [ + (value) => { + value.auction.auctionId = 'A'.repeat(129); + }, + (value) => { + value.auction.auctionId = 'contains space'; + }, + (value) => { + value.auction.results[0]!.candidateId = 'short'; + }, + (value) => { + value.auction.results[0]!.slot = `bad\u0000slot`; + }, + (value) => { + value.bids[0]!.provider = '-aps'; + }, + (value) => { + value.bids[0]!.upstreamBidId = 'é'.repeat(33); + }, + (value) => { + value.bids[0]!.cpm = Number.POSITIVE_INFINITY; + }, + (value) => { + value.bids[0]!.cpm = -0.01; + }, + (value) => { + value.bids[0]!.currency = 'EUR'; + }, + (value) => { + value.bids[0]!.rendererReservationId = 'r1_short'; + }, + (value) => { + value.bids[0]!.targeting = { hb_adid: reservationId() }; + }, + (value) => { + value.bids[0]!.targeting = { ['k'.repeat(21)]: 'v' }; + }, + (value) => { + value.bids[0]!.targeting = { key: '😀'.repeat(41) }; + }, + (value) => { + value.bids[0]!.targeting = { key: 'é'.repeat(81) }; + }, + (value) => { + value.bids[0]!.targeting = { key: 'bad\u0001value' }; + }, + (value) => { + value.bids[0]!.targeting = { key: String.fromCharCode(0xd800) }; + }, + ]; + + for (const mutate of mutations) { + const value = browserProjection(); + mutate(value); + expect(parseBrowserAuctionProjectionV1(value)).toBeUndefined(); + } + }); + + it('enforces exact targeting entry, key, scalar, and UTF-8 byte boundaries', () => { + for (const count of [31, 32]) { + const value = browserProjection(); + value.bids[0]!.targeting = Object.fromEntries( + Array.from({ length: count }, (_, index) => [`k_${index}`, 'v']) + ); + expect(parseBrowserAuctionProjectionV1(value)).toBeDefined(); + } + const tooManyEntries = browserProjection(); + tooManyEntries.bids[0]!.targeting = Object.fromEntries( + Array.from({ length: 33 }, (_, index) => [`k_${index}`, 'v']) + ); + expect(parseBrowserAuctionProjectionV1(tooManyEntries)).toBeUndefined(); + + for (const length of [19, 20]) { + const value = browserProjection(); + value.bids[0]!.targeting = { ['k'.repeat(length)]: 'v' }; + expect(parseBrowserAuctionProjectionV1(value)).toBeDefined(); + } + const keyTooLong = browserProjection(); + keyTooLong.bids[0]!.targeting = { ['k'.repeat(21)]: 'v' }; + expect(parseBrowserAuctionProjectionV1(keyTooLong)).toBeUndefined(); + + for (const scalars of [39, 40]) { + const value = browserProjection(); + value.bids[0]!.targeting = { key: 'a'.repeat(scalars) }; + expect(parseBrowserAuctionProjectionV1(value)).toBeDefined(); + } + const tooManyScalars = browserProjection(); + tooManyScalars.bids[0]!.targeting = { key: 'a'.repeat(41) }; + expect(parseBrowserAuctionProjectionV1(tooManyScalars)).toBeUndefined(); + + for (const valueText of ['😀'.repeat(39) + '€', '😀'.repeat(40)]) { + const value = browserProjection(); + value.bids[0]!.targeting = { key: valueText }; + expect(parseBrowserAuctionProjectionV1(value)).toBeDefined(); + } + const tooManyBytes = browserProjection(); + tooManyBytes.bids[0]!.targeting = { key: '😀'.repeat(40) + 'a' }; + expect(parseBrowserAuctionProjectionV1(tooManyBytes)).toBeUndefined(); + }); + + it('deep-copies every admitted targeting key as own data, including __proto__', () => { + const value = browserProjection(); + value.bids[0]!.targeting = JSON.parse('{"__proto__":"publisher-value"}') as Record< + string, + string + >; + + const parsed = parseBrowserAuctionProjectionV1(value); + + expect(parsed).toBeDefined(); + expect(Object.getPrototypeOf(parsed!.bids[0]!.targeting)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(parsed!.bids[0]!.targeting, '__proto__')).toBe( + true + ); + expect(parsed!.bids[0]!.targeting['__proto__']).toBe('publisher-value'); + }); + + it('enforces canonical UTF-8 JSON just below, at, and above 8 MiB', () => { + const lengths = Array.from({ length: 16 }, () => 512 * 1024); + lengths[15] = 1; + const baseline = largeAdmProjection(lengths); + const baselineBytes = new TextEncoder().encode(JSON.stringify(baseline)).length; + const exactTail = 1 + MAX_BROWSER_AUCTION_PROJECTION_BYTES - baselineBytes; + expect(exactTail).toBeLessThanOrEqual(512 * 1024); + + for (const [delta, accepted] of [ + [-1, true], + [0, true], + [1, false], + ] as const) { + lengths[15] = exactTail + delta; + const value = largeAdmProjection(lengths); + expect(new TextEncoder().encode(JSON.stringify(value)).length).toBe( + MAX_BROWSER_AUCTION_PROJECTION_BYTES + delta + ); + expect(parseBrowserAuctionProjectionV1(value) !== undefined).toBe(accepted); + } + }); + + it('uses captured validation intrinsics after platform prototypes are poisoned', () => { + const valid = largeAdmProjection([16]); + const invalid = { ...largeAdmProjection([16]), unknown: true }; + const mismatchedWinner = largeAdmProjection([16]); + mismatchedWinner.auction.results[0]!.slot = 'mismatched-slot'; + const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const everyDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'every'); + const includesDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'includes'); + const someDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'some'); + const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); + const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); + const calls = { encode: 0, every: 0, includes: 0, iterator: 0, some: 0, test: 0 }; + let parsed: BrowserAuctionProjectionV1 | undefined; + let rejected: BrowserAuctionProjectionV1 | undefined; + let rejectedMismatch: BrowserAuctionProjectionV1 | undefined; + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + value: () => { + calls.iterator += 1; + throw new Error('poisoned array iterator'); + }, + }); + Object.defineProperty(TextEncoder.prototype, 'encode', { + configurable: true, + value: () => { + calls.encode += 1; + throw new Error('poisoned text encoder'); + }, + }); + Object.defineProperty(RegExp.prototype, 'test', { + configurable: true, + value: () => { + calls.test += 1; + throw new Error('poisoned regular expression'); + }, + }); + Object.defineProperty(Array.prototype, 'every', { + configurable: true, + value: () => { + calls.every += 1; + throw new Error('poisoned array every'); + }, + }); + Object.defineProperty(Array.prototype, 'includes', { + configurable: true, + value: () => { + calls.includes += 1; + throw new Error('poisoned array includes'); + }, + }); + Object.defineProperty(Array.prototype, 'some', { + configurable: true, + value: () => { + calls.some += 1; + throw new Error('poisoned array some'); + }, + }); + try { + parsed = parseBrowserAuctionProjectionV1(valid); + rejected = parseBrowserAuctionProjectionV1(invalid); + rejectedMismatch = parseBrowserAuctionProjectionV1(mismatchedWinner); + } finally { + if (iteratorDescriptor) { + Object.defineProperty(Array.prototype, Symbol.iterator, iteratorDescriptor); + } + if (encodeDescriptor) + Object.defineProperty(TextEncoder.prototype, 'encode', encodeDescriptor); + if (testDescriptor) Object.defineProperty(RegExp.prototype, 'test', testDescriptor); + if (everyDescriptor) Object.defineProperty(Array.prototype, 'every', everyDescriptor); + if (includesDescriptor) + Object.defineProperty(Array.prototype, 'includes', includesDescriptor); + if (someDescriptor) Object.defineProperty(Array.prototype, 'some', someDescriptor); + } + + expect(parsed).toBeDefined(); + expect(rejected).toBeUndefined(); + expect(rejectedMismatch).toBeUndefined(); + expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, some: 0, test: 0 }); + }); + + it('requires cache sources to match one frozen cache policy exactly', () => { + const cacheId = 'f47447a0-b759-4f2f-9887-af458b79b570'; + const policy = parseCacheFetchPolicyV1({ + version: 1, + baseUrl: 'https://cache.example:8443/pbc/v1/cache', + }); + expect(policy).toBeDefined(); + + const cacheProjection = () => { + const value = browserProjection() as unknown as BrowserAuctionProjectionV1; + value.bids[0]!.renderSource = { + type: 'cache', + version: 1, + cacheId, + fetchUrl: `https://cache.example:8443/pbc/v1/cache?uuid=${cacheId}`, + width: 300, + height: 250, + }; + return value; + }; + + expect(parseBrowserAuctionProjectionV1(cacheProjection())).toBeUndefined(); + expect(parseBrowserAuctionProjectionV1(cacheProjection(), policy)).toBeDefined(); + + for (const fetchUrl of [ + `https://other.example:8443/pbc/v1/cache?uuid=${cacheId}`, + `https://cache.example/pbc/v1/cache?uuid=${cacheId}`, + `https://cache.example:8443/other?uuid=${cacheId}`, + `https://user@cache.example:8443/pbc/v1/cache?uuid=${cacheId}`, + `https://cache.example:8443/pbc/v1/cache?uuid=${cacheId}&uuid=${cacheId}`, + `https://cache.example:8443/pbc/v1/cache?uuid=${cacheId}&extra=1`, + `https://cache.example:8443/pbc/v1/cache?uuid=${cacheId}#fragment`, + ]) { + const value = cacheProjection(); + if (value.bids[0]!.renderSource.type !== 'cache') throw new Error('expected cache source'); + value.bids[0]!.renderSource.fetchUrl = fetchUrl; + expect(parseBrowserAuctionProjectionV1(value, policy)).toBeUndefined(); + } + }); +}); + +describe('auction/parseTrustedServerAuctionResponseV1', () => { + interface MutableWireBid { + id: string; + impid: string; + price: number; + w: number; + h: number; + adm?: string; + ext: { + trusted_server: { + candidate_id: string; + slot_id: string; + render_source: unknown; + extra?: boolean; + }; + }; + } + + function response(): { + id: string; + cur: string; + seatbid: Array<{ seat: string; bid: MutableWireBid[] }>; + ext: { trusted_server: { slot_results: unknown } }; + } { + const projection = browserProjection(); + const winner = projection.bids[0]!; + return { + id: projection.auction.auctionId, + cur: 'USD', + seatbid: [ + { + seat: winner.provider, + bid: [ + { + id: winner.rendererReservationId, + impid: winner.slot, + price: winner.cpm, + w: winner.renderSource.width, + h: winner.renderSource.height, + ext: { + trusted_server: { + candidate_id: winner.candidateId, + slot_id: winner.slot, + render_source: winner.renderSource, + }, + }, + }, + ], + }, + ], + ext: { trusted_server: { slot_results: projection.auction } }, + }; + } + + function admResponse(admLengths: number[]) { + const projected = largeAdmProjection(admLengths); + const canonical: BrowserAuctionProjectionV1 = { + version: 1, + auction: projected.auction, + slots: [], + bids: projected.bids.map((bid) => ({ + ...bid, + upstreamBidId: bid.rendererReservationId, + })), + }; + return { + canonical, + wire: { + id: canonical.auction.auctionId, + cur: 'USD', + seatbid: [ + { + seat: 'prebid', + bid: canonical.bids.map((bid) => { + if (bid.renderSource.type !== 'adm') throw new Error('expected ADM source'); + return { + id: bid.rendererReservationId, + impid: bid.slot, + price: bid.cpm, + adm: bid.renderSource.adm, + w: bid.renderSource.width, + h: bid.renderSource.height, + ext: { + trusted_server: { + candidate_id: bid.candidateId, + slot_id: bid.slot, + render_source: bid.renderSource, + }, + }, + }; + }), + }, + ], + ext: { trusted_server: { slot_results: canonical.auction } }, + }, + }; + } + + it('accepts the exact four-way decision/candidate/impid/slot join', () => { + const parsed = parseTrustedServerAuctionResponseV1(response()); + + expect(parsed?.auction.results).toEqual(browserProjection().auction.results); + expect(parsed?.bids[0]).toEqual( + expect.objectContaining({ + candidateId: candidateId(), + rendererReservationId: reservationId(), + impid: 'slot-1', + renderSource: apsRenderer('fictional-creative-id'), + }) + ); + }); + + it('caps the deduplicated canonical projection instead of duplicated ADM wire bytes', () => { + const lengths = Array.from({ length: 16 }, () => 512 * 1024); + lengths[15] = 1; + const baseline = admResponse(lengths).canonical; + const baselineBytes = new TextEncoder().encode(JSON.stringify(baseline)).byteLength; + const exactTail = 1 + MAX_BROWSER_AUCTION_PROJECTION_BYTES - baselineBytes; + expect(exactTail).toBeLessThanOrEqual(512 * 1024); + + for (const [delta, accepted] of [ + [0, true], + [1, false], + ] as const) { + lengths[15] = exactTail + delta; + const { canonical, wire } = admResponse(lengths); + expect(new TextEncoder().encode(JSON.stringify(canonical)).byteLength).toBe( + MAX_BROWSER_AUCTION_PROJECTION_BYTES + delta + ); + expect(new TextEncoder().encode(JSON.stringify(wire)).byteLength).toBeGreaterThan( + MAX_BROWSER_AUCTION_PROJECTION_BYTES + ); + expect( + new TextEncoder().encode(JSON.stringify(canonical)).length <= + MAX_BROWSER_AUCTION_PROJECTION_BYTES + ).toBe(accepted); + expect(parseTrustedServerAuctionResponseV1(wire) !== undefined).toBe(accepted); + } + }); + + it.each([ + ['Object', Object.prototype], + ['Array', Array.prototype], + ] as const)( + 'measures projection and response own data without inherited %s.prototype.toJSON', + (_name, prototype) => { + const lengths = Array.from({ length: 16 }, () => 512 * 1024); + lengths[15] = 1; + const baselineBytes = new TextEncoder().encode( + JSON.stringify(largeAdmProjection(lengths)) + ).length; + lengths[15] = 2 + MAX_BROWSER_AUCTION_PROJECTION_BYTES - baselineBytes; + const oversizedProjection = largeAdmProjection(lengths); + expect(new TextEncoder().encode(JSON.stringify(oversizedProjection)).length).toBe( + MAX_BROWSER_AUCTION_PROJECTION_BYTES + 1 + ); + const acceptedResponse = response(); + + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'toJSON'); + const inheritedToJson = vi.fn(() => ({})); + try { + Object.defineProperty(prototype, 'toJSON', { + configurable: true, + value: inheritedToJson, + writable: true, + }); + + expect(parseBrowserAuctionProjectionV1(oversizedProjection)).toBeUndefined(); + expect(inheritedToJson).not.toHaveBeenCalled(); + expect(parseTrustedServerAuctionResponseV1(acceptedResponse)).toBeDefined(); + expect(inheritedToJson).not.toHaveBeenCalled(); + } finally { + if (descriptor) Object.defineProperty(prototype, 'toJSON', descriptor); + else Reflect.deleteProperty(prototype, 'toJSON'); + } + } + ); + + it('returns direct winners in decision order regardless of response order', () => { + const value = response(); + const first = value.seatbid[0]!.bid[0]!; + const second = structuredClone(first); + second.id = reservationId(1); + second.impid = 'slot-2'; + second.ext.trusted_server.candidate_id = candidateId(1); + second.ext.trusted_server.slot_id = 'slot-2'; + value.seatbid[0]!.bid = [second, first]; + const decisions = value.ext.trusted_server.slot_results as ReturnType< + typeof browserProjection + >['auction']; + decisions.results[1] = { + slot: 'slot-2', + outcome: 'winner', + candidateId: candidateId(1), + }; + + expect(parseTrustedServerAuctionResponseV1(value)?.bids.map((bid) => bid.candidateId)).toEqual([ + candidateId(), + candidateId(1), + ]); + }); + + it('rejects missing, duplicate, extra, and mismatched joins transactionally', () => { + const missing = response(); + missing.seatbid = []; + const duplicate = response(); + duplicate.seatbid[0]!.bid.push(structuredClone(duplicate.seatbid[0]!.bid[0]!)); + const mismatchedImpid = response(); + mismatchedImpid.seatbid[0]!.bid[0]!.impid = 'slot-other'; + const mismatchedSlot = response(); + mismatchedSlot.seatbid[0]!.bid[0]!.ext.trusted_server.slot_id = 'slot-other'; + const unknownTrustedKey = response(); + Object.assign(unknownTrustedKey.seatbid[0]!.bid[0]!.ext.trusted_server, { extra: true }); + + for (const value of [missing, duplicate, mismatchedImpid, mismatchedSlot, unknownTrustedKey]) { + expect(parseTrustedServerAuctionResponseV1(value)).toBeUndefined(); + } + }); + + it('rejects non-USD currency, duplicate reservations, and unknown outer wire keys', () => { + const nonUsd = response(); + nonUsd.cur = 'EUR'; + + const duplicateReservation = response(); + const second = structuredClone(duplicateReservation.seatbid[0]!.bid[0]!); + second.impid = 'slot-2'; + second.ext.trusted_server.slot_id = 'slot-2'; + second.ext.trusted_server.candidate_id = candidateId(1); + duplicateReservation.seatbid[0]!.bid.push(second); + const decisions = duplicateReservation.ext.trusted_server.slot_results as ReturnType< + typeof browserProjection + >['auction']; + decisions.results[1] = { + slot: 'slot-2', + outcome: 'winner', + candidateId: candidateId(1), + }; + + const unknownBody = response() as ReturnType & { unknown?: boolean }; + unknownBody.unknown = true; + const unknownBid = response(); + Object.assign(unknownBid.seatbid[0]!.bid[0]!, { unknown: true }); + const emptySeat = response(); + emptySeat.seatbid[0]!.bid = []; + + for (const value of [nonUsd, duplicateReservation, unknownBody, unknownBid, emptySeat]) { + expect(parseTrustedServerAuctionResponseV1(value)).toBeUndefined(); + } + }); + + it('permits matching adm only for ADM sources', () => { + const adm = response(); + const source = { + type: 'adm' as const, + version: 1 as const, + adm: '
ok
', + width: 1, + height: 1, + }; + const bid = adm.seatbid[0]!.bid[0]!; + bid.w = 1; + bid.h = 1; + bid.adm = source.adm; + bid.ext.trusted_server.render_source = source; + expect(parseTrustedServerAuctionResponseV1(adm)).toBeDefined(); + + const admWithoutStandardField = structuredClone(adm); + delete admWithoutStandardField.seatbid[0]!.bid[0]!.adm; + expect(parseTrustedServerAuctionResponseV1(admWithoutStandardField)).toBeDefined(); + + const mismatch = structuredClone(adm); + mismatch.seatbid[0]!.bid[0]!.adm = '
different
'; + expect(parseTrustedServerAuctionResponseV1(mismatch)).toBeUndefined(); + + const apsWithAdm = response(); + apsWithAdm.seatbid[0]!.bid[0]!.adm = '
forbidden
'; + expect(parseTrustedServerAuctionResponseV1(apsWithAdm)).toBeUndefined(); + }); + + it('binds direct cache winners to the same frozen boot policy', () => { + const cacheId = 'f47447a0-b759-4f2f-9887-af458b79b570'; + const policy = parseCacheFetchPolicyV1({ + version: 1, + baseUrl: 'https://cache.example:8443/pbc/v1/cache', + }); + const value = response(); + const bid = value.seatbid[0]!.bid[0]!; + bid.ext.trusted_server.render_source = { + type: 'cache', + version: 1, + cacheId, + fetchUrl: `https://cache.example:8443/pbc/v1/cache?uuid=${cacheId}`, + width: bid.w, + height: bid.h, + }; + + expect(parseTrustedServerAuctionResponseV1(value)).toBeUndefined(); + expect(parseTrustedServerAuctionResponseV1(value, policy)).toBeDefined(); + + const mismatched = structuredClone(value); + const source = mismatched.seatbid[0]!.bid[0]!.ext.trusted_server.render_source as { + fetchUrl: string; + }; + source.fetchUrl = `https://cache.example:9443/pbc/v1/cache?uuid=${cacheId}`; + expect(parseTrustedServerAuctionResponseV1(mismatched, policy)).toBeUndefined(); }); }); @@ -365,7 +1165,7 @@ describe('auction/sendAuction', () => { }) ); expect(bids).toHaveLength(1); - expect(bids[0].price).toBe(2.5); + expect(bids[0]!.price).toBe(2.5); }); it('returns empty array on network error', async () => { diff --git a/crates/trusted-server-js/lib/test/core/config.test.ts b/crates/trusted-server-js/lib/test/core/config.test.ts index 6abacdf54..e1aec5ec8 100644 --- a/crates/trusted-server-js/lib/test/core/config.test.ts +++ b/crates/trusted-server-js/lib/test/core/config.test.ts @@ -1,22 +1,84 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; describe('config', () => { - beforeEach(async () => { - // reset module state between tests - await vi.resetModules(); + it('validates, snapshots, and freezes one exact cache fetch policy', async () => { + const { parseCacheFetchPolicyV1 } = await import('../../src/core/config'); + const input = { + version: 1, + baseUrl: 'https://cache.example:8443/pbc/v1/cache', + }; + + const policy = parseCacheFetchPolicyV1(input); + input.baseUrl = 'https://mutated.example/cache'; + + expect(policy).toEqual({ + version: 1, + baseUrl: 'https://cache.example:8443/pbc/v1/cache', + }); + expect(Object.isFrozen(policy)).toBe(true); + }); + + it('accepts an exact 4,096-byte cache base URL and rejects the next byte', async () => { + const { parseCacheFetchPolicyV1 } = await import('../../src/core/config'); + const prefix = 'https://cache.example/'; + const exactBaseUrl = `${prefix}${'x'.repeat(4_096 - prefix.length)}`; + expect(new TextEncoder().encode(exactBaseUrl)).toHaveLength(4_096); + + expect(parseCacheFetchPolicyV1({ version: 1, baseUrl: exactBaseUrl })).toEqual({ + version: 1, + baseUrl: exactBaseUrl, + }); + expect(parseCacheFetchPolicyV1({ version: 1, baseUrl: `${exactBaseUrl}x` })).toBeUndefined(); }); - it('sets and gets config, controls log level', async () => { - const { setConfig, getConfig } = await import('../../src/core/config'); - const { log } = await import('../../src/core/log'); + it.each([4_095, 4_096, 4_097])( + 'enforces the cache base URL byte boundary for multibyte UTF-8 at %s bytes', + async (targetBytes) => { + const { parseCacheFetchPolicyV1 } = await import('../../src/core/config'); + const prefix = 'https://cache.example/'; + const remainingBytes = targetBytes - new TextEncoder().encode(prefix).byteLength; + const baseUrl = `${prefix}${'é'.repeat(Math.floor(remainingBytes / 2))}${ + remainingBytes % 2 === 0 ? '' : 'x' + }`; + expect(new TextEncoder().encode(baseUrl)).toHaveLength(targetBytes); - setConfig({ a: 1 }); - expect(getConfig()).toMatchObject({ a: 1 }); + if (targetBytes <= 4_096) { + expect(parseCacheFetchPolicyV1({ version: 1, baseUrl })).toEqual({ + version: 1, + baseUrl, + }); + } else { + expect(parseCacheFetchPolicyV1({ version: 1, baseUrl })).toBeUndefined(); + } + } + ); - setConfig({ debug: true }); - expect(log.getLevel()).toBe('debug'); + it('rejects malformed cache policies before integration preparation', async () => { + const { parseCacheFetchPolicyV1 } = await import('../../src/core/config'); + const accessor = { version: 1 } as { version: number; baseUrl?: string }; + Object.defineProperty(accessor, 'baseUrl', { + enumerable: true, + get: () => 'https://cache.example/pbc/v1/cache', + }); + const inherited = Object.create({ inherited: true }) as { + version: number; + baseUrl: string; + }; + inherited.version = 1; + inherited.baseUrl = 'https://cache.example/pbc/v1/cache'; - setConfig({ logLevel: 'info' }); - expect(log.getLevel()).toBe('info'); + for (const value of [ + { version: 1, baseUrl: 'http://cache.example/pbc/v1/cache' }, + { version: 1, baseUrl: 'https://user@cache.example/pbc/v1/cache' }, + { version: 1, baseUrl: 'https://cache.example/' }, + { version: 1, baseUrl: 'https://cache.example/pbc/v1/cache?existing=1' }, + { version: 1, baseUrl: 'https://cache.example/pbc/v1/cache#fragment' }, + { version: 2, baseUrl: 'https://cache.example/pbc/v1/cache' }, + { version: 1, baseUrl: 'https://cache.example/pbc/v1/cache', extra: true }, + accessor, + inherited, + ]) { + expect(parseCacheFetchPolicyV1(value)).toBeUndefined(); + } }); }); diff --git a/crates/trusted-server-js/lib/test/core/context.test.ts b/crates/trusted-server-js/lib/test/core/context.test.ts deleted file mode 100644 index 74854837e..000000000 --- a/crates/trusted-server-js/lib/test/core/context.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; - -describe('context provider registry', () => { - beforeEach(async () => { - await vi.resetModules(); - }); - - it('returns empty context when no providers registered', async () => { - const { collectContext } = await import('../../src/core/context'); - expect(collectContext()).toEqual({}); - }); - - it('collects data from a single provider', async () => { - const { registerContextProvider, collectContext } = await import('../../src/core/context'); - registerContextProvider('test', () => ({ foo: 'bar' })); - expect(collectContext()).toEqual({ foo: 'bar' }); - }); - - it('merges data from multiple providers', async () => { - const { registerContextProvider, collectContext } = await import('../../src/core/context'); - registerContextProvider('a', () => ({ a: 1 })); - registerContextProvider('b', () => ({ b: 2 })); - expect(collectContext()).toEqual({ a: 1, b: 2 }); - }); - - it('later providers overwrite earlier ones on key collision', async () => { - const { registerContextProvider, collectContext } = await import('../../src/core/context'); - registerContextProvider('first', () => ({ key: 'first' })); - registerContextProvider('second', () => ({ key: 'second' })); - expect(collectContext()).toEqual({ key: 'second' }); - }); - - it('skips providers that return undefined', async () => { - const { registerContextProvider, collectContext } = await import('../../src/core/context'); - registerContextProvider('noop', () => undefined); - registerContextProvider('kept', () => ({ kept: true })); - expect(collectContext()).toEqual({ kept: true }); - }); - - it('skips providers that throw', async () => { - const { registerContextProvider, collectContext } = await import('../../src/core/context'); - registerContextProvider('boom', () => { - throw new Error('boom'); - }); - registerContextProvider('survivor', () => ({ survived: true })); - expect(collectContext()).toEqual({ survived: true }); - }); - - it('re-registration with same id replaces previous provider', async () => { - const { registerContextProvider, collectContext } = await import('../../src/core/context'); - registerContextProvider('dup', () => ({ v: 1 })); - registerContextProvider('dup', () => ({ v: 2 })); - expect(collectContext()).toEqual({ v: 2 }); - }); -}); diff --git a/crates/trusted-server-js/lib/test/core/index.test.ts b/crates/trusted-server-js/lib/test/core/index.test.ts index a02082b59..5d82a149b 100644 --- a/crates/trusted-server-js/lib/test/core/index.test.ts +++ b/crates/trusted-server-js/lib/test/core/index.test.ts @@ -1,95 +1,136 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; - -import type { AuctionBidData, AuctionSlot, TsjsApi } from '../../src/core/types'; - -const ORIGINAL_FETCH = global.fetch; - -describe('core/index', () => { +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TsjsApi } from '../../src/core/types'; + +const RELEASE = 'a'.repeat(64); +const CRITICAL_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + +function boot() { + return { + abi: 1, + releaseId: RELEASE, + manifest: { + version: 1, + releaseId: RELEASE, + criticalSrc: CRITICAL_SRC, + integrations: [{ id: 'render_runtime', phase: 'critical' }], + }, + 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 } }, + }; +} + +async function loadMinimalProductionRuntime(): Promise { + await import('../../src/composition/index'); + await import('../../src/integrations/render_runtime/index'); +} + +function installCriticalScript(): void { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL(CRITICAL_SRC, window.location.origin).href; + document.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); +} + +describe('core production bootstrap', () => { beforeEach(async () => { await vi.resetModules(); + document.head.replaceChildren(); document.body.innerHTML = ''; - delete window.tsjs; - }); - - afterEach(() => { - global.fetch = ORIGINAL_FETCH; + delete (window as unknown as { tsjs?: unknown }).tsjs; + installCriticalScript(); }); - it('initializes tsjs API with expected surface', async () => { - await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; - expect(api).toBeDefined(); - expect(typeof api.version).toBe('string'); - expect(Array.isArray(api.que)).toBe(true); + it('commits the exact hard-cutover API and drains the retained preload queue', async () => { + const queued = vi.fn(function (this: TsjsApi) { + expect(this).toBe((window as unknown as { tsjs?: unknown }).tsjs); + }); + const preload = { + boot: boot(), + que: [queued], + _integrationConfig: {}, + renderAdUnit: vi.fn(), + bids: { legacy: true }, + }; + (window as unknown as { tsjs?: unknown }).tsjs = preload; + + await loadMinimalProductionRuntime(); + await vi.waitFor(() => + expect((window as unknown as { tsjs?: TsjsApi }).tsjs?._internal?.state).toBe('kernel') + ); + + const api = (window as unknown as { tsjs: TsjsApi }).tsjs; + expect(api).toBe(preload); + expect(api.version).toBe('1.0.0'); + expect(api.releaseId).toBe(RELEASE); + expect(api.boot.releaseId).toBe(RELEASE); + expect(api.boot.manifest.releaseId).toBe(RELEASE); + expect(Object.isFrozen(api.boot)).toBe(true); + expect(Object.isFrozen(api.que)).toBe(true); expect(typeof api.addAdUnits).toBe('function'); - expect(typeof api.renderAdUnit).toBe('function'); - expect(typeof api.renderAllAdUnits).toBe('function'); - expect(typeof api.setConfig).toBe('function'); - expect(typeof api.getConfig).toBe('function'); expect(typeof api.requestAds).toBe('function'); + expect(api._registerIntegration({})).toBe(false); + expect(queued).toHaveBeenCalledOnce(); + expect(preload).not.toHaveProperty('_integrationConfig'); + expect(preload).not.toHaveProperty('renderAdUnit'); + expect(preload).not.toHaveProperty('bids'); + expect(preload).not.toHaveProperty('renderAllAdUnits'); + expect(preload).not.toHaveProperty('setConfig'); + expect(preload).not.toHaveProperty('getConfig'); }); - it('defaults adSlots and bids so gated-off pages never see undefined', async () => { - await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; - expect(api.adSlots).toEqual([]); - expect(api.bids).toEqual({}); + it('starts installation in the combined bundle task without waiting for DOM readiness', async () => { + const readyState = vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading'); + const preload = { boot: boot(), que: [], _integrationConfig: {} }; + (window as unknown as { tsjs?: unknown }).tsjs = preload; + + try { + await loadMinimalProductionRuntime(); + await vi.waitFor(() => + expect((window as unknown as { tsjs?: TsjsApi }).tsjs?._internal?.state).toBe('kernel') + ); + } finally { + readyState.mockRestore(); + } }); - it('preserves edge-injected adSlots and bids set before the bundle loads', async () => { - window.tsjs = { - adSlots: [{ id: 'pre-injected' } as AuctionSlot], - bids: { 'pre-injected': { hb_pb: '1.00' } } as Record, - } as TsjsApi; + it('publishes no terminal API when the transient integration-config transport is malformed', async () => { + const preload = { + boot: boot(), + que: [], + _integrationConfig: new (class Config {})(), + }; + (window as unknown as { tsjs?: unknown }).tsjs = preload; - await import('../../src/core/index'); + await import('../../src/composition/index'); + await Promise.resolve(); - expect(window.tsjs!.adSlots).toEqual([{ id: 'pre-injected' }]); - expect(window.tsjs!.bids).toEqual({ 'pre-injected': { hb_pb: '1.00' } }); + expect(preload).not.toHaveProperty('_integrationConfig'); + expect(preload).not.toHaveProperty('_internal'); + expect(preload).not.toHaveProperty('requestAds'); }); - it('flushes queued callbacks that existed before initialization', async () => { - const callback = vi.fn(function (this: TsjsApi) { - expect(this).toBe(window.tsjs); + it('does not publish a fallback API over a non-configurable integration transport', async () => { + const preload = { boot: boot(), que: [] }; + Object.defineProperty(preload, '_integrationConfig', { + configurable: false, + enumerable: true, + value: {}, }); - window.tsjs = { que: [callback] as Array<() => void> } as TsjsApi; - - await import('../../src/core/index'); - - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('installs queue that executes callbacks immediately with api context', async () => { - await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; - const fn = vi.fn(); - - api.que.push(fn); - - expect(fn).toHaveBeenCalledTimes(1); - expect(fn.mock.instances[0]).toBe(api); - }); - - it('renders registered ad units using core rendering helpers', async () => { - await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; - - api.addAdUnits([ - { code: 'slot-1', mediaTypes: { banner: { sizes: [[300, 250]] } } }, - { code: 'slot-2', mediaTypes: { banner: { sizes: [[320, 50]] } } }, - ]); - - api.renderAllAdUnits(); - - expect(document.getElementById('slot-1')?.textContent).toContain('300x250'); - expect(document.getElementById('slot-2')?.textContent).toContain('320x50'); - }); + (window as unknown as { tsjs?: unknown }).tsjs = preload; - it('exposes requestAds from the core request module', async () => { - const { requestAds } = await import('../../src/core/request'); - await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; + await import('../../src/composition/index'); + await Promise.resolve(); - expect(api.requestAds).toBe(requestAds); + expect(preload).toHaveProperty('_integrationConfig'); + expect(preload).not.toHaveProperty('_internal'); + expect(preload).not.toHaveProperty('requestAds'); }); }); diff --git a/crates/trusted-server-js/lib/test/core/log.test.ts b/crates/trusted-server-js/lib/test/core/log.test.ts new file mode 100644 index 000000000..75c127273 --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/log.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { publicLog as log } from '../../src/kernel/fallback'; + +describe('log', () => { + afterEach(() => { + log.setLevel('warn'); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('rejects invalid runtime levels without changing the current level', () => { + log.setLevel('info'); + + expect(() => log.setLevel('verbose' as never)).toThrow(TypeError); + expect(log.getLevel()).toBe('info'); + expect(log.setLevel('debug')).toBeUndefined(); + }); + + it('isolates absent and throwing console methods', () => { + vi.stubGlobal('console', undefined); + expect(() => log.warn('absent')).not.toThrow(); + + vi.stubGlobal('console', { + warn: vi.fn(() => { + throw new Error('host console'); + }), + }); + expect(() => log.warn('throwing')).not.toThrow(); + }); + + it('isolates throwing console and method accessors', () => { + const originalConsole = Object.getOwnPropertyDescriptor(globalThis, 'console'); + try { + Object.defineProperty(globalThis, 'console', { + configurable: true, + get: () => { + throw new Error('console accessor'); + }, + }); + expect(() => log.warn('hostile console')).not.toThrow(); + + const hostileConsole = {}; + Object.defineProperty(hostileConsole, 'warn', { + get: () => { + throw new Error('method accessor'); + }, + }); + Object.defineProperty(globalThis, 'console', { + configurable: true, + value: hostileConsole, + writable: true, + }); + expect(() => log.warn('hostile method')).not.toThrow(); + } finally { + if (originalConsole) Object.defineProperty(globalThis, 'console', originalConsole); + else Reflect.deleteProperty(globalThis, 'console'); + } + }); +}); diff --git a/crates/trusted-server-js/lib/test/core/public_types.test.ts b/crates/trusted-server-js/lib/test/core/public_types.test.ts new file mode 100644 index 000000000..21a5f037d --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/public_types.test.ts @@ -0,0 +1,43 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import type { + AddAdUnitsResult, + ProgrammaticAdUnit, + RequestAdsOptions, + RequestAdsResult, + TsjsApi, + TsjsCommandQueue, + TsjsDiagnostics, + TsjsLog, +} from '../../src'; + +describe('public hard-cutover types', () => { + it('exports the exact Promise API without legacy helper names', () => { + type ExpectedKeys = + | 'version' + | 'releaseId' + | 'boot' + | 'que' + | 'log' + | '_registerIntegration' + | 'addAdUnits' + | 'requestAds' + | 'diagnostics' + | '_internal'; + + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<'1.0.0'>(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf['diagnostics']>().toEqualTypeOf< + Readonly + >(); + expectTypeOf().parameters.toEqualTypeOf< + [ProgrammaticAdUnit | readonly ProgrammaticAdUnit[]] + >(); + expectTypeOf().returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + (options?: RequestAdsOptions) => Promise + >(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/core/queue.test.ts b/crates/trusted-server-js/lib/test/core/queue.test.ts new file mode 100644 index 000000000..a42d424d6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/queue.test.ts @@ -0,0 +1,247 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + canPublishTerminalFields, + commitQueue, + prepareQueue, + publishQueue, +} from '../../src/core/queue'; +import { log } from '../../src/core/log'; + +describe('terminal queue handoff', () => { + afterEach(() => vi.restoreAllMocks()); + + it('snapshots callable data entries, commits atomically, then drains FIFO with isolation', () => { + const calls: string[] = []; + const warning = vi.spyOn(log, 'warn').mockImplementation(() => undefined); + const target: { que?: unknown; ready?: boolean } = { + que: [ + function (this: unknown) { + expect(this).toBe(target); + calls.push('first'); + (target.que as unknown[]).push(() => calls.push('nested')); + }, + 'ignored', + () => { + calls.push('throwing'); + throw new Error('publisher callback'); + }, + () => calls.push('last'), + ], + }; + const ingress = prepareQueue(target); + + const published = publishQueue(target, ingress, { ready: true }); + (target.que as unknown[]).push(() => calls.push('after-commit')); + published.drain(); + published.drain(); + const queue = published.queue; + + expect(calls).toEqual(['after-commit', 'first', 'nested', 'throwing', 'last']); + expect(warning).toHaveBeenCalledTimes(1); + expect(target.ready).toBe(true); + expect(target.que).toBe(queue); + expect(Array.isArray(queue)).toBe(true); + expect(queue).toHaveLength(0); + expect(Object.prototype.hasOwnProperty.call(queue, 'push')).toBe(true); + expect(Object.isFrozen(queue)).toBe(true); + + ingress.push(() => calls.push('retained')); + expect(calls[calls.length - 1]).toBe('retained'); + expect(Object.isFrozen(ingress)).toBe(true); + expect(Reflect.set(ingress, 'push', Array.prototype.push)).toBe(false); + expect(() => Array.prototype.push.call(ingress, () => calls.push('lost'))).toThrow(TypeError); + expect(calls).not.toContain('lost'); + expect(ingress).toHaveLength(0); + expect(queue).toHaveLength(0); + }); + + it('preflights terminal fields before changing the ingress queue', () => { + const callback = vi.fn(); + const target: { que?: unknown; version?: string } = { que: [callback] }; + Object.defineProperty(target, 'version', { + configurable: false, + enumerable: true, + value: 'publisher', + writable: false, + }); + const ingress = prepareQueue(target); + + expect(canPublishTerminalFields(target, { version: '1.0.0' })).toBe(false); + expect(() => publishQueue(target, ingress, { version: '1.0.0' })).toThrow(TypeError); + expect(target.que).toBe(ingress); + expect(ingress).toHaveLength(1); + expect(ingress.push).toBe(Array.prototype.push); + expect(Object.isFrozen(ingress)).toBe(false); + expect(callback).not.toHaveBeenCalled(); + }); + + it('keeps callback isolation independent of logger failures', () => { + const calls: string[] = []; + vi.spyOn(log, 'warn').mockImplementation(() => { + throw new Error('hostile warning sink'); + }); + vi.spyOn(log, 'debug').mockImplementation(() => { + throw new Error('hostile debug sink'); + }); + const target = { + que: [ + () => { + calls.push('throwing'); + throw new Error('publisher callback'); + }, + () => calls.push('last'), + ], + }; + const ingress = prepareQueue(target); + + expect(() => commitQueue(target, ingress)).not.toThrow(); + expect(calls).toEqual(['throwing', 'last']); + }); + + it('makes the committed public fields and queue immutable', () => { + const target: { que?: unknown; version?: string } = { que: [] }; + const ingress = prepareQueue(target); + const queue = commitQueue(target, ingress, { version: '1.0.0' }); + const callback = vi.fn(); + + expect(() => Array.prototype.push.call(queue, callback)).toThrow(TypeError); + expect(() => Array.prototype.splice.call(queue, 0, 0, callback)).toThrow(TypeError); + expect(() => Reflect.set(queue, 0, callback)).not.toThrow(); + expect(Reflect.set(queue, 0, callback)).toBe(false); + expect(Reflect.set(queue, 'length', 1)).toBe(false); + expect(Reflect.deleteProperty(queue, 'push')).toBe(false); + expect(() => Object.defineProperty(queue, '0', { value: callback })).toThrow(TypeError); + expect(queue).toHaveLength(0); + expect(callback).not.toHaveBeenCalled(); + + expect(Reflect.set(target, 'version', 'changed')).toBe(false); + expect(Reflect.set(target, 'que', [])).toBe(false); + expect(Object.getOwnPropertyDescriptor(target, 'que')).toMatchObject({ + configurable: false, + writable: false, + }); + }); + + it.each([ + ['index assignment', 'queue[0] = callback;', true, false, undefined], + ['length assignment', 'queue.length = 1;', true, false, undefined], + ['push deletion', 'delete queue.push;', true, false, undefined], + ['push replacement', 'queue.push = Array.prototype.push;', true, false, undefined], + ['inherited native splice', 'queue.splice(0, 0, callback);', true, true, undefined], + ['borrowed native push', 'Array.prototype.push.call(queue, callback);', true, true, undefined], + [ + 'borrowed native splice', + 'Array.prototype.splice.call(queue, 0, 0, callback);', + true, + true, + undefined, + ], + [ + 'Object.defineProperty', + "Object.defineProperty(queue, '0', { value: callback });", + true, + true, + undefined, + ], + [ + 'Object.defineProperty length', + "Object.defineProperty(queue, 'length', { value: 1 });", + true, + true, + undefined, + ], + [ + 'Object.defineProperty push', + "Object.defineProperty(queue, 'push', { value: Array.prototype.push });", + true, + true, + undefined, + ], + [ + 'Object.defineProperties', + 'Object.defineProperties(queue, { 0: { value: callback } });', + true, + true, + undefined, + ], + [ + 'Reflect.defineProperty', + "return Reflect.defineProperty(queue, '0', { value: callback });", + false, + false, + false, + ], + ['Reflect.set index', "return Reflect.set(queue, '0', callback);", false, false, false], + ['Reflect.set length', "return Reflect.set(queue, 'length', 1);", false, false, false], + [ + 'Reflect.deleteProperty push', + "return Reflect.deleteProperty(queue, 'push');", + false, + false, + false, + ], + ] as const)( + 'rejects terminal %s in strict and sloppy callers', + (_name, mutation, strictThrows, sloppyThrows, expectedResult) => { + const target: { que?: unknown } = { que: [] }; + const queue = commitQueue(target, prepareQueue(target)); + const originalPush = queue.push; + const callback = vi.fn(); + const strictMutation = new Function('queue', 'callback', `'use strict'; ${mutation}`) as ( + queue: unknown[], + callback: () => void + ) => void; + const sloppyMutation = new Function('queue', 'callback', mutation) as ( + queue: unknown[], + callback: () => void + ) => unknown; + + if (strictThrows) { + expect(() => strictMutation(queue, callback)).toThrow(TypeError); + } else { + expect(strictMutation(queue, callback)).toBe(expectedResult); + } + if (sloppyThrows) { + expect(() => sloppyMutation(queue, callback)).toThrow(TypeError); + } else { + expect(sloppyMutation(queue, callback)).toBe(expectedResult); + } + expect(queue).toHaveLength(0); + expect(queue.push).toBe(originalPush); + expect(Object.prototype.hasOwnProperty.call(queue, 'push')).toBe(true); + expect(Object.isFrozen(queue)).toBe(true); + expect(callback).not.toHaveBeenCalled(); + } + ); + + it('prepares one actual ingress Array without reading hostile entries', () => { + const getter = vi.fn(() => () => undefined); + const hostile: unknown[] = []; + Object.defineProperty(hostile, '0', { configurable: true, enumerable: true, get: getter }); + hostile.length = 1; + const target: { que?: unknown } = { que: hostile }; + + const ingress = prepareQueue(target); + const queue = commitQueue(target, ingress); + + expect(ingress).toBe(hostile); + expect(getter).not.toHaveBeenCalled(); + expect(queue).toHaveLength(0); + }); + + it('copies data entries out of a frozen or custom-push publisher Array', () => { + const callback = vi.fn(); + const hostile = Object.freeze(Object.assign([callback], { push: vi.fn() })); + const target: { que?: unknown } = { que: hostile }; + + const ingress = prepareQueue(target); + + expect(ingress).not.toBe(hostile); + expect(Array.isArray(ingress)).toBe(true); + expect(ingress[0]).toBe(callback); + expect(ingress.push).toBe(Array.prototype.push); + expect(() => commitQueue(target, ingress)).not.toThrow(); + expect(callback).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/core/registry.test.ts b/crates/trusted-server-js/lib/test/core/registry.test.ts index f06527e42..006030926 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -1,29 +1,346 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; -import type { AdUnit } from '../../src/core/types'; +import { + AdUnitRegistrationError, + prepareProgrammaticAdUnits, + serializeAuctionRequestBody, +} from '../../src/core/registry'; + +function unit(code = 'programmatic-slot'): Record { + return { + code, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'fictional', params: { placement: 7 } }], + }; +} + +function expectRegistrationError( + callback: () => unknown, + code: AdUnitRegistrationError['code'], + unitIndex?: number +): void { + try { + callback(); + throw new Error('should reject registration'); + } catch (error) { + expect(error).toBeInstanceOf(AdUnitRegistrationError); + expect(error).toMatchObject({ code, ...(unitIndex === undefined ? {} : { unitIndex }) }); + } +} describe('registry', () => { - beforeEach(async () => { - await vi.resetModules(); - }); - - it('adds ad units and returns size', async () => { - const { addAdUnits, firstSize, getAllUnits } = await import('../../src/core/registry'); - const unit = { - code: 'u1', - mediaTypes: { - banner: { - sizes: [ - [320, 50], - [300, 250], - ], + it('detaches and recursively freezes one or many exact programmatic units', () => { + const first = unit('first'); + const second = unit('second'); + const prepared = prepareProgrammaticAdUnits([first, second], new Set(['server-slot'])); + + expect(prepared.map(({ code }) => code)).toEqual(['first', 'second']); + expect(Object.isFrozen(prepared)).toBe(true); + expect(Object.isFrozen(prepared[0])).toBe(true); + expect(Object.isFrozen(prepared[0]?.mediaTypes.banner.sizes)).toBe(true); + expect(Object.isFrozen(prepared[0]?.bids?.[0]?.params)).toBe(true); + ( + (first.bids as Array<{ params: { placement: number } }>)[0]!.params as { placement: number } + ).placement = 99; + expect(prepared[0]?.bids?.[0]?.params).toEqual({ placement: 7 }); + + expect(prepareProgrammaticAdUnits(unit('single'), new Set())).toHaveLength(1); + }); + + it.each([ + [null, 'invalid_unit', 0], + [[], 'invalid_units', undefined], + [Array.from({ length: 257 }, (_, index) => unit(`slot-${index}`)), 'invalid_units', undefined], + [{ ...unit(), unknown: true }, 'invalid_unit', 0], + [{ code: '', mediaTypes: { banner: { sizes: [[300, 250]] } } }, 'invalid_code', 0], + [[unit('same'), unit('same')], 'duplicate_code', 1], + [unit('occupied'), 'slot_collision', 0], + [{ code: 'slot', mediaTypes: {} }, 'invalid_media_types', 0], + [{ code: 'slot', mediaTypes: { banner: { sizes: [] } } }, 'invalid_media_types', 0], + [{ code: 'slot', mediaTypes: { banner: { sizes: [[0, 250]] } } }, 'invalid_dimensions', 0], + [{ code: 'slot', mediaTypes: { banner: { sizes: [[1.5, 250]] } } }, 'invalid_dimensions', 0], + [ + { code: 'slot', mediaTypes: { banner: { sizes: [[4_097, 250]] } } }, + 'dimensions_out_of_range', + 0, + ], + [{ ...unit(), bids: null }, 'invalid_bids', 0], + [{ ...unit(), bids: [{ bidder: '' }] }, 'invalid_bidder', 0], + [{ ...unit(), bids: [{ bidder: 'a'.repeat(65) }] }, 'invalid_bidder', 0], + [{ ...unit(), bids: [{ bidder: 'fictional', params: [] }] }, 'invalid_params', 0], + ] as const)('rejects invalid registration %# with the exact code', (candidate, code, index) => { + const occupied = new Set(candidate === null ? [] : ['occupied']); + expectRegistrationError(() => prepareProgrammaticAdUnits(candidate, occupied), code, index); + }); + + it('rejects accessors, foreign prototypes, cyclic params, and oversized bodies without reads', () => { + const getter = vi.fn(() => 'accessed'); + const accessor = unit(); + Object.defineProperty(accessor, 'code', { enumerable: true, get: getter }); + expectRegistrationError( + () => prepareProgrammaticAdUnits(accessor, new Set()), + 'invalid_unit', + 0 + ); + expect(getter).not.toHaveBeenCalled(); + + const foreign = Object.assign(Object.create({ inherited: true }), unit()); + expectRegistrationError( + () => prepareProgrammaticAdUnits(foreign, new Set()), + 'invalid_unit', + 0 + ); + + const cyclic: Record = {}; + cyclic.self = cyclic; + expectRegistrationError( + () => + prepareProgrammaticAdUnits( + { ...unit(), bids: [{ bidder: 'fictional', params: cyclic }] }, + new Set() + ), + 'invalid_params', + 0 + ); + + expectRegistrationError( + () => + prepareProgrammaticAdUnits( + { + ...unit(), + bids: [{ bidder: 'fictional', params: { payload: 'x'.repeat(256 * 1024) } }], + }, + new Set() + ), + 'request_body_too_large' + ); + }); + + it('accepts exact bidder and dimension boundaries and enforces combined capacity last', () => { + for (const bidderLength of [63, 64]) { + expect( + prepareProgrammaticAdUnits( + { ...unit(), bids: [{ bidder: 'a'.repeat(bidderLength), params: {} }] }, + new Set() + ) + ).toHaveLength(1); + } + for (const dimension of [1, 4_096]) { + expect( + prepareProgrammaticAdUnits( + { + code: `slot-${dimension}`, + mediaTypes: { banner: { sizes: [[dimension, dimension]] } }, + }, + new Set() + ) + ).toHaveLength(1); + } + for (const existingCount of [254, 255]) { + const existing = new Set( + Array.from({ length: existingCount }, (_, index) => `server-${index}`) + ); + expect(prepareProgrammaticAdUnits(unit(`at-${existingCount + 1}`), existing)).toHaveLength(1); + } + const existing = new Set(Array.from({ length: 256 }, (_, index) => `server-${index}`)); + expectRegistrationError( + () => prepareProgrammaticAdUnits(unit('overflow'), existing), + 'registry_capacity' + ); + }); + + it('enforces the encoded auction-unit body cap at the exact byte boundary', () => { + const candidate = { + code: 'body-boundary', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'fictional', params: { payload: '' } }], + }; + const baseBytes = new TextEncoder().encode( + JSON.stringify({ adUnits: [candidate], config: {} }) + ).byteLength; + const payloadAtLimit = 'x'.repeat(256 * 1024 - baseBytes); + candidate.bids[0]!.params.payload = payloadAtLimit; + expect( + new TextEncoder().encode(JSON.stringify({ adUnits: [candidate], config: {} })) + ).toHaveLength(256 * 1024); + expect(prepareProgrammaticAdUnits(candidate, new Set())).toHaveLength(1); + + candidate.bids[0]!.params.payload += 'x'; + expectRegistrationError( + () => prepareProgrammaticAdUnits(candidate, new Set()), + 'request_body_too_large' + ); + }); + + it('bounds validation work before rejecting repeated shared params by aggregate body size', () => { + let ownKeysCalls = 0; + const sharedParams = new Proxy( + Object.fromEntries( + Array.from({ length: 16_384 }, (_, index) => [`p${index.toString(36)}`, 'x']) + ), + { + ownKeys: (target) => { + ownKeysCalls += 1; + return Reflect.ownKeys(target); + }, + } + ); + const candidates = Array.from({ length: 32 }, (_, index) => ({ + ...unit(`shared-${index}`), + bids: [{ bidder: 'fictional', params: sharedParams }], + })); + + expectRegistrationError( + () => prepareProgrammaticAdUnits(candidates, new Set()), + 'request_body_too_large' + ); + expect(ownKeysCalls).toBeLessThanOrEqual(4); + }); + + it('charges acyclic shared-DAG multiplicity to the aggregate body budget', () => { + 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: (target, key) => { + descriptorReads[nodeIndex] = (descriptorReads[nodeIndex] ?? 0) + 1; + return Reflect.getOwnPropertyDescriptor(target, key); }, + }); + } + + expectRegistrationError( + () => + prepareProgrammaticAdUnits( + { + ...unit('shared-dag'), + bids: [{ bidder: 'fictional', params: shared }], + }, + new Set() + ), + 'request_body_too_large' + ); + expect(descriptorReads.every((reads) => reads <= 2)).toBe(true); + }); + + it('uses captured validation intrinsics after platform prototypes are poisoned', () => { + const validCandidate = unit('poison-safe'); + const invalidCandidate = { ...unit('unknown-key'), unknown: true }; + const invalidDimensions = { + ...unit('invalid-dimensions'), + mediaTypes: { banner: { sizes: [['bad', 250]] } }, + }; + const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const everyDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'every'); + const includesDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'includes'); + const someDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'some'); + const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); + const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); + const calls = { encode: 0, every: 0, includes: 0, iterator: 0, some: 0, test: 0 }; + let prepared: ReturnType | undefined; + let invalidError: unknown; + let invalidDimensionsError: unknown; + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + value: () => { + calls.iterator += 1; + throw new Error('poisoned array iterator'); }, - } as AdUnit; - addAdUnits(unit); + }); + Object.defineProperty(TextEncoder.prototype, 'encode', { + configurable: true, + value: () => { + calls.encode += 1; + throw new Error('poisoned text encoder'); + }, + }); + Object.defineProperty(RegExp.prototype, 'test', { + configurable: true, + value: () => { + calls.test += 1; + throw new Error('poisoned regular expression'); + }, + }); + Object.defineProperty(Array.prototype, 'every', { + configurable: true, + value: () => { + calls.every += 1; + throw new Error('poisoned array every'); + }, + }); + Object.defineProperty(Array.prototype, 'includes', { + configurable: true, + value: () => { + calls.includes += 1; + throw new Error('poisoned array includes'); + }, + }); + Object.defineProperty(Array.prototype, 'some', { + configurable: true, + value: () => { + calls.some += 1; + throw new Error('poisoned array some'); + }, + }); + try { + prepared = prepareProgrammaticAdUnits(validCandidate, new Set()); + try { + prepareProgrammaticAdUnits(invalidCandidate, new Set()); + } catch (error) { + invalidError = error; + } + try { + prepareProgrammaticAdUnits(invalidDimensions, new Set()); + } catch (error) { + invalidDimensionsError = error; + } + } finally { + if (iteratorDescriptor) { + Object.defineProperty(Array.prototype, Symbol.iterator, iteratorDescriptor); + } + if (encodeDescriptor) + Object.defineProperty(TextEncoder.prototype, 'encode', encodeDescriptor); + if (testDescriptor) Object.defineProperty(RegExp.prototype, 'test', testDescriptor); + if (everyDescriptor) Object.defineProperty(Array.prototype, 'every', everyDescriptor); + if (includesDescriptor) + Object.defineProperty(Array.prototype, 'includes', includesDescriptor); + if (someDescriptor) Object.defineProperty(Array.prototype, 'some', someDescriptor); + } + + expect(prepared?.[0]?.code).toBe('poison-safe'); + expect(invalidError).toBeInstanceOf(AdUnitRegistrationError); + expect(invalidError).toMatchObject({ code: 'invalid_unit', unitIndex: 0 }); + expect(invalidDimensionsError).toBeInstanceOf(AdUnitRegistrationError); + expect(invalidDimensionsError).toMatchObject({ code: 'invalid_dimensions', unitIndex: 0 }); + expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, some: 0, test: 0 }); + }); + + it('serializes detached auction data without invoking inherited toJSON hooks', () => { + const prepared = prepareProgrammaticAdUnits(unit(), new Set()); + const context = Object.freeze({ segments: Object.freeze(['one']) }); + const publisherHook = vi.fn(() => { + throw new Error('publisher toJSON hook'); + }); + Object.defineProperty(Object.prototype, 'toJSON', { + configurable: true, + value: publisherHook, + }); + Object.defineProperty(Array.prototype, 'toJSON', { + configurable: true, + value: publisherHook, + }); + let body: string | undefined; + try { + body = serializeAuctionRequestBody(prepared, context); + } finally { + Reflect.deleteProperty(Object.prototype, 'toJSON'); + Reflect.deleteProperty(Array.prototype, 'toJSON'); + } - const all = getAllUnits(); - expect(all.length).toBe(1); - expect(firstSize(all[0])!.join('x')).toBe('320x50'); + expect(publisherHook).not.toHaveBeenCalled(); + expect(body).toBe(JSON.stringify({ adUnits: prepared, config: context })); }); }); diff --git a/crates/trusted-server-js/lib/test/core/render.test.ts b/crates/trusted-server-js/lib/test/core/render.test.ts index 5822f42b4..913475b69 100644 --- a/crates/trusted-server-js/lib/test/core/render.test.ts +++ b/crates/trusted-server-js/lib/test/core/render.test.ts @@ -39,6 +39,131 @@ describe('render', () => { expect(sandbox).not.toContain('allow-same-origin'); }); + it('prepares and appends one exact ADM iframe with srcdoc already assigned', async () => { + const { ADM_IFRAME_SANDBOX, prepareAdmIframe } = await import('../../src/core/render'); + const container = document.createElement('div'); + document.body.appendChild(container); + const loaded = vi.fn(); + const failed = vi.fn(); + const observer = new MutationObserver(() => undefined); + observer.observe(container, { childList: true }); + const handle = prepareAdmIframe({ + adm: '
fictional ADM creative
', + container, + height: 250, + onError: failed, + onLoad: loaded, + width: 300, + }); + + expect(handle).toBeDefined(); + if (!handle) throw new Error('should prepare an ADM iframe'); + expect(handle.frame.parentNode).toBeNull(); + expect(handle.frame.srcdoc).toContain('fictional ADM creative'); + expect(handle.frame.hasAttribute('src')).toBe(false); + expect(handle.frame.getAttribute('sandbox')).toBe(ADM_IFRAME_SANDBOX); + expect(handle.frame.referrerPolicy).toBe('no-referrer'); + expect(handle.frame.width).toBe('300'); + expect(handle.frame.height).toBe('250'); + expect(handle.frame.style.width).toBe('300px'); + expect(handle.frame.style.height).toBe('250px'); + expect(handle.append()).toBe(true); + expect(handle.append()).toBe(false); + const mutations = observer.takeRecords(); + expect(mutations).toHaveLength(1); + const inserted = mutations[0]?.addedNodes.item(0) as HTMLIFrameElement | null; + expect(inserted).toBe(handle.frame); + expect(inserted?.srcdoc).toBe(handle.frame.srcdoc); + expect(inserted?.srcdoc.length).toBeGreaterThan(0); + expect(handle.activate()).toBe(true); + handle.frame.dispatchEvent(new Event('load')); + handle.frame.dispatchEvent(new Event('load')); + expect(loaded).toHaveBeenCalledOnce(); + expect(failed).not.toHaveBeenCalled(); + expect(handle.current()).toBe(true); + handle.dispose(); + expect(handle.frame.isConnected).toBe(false); + observer.disconnect(); + }); + + it('ignores a poisoned detached factory frame and rejects a pre-append load', async () => { + const { prepareAdmIframe } = await import('../../src/core/render'); + const poisoned = document.createElement('iframe'); + poisoned.title = 'publisher frame'; + const unrelated = document.createElement('div'); + document.body.appendChild(unrelated); + poisoned.remove = vi.fn(() => unrelated.remove()); + Object.defineProperty(poisoned, 'srcdoc', { + configurable: true, + get: () => '
lie
', + set: vi.fn(), + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const createElement = vi.spyOn(document, 'createElement').mockReturnValueOnce(poisoned); + const loaded = vi.fn(); + const failed = vi.fn(); + + try { + const handle = prepareAdmIframe({ + adm: '
exact creative
', + container, + height: 250, + onError: failed, + onLoad: loaded, + width: 300, + }); + expect(handle).toBeDefined(); + if (!handle) throw new Error('should prepare a native ADM iframe'); + expect(createElement).not.toHaveBeenCalled(); + expect(handle.frame).not.toBe(poisoned); + handle.frame.dispatchEvent(new Event('load')); + expect(handle.append()).toBe(true); + expect(handle.activate()).toBe(true); + expect(loaded).not.toHaveBeenCalled(); + expect(failed).not.toHaveBeenCalled(); + handle.dispose(); + expect(poisoned.remove).not.toHaveBeenCalled(); + expect(poisoned.title).toBe('publisher frame'); + expect(unrelated.isConnected).toBe(true); + } finally { + createElement.mockRestore(); + } + }); + + it('commits only predecessors and keeps the accepted frame exactly disposable', async () => { + const { prepareAdmIframe } = await import('../../src/core/render'); + const container = document.createElement('div'); + const predecessor = document.createElement('div'); + const laterSibling = document.createElement('div'); + container.appendChild(predecessor); + document.body.appendChild(container); + const handle = prepareAdmIframe({ + adm: '
accepted creative
', + container, + height: 250, + onError: vi.fn(), + onLoad: vi.fn(), + width: 300, + }); + + expect(handle).toBeDefined(); + if (!handle) throw new Error('should prepare an ADM iframe'); + expect(handle.append()).toBe(true); + container.appendChild(laterSibling); + expect(handle.activate()).toBe(true); + handle.frame.dispatchEvent(new Event('load')); + expect(handle.commit()).toBe(true); + expect(predecessor.isConnected).toBe(false); + expect(laterSibling.isConnected).toBe(true); + expect(handle.frame.isConnected).toBe(true); + + handle.dispose(); + handle.dispose(); + expect(handle.frame.isConnected).toBe(false); + expect(laterSibling.isConnected).toBe(true); + }); + it('preserves dollar sequences when building the creative document', async () => { const { buildCreativeDocument } = await import('../../src/core/render'); const creativeHtml = "
$& $$ $1 $` $'
"; diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index b1625f9da..f130b92ef 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -1,467 +1,72 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import envelope from '../fixtures/aps-renderer-v1.json'; +import type { TsjsApi } from '../../src/core/types'; -/** Test view of the global scope with a mockable `fetch`. */ -const testGlobal = globalThis as unknown as { fetch: ReturnType }; +const RELEASE = 'a'.repeat(64); +const CRITICAL_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; -type AddAdUnitsArg = Parameters[0]; +function boot() { + return { + abi: 1, + releaseId: RELEASE, + manifest: { + version: 1, + releaseId: RELEASE, + criticalSrc: CRITICAL_SRC, + integrations: [{ id: 'render_runtime', phase: 'critical' }], + }, + 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 } }, + }; +} -async function flushRequestAds(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)); +async function loadMinimalProductionRuntime(): Promise { + await import('../../src/composition/index'); + await import('../../src/integrations/render_runtime/index'); } -describe('request.requestAds', () => { - let originalFetch: typeof globalThis.fetch; +function installCriticalScript(): void { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL(CRITICAL_SRC, window.location.origin).href; + document.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); +} +describe('hard-cutover requestAds API', () => { beforeEach(async () => { await vi.resetModules(); - document.body.innerHTML = ''; - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it('sends fetch and renders creatives via iframe from response', async () => { - // mock fetch - returns creative HTML inline in adm field - const creativeHtml = '
Test Creative
'; - testGlobal.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - seatbid: [ - { - seat: 'trusted-server', - bid: [{ impid: 'slot1', adm: creativeHtml, crid: 'creative-1' }], - }, - ], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { log } = await import('../../src/core/log'); - const { requestAds } = await import('../../src/core/request'); - const infoSpy = vi.spyOn(log, 'info').mockImplementation(() => undefined); - - document.body.innerHTML = '
'; - addAdUnits({ - code: 'slot1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - } as unknown as AddAdUnitsArg); - - requestAds(); - await flushRequestAds(); - - expect(testGlobal.fetch).toHaveBeenCalled(); - - // Verify iframe was created with creative HTML in srcdoc - const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; - expect(iframe).toBeTruthy(); - expect(iframe!.srcdoc).toContain(creativeHtml); - - const renderCall = infoSpy.mock.calls.find( - ([message]) => message === 'renderCreativeInline: rendered' - ); - expect(renderCall?.[1]).toEqual( - expect.objectContaining({ - slotId: 'slot1', - seat: 'trusted-server', - creativeId: 'creative-1', - originalLength: creativeHtml.length, - }) - ); + document.head.replaceChildren(); + delete (window as unknown as { tsjs?: unknown }).tsjs; + installCriticalScript(); }); - it('dispatches a valid APS descriptor to the opaque static renderer route', async () => { - const apsBid = envelope.seatbid[0].bid[0]; - const renderer = { - type: 'aps', - version: 1, - accountId: 'example-account-id', - bidId: apsBid.id, - tagType: apsBid.ext.tagtype, - creativeUrl: apsBid.ext.creativeurl, - aaxResponse: btoa(JSON.stringify(envelope)), - width: apsBid.w, - height: apsBid.h, + it('replaces a callback-era request function with the exact Promise result surface', async () => { + const legacyCallback = vi.fn(); + (window as unknown as { tsjs?: unknown }).tsjs = { + boot: boot(), + que: [], + _integrationConfig: {}, + requestAds: legacyCallback, }; - testGlobal.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - seatbid: [ - { - seat: 'aps', - bid: [ - { - impid: 'slot1', - price: 1.23, - w: 300, - h: 250, - ext: { trusted_server: { renderer } }, - }, - ], - }, - ], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - document.body.innerHTML = '
existing
'; - addAdUnits({ - code: 'slot1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - } as unknown as AddAdUnitsArg); - - requestAds(); - await flushRequestAds(); - - const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; - expect(iframe).not.toBeNull(); - expect(iframe!.src).toContain('/integrations/aps/renderer#tsaps='); - expect(iframe!.srcdoc).toBe(''); - expect(iframe!.getAttribute('sandbox')).not.toContain('allow-same-origin'); - expect(document.querySelector('#slot1 span')).not.toBeNull(); - - const postMessage = vi.spyOn(iframe!.contentWindow!, 'postMessage'); - iframe!.dispatchEvent(new Event('load')); - expect(document.querySelector('#slot1 span')).not.toBeNull(); - expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ renderer }), '*'); - - const message = postMessage.mock.calls[0][0] as { nonce: string }; - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: message.nonce }, - source: iframe!.contentWindow, - }) - ); - expect(document.querySelector('#slot1 span')).toBeNull(); - }); - - it('does not mutate the slot for an invalid APS descriptor', async () => { - testGlobal.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - seatbid: [ - { - seat: 'aps', - bid: [ - { - impid: 'slot1', - ext: { - trusted_server: { - renderer: { type: 'aps', version: 1, aaxResponse: 'invalid' }, - }, - }, - }, - ], - }, - ], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - document.body.innerHTML = '
existing
'; - addAdUnits({ - code: 'slot1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - } as unknown as AddAdUnitsArg); - - requestAds(); - await flushRequestAds(); - - expect(document.querySelector('#slot1 iframe')).toBeNull(); - expect(document.querySelector('#slot1 span')).not.toBeNull(); - }); - - it('does not render on non-JSON response', async () => { - testGlobal.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'text/plain' }, - json: async () => ({}), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - - document.body.innerHTML = '
'; - addAdUnits({ - code: 'slot1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - } as unknown as AddAdUnitsArg); - - requestAds(); - await flushRequestAds(); - - expect(testGlobal.fetch).toHaveBeenCalled(); - expect(document.querySelector('iframe')).toBeNull(); - }); - - it('ignores fetch rejection gracefully', async () => { - testGlobal.fetch = vi.fn().mockRejectedValue(new Error('network-error')); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - - document.body.innerHTML = '
'; - addAdUnits({ - code: 'slot1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - } as unknown as AddAdUnitsArg); - - requestAds(); - await flushRequestAds(); - - expect(testGlobal.fetch).toHaveBeenCalled(); - expect(document.querySelector('iframe')).toBeNull(); - }); - - it('inserts an iframe with creative HTML from unified auction', async () => { - // mock fetch for unified auction endpoint - returns inline HTML - const creativeHtml = 'Ad'; - testGlobal.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - seatbid: [ - { - seat: 'trusted-server', - bid: [{ impid: 'slot1', adm: creativeHtml, crid: 'creative-2' }], - }, - ], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - - // Prepare slot in DOM - const div = document.createElement('div'); - div.id = 'slot1'; - document.body.appendChild(div); - - // Add an ad unit and request - addAdUnits({ - code: 'slot1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - } as unknown as AddAdUnitsArg); - requestAds(); - - await flushRequestAds(); - - // Verify iframe was inserted with creative HTML in srcdoc - const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; - expect(iframe).toBeTruthy(); - expect(iframe!.srcdoc).toContain(''); - expect(iframe!.srcdoc).toContain('Ad'); - }); - - it('renders creatives with safe URI markup', async () => { - const creativeHtml = - 'Contactad'; - testGlobal.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - seatbid: [ - { - seat: 'trusted-server', - bid: [{ impid: 'slot1', adm: creativeHtml, crid: 'creative-safe-uri' }], - }, - ], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - - document.body.innerHTML = '
'; - addAdUnits({ - code: 'slot1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - } as unknown as AddAdUnitsArg); - - requestAds(); - await flushRequestAds(); - - const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; - expect(iframe).toBeTruthy(); - expect(iframe!.srcdoc).toContain('mailto:test@example.com'); - expect(iframe!.srcdoc).toContain('https://example.com/ad.png'); - }); - - it('rejects malformed non-string creative HTML without blanking the slot', async () => { - testGlobal.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - seatbid: [ - { - seat: 'appnexus', - bid: [{ impid: 'slot1', adm: { html: '
bad
' }, crid: 'creative-invalid' }], - }, - ], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { log } = await import('../../src/core/log'); - const { requestAds } = await import('../../src/core/request'); - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined); - - document.body.innerHTML = '
existing
'; - addAdUnits({ - code: 'slot1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - } as unknown as AddAdUnitsArg); - - requestAds(); - await flushRequestAds(); - - expect(document.querySelector('#slot1 iframe')).toBeNull(); - // Invalid-type rejection must not blank existing slot content. - expect(document.querySelector('#slot1')?.innerHTML).toBe('existing'); - - const rejectionCall = warnSpy.mock.calls.find( - ([message]) => message === 'renderCreativeInline: rejected creative' - ); - expect(rejectionCall?.[1]).toEqual( - expect.objectContaining({ - slotId: 'slot1', - seat: 'appnexus', - creativeId: 'creative-invalid', - rejectionReason: 'invalid-creative-html', - }) - ); - expect(JSON.stringify(rejectionCall)).not.toContain('[object Object]'); - }); - - it('does not blank the slot when a later bid for the same slot is rejected', async () => { - // Regression: multi-bid scenario where a rejected bid must not erase an earlier - // successful render into the same slot. - const goodCreative = '
Safe Ad
'; - testGlobal.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - seatbid: [ - { - seat: 'seat-a', - bid: [{ impid: 'slot1', adm: goodCreative, crid: 'creative-good' }], - }, - { - // Non-string adm is rejected client-side as invalid-creative-html. - seat: 'seat-b', - bid: [{ impid: 'slot1', adm: { html: '
bad
' }, crid: 'creative-bad' }], - }, - ], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - - document.body.innerHTML = '
'; - addAdUnits({ - code: 'slot1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - } as unknown as AddAdUnitsArg); - - requestAds(); - await flushRequestAds(); - - // The good creative should have rendered; the bad one should not have blanked it. - const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; - expect(iframe).toBeTruthy(); - expect(iframe!.srcdoc).toContain(goodCreative); - }); - - it('rejects creatives that sanitize to empty markup', async () => { - testGlobal.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - seatbid: [ - { - seat: 'appnexus', - bid: [{ impid: 'slot1', adm: ' ', crid: 'creative-empty' }], - }, - ], - }), - }); - const { addAdUnits } = await import('../../src/core/registry'); - const { log } = await import('../../src/core/log'); - const { requestAds } = await import('../../src/core/request'); - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined); - - document.body.innerHTML = '
'; - addAdUnits({ - code: 'slot1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - } as unknown as AddAdUnitsArg); - - requestAds(); - await flushRequestAds(); - - expect(document.querySelector('#slot1 iframe')).toBeNull(); - - const rejectionCall = warnSpy.mock.calls.find( - ([message]) => message === 'renderCreativeInline: rejected creative' - ); - expect(rejectionCall?.[1]).toEqual( - expect.objectContaining({ - slotId: 'slot1', - seat: 'appnexus', - creativeId: 'creative-empty', - rejectionReason: 'empty-after-sanitize', - }) + await loadMinimalProductionRuntime(); + await vi.waitFor(() => + expect((window as unknown as { tsjs?: TsjsApi }).tsjs?._internal?.state).toBe('kernel') ); - }); - - it('skips iframe insertion when slot is missing', async () => { - // mock fetch for unified auction endpoint - returns inline HTML - testGlobal.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - seatbid: [ - { - bid: [{ impid: 'missing-slot', adm: '
Creative for missing slot
' }], - }, - ], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - - addAdUnits({ - code: 'missing-slot', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - } as unknown as AddAdUnitsArg); - requestAds(); - - await flushRequestAds(); - // No iframe should be inserted because the slot isn't present in DOM - const iframe = document.querySelector('iframe'); - expect(iframe).toBeNull(); + const api = (window as unknown as { tsjs: TsjsApi }).tsjs; + const result = api.requestAds(); + expect(result).toBeInstanceOf(Promise); + await expect(result).resolves.toEqual({ slots: [] }); + expect(legacyCallback).not.toHaveBeenCalled(); + expect(api).not.toHaveProperty('renderAdUnit'); + expect(api).not.toHaveProperty('renderAllAdUnits'); }); }); diff --git a/crates/trusted-server-js/lib/test/core/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts deleted file mode 100644 index 2181340f2..000000000 --- a/crates/trusted-server-js/lib/test/core/trace.test.ts +++ /dev/null @@ -1,515 +0,0 @@ -import { execFileSync } from 'node:child_process'; - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import { - recordRender, - updateRender, - stampCreativeTrace, - traceOverlayEnabled, - renderTracePanel, - RENDER_EVENT_NAME, - TRACE_PANEL_ID, - TRACE_BADGE_CLASS, -} from '../../src/core/trace'; -import type { RenderRecord, TsjsApi } from '../../src/core/types'; - -function clearTraceCookie(): void { - document.cookie = 'ts-trace=; Max-Age=0; Path=/'; -} - -function removePanel(): void { - document.getElementById(TRACE_PANEL_ID)?.remove(); -} - -describe('trace/recordRender', () => { - beforeEach(() => { - delete (window as { tsjs?: TsjsApi }).tsjs; - clearTraceCookie(); - removePanel(); - }); - - it('writes a render record into window.tsjs.renders', () => { - const record = recordRender({ - slotId: 'slot-1', - path: 'auction', - rendered: true, - elementId: 'slot-1', - auctionId: 'auction-abc', - bidder: 'kargo', - creativeId: 'cr-1', - admHash: 'a1b2c3d4e5f60718', - servedFrom: 'inline', - }); - - expect(window.tsjs?.renders?.['slot-1']).toEqual(record); - expect(record.count).toBe(1); - expect(record.at).toBeGreaterThan(0); - }); - - it('overwrites the previous record and increments count on re-render', () => { - recordRender({ slotId: 'slot-1', path: 'ssat', rendered: true, auctionId: 'a-1' }); - const second = recordRender({ - slotId: 'slot-1', - path: 'ssat', - rendered: true, - auctionId: 'a-2', - }); - - const entry = window.tsjs?.renders?.['slot-1']; - expect(entry?.auctionId).toBe('a-2'); - expect(entry?.count).toBe(2); - expect(second.count).toBe(2); - }); - - it('fires a tsjs:adRendered CustomEvent with the record as detail', () => { - const listener = vi.fn(); - window.addEventListener(RENDER_EVENT_NAME, listener); - - const record = recordRender({ slotId: 'slot-ev', path: 'auction', rendered: true }); - - expect(listener).toHaveBeenCalledTimes(1); - const event = listener.mock.calls[0][0] as CustomEvent; - expect(event.detail).toEqual(record); - - window.removeEventListener(RENDER_EVENT_NAME, listener); - }); - - it('allocates one sequence across separately bundled IIFEs', async () => { - const buildTraceBundle = (): string => - execFileSync( - './node_modules/.bin/esbuild', - ['--bundle', '--format=iife', '--platform=browser', '--loader=ts'], - { - cwd: process.cwd(), - encoding: 'utf8', - input: - 'import { recordRender } from "./src/core/trace.ts";' + - 'window.__recordFromTraceBundle = recordRender;', - } - ); - - const firstBundle = buildTraceBundle(); - const secondBundle = buildTraceBundle(); - const testWindow = window as typeof window & { - __recordFromTraceBundle?: typeof recordRender; - }; - - Function(firstBundle)(); - const firstRecord = testWindow.__recordFromTraceBundle!; - const first = firstRecord({ slotId: 'iife-a', path: 'auction', rendered: true }); - - Function(secondBundle)(); - const secondRecord = testWindow.__recordFromTraceBundle!; - const second = secondRecord({ slotId: 'iife-b', path: 'ssat', rendered: true }); - - expect(second.seq).toBe(first.seq + 1); - expect(window.tsjs?.renderSeq).toBe(second.seq); - delete testWindow.__recordFromTraceBundle; - }); - - it('enriches an existing impression without changing its bookkeeping', () => { - const original = recordRender({ - slotId: 'slot-enrich', - path: 'ssat', - rendered: true, - injected: false, - servedFrom: 'gam', - }); - const bookkeeping = { - seq: original.seq, - count: original.count, - at: original.at, - historyLength: window.tsjs?.renderLog?.length, - }; - - const updated = updateRender(original, { injected: true, servedFrom: 'pbs-cache' }); - - expect(updated).toBe(original); - expect(window.tsjs?.renders?.['slot-enrich']).toBe(original); - expect(window.tsjs?.renderLog?.[0]).toBe(original); - expect(updated).toEqual(expect.objectContaining({ injected: true, servedFrom: 'pbs-cache' })); - expect({ - seq: updated.seq, - count: updated.count, - at: updated.at, - historyLength: window.tsjs?.renderLog?.length, - }).toEqual(bookkeeping); - }); -}); - -describe('trace/stampCreativeTrace', () => { - it('stamps data-ts-* attributes for present fields only', () => { - const el = document.createElement('div'); - const record: RenderRecord = { - slotId: 'slot-1', - path: 'ssat', - rendered: true, - auctionId: 'ts-req-abc', - bidder: 'kargo', - adId: 'cache-uuid-1', - admHash: 'a1b2c3d4e5f60718', - count: 1, - seq: 1, - at: 1, - }; - - stampCreativeTrace(el, record); - - expect(el.getAttribute('data-ts-slot-id')).toBe('slot-1'); - expect(el.getAttribute('data-ts-render-path')).toBe('ssat'); - expect(el.getAttribute('data-ts-rendered')).toBe('true'); - expect(el.getAttribute('data-ts-auction-id')).toBe('ts-req-abc'); - expect(el.getAttribute('data-ts-bidder')).toBe('kargo'); - expect(el.getAttribute('data-ts-ad-id')).toBe('cache-uuid-1'); - expect(el.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); - // creativeId absent — attribute must not exist. - expect(el.hasAttribute('data-ts-creative-id')).toBe(false); - }); - - it('removes stale attributes when a re-render lacks a field', () => { - const el = document.createElement('div'); - const first: RenderRecord = { - slotId: 'slot-1', - path: 'ssat', - rendered: true, - auctionId: 'auction-old', - admHash: 'a1b2c3d4e5f60718', - servedFrom: 'gam', - count: 1, - seq: 1, - at: 1, - }; - stampCreativeTrace(el, first); - - const second: RenderRecord = { - slotId: 'slot-1', - path: 'ssat', - rendered: true, - auctionId: 'auction-new', - count: 2, - seq: 2, - at: 2, - }; - stampCreativeTrace(el, second); - - expect(el.getAttribute('data-ts-auction-id')).toBe('auction-new'); - // The previous auction's hash and mechanism must not survive the re-stamp. - expect(el.hasAttribute('data-ts-adm-hash')).toBe(false); - expect(el.hasAttribute('data-ts-served-from')).toBe(false); - }); -}); - -describe('trace/floating panel', () => { - const record: Omit = { - slotId: 'slot-1', - path: 'ssat', - rendered: true, - injected: true, - visible: true, - gamEmpty: false, - auctionId: 'ts-req-abcdef123456', - bidder: 'kargo', - admHash: 'a1b2c3d4e5f60718', - servedFrom: 'gam', - }; - - beforeEach(() => { - delete (window as { tsjs?: TsjsApi }).tsjs; - clearTraceCookie(); - removePanel(); - }); - - afterEach(() => { - clearTraceCookie(); - removePanel(); - }); - - it('reports the overlay disabled without the ts-trace cookie', () => { - expect(traceOverlayEnabled()).toBe(false); - }); - - it('does not create a panel when the overlay is disarmed', () => { - recordRender(record); - expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); - }); - - it('renders a panel row per traced slot with honest status', () => { - document.cookie = 'ts-trace=1; Path=/'; - // slot-1: TS placed + visible → ok. slot-2: nothing rendered → empty. - recordRender(record); - recordRender({ - slotId: 'slot-2', - path: 'auction', - rendered: false, - injected: false, - visible: false, - bidder: 'appnexus', - }); - - const panel = document.getElementById(TRACE_PANEL_ID); - expect(panel).toBeTruthy(); - // Only slot-1 is honestly ok; slot-2 rendered nothing. - expect(panel!.textContent).toContain('TS Render Trace · 1/2 slots ok'); - expect(panel!.textContent).toContain('✓ slot-1 · ok'); - expect(panel!.textContent).toContain('✗ slot-2 · empty'); - expect(panel!.textContent).toContain('ssat · kargo'); - expect(panel!.textContent).toContain('auction · appnexus'); - }); - - it('marks a rendered-but-hidden slot as hidden, not ok', () => { - document.cookie = 'ts-trace=1; Path=/'; - // GAM rendered non-empty, TS injected, but a reveal gate keeps it hidden. - recordRender({ ...record, visible: false }); - - const panel = document.getElementById(TRACE_PANEL_ID); - expect(panel!.textContent).toContain('0/1 slots ok'); - expect(panel!.textContent).toContain('⚠ slot-1 · hidden'); - }); - - it('marks a targeting-only GAM slot as gam-only, not a confirmed TS render', () => { - document.cookie = 'ts-trace=1; Path=/'; - // GAM rendered something, but TS never placed it (prod targeting path). - recordRender({ ...record, injected: false, gamEmpty: false, visible: true }); - - const panel = document.getElementById(TRACE_PANEL_ID); - expect(panel!.textContent).toContain('0/1 slots ok'); - expect(panel!.textContent).toContain('◐ slot-1 · gam-only'); - }); - - it('never claims ok when a render path did not report placement', () => { - document.cookie = 'ts-trace=1; Path=/'; - // Regression: an unset `injected` must not fall through to ok — that would - // claim a confirmed TS render for a slot TS only targeted. - const { injected: _omitted, ...withoutInjected } = record; - recordRender({ ...withoutInjected, gamEmpty: false, visible: true }); - - const panel = document.getElementById(TRACE_PANEL_ID); - expect(panel!.textContent).toContain('0/1 slots ok'); - expect(panel!.textContent).toContain('◐ slot-1 · gam-only'); - }); - - it('reuses a single panel across renders and reflects the latest count', () => { - document.cookie = 'ts-trace=1; Path=/'; - recordRender(record); - recordRender(record); - - const panels = document.querySelectorAll(`#${TRACE_PANEL_ID}`); - expect(panels).toHaveLength(1); - // Second render of the same slot bumps the count and appends a history row. - expect(panels[0].textContent).toContain('TS Render Trace · 1/1 slots ok'); - expect(panels[0].textContent).toContain('×2'); - }); - - it("keeps GAM's fill signal and drops ? placeholders on an unattributed refresh", () => { - document.cookie = 'ts-trace=1; Path=/'; - // A publisher-driven GAM refresh: TS ran no auction for it, so there is no - // bidder/hash/auction id — but GAM still reported whether it filled, and - // that is the most useful field on the row. - recordRender({ - slotId: 'slot-1', - path: 'gam-refresh', - rendered: true, - gamEmpty: false, - injected: false, - visible: true, - servedFrom: 'gam', - }); - - const panel = document.getElementById(TRACE_PANEL_ID)!; - expect(panel.textContent).toContain('gam:filled'); - expect(panel.textContent).toContain('no TS attribution'); - // Absent attribution must not render as a failed lookup, and an auction - // segment must not appear at all when there is no auction to name. - expect(panel.textContent).not.toContain('· ? ·'); - expect(panel.textContent).not.toContain('auction ?'); - }); - - it('still reports gam:empty for a refresh GAM declined to fill', () => { - document.cookie = 'ts-trace=1; Path=/'; - recordRender({ - slotId: 'slot-1', - path: 'gam-refresh', - rendered: false, - gamEmpty: true, - injected: false, - visible: true, - }); - - const panel = document.getElementById(TRACE_PANEL_ID)!; - expect(panel.textContent).toContain('gam:empty'); - expect(panel.textContent).toContain('✗ slot-1 · empty'); - }); - - it('gives each render a page-global seq the badge and its panel row share', () => { - document.cookie = 'ts-trace=1; Path=/'; - const el = document.createElement('div'); - el.id = 'slot-el'; - document.body.appendChild(el); - - // Two slots interleaved: seq must be unique page-wide, not per-slot, so a - // badge reading #N identifies exactly one row. - const first = recordRender(record); - const other = recordRender({ ...record, slotId: 'slot-2' }); - expect(other.seq).toBe(first.seq + 1); - - stampCreativeTrace(el, other); - const badge = el.querySelector(`.${TRACE_BADGE_CLASS}`) as HTMLElement; - expect(badge.textContent).toContain(`#${other.seq}`); - // The same number appears on that render's row in the panel. - expect(document.getElementById(TRACE_PANEL_ID)!.textContent).toContain(`#${other.seq}`); - - el.remove(); - }); - - it('marks only the live render for a slot as current', () => { - document.cookie = 'ts-trace=1; Path=/'; - recordRender(record); - const latest = recordRender(record); - - const panel = document.getElementById(TRACE_PANEL_ID)!; - // Both renders are in the log, but only the newest is still on screen. - expect(panel.textContent).toContain(`#${latest.seq}`); - expect(panel.textContent!.match(/◂ current/g)).toHaveLength(1); - }); - - it('uses record identity when duplicate sequence values exist', () => { - document.cookie = 'ts-trace=1; Path=/'; - const oldRecord = { ...record, auctionId: 'auction-old', count: 1, seq: 7, at: 1 }; - const liveRecord = { ...record, auctionId: 'auction-live', count: 2, seq: 7, at: 2 }; - (window as { tsjs?: TsjsApi }).tsjs = { - renders: { 'slot-1': liveRecord }, - renderLog: [oldRecord, liveRecord], - } as unknown as TsjsApi; - - renderTracePanel(); - - const rows = [...document.querySelectorAll(`#${TRACE_PANEL_ID} div[style*="cursor"]`)]; - const oldRow = rows.find((row) => row.getAttribute('title')?.includes('auction: auction-old')); - const liveRow = rows.find((row) => - row.getAttribute('title')?.includes('auction: auction-live') - ); - expect(oldRow?.textContent).not.toContain('◂ current'); - expect(liveRow?.textContent).toContain('◂ current'); - }); - - it('close button removes the panel', () => { - document.cookie = 'ts-trace=1; Path=/'; - recordRender(record); - const panel = document.getElementById(TRACE_PANEL_ID)!; - const close = panel.querySelector('button') as HTMLButtonElement; - close.click(); - expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); - }); - - it('renderTracePanel is a no-op while disarmed even if renders exist', () => { - (window as { tsjs?: TsjsApi }).tsjs = { - renders: { 'slot-1': { ...record, count: 1, seq: 1, at: 1 } }, - } as unknown as TsjsApi; - renderTracePanel(); - expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); - }); - - it('clicking a row logs the full record', async () => { - document.cookie = 'ts-trace=1; Path=/'; - const { log } = await import('../../src/core/log'); - const infoSpy = vi.spyOn(log, 'info').mockImplementation(() => undefined); - - recordRender(record); - const row = document.getElementById(TRACE_PANEL_ID)!.querySelector('div[style*="cursor"]'); - (row as HTMLElement).click(); - - const call = infoSpy.mock.calls.find(([m]) => m === 'trace: render record'); - expect(call?.[1]).toEqual( - expect.objectContaining({ slotId: 'slot-1', auctionId: record.auctionId }) - ); - infoSpy.mockRestore(); - }); -}); - -describe('trace/confirmation badge', () => { - beforeEach(() => { - delete (window as { tsjs?: TsjsApi }).tsjs; - clearTraceCookie(); - document.body.innerHTML = ''; - }); - afterEach(() => { - clearTraceCookie(); - document.body.innerHTML = ''; - }); - - const okRecord: RenderRecord = { - slotId: 'slot-1', - path: 'ssat', - rendered: true, - injected: true, - visible: true, - gamEmpty: false, - bidder: 'mocktioneer', - admHash: 'a1b2c3d4e5f60718', - servedFrom: 'gam', - count: 1, - seq: 1, - at: 1, - }; - - it('badges an ok slot when armed', () => { - document.cookie = 'ts-trace=1; Path=/'; - const el = document.createElement('div'); - document.body.appendChild(el); - stampCreativeTrace(el, okRecord); - const badge = el.querySelector(`.${TRACE_BADGE_CLASS}`) as HTMLElement; - expect(badge).toBeTruthy(); - expect(badge.textContent).toBe('TS ✓ #1 · mocktioneer'); - }); - - it('does not badge a hidden slot', () => { - document.cookie = 'ts-trace=1; Path=/'; - const el = document.createElement('div'); - document.body.appendChild(el); - stampCreativeTrace(el, { ...okRecord, visible: false }); - expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); - }); - - it('removes the previous badge when a filled slot becomes empty', () => { - document.cookie = 'ts-trace=1; Path=/'; - const el = document.createElement('div'); - document.body.appendChild(el); - stampCreativeTrace(el, okRecord); - expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeTruthy(); - - stampCreativeTrace(el, { ...okRecord, rendered: false, injected: false, gamEmpty: true }); - - expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); - }); - - it('removes the previous badge when a visible slot becomes hidden', () => { - document.cookie = 'ts-trace=1; Path=/'; - const el = document.createElement('div'); - document.body.appendChild(el); - stampCreativeTrace(el, okRecord); - expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeTruthy(); - - stampCreativeTrace(el, { ...okRecord, visible: false }); - - expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); - }); - - it('does not badge when the overlay is disarmed', () => { - clearTraceCookie(); - const el = document.createElement('div'); - document.body.appendChild(el); - stampCreativeTrace(el, okRecord); - expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); - }); - - it('never badges an iframe element', () => { - document.cookie = 'ts-trace=1; Path=/'; - const iframe = document.createElement('iframe'); - document.body.appendChild(iframe); - stampCreativeTrace(iframe, okRecord); - expect(iframe.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); - // Attributes still stamped on the iframe though. - expect(iframe.getAttribute('data-ts-slot-id')).toBe('slot-1'); - }); -}); diff --git a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts new file mode 100644 index 000000000..65075d23f --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -0,0 +1,1290 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createRenderTraceStore, + DiagnosticsSubscriberLimitError, + type RenderTraceGptFactV1, + type RenderTracePresentationSource, + type RenderTraceRuntimeOptions, +} from '../../src/core/trace'; +import { + createRenderTracePresentation, + TRACE_BADGE_CLASS, + TRACE_PANEL_ID, +} from '../../src/integrations/gpt_diagnostics/presentation'; + +function createPresentedRenderTrace( + options: RenderTraceRuntimeOptions & { + readonly document: Document; + readonly exportRecord?: (record: Readonly>) => void; + readonly overlayEnabled: boolean; + } +) { + const owner = createRenderTraceStore({ + ...options, + schedule: + options.schedule ?? + ((callback) => { + callback(); + return () => undefined; + }), + }); + if (options.overlayEnabled) { + owner.attachPresentation((source) => + createRenderTracePresentation(source, { + document: options.document, + ...(options.exportRecord === undefined ? {} : { exportRecord: options.exportRecord }), + ...(options.onPresentationError === undefined + ? {} + : { onError: options.onPresentationError }), + }) + ); + } + return owner; +} + +function harness() { + const tasks: Array<() => void> = []; + const owner = createRenderTraceStore({ + scheduler: { + set: (callback) => { + tasks.push(callback); + return callback; + }, + clear: (handle) => { + const index = tasks.indexOf(handle as () => void); + if (index >= 0) tasks.splice(index, 1); + }, + }, + }); + return { + owner, + tasks, + drain: (): void => { + while (tasks.length > 0) tasks.shift()?.(); + }, + }; +} + +describe('render trace diagnostics runtime', () => { + it('exposes one exact frozen read-only public surface with copied snapshots', () => { + const { owner } = harness(); + const target = window as unknown as { tsjs?: Record }; + const existingApi = (target.tsjs = {}); + const event = vi.fn(); + window.addEventListener('tsjs:adRendered', event); + const record = owner.record({ slotId: 'slot-a', path: 'auction', rendered: true }); + + expect(Reflect.ownKeys(owner.diagnostics).sort()).toEqual(['current', 'history', 'subscribe']); + expect(Object.isFrozen(owner.diagnostics)).toBe(true); + const current = owner.diagnostics.current(); + const history = owner.diagnostics.history(); + expect(Object.isFrozen(current)).toBe(true); + expect(Object.isFrozen(history)).toBe(true); + expect(Object.isFrozen(current['slot-a'])).toBe(true); + expect(current['slot-a']).toEqual(record); + expect(current['slot-a']).not.toBe(record); + expect(history[0]).toEqual(record); + expect(history[0]).not.toBe(record); + expect(target.tsjs).toBe(existingApi); + expect(target.tsjs).toEqual({}); + expect(event).not.toHaveBeenCalled(); + window.removeEventListener('tsjs:adRendered', event); + delete target.tsjs; + }); + + it('commits before one asynchronous frozen public delivery', () => { + const { owner, tasks, drain } = harness(); + const listener = vi.fn(); + owner.diagnostics.subscribe(listener); + + const record = owner.record({ slotId: 'slot-a', path: 'ssat', rendered: true }); + + expect(owner.diagnostics.current()['slot-a']).toEqual(record); + expect(listener).not.toHaveBeenCalled(); + expect(tasks).toHaveLength(1); + drain(); + expect(listener).toHaveBeenCalledTimes(1); + const delivered = listener.mock.calls[0]?.[0]; + expect(delivered).toEqual(record); + expect(delivered).not.toBe(record); + expect(Object.isFrozen(delivered)).toBe(true); + }); + + it('enforces the 32-subscriber cap after callable validation and reuses capacity', () => { + const { owner } = harness(); + const releases = Array.from({ length: 32 }, () => owner.diagnostics.subscribe(() => undefined)); + + expect(() => owner.diagnostics.subscribe(null as never)).toThrow(TypeError); + expect(() => owner.diagnostics.subscribe(() => undefined)).toThrowError( + expect.objectContaining({ code: 'subscriber_capacity', surface: 'renderTrace' }) + ); + expect(() => owner.diagnostics.subscribe(() => undefined)).toThrow( + DiagnosticsSubscriberLimitError + ); + releases[0]?.(); + releases[0]?.(); + expect(owner.diagnostics.subscribe(() => undefined)).toBeTypeOf('function'); + }); + + it('reserves an independent internal presentation subscription outside public capacity', () => { + const { owner, drain } = harness(); + const publicListeners = Array.from({ length: 32 }, () => vi.fn()); + const publicReleases = publicListeners.map((listener) => owner.diagnostics.subscribe(listener)); + const presentationListener = vi.fn(); + const disposePresentation = vi.fn(); + const attachPresentation = ( + owner as unknown as { + attachPresentation: ( + factory: (source: { + current: typeof owner.diagnostics.current; + history: typeof owner.diagnostics.history; + subscribe: (listener: () => void) => () => void; + }) => Readonly<{ dispose: () => void }> + ) => () => void; + } + ).attachPresentation; + const detach = attachPresentation((source) => { + source.subscribe(presentationListener); + return Object.freeze({ dispose: disposePresentation }); + }); + + expect(() => owner.diagnostics.subscribe(vi.fn())).toThrow( + expect.objectContaining({ code: 'subscriber_capacity', surface: 'renderTrace' }) + ); + owner.record({ slotId: 'slot-a', path: 'auction', rendered: true }); + drain(); + expect(publicListeners.every((listener) => listener.mock.calls.length === 1)).toBe(true); + expect(presentationListener).toHaveBeenCalledOnce(); + + detach(); + detach(); + expect(disposePresentation).toHaveBeenCalledOnce(); + publicReleases.forEach((release) => release()); + }); + + it('makes retained presentation sources empty and inert immediately after detach', () => { + const { owner } = harness(); + let retainedSource: + | Readonly<{ + current: typeof owner.diagnostics.current; + history: typeof owner.diagnostics.history; + subscribe: (listener: () => void) => () => void; + }> + | undefined; + owner.record({ slotId: 'slot-a', path: 'auction', rendered: true }); + const detach = owner.attachPresentation((source) => { + retainedSource = source; + source.subscribe(() => undefined); + return Object.freeze({ dispose: vi.fn() }); + }); + + expect(retainedSource?.current()).toHaveProperty('slot-a'); + detach(); + const current = retainedSource?.current(); + const history = retainedSource?.history(); + expect(current).toEqual({}); + expect(Object.getPrototypeOf(current)).toBeNull(); + expect(Object.isFrozen(current)).toBe(true); + expect(history).toEqual([]); + expect(Object.isFrozen(history)).toBe(true); + expect(() => retainedSource?.subscribe(() => undefined)).toThrow(TypeError); + }); + + it('preserves the first private presentation listener when a duplicate subscribe fails', () => { + const { owner, drain } = harness(); + const first = vi.fn(); + const second = vi.fn(); + const detach = owner.attachPresentation((source) => { + source.subscribe(first); + expect(() => source.subscribe(second)).toThrow( + 'render trace presentation subscription is unavailable' + ); + return Object.freeze({ dispose: vi.fn() }); + }); + + owner.record({ slotId: 'first-listener-slot', path: 'auction', rendered: true }); + drain(); + expect(first).toHaveBeenCalledOnce(); + expect(second).not.toHaveBeenCalled(); + detach(); + }); + + it('allows private presentation unsubscribe and resubscribe without reviving the first listener', () => { + const { owner, drain } = harness(); + const first = vi.fn(); + const second = vi.fn(); + const detach = owner.attachPresentation((source) => { + const releaseFirst = source.subscribe(first); + releaseFirst(); + releaseFirst(); + source.subscribe(second); + return Object.freeze({ dispose: vi.fn() }); + }); + + owner.record({ slotId: 'resubscribed-slot', path: 'auction', rendered: true }); + drain(); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledOnce(); + detach(); + }); + + it('makes retained private presentation references inert after trace-owner disposal', () => { + const { owner } = harness(); + const disposeControls = vi.fn(); + let retainedSource: RenderTracePresentationSource | undefined; + let retainedUnsubscribe: (() => void) | undefined; + const detach = owner.attachPresentation((source) => { + retainedSource = source; + retainedUnsubscribe = source.subscribe(() => undefined); + return Object.freeze({ dispose: disposeControls }); + }); + owner.record({ slotId: 'owner-disposed-slot', path: 'auction', rendered: true }); + + owner.dispose(); + expect(retainedSource?.current()).toEqual({}); + expect(Object.getPrototypeOf(retainedSource?.current())).toBeNull(); + expect(retainedSource?.history()).toEqual([]); + expect(() => retainedSource?.subscribe(() => undefined)).toThrow(TypeError); + expect(() => retainedUnsubscribe?.()).not.toThrow(); + expect(() => detach()).not.toThrow(); + expect(disposeControls).toHaveBeenCalledOnce(); + }); + + it('coalesces zero, one, and two presentation updates without letting a hostile late task steal resubscribed work', () => { + const tasks: Array<() => void> = []; + const owner = createRenderTraceStore({ + schedule: (callback) => { + tasks.push(callback); + return () => undefined; + }, + }); + const firstSnapshots: number[][] = []; + const secondSnapshots: number[][] = []; + let source: RenderTracePresentationSource | undefined; + let releaseFirst: (() => void) | undefined; + const detach = owner.attachPresentation((candidate) => { + source = candidate; + releaseFirst = candidate.subscribe(() => + firstSnapshots.push(candidate.history().map(({ seq }) => seq)) + ); + return Object.freeze({ dispose: vi.fn() }); + }); + + expect(tasks).toEqual([]); + owner.record({ slotId: 'one-update', path: 'auction', rendered: true }); + expect(tasks).toHaveLength(1); + tasks.shift()?.(); + expect(firstSnapshots).toEqual([[1]]); + + owner.record({ slotId: 'two-updates-a', path: 'auction', rendered: true }); + owner.record({ slotId: 'two-updates-b', path: 'auction', rendered: true }); + expect(tasks).toHaveLength(1); + tasks.shift()?.(); + expect(firstSnapshots).toEqual([[1], [1, 2, 3]]); + + owner.record({ slotId: 'cancelled-update', path: 'auction', rendered: true }); + const hostileLateTask = tasks.shift()!; + releaseFirst?.(); + source?.subscribe(() => secondSnapshots.push(source!.history().map(({ seq }) => seq))); + owner.record({ slotId: 'resubscribed-update', path: 'auction', rendered: true }); + expect(tasks).toHaveLength(1); + const currentTask = tasks.shift()!; + hostileLateTask(); + expect(secondSnapshots).toEqual([]); + currentTask(); + expect(secondSnapshots).toEqual([[1, 2, 3, 4, 5]]); + detach(); + }); + + it.each(['factory throw', 'malformed controls', 'missing listener'] as const)( + 'rolls back %s and permits a later presentation retry', + (failure) => { + const { owner } = harness(); + const disposeCandidate = vi.fn(); + + expect(() => + owner.attachPresentation((source) => { + if (failure === 'factory throw') throw new Error('fictional presentation failure'); + if (failure !== 'missing listener') source.subscribe(() => undefined); + return Object.freeze( + failure === 'malformed controls' + ? { dispose: disposeCandidate, extra: true } + : { dispose: disposeCandidate } + ) as never; + }) + ).toThrow(); + expect(disposeCandidate).toHaveBeenCalledTimes(failure === 'factory throw' ? 0 : 1); + + const retryDispose = vi.fn(); + const detach = owner.attachPresentation((source) => { + source.subscribe(() => undefined); + return Object.freeze({ dispose: retryDispose }); + }); + detach(); + expect(retryDispose).toHaveBeenCalledOnce(); + } + ); + + it('validates callability before duplicate state and rejects reentrant attachment', () => { + const { owner } = harness(); + const nestedFactory = vi.fn(); + const detach = owner.attachPresentation((source) => { + expect(() => owner.attachPresentation(nestedFactory)).toThrow( + 'render trace presentation is unavailable' + ); + source.subscribe(() => undefined); + return Object.freeze({ dispose: vi.fn() }); + }); + + expect(nestedFactory).not.toHaveBeenCalled(); + expect(() => owner.attachPresentation(null as never)).toThrow( + 'render trace presentation factory must be callable' + ); + expect(() => owner.attachPresentation(() => Object.freeze({ dispose: vi.fn() }))).toThrow( + 'render trace presentation is unavailable' + ); + detach(); + }); + + it('creates no DOM stamps, UI, or scheduled work before deferred presentation attaches', () => { + const slot = document.createElement('div'); + slot.id = 'critical-only-slot'; + document.body.append(slot); + const { owner, tasks } = harness(); + + owner.record({ + slotId: 'critical-only-slot', + elementId: 'critical-only-slot', + path: 'ssat', + rendered: true, + injected: true, + visible: true, + }); + + expect(tasks).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(); + owner.dispose(); + slot.remove(); + }); + + it('captures membership per commit and suppresses unsubscribe before delivery', () => { + const { owner, drain } = harness(); + const first = vi.fn(); + const second = vi.fn(); + const releaseFirst = owner.diagnostics.subscribe(first); + owner.record({ slotId: 'slot-a', path: 'auction', rendered: true }); + releaseFirst(); + owner.diagnostics.subscribe(second); + drain(); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + + owner.record({ slotId: 'slot-b', path: 'auction', rendered: true }); + drain(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('coalesces pending same-impression enrichment without changing FIFO order', () => { + const { owner, drain, tasks } = harness(); + const received: Array<{ seq: number; injected?: boolean }> = []; + owner.diagnostics.subscribe((record) => received.push(record)); + const first = owner.record({ + slotId: 'slot-a', + path: 'ssat', + rendered: true, + injected: false, + }); + const second = owner.record({ slotId: 'slot-b', path: 'auction', rendered: false }); + owner.enrich(first!, { injected: true, servedFrom: 'pbs-cache' }); + + expect(tasks).toHaveLength(1); + drain(); + expect(received.map(({ seq }) => seq)).toEqual([first!.seq, second!.seq]); + expect(received[0]).toEqual(expect.objectContaining({ injected: true })); + }); + + it('bounds current state and history and prunes navigation-owned slots', () => { + const { owner } = harness(); + for (let index = 0; index < 256; index += 1) { + expect( + owner.record({ slotId: `slot-${index}`, path: 'auction', rendered: true }) + ).toBeDefined(); + } + owner.record({ slotId: 'slot-over-capacity', path: 'auction', rendered: true }); + expect(Object.keys(owner.diagnostics.current())).toHaveLength(256); + owner.prune('slot-0'); + expect(owner.diagnostics.current()).not.toHaveProperty('slot-0'); + owner.record({ slotId: 'slot-after-prune', path: 'auction', rendered: true }); + + for (let index = 0; index < 10; index += 1) { + owner.record({ slotId: 'slot-1', path: 'gam-refresh', rendered: index % 2 === 0 }); + } + const history = owner.diagnostics.history(); + expect(history).toHaveLength(200); + expect(history[0]?.seq).toBeGreaterThan(1); + }); + + it('retains a bounded document-lifetime slot count after current-state pruning', () => { + const { owner } = harness(); + const first = owner.record({ slotId: 'reused-slot', path: 'auction', rendered: true })!; + + expect(owner.prune('reused-slot', first.seq)).toBe(true); + const second = owner.record({ slotId: 'reused-slot', path: 'gam-refresh', rendered: false })!; + + expect(second.count).toBe(2); + }); + + it('evicts the counter paired with current-state rollover without resetting retained slots', () => { + const { owner } = harness(); + for (let index = 0; index < 256; index += 1) { + owner.record({ slotId: `slot-${index}`, path: 'auction', rendered: true }); + } + const refreshedA = owner.record({ slotId: 'slot-0', path: 'ssat', rendered: true })!; + expect(refreshedA.count).toBe(2); + + owner.record({ slotId: 'slot-256', path: 'auction', rendered: true }); + expect(owner.diagnostics.current()).not.toHaveProperty('slot-0'); + const refreshedB = owner.record({ slotId: 'slot-1', path: 'ssat', rendered: true })!; + + expect(refreshedB.count).toBe(2); + expect(owner.diagnostics.current()['slot-1']?.count).toBe(2); + expect(Object.values(owner.diagnostics.current()).every(({ count }) => count >= 1)).toBe(true); + }); + + it('keeps a slot count monotonic across current-state and counter-capacity churn', () => { + const { owner } = harness(); + const first = owner.record({ slotId: 'reused-slot', path: 'auction', rendered: true })!; + expect(owner.prune('reused-slot', first.seq)).toBe(true); + const second = owner.record({ slotId: 'reused-slot', path: 'ssat', rendered: true })!; + expect(second.count).toBe(2); + expect(owner.prune('reused-slot', second.seq)).toBe(true); + + for (let index = 0; index < 255; index += 1) { + owner.record({ slotId: `capacity-${index}`, path: 'auction', rendered: true }); + } + owner.record({ slotId: 'capacity-255', path: 'auction', rendered: true }); + const afterBoundedEviction = owner.record({ + slotId: 'reused-slot', + path: 'auction', + rendered: true, + })!; + + expect(afterBoundedEviction.count).toBe(3); + }); + + it('bounds SPA slot counters at exactly 768 with protected access-order eviction', () => { + const { owner } = harness(); + for (let index = 0; index < 768; index += 1) { + owner.record({ slotId: `spa-slot-${index}`, path: 'auction', rendered: true }); + } + + const currentProtected = owner.record({ + slotId: 'spa-slot-0', + path: 'ssat', + rendered: true, + }); + expect(currentProtected?.count).toBe(2); + owner.record({ slotId: 'spa-slot-overflow-1', path: 'auction', rendered: true }); + expect(owner.record({ slotId: 'spa-slot-0', path: 'ssat', rendered: true })?.count).toBe(3); + expect(owner.prune('spa-slot-0')).toBe(true); + owner.record({ slotId: 'spa-slot-overflow-2', path: 'auction', rendered: true }); + expect(owner.record({ slotId: 'spa-slot-0', path: 'ssat', rendered: true })?.count).toBe(4); + + expect(owner.record({ slotId: 'spa-slot-1', path: 'auction', rendered: true })?.count).toBe(1); + }); + + it('protects a dormant counter referenced by an exact GPT binding', () => { + const { owner } = harness(); + const navigationGeneration = Object.freeze({}); + owner.record({ slotId: 'bound-counter-slot', path: 'auction', rendered: true }); + expect(owner.prune('bound-counter-slot')).toBe(true); + for (let index = 0; index < 200; index += 1) { + owner.record({ slotId: `binding-spa-slot-${index}`, path: 'auction', rendered: true }); + } + owner.observeGptFact( + Object.freeze({ + kind: 'slotRequested', + slot: Object.freeze({ token: 'gt1_1', cycleOrdinal: 1 }), + }), + () => + Object.freeze({ + slotId: 'bound-counter-slot', + navigationGeneration, + traceToken: 'gt1_1', + }) + ); + for (let index = 200; index < 767; index += 1) { + owner.record({ slotId: `binding-spa-slot-${index}`, path: 'auction', rendered: true }); + } + + owner.record({ slotId: 'binding-spa-overflow', path: 'auction', rendered: true }); + + expect( + owner.record({ slotId: 'bound-counter-slot', path: 'auction', rendered: true })?.count + ).toBe(2); + expect( + owner.record({ slotId: 'binding-spa-slot-0', path: 'auction', rendered: true })?.count + ).toBe(1); + }); + + it('retains impression bookkeeping and refuses truth-weakening enrichment', () => { + const { owner } = harness(); + const record = owner.record({ + slotId: 'slot-a', + path: 'ssat', + rendered: true, + injected: true, + })!; + + const enriched = owner.enrich(record, { + rendered: false, + injected: false, + visible: true, + servedFrom: 'pbs-cache', + })!; + + expect(enriched).toEqual( + expect.objectContaining({ + at: record.at, + count: record.count, + seq: record.seq, + rendered: true, + injected: true, + visible: true, + servedFrom: 'pbs-cache', + }) + ); + expect(owner.diagnostics.history()).toHaveLength(1); + }); + + it('records an unattributed GPT request as one GAM-refresh impression', () => { + const { owner } = harness(); + const navigationGeneration = Object.freeze({}); + const resolve = () => + Object.freeze({ + slotId: 'publisher-slot', + elementId: 'publisher-slot', + navigationGeneration, + traceToken: 'gt1_1', + visible: true, + }); + + owner.observeGptFact( + Object.freeze({ + kind: 'slotRequested', + observedAtMs: 1, + slot: Object.freeze({ token: 'gt1_1', cycleOrdinal: 1, elementId: 'publisher-slot' }), + }), + resolve + ); + owner.observeGptFact( + Object.freeze({ + kind: 'slotRenderEnded', + observedAtMs: 2, + slot: Object.freeze({ token: 'gt1_1', cycleOrdinal: 1, elementId: 'publisher-slot' }), + isEmpty: false, + }), + resolve + ); + + expect(owner.diagnostics.current()['publisher-slot']).toEqual( + expect.objectContaining({ + path: 'gam-refresh', + rendered: true, + gamEmpty: false, + injected: false, + visible: true, + servedFrom: 'gam', + }) + ); + expect(owner.diagnostics.history()).toHaveLength(1); + }); + + it('reconciles a later trusted terminal into the GPT-first impression', () => { + const { owner, tasks, drain } = harness(); + const listener = vi.fn(); + const navigationGeneration = Object.freeze({}); + const slot = Object.freeze({ token: 'gt1_1', cycleOrdinal: 1, elementId: 'reverse-slot' }); + const resolve = () => + Object.freeze({ + slotId: 'reverse-slot', + elementId: 'reverse-slot', + navigationGeneration, + traceToken: 'gt1_1', + visible: true, + }); + owner.diagnostics.subscribe(listener); + + owner.observeGptFact(Object.freeze({ kind: 'slotRequested', observedAtMs: 1, slot }), resolve); + owner.observeGptFact( + Object.freeze({ kind: 'slotRenderEnded', observedAtMs: 2, slot, isEmpty: false }), + resolve + ); + const provisional = owner.diagnostics.current()['reverse-slot']; + expect(provisional).toEqual( + expect.objectContaining({ path: 'gam-refresh', rendered: true, injected: false }) + ); + + const terminal = owner.record({ + slotId: 'reverse-slot', + path: 'ssat', + rendered: true, + injected: true, + bidder: 'trusted-bidder', + bidId: 'trusted-bid', + creativeId: 'trusted-creative', + servedFrom: 'pbs-cache', + })!; + + expect(terminal).toEqual( + expect.objectContaining({ + seq: provisional?.seq, + count: provisional?.count, + at: provisional?.at, + path: 'ssat', + bidder: 'trusted-bidder', + bidId: 'trusted-bid', + creativeId: 'trusted-creative', + servedFrom: 'pbs-cache', + rendered: true, + injected: true, + gamEmpty: false, + }) + ); + expect(owner.diagnostics.history()).toEqual([terminal]); + expect(tasks).toHaveLength(1); + drain(); + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ seq: terminal.seq, path: 'ssat' }) + ); + + owner.observeGptFact( + Object.freeze({ + kind: 'slotVisibilityChanged', + observedAtMs: 3, + slot, + inViewPercentage: 0, + }), + resolve + ); + expect(owner.diagnostics.current()['reverse-slot']).toEqual( + expect.objectContaining({ seq: terminal.seq, path: 'ssat', visible: false }) + ); + expect(owner.diagnostics.history()).toHaveLength(1); + }); + + it('enriches only the same GPT impression without weakening TS placement truth', () => { + const { owner } = harness(); + const navigationGeneration = Object.freeze({}); + const slot = Object.freeze({ token: 'gt1_1', cycleOrdinal: 1, elementId: 'ts-slot' }); + const resolve = () => + Object.freeze({ + slotId: 'ts-slot', + elementId: 'ts-slot', + navigationGeneration, + traceToken: 'gt1_1', + visible: true, + }); + owner.record({ slotId: 'ts-slot', path: 'gam-refresh', rendered: false, injected: false }); + owner.observeGptFact(Object.freeze({ kind: 'slotRequested', observedAtMs: 1, slot }), resolve); + const trusted = owner.record({ + slotId: 'ts-slot', + path: 'ssat', + rendered: true, + injected: true, + servedFrom: 'pbs-cache', + })!; + + owner.observeGptFact( + Object.freeze({ kind: 'slotRenderEnded', observedAtMs: 2, slot, isEmpty: false }), + resolve + ); + owner.observeGptFact( + Object.freeze({ kind: 'slotRenderEnded', observedAtMs: 3, slot, isEmpty: true }), + resolve + ); + + expect(owner.diagnostics.history()).toHaveLength(2); + expect(owner.diagnostics.current()['ts-slot']).toEqual( + expect.objectContaining({ + seq: trusted.seq, + path: 'ssat', + rendered: true, + injected: true, + gamEmpty: false, + servedFrom: 'pbs-cache', + }) + ); + }); + + it('routes all GPT lifecycle facts and scopes visibility to the active physical request', () => { + const { owner } = harness(); + const navigationGeneration = Object.freeze({}); + let currentTraceToken = 'gt1_1'; + const resolve = () => + Object.freeze({ + slotId: 'visible-slot', + navigationGeneration, + traceToken: currentTraceToken, + visible: false, + }); + const fact = ( + kind: RenderTraceGptFactV1['kind'], + token: string, + cycleOrdinal: number, + fields: Readonly> = Object.freeze( + {} + ) + ): Readonly => + Object.freeze({ + kind, + observedAtMs: 1, + slot: Object.freeze({ token, cycleOrdinal }), + ...fields, + }); + + owner.observeGptFact(fact('slotRequested', 'gt1_1', 1), resolve); + owner.observeGptFact(fact('slotResponseReceived', 'gt1_1', 1), resolve); + owner.observeGptFact(fact('slotRenderEnded', 'gt1_1', 1, { isEmpty: false }), resolve); + owner.observeGptFact(fact('slotOnload', 'gt1_1', 1), resolve); + owner.observeGptFact(fact('impressionViewable', 'gt1_1', 1), resolve); + expect(owner.diagnostics.current()['visible-slot']?.visible).toBe(true); + owner.observeGptFact( + fact('slotVisibilityChanged', 'gt1_1', 1, { inViewPercentage: 0 }), + resolve + ); + expect(owner.diagnostics.current()['visible-slot']?.visible).toBe(false); + + currentTraceToken = 'gt1_2'; + owner.observeGptFact(fact('slotRequested', 'gt1_2', 1), resolve); + owner.observeGptFact(fact('slotRenderEnded', 'gt1_1', 1, { isEmpty: true }), resolve); + owner.observeGptFact(fact('impressionViewable', 'gt1_1', 1), resolve); + owner.observeGptFact(fact('impressionViewable', 'gt1_2', 1), resolve); + expect( + owner.diagnostics.current()['visible-slot']?.visible, + 'a pre-render callback for a replacement physical request must not enrich the old impression' + ).toBe(false); + expect(owner.diagnostics.history()).toHaveLength(1); + }); + + it('rejects late onload visibility resolved through a same-element physical replacement', () => { + const { owner } = harness(); + const navigationGeneration = Object.freeze({}); + const firstSlot = Object.freeze({ + token: 'gt1_1', + cycleOrdinal: 1, + elementId: 'reused-element', + }); + const replacementSlot = Object.freeze({ + token: 'gt1_2', + cycleOrdinal: 1, + elementId: 'reused-element', + }); + const resolution = (traceToken: string, visible: boolean) => + Object.freeze({ + slotId: 'registered-slot', + elementId: 'reused-element', + navigationGeneration, + traceToken, + visible, + }); + + owner.observeGptFact(Object.freeze({ kind: 'slotRequested', slot: firstSlot }), () => + resolution('gt1_1', false) + ); + owner.observeGptFact( + Object.freeze({ kind: 'slotRenderEnded', slot: firstSlot, isEmpty: false }), + () => resolution('gt1_1', false) + ); + owner.observeGptFact(Object.freeze({ kind: 'slotRequested', slot: replacementSlot }), () => + resolution('gt1_2', false) + ); + owner.observeGptFact( + Object.freeze({ kind: 'slotRenderEnded', slot: replacementSlot, isEmpty: false }), + () => resolution('gt1_2', false) + ); + + owner.observeGptFact(Object.freeze({ kind: 'slotOnload', slot: firstSlot }), () => + resolution('gt1_2', true) + ); + + expect(owner.diagnostics.history()).toEqual([ + expect.objectContaining({ seq: 1, visible: false }), + expect.objectContaining({ seq: 2, visible: false }), + ]); + expect(owner.diagnostics.current()['registered-slot']).toEqual( + expect.objectContaining({ seq: 2, visible: false }) + ); + }); + + it('joins GPT enrichment by the exact token-cycle pair and enriches retired history only', () => { + const { owner } = harness(); + const navigationGeneration = Object.freeze({}); + const resolve = () => + Object.freeze({ + slotId: 'refresh-slot', + navigationGeneration, + traceToken: 'gt1_1', + visible: false, + }); + const fact = ( + kind: RenderTraceGptFactV1['kind'], + cycleOrdinal: number, + fields: Readonly> = Object.freeze({}) + ): Readonly => + Object.freeze({ + kind, + slot: Object.freeze({ token: 'gt1_1', cycleOrdinal }), + ...fields, + }); + + owner.observeGptFact(fact('slotRequested', 1), resolve); + owner.observeGptFact(fact('slotRenderEnded', 1, { isEmpty: false }), resolve); + owner.observeGptFact(fact('slotRequested', 2), resolve); + owner.observeGptFact(fact('slotRenderEnded', 2, { isEmpty: false }), resolve); + const beforeLate = owner.diagnostics.history(); + expect(beforeLate).toHaveLength(2); + expect(beforeLate[0]?.visible).toBe(false); + expect(beforeLate[1]?.visible).toBe(false); + + owner.observeGptFact(fact('impressionViewable', 1), resolve); + owner.observeGptFact(fact('impressionViewable', 3), resolve); + + const afterLate = owner.diagnostics.history(); + expect(afterLate[0]).toEqual(expect.objectContaining({ seq: 1, visible: true })); + expect(afterLate[1]).toEqual(expect.objectContaining({ seq: 2, visible: false })); + expect(owner.diagnostics.current()['refresh-slot']).toEqual( + expect.objectContaining({ seq: 2, visible: false }) + ); + }); + + it.each([ + ['zero cycle', 'gt1_1', 0], + ['fractional cycle', 'gt1_1', 1.5], + ['overflow cycle', 'gt1_1', 4_294_967_296], + ['noncanonical token', 'gt1_01', 1], + ])('drops %s GPT trace identities', (_label, token, cycleOrdinal) => { + const { owner } = harness(); + + owner.observeGptFact( + Object.freeze({ + kind: 'slotRequested', + slot: Object.freeze({ token, cycleOrdinal }), + }), + () => + Object.freeze({ + slotId: 'invalid-slot', + navigationGeneration: Object.freeze({}), + traceToken: token, + }) + ); + + expect(owner.diagnostics.current()).toEqual({}); + expect(owner.diagnostics.history()).toEqual([]); + }); + + it('rejects a GPT request whose resolved physical token does not match', () => { + const { owner } = harness(); + const navigationGeneration = Object.freeze({}); + const slot = Object.freeze({ token: 'gt1_1', cycleOrdinal: 1, elementId: 'token-slot' }); + const resolve = () => + Object.freeze({ + slotId: 'token-slot', + navigationGeneration, + traceToken: 'gt1_2', + }); + + owner.observeGptFact(Object.freeze({ kind: 'slotRequested', slot }), resolve); + owner.observeGptFact(Object.freeze({ kind: 'slotRenderEnded', slot, isEmpty: false }), resolve); + + expect(owner.diagnostics.current()).toEqual({}); + expect(owner.diagnostics.history()).toEqual([]); + }); + + it('admits exactly 256 live GPT impression bindings and refuses the 257th', () => { + const { owner } = harness(); + const navigationGeneration = Object.freeze({}); + const facts = Array.from({ length: 257 }, (_, index) => { + const ordinal = index + 1; + const token = `gt1_${ordinal.toString(36)}`; + const elementId = `binding-slot-${ordinal}`; + const slot = Object.freeze({ token, cycleOrdinal: 1, elementId }); + const resolve = () => + Object.freeze({ + slotId: elementId, + navigationGeneration, + traceToken: token, + }); + return { resolve, slot }; + }); + + for (const { resolve, slot } of facts) { + owner.observeGptFact( + Object.freeze({ kind: 'slotRequested', observedAtMs: 1, slot }), + resolve + ); + } + for (let index = 0; index < 255; index += 1) { + const fact = facts[index]!; + owner.observeGptFact( + Object.freeze({ + kind: 'slotRenderEnded', + observedAtMs: 2, + slot: fact.slot, + isEmpty: false, + }), + fact.resolve + ); + } + expect(Object.keys(owner.diagnostics.current())).toHaveLength(255); + + const atCapacity = facts[255]!; + owner.observeGptFact( + Object.freeze({ + kind: 'slotRenderEnded', + observedAtMs: 2, + slot: atCapacity.slot, + isEmpty: false, + }), + atCapacity.resolve + ); + expect(Object.keys(owner.diagnostics.current())).toHaveLength(256); + + const overflow = facts[256]!; + owner.observeGptFact( + Object.freeze({ + kind: 'slotRenderEnded', + observedAtMs: 2, + slot: overflow.slot, + isEmpty: false, + }), + overflow.resolve + ); + expect(Object.keys(owner.diagnostics.current())).toHaveLength(256); + expect(owner.diagnostics.current()).not.toHaveProperty('binding-slot-257'); + }); + + it('retires an open GPT binding on navigation disposal before it creates a row', () => { + const { owner } = harness(); + const navigationGeneration = Object.freeze({}); + const slot = Object.freeze({ token: 'gt1_1', cycleOrdinal: 1, elementId: 'open-slot' }); + const resolve = () => + Object.freeze({ + slotId: 'open-slot', + navigationGeneration, + traceToken: 'gt1_1', + }); + + owner.observeGptFact(Object.freeze({ kind: 'slotRequested', slot }), resolve); + expect(owner.pruneNavigation(navigationGeneration)).toBe(1); + owner.observeGptFact(Object.freeze({ kind: 'slotRenderEnded', slot, isEmpty: false }), resolve); + + expect(owner.diagnostics.current()).toEqual({}); + expect(owner.diagnostics.history()).toEqual([]); + }); + + it('retires an open GPT binding when its render can no longer resolve the slot', () => { + const { owner } = harness(); + const navigationGeneration = Object.freeze({}); + const slot = Object.freeze({ token: 'gt1_1', cycleOrdinal: 1, elementId: 'stale-slot' }); + const resolution = Object.freeze({ + slotId: 'stale-slot', + navigationGeneration, + traceToken: 'gt1_1', + }); + + owner.observeGptFact(Object.freeze({ kind: 'slotRequested', slot }), () => resolution); + owner.observeGptFact( + Object.freeze({ kind: 'slotRenderEnded', slot, isEmpty: false }), + () => undefined + ); + owner.observeGptFact( + Object.freeze({ kind: 'slotRenderEnded', slot, isEmpty: false }), + () => resolution + ); + + expect(owner.diagnostics.current()).toEqual({}); + expect(owner.diagnostics.history()).toEqual([]); + }); + + it('makes repeated retained record calls wholly inert after disposal', () => { + const now = vi.fn(() => 41); + const schedule = vi.fn(() => vi.fn()); + const owner = createRenderTraceStore({ now, schedule }); + const listener = vi.fn(); + const resolve = vi.fn(() => + Object.freeze({ + slotId: 'disposed-binding-slot', + navigationGeneration: Object.freeze({}), + traceToken: 'gt1_1', + }) + ); + const retainedRecord = owner.record; + owner.diagnostics.subscribe(listener); + owner.record({ slotId: 'before-dispose', path: 'auction', rendered: true }); + owner.observeGptFact( + Object.freeze({ + kind: 'slotRequested', + slot: Object.freeze({ token: 'gt1_1', cycleOrdinal: 1 }), + }), + resolve + ); + owner.dispose(); + now.mockClear(); + schedule.mockClear(); + listener.mockClear(); + resolve.mockClear(); + const reads = vi.fn(); + const hostileInput = new Proxy( + { slotId: 'after-dispose', path: 'auction' as const, rendered: true }, + { + get: (target, property, receiver) => ( + reads(property), + Reflect.get(target, property, receiver) + ), + } + ); + + expect(retainedRecord(hostileInput)).toBeUndefined(); + expect(retainedRecord(hostileInput)).toBeUndefined(); + owner.observeGptFact( + Object.freeze({ + kind: 'slotRenderEnded', + slot: Object.freeze({ token: 'gt1_1', cycleOrdinal: 1 }), + isEmpty: false, + }), + resolve + ); + + expect(reads).not.toHaveBeenCalled(); + expect(now).not.toHaveBeenCalled(); + expect(schedule).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + expect(owner.diagnostics.current()).toEqual({}); + expect(owner.diagnostics.history()).toEqual([]); + }); + + it('drops the oldest of 201 pending records and cancels work on disposal', () => { + const { owner, tasks, drain } = harness(); + const listener = vi.fn(); + owner.diagnostics.subscribe(listener); + for (let index = 0; index < 201; index += 1) { + owner.record({ slotId: 'slot-a', path: 'auction', rendered: true }); + } + expect(tasks).toHaveLength(1); + drain(); + expect(listener).toHaveBeenCalledTimes(200); + expect(listener.mock.calls[0]?.[0].seq).toBe(2); + + owner.record({ slotId: 'slot-a', path: 'auction', rendered: true }); + expect(tasks).toHaveLength(1); + owner.dispose(); + owner.dispose(); + drain(); + expect(listener).toHaveBeenCalledTimes(200); + const late = owner.record({ slotId: 'late', path: 'auction', rendered: true }); + expect(late).toBeUndefined(); + expect(owner.diagnostics.current()).toEqual({}); + expect(owner.diagnostics.history()).toEqual([]); + expect(owner.diagnostics.subscribe(() => undefined)).toBeTypeOf('function'); + expect(() => owner.diagnostics.subscribe(null as never)).toThrow(TypeError); + }); + + it('uses the server-resolved boot bit instead of reading the trace cookie', () => { + document.cookie = 'ts-trace=1; Path=/'; + const disarmedSlot = document.createElement('div'); + disarmedSlot.id = 'disarmed-slot'; + document.body.append(disarmedSlot); + const disarmed = createPresentedRenderTrace({ document, overlayEnabled: false }); + + disarmed.record({ + slotId: 'disarmed-slot', + elementId: 'disarmed-slot', + path: 'auction', + rendered: true, + injected: true, + visible: true, + }); + + expect(disarmedSlot.getAttribute('data-ts-rendered')).toBeNull(); + expect(disarmedSlot.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); + disarmed.dispose(); + disarmedSlot.remove(); + document.cookie = 'ts-trace=; Max-Age=0; Path=/'; + + const armedSlot = document.createElement('div'); + armedSlot.id = 'armed-slot'; + document.body.append(armedSlot); + const armed = createPresentedRenderTrace({ document, overlayEnabled: true }); + armed.record({ + slotId: 'armed-slot', + elementId: 'armed-slot', + path: 'ssat', + rendered: true, + injected: true, + visible: true, + }); + + const badge = armedSlot.querySelector(`.${TRACE_BADGE_CLASS}`) as HTMLElement | null; + expect(badge).not.toBeNull(); + expect(badge?.style.pointerEvents).toBe('none'); + expect(document.getElementById(TRACE_PANEL_ID)).not.toBeNull(); + armed.dispose(); + armedSlot.remove(); + }); + + it('removes stale stamps and badges on a later physical impression', () => { + const slot = document.createElement('div'); + slot.id = 'restamped-slot'; + document.body.append(slot); + const owner = createPresentedRenderTrace({ document, overlayEnabled: true }); + owner.record({ + slotId: 'restamped-slot', + elementId: 'restamped-slot', + path: 'ssat', + rendered: true, + injected: true, + visible: true, + bidder: 'first-bidder', + admHash: 'first-hash', + }); + expect(slot.getAttribute('data-ts-bidder')).toBe('first-bidder'); + expect(slot.querySelector(`.${TRACE_BADGE_CLASS}`)).not.toBeNull(); + + owner.record({ + slotId: 'restamped-slot', + elementId: 'restamped-slot', + path: 'gam-refresh', + rendered: true, + injected: true, + visible: false, + }); + + expect(slot.hasAttribute('data-ts-bidder')).toBe(false); + expect(slot.hasAttribute('data-ts-adm-hash')).toBe(false); + expect(slot.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + owner.dispose(); + expect(slot.hasAttribute('data-ts-slot-id')).toBe(false); + slot.remove(); + }); + + it('stamps iframe slots without placing UI inside the creative frame', () => { + const iframe = document.createElement('iframe'); + iframe.id = 'iframe-slot'; + document.body.append(iframe); + const owner = createPresentedRenderTrace({ document, overlayEnabled: true }); + + owner.record({ + slotId: 'iframe-slot', + elementId: 'iframe-slot', + path: 'ssat', + rendered: true, + injected: true, + visible: true, + }); + + expect(iframe.getAttribute('data-ts-rendered')).toBe('true'); + expect(iframe.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + owner.dispose(); + iframe.remove(); + }); + + it('does not claim an ok badge before visibility is positively observed', () => { + const slot = document.createElement('div'); + slot.id = 'unobserved-slot'; + document.body.append(slot); + const owner = createPresentedRenderTrace({ document, overlayEnabled: true }); + + owner.record({ + slotId: 'unobserved-slot', + elementId: 'unobserved-slot', + path: 'auction', + rendered: true, + injected: true, + }); + + expect(slot.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + expect(document.getElementById(TRACE_PANEL_ID)?.textContent).toContain('hidden'); + owner.dispose(); + slot.remove(); + }); + + it('does not claim or remove a publisher-owned overlay id collision', () => { + const publisherPanel = document.createElement('div'); + publisherPanel.id = TRACE_PANEL_ID; + publisherPanel.textContent = 'publisher'; + document.body.append(publisherPanel); + const owner = createPresentedRenderTrace({ document, overlayEnabled: true }); + + owner.record({ slotId: 'slot-a', path: 'auction', rendered: true }); + + expect(document.getElementById(TRACE_PANEL_ID)).toBe(publisherPanel); + expect(publisherPanel.textContent).toBe('publisher'); + owner.dispose(); + expect(document.getElementById(TRACE_PANEL_ID)).toBe(publisherPanel); + publisherPanel.remove(); + }); + + it('keeps a bounded newest-first overlay and exports frozen row data', () => { + const exportRecord = vi.fn(); + const tasks: Array<() => void> = []; + const owner = createPresentedRenderTrace({ + document, + overlayEnabled: true, + exportRecord, + schedule: (callback) => { + tasks.push(callback); + return () => { + const index = tasks.indexOf(callback); + if (index >= 0) tasks.splice(index, 1); + }; + }, + }); + for (let index = 1; index <= 201; index += 1) { + owner.record({ slotId: `slot-${index}`, path: 'auction', rendered: true }); + } + expect(tasks).toHaveLength(1); + tasks.shift()?.(); + expect(tasks).toEqual([]); + + const panel = document.getElementById(TRACE_PANEL_ID)!; + const rows = [...panel.querySelectorAll('[data-ts-trace-seq]')]; + expect(rows).toHaveLength(200); + expect(rows[0]?.dataset['tsTraceSeq']).toBe('201'); + expect(rows[rows.length - 1]?.dataset['tsTraceSeq']).toBe('2'); + rows[0]?.click(); + expect(exportRecord).toHaveBeenCalledOnce(); + expect(exportRecord).toHaveBeenCalledWith( + expect.objectContaining({ slotId: 'slot-201', seq: 201 }) + ); + expect(Object.isFrozen(exportRecord.mock.calls[0]?.[0])).toBe(true); + owner.dispose(); + expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); + }); + + it('isolates presentation failures after committing diagnostics state', () => { + const onPresentationError = vi.fn(); + const hostileDocument = { + getElementById: () => { + throw new Error('hostile document'); + }, + } as unknown as Document; + const owner = createPresentedRenderTrace({ + document: hostileDocument, + overlayEnabled: true, + onPresentationError, + }); + + expect(() => owner.record({ slotId: 'slot-a', path: 'auction', rendered: true })).not.toThrow(); + expect(owner.diagnostics.current()['slot-a']).toEqual( + expect.objectContaining({ slotId: 'slot-a', rendered: true }) + ); + expect(onPresentationError).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs b/crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs new file mode 100644 index 000000000..3d47085b9 --- /dev/null +++ b/crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs @@ -0,0 +1,330 @@ +import assert from 'node:assert/strict'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { ESLint, Linter } from 'eslint'; +import ts from 'typescript'; + +import noAdtechGlobals from '../../eslint-rules/no-adtech-globals.js'; +import { ARCHITECTURE_INTEGRATION_DIRECTORIES } from '../../eslint.config.js'; + +const ruleId = 'tsjs/no-adtech-globals'; +const packageRoot = path.resolve(import.meta.dirname, '../..'); + +function lint(source, filename = 'src/kernel/new-runtime.js') { + const linter = new Linter({ configType: 'flat' }); + + return linter.verify( + source, + [ + { + files: ['**/*.js', '**/*.ts', '**/*.tsx'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { + globalThis: 'readonly', + self: 'readonly', + window: 'readonly', + }, + }, + plugins: { + tsjs: { + rules: { + 'no-adtech-globals': noAdtechGlobals, + }, + }, + }, + rules: { + [ruleId]: 'error', + }, + }, + ], + { filename } + ); +} + +function assertRejected(source, filename) { + const messages = lint(source, filename); + assert.ok(messages.length > 0, `expected an ad-tech-global error for: ${source}`); + assert.ok(messages.every((message) => message.ruleId === ruleId)); + assert.ok(messages.every((message) => message.messageId === 'externalGlobalOwnedByAdapter')); +} + +async function collectRelativeModuleGraph(entry) { + const pending = [path.resolve(packageRoot, entry)]; + const visited = new Set(); + while (pending.length > 0) { + const filename = pending.pop(); + if (!filename || visited.has(filename)) continue; + visited.add(filename); + const source = await readFile(filename, 'utf8'); + const sourceFile = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, false); + for (const statement of sourceFile.statements) { + const moduleSpecifier = + (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; + if (!moduleSpecifier?.startsWith('.')) continue; + const unresolved = path.resolve(path.dirname(filename), moduleSpecifier); + const candidates = [ + unresolved, + `${unresolved}.ts`, + `${unresolved}.tsx`, + path.join(unresolved, 'index.ts'), + ]; + let resolved; + for (const candidate of candidates) { + try { + await readFile(candidate, 'utf8'); + resolved = candidate; + break; + } catch { + // Try the next TypeScript module resolution candidate. + } + } + assert.ok(resolved, `could not resolve ${moduleSpecifier} from ${filename}`); + pending.push(resolved); + } + } + return [...visited].map((filename) => path.relative(packageRoot, filename).replaceAll('\\', '/')); +} + +test('rejects direct GPT and Prebid access through every browser global root', () => { + for (const source of [ + 'window.googletag.cmd.push(run);', + "globalThis['pbjs'].requestBids();", + 'window[`googletag`].cmd.push(run);', + 'self.googletag?.pubads();', + 'googletag.cmd.push(run);', + 'pbjs.requestBids();', + ]) { + assertRejected(source); + } +}); + +test('rejects same-file aliases of roots and external objects', () => { + for (const source of [ + 'const root = window; root.googletag.cmd.push(run);', + 'const first = globalThis; const second = first; second.pbjs.requestBids();', + 'let root; root = self; root.googletag.pubads();', + 'const tag = window.googletag; tag.cmd.push(run);', + 'const prebid = globalThis.pbjs; prebid.requestBids();', + 'const { googletag: tag } = window; tag.cmd.push(run);', + 'const { pbjs: prebid } = self; prebid.requestBids();', + 'const { googletag } = window;', + "let prebid; ({ ['pbjs']: prebid } = globalThis);", + 'let root; ({ window: root } = globalThis); root.pbjs.requestBids();', + 'const { ...root } = window; root.googletag.cmd.push(run);', + 'const root = (0, window); root.pbjs.requestBids();', + 'class Owner { bind() { this.root = window; } read() { return this.root.googletag; } }', + 'class Owner { root = window; read() { return this.root.pbjs; } }', + 'class Owner { bind() { this.root = this.root ?? window; } read() { return this.root.pbjs; } }', + 'class Owner { #root = window; read() { return this.#root.googletag; } }', + 'function inspect(root = window) { return root.googletag; }', + 'function inspect({ window: root } = globalThis) { return root.pbjs; }', + 'for (const root of [window]) { root.googletag; }', + 'let root; for (root of [globalThis]) { root.pbjs; }', + ]) { + assertRejected(source); + } +}); + +test('is scope-aware and permits unrelated shadowed values', () => { + assert.deepEqual( + lint(` + function inspect(window, globalThis, self, googletag, pbjs) { + window.googletag; + globalThis.pbjs; + self.googletag; + googletag.cmd; + pbjs.requestBids; + } + void inspect; + `), + [] + ); + assert.deepEqual( + lint(` + const values = [window]; + values.googletag; + [window].pbjs; + `), + [] + ); + assert.deepEqual( + lint(` + class SelfReference { + bind() { this.root = this.root; } + read() { return this.root.googletag; } + } + void SelfReference; + `), + [] + ); + assert.deepEqual( + lint(` + class BrowserState { bind() { this.root = window; } } + class LocalState { + constructor() { this.root = { googletag: 'local' }; } + read() { return this.root.googletag; } + } + void BrowserState; + void LocalState; + `), + [] + ); + assert.deepEqual( + lint(` + class LocalState { + constructor() { this.window = { googletag: 'local' }; } + read() { return this.window.googletag; } + } + void LocalState; + `), + [] + ); +}); + +test('permits TSJS API and messaging access outside adapters', () => { + assert.deepEqual( + lint(` + window.tsjs?.requestAds(); + globalThis.window?.postMessage({ type: 'TSJS_V1' }, '*'); + self.addEventListener('message', onMessage, { capture: true }); + `), + [] + ); +}); + +test('permits external-global ownership only in adapter source files', () => { + assert.deepEqual( + lint('const root = window; root.googletag; globalThis.pbjs;', 'src/adapters/googletag.js'), + [] + ); + assertRejected('window.googletag;', 'src/adapters-pretender/googletag.js'); +}); + +test('integration entrypoints have no ad-tech-global exemption', () => { + assertRejected('window.googletag;', 'src/integrations/gpt/index.ts'); + assertRejected('globalThis.pbjs;', 'src/integrations/prebid/index.ts'); +}); + +test('restricted paths enforce dependency direction and exact target-file exemptions', async () => { + const eslint = new ESLint({ cwd: packageRoot }); + const restrictedRuleId = 'import-x/no-restricted-paths'; + + async function restrictedMessages(source, relativeFilename) { + const [result] = await eslint.lintText(source, { + filePath: path.join(packageRoot, relativeFilename), + }); + assert.ok(result); + assert.equal(result.fatalErrorCount, 0); + return result.messages.filter((message) => message.ruleId === restrictedRuleId); + } + + async function projectRuleMessages(source, relativeFilename, projectRuleId) { + const [result] = await eslint.lintText(source, { + filePath: path.join(packageRoot, relativeFilename), + }); + assert.ok(result); + assert.equal(result.fatalErrorCount, 0); + return result.messages.filter((message) => message.ruleId === projectRuleId); + } + + assert.ok( + (await restrictedMessages("import '../integrations/aps/render';", 'src/core/new-request.ts')) + .length > 0 + ); + assert.ok( + (await restrictedMessages("import '../adapters/googletag';", 'src/kernel/probe.tsx')).length > 0 + ); + assert.ok( + ( + await projectRuleMessages( + 'window.googletag;', + 'src/kernel/probe.tsx', + 'tsjs/no-adtech-globals' + ) + ).length > 0 + ); + assert.ok( + (await restrictedMessages("import '../adapters/googletag';", 'src/core/new-index.ts')).length > + 0 + ); + assert.ok((await restrictedMessages("import '../index';", 'src/adapters/probe.ts')).length > 0); + assert.ok( + (await restrictedMessages("import '../core/log.js';", 'src/adapters/probe.ts')).length > 0 + ); + assert.ok( + (await restrictedMessages("import '../index.js';", 'src/adapters/probe.ts')).length > 0 + ); + assert.ok( + (await restrictedMessages("import '../adapters/googletag.js';", 'src/kernel/probe.ts')).length > + 0 + ); + assert.ok( + (await restrictedMessages("import '../core/types';", 'src/adapters/new-adapter.ts')).length > 0 + ); + assert.ok( + (await restrictedMessages("import '../core/types';", 'src/services/new-service.ts')).length > 0 + ); + assert.ok( + (await restrictedMessages("import '../composition/browser';", 'src/kernel/new-runtime.ts')) + .length > 0 + ); + assert.ok( + ( + await restrictedMessages( + "import '../../composition/browser';", + 'src/integrations/gpt/new-module.ts' + ) + ).length > 0 + ); + assert.ok( + (await restrictedMessages("import '../prebid/index';", 'src/integrations/gpt/new-module.ts')) + .length > 0 + ); + + assert.ok( + (await restrictedMessages("import '../integrations/aps/render';", 'src/core/request.ts')).length > + 0 + ); + assert.ok( + (await restrictedMessages("import '../integrations/aps/render';", 'src/kernel/request.ts')) + .length > 0 + ); + assert.deepEqual( + await restrictedMessages("import '../adapters/googletag';", 'src/composition/new-browser.ts'), + [] + ); + assert.deepEqual( + await restrictedMessages("import './script_guard';", 'src/integrations/gpt/new-module.ts'), + [] + ); +}); + +test('generated fallback source graph excludes APS integration implementation', async () => { + const graph = await collectRelativeModuleGraph('src/integrations/gpt/bootstrap_fallback.ts'); + + assert.ok(graph.includes('src/kernel/fallback.ts')); + assert.deepEqual( + graph.filter((filename) => filename.startsWith('src/integrations/aps/')), + [] + ); +}); + +test('every current integration directory participates in cross-integration isolation', async () => { + const entries = await readdir(path.join(packageRoot, 'src/integrations'), { + withFileTypes: true, + }); + const actual = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + assert.deepEqual(actual, [...ARCHITECTURE_INTEGRATION_DIRECTORIES].sort()); +}); 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/performance/aps-tsjs-prechange.json b/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json new file mode 100644 index 000000000..2ac928ea3 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json @@ -0,0 +1,929 @@ +{ + "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": "63bc1a1928e7bb53e0aa4de86a1556b9eee2db3c" + }, + "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": "d335ea16a9b8d87c2625f39969591c75dea81210822421d6631f7d11f455e80c", + "artifacts": [ + { + "id": "bootstrap", + "role": "bootstrap", + "phase": null, + "trigger": null, + "inputs": [], + "outputs": [], + "file": "gpt-bootstrap-fallback.js", + "bytes": 42399, + "hash": "ed7231527be79dfd864bcc8275f90d51bf39c57eae92a8c6fe5ece8d68ef2f23" + }, + { + "id": "core", + "role": "core", + "phase": null, + "trigger": null, + "inputs": [], + "outputs": ["runtime.v1"], + "file": "tsjs-core.js", + "bytes": 85581, + "hash": "f20236dd9b885218040d7dbd0c9aaa964e542a2c05f31d330e475f29979eb120" + }, + { + "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": 166529, + "hash": "27bf0bb447267c6ac8ca23110fff1b564ffed8f1c7cbc63e5db187b875c9b919" + }, + { + "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": "26b7a43479182619221f63df13a1d3589f00c1db59c993ebd656087501b1c37b" + }, + { + "id": "creative", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": ["runtime.v1"], + "outputs": [], + "file": "tsjs-creative.js", + "bytes": 21093, + "hash": "14bc52d375444fb4b1dfb2dc8fad6759f8b048da8cbfe3d32d868c43c5df84e3" + }, + { + "id": "datadome", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": ["runtime.v1"], + "outputs": [], + "file": "tsjs-datadome.js", + "bytes": 23375, + "hash": "f7558eff608af6f9eccff72c0a2c823152cd9e43dc309efcc39d9cb6b20e5c9b" + }, + { + "id": "didomi", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": ["runtime.v1"], + "outputs": [], + "file": "tsjs-didomi.js", + "bytes": 15489, + "hash": "c2c1777e297cdc6f2d97986e485dfc19af1192461b5ec4ee9dc494cff14d3f0e" + }, + { + "id": "google_tag_manager", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": ["runtime.v1"], + "outputs": [], + "file": "tsjs-google_tag_manager.js", + "bytes": 26343, + "hash": "a0576032a8486ec3b13d37a4127dfc243720c0b0764913af51f87a37c4dd2fda" + }, + { + "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": 179867, + "hash": "8eb8df3b1719eb50fc2a112f4c6b04a769dbb822d8f9a7d7f007db6c7cb9717f" + }, + { + "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": "c45b05bc61592f6bedfbc99f8d3f434dee0b9d0bf27dc830c94de98b91e8e767" + }, + { + "id": "lockr", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": ["runtime.v1"], + "outputs": [], + "file": "tsjs-lockr.js", + "bytes": 24031, + "hash": "7a445686082b0b5ce159e8522de22a8bc0c5315d9720b68e333b845c555b9509" + }, + { + "id": "osano_consent", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": ["runtime.v1"], + "outputs": ["osano_consent.v1"], + "file": "tsjs-osano_consent.js", + "bytes": 10511, + "hash": "1bbca9e39e540a61fdb152f6b7c0942524177280ae0414a39b118d201e9f6f6e" + }, + { + "id": "permutive_context", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": ["runtime.v1"], + "outputs": ["permutive_context.v1"], + "file": "tsjs-permutive_context.js", + "bytes": 17818, + "hash": "073f049e6b3e831638cc85527d934c9448348c890346654a47283a720c36242c" + }, + { + "id": "sourcepoint_consent", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": ["runtime.v1"], + "outputs": ["sourcepoint_consent.v1"], + "file": "tsjs-sourcepoint_consent.js", + "bytes": 18789, + "hash": "dd8ab39f9770105edd81e05b654b4e4f3be1a32a5f4703e9f7d3138429b4668d" + }, + { + "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": "9d0dcef064192df2896d46acec902aaa7b680e185d93425d7f8c8971e8230026" + }, + { + "id": "testlight", + "role": "integration", + "phase": "critical", + "trigger": null, + "inputs": ["runtime.v1"], + "outputs": [], + "file": "tsjs-testlight.js", + "bytes": 16232, + "hash": "72024e36854c50d92987a90415a8f7322d91a49fa3a41e62a05ea2e83b9446f8" + }, + { + "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": "228691fa182ed310a2d90c20474a94e414ff52107275827289bd4b058effa727" + }, + { + "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": "fbba0fddf0aa6103279e6eab7644324fe678c826b04bf90710c03cb93b3d93ca" + }, + { + "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": "7fa5944563654d8a4f30e49ac38568070690eb7b53111ab8141b569266f3cb75" + }, + { + "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": "4cc599b24335a6b5834b26a273f9db96d91be299046a2da5b8bcca3e94bca633" + }, + { + "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": "2b5168cba9cf11ea570372192200308824be06c30c0d3fe928fd8ab43565b7af" + }, + { + "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": "314bb30e0c32174c4513112062175c83bb825902a515fcd88ab7fc6e3454831a" + } + ] + }, + "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": 11873, + "brotliBytes": 10556, + "sha256": "ed7231527be79dfd864bcc8275f90d51bf39c57eae92a8c6fe5ece8d68ef2f23" + }, + "minimal": { + "artifactIds": ["core", "render_runtime"], + "files": ["tsjs-core.js", "tsjs-render_runtime.js"], + "rawBytes": 252112, + "gzipBytes": 67751, + "brotliBytes": 51295, + "sha256": "16969c77296845647f39aa4b33120acdc0e57ee4dad66672f0d0cf8832705425" + }, + "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": 513171, + "gzipBytes": 139994, + "brotliBytes": 100070, + "sha256": "24ce899c89720b21c3811c85246b036c27205285c89b9e6156833785f5d14cda" + }, + "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": 756714, + "gzipBytes": 188047, + "brotliBytes": 127965, + "sha256": "97db2f847691459e03aa4cbf81ef037675eada923b67a10bf6eec9e20623d25d" + } + } + } +} 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..9193c99bc --- /dev/null +++ b/crates/trusted-server-js/lib/test/helpers/legacy_gpt_registration.ts @@ -0,0 +1,82 @@ +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 { + return Object.freeze({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId, + 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)); + }, + }); + }, + }); +} diff --git a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts new file mode 100644 index 000000000..15b0f08a1 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createApsIntegrationRegistration } from '../../../src/integrations/aps/module'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + PreparedIntegration, +} from '../../../src/kernel/integration_registry'; +import type { RenderAttempt } from '../../../src/services/render'; + +const RELEASE_ID = 'a'.repeat(64); + +describe('APS provider', () => { + it('prepares inertly, registers exact owned state during activation, and removes it on rollback', () => { + const preparationRelease: Array<() => void> = []; + const activationRelease: Array<() => void> = []; + let registeredRenderer: + ((attempt: RenderAttempt, container: HTMLElement) => boolean) | undefined; + let registeredValidation: Readonly> | undefined; + const releaseRenderer = vi.fn(() => { + registeredRenderer = undefined; + }); + const releaseValidation = vi.fn(() => { + registeredValidation = undefined; + }); + const render = Object.freeze({ + publisherOrigin: window.location.origin, + rendererNonces: Object.freeze({}), + registerRenderer: vi.fn( + (_type: 'aps', renderer: (attempt: RenderAttempt, container: HTMLElement) => boolean) => { + registeredRenderer = renderer; + return releaseRenderer; + } + ), + }); + const messages = Object.freeze({ + messaging: Object.freeze({}), + registerApsValidation: vi.fn((validation: Readonly>) => { + registeredValidation = validation; + return releaseValidation; + }), + }); + const registration = createApsIntegrationRegistration(RELEASE_ID); + const prepared = registration.prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ 'render.v1': render, 'messages.v1': messages }), + signal: new AbortController().signal, + onDispose: (callback: () => void) => preparationRelease.push(callback), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + const aps = prepared.interfaces?.['aps.v1'] as { + render: (attempt: RenderAttempt, container: HTMLElement) => boolean; + }; + + expect(Object.isFrozen(aps)).toBe(true); + expect(render.registerRenderer).not.toHaveBeenCalled(); + expect(messages.registerApsValidation).not.toHaveBeenCalled(); + expect(aps.render({} as RenderAttempt, document.createElement('div'))).toBe(false); + + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + expect(render.registerRenderer).toHaveBeenCalledOnce(); + expect(messages.registerApsValidation).toHaveBeenCalledOnce(); + expect(registeredRenderer).toBe(aps.render); + expect(registeredValidation).toMatchObject({ + expectedPublisherOrigin: window.location.origin, + expectedRendererUrl: new URL('/integrations/aps/renderer/v1', window.location.origin).href, + }); + + activationRelease.reverse().forEach((callback) => callback()); + expect(releaseRenderer).toHaveBeenCalledOnce(); + expect(releaseValidation).toHaveBeenCalledOnce(); + expect(registeredRenderer).toBeUndefined(); + expect(registeredValidation).toBeUndefined(); + expect(aps.render({} as RenderAttempt, document.createElement('div'))).toBe(false); + preparationRelease.reverse().forEach((callback) => callback()); + }); + + it('pre-registers rollback before either activation mutation can throw', () => { + const activationRelease: Array<() => void> = []; + const releaseValidation = vi.fn(); + const render = Object.freeze({ + publisherOrigin: window.location.origin, + rendererNonces: Object.freeze({}), + registerRenderer: vi.fn(() => { + throw new Error('renderer collision'); + }), + }); + const messages = Object.freeze({ + messaging: Object.freeze({}), + registerApsValidation: vi.fn(() => releaseValidation), + }); + const prepared = createApsIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ 'render.v1': render, 'messages.v1': messages }), + signal: new AbortController().signal, + onDispose: vi.fn(), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + + expect(() => + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ) + ).toThrow('renderer collision'); + activationRelease.reverse().forEach((callback) => callback()); + expect(releaseValidation).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts index d3eb7e3c9..747d5149f 100644 --- a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts @@ -1,18 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import corpusFixture from '../../fixtures/aps-renderer-v1-corpus.json'; import envelope from '../../fixtures/aps-renderer-v1.json'; import type { ApsRendererV1 } from '../../../src/core/types'; -import { log } from '../../../src/core/log'; +import { classifyApsRendererV1 } from '../../../src/core/contracts/generated/renderer_validator_v1'; import { - APS_RENDERER_PATH, - APS_RENDERER_SANDBOX, - APS_UNIVERSAL_CREATIVE_RENDERER, - APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, - apsRendererUrl, - getApsPrebidRenderer, parseApsRendererDescriptor, - registerApsPrebidRenderer, - renderApsCreative, + prepareApsRenderSource, validateApsRenderer, } from '../../../src/integrations/aps/render'; @@ -34,7 +28,7 @@ function encodeEnvelopeAtSize(size: number): string { } function descriptor(overrides: Partial = {}): ApsRendererV1 { - const bid = envelope.seatbid[0].bid[0]; + const bid = envelope.seatbid[0]!.bid[0]!; return { type: 'aps', version: 1, @@ -50,7 +44,265 @@ function descriptor(overrides: Partial = {}): ApsRendererV1 { }; } +type CorpusResult = + 'accepted' | 'descriptor_invalid' | 'invalid_dimensions' | 'dimensions_out_of_range'; + +interface CorpusVector { + id: string; + expected: CorpusResult; + operation: Record; +} + +interface RendererCorpus { + publisherOrigin: string; + baseDescriptor: Record; + vectors: CorpusVector[]; +} + +interface MaterializedCorpusVector { + id: string; + expected: CorpusResult; + publisherOrigin: string; + descriptor: Record; +} + +const rendererCorpus = corpusFixture as unknown as RendererCorpus; + +function mutableRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function jsonPathParent( + root: unknown, + path: readonly (string | number)[] +): { parent: unknown; key: string | number } { + if (path.length === 0) throw new Error('corpus path should not be empty'); + let parent = root; + for (const segment of path.slice(0, -1)) { + if (typeof segment === 'number') { + if (!Array.isArray(parent)) throw new Error('corpus numeric path should address an array'); + parent = parent[segment]; + } else { + if (!mutableRecord(parent)) throw new Error('corpus string path should address an object'); + parent = parent[segment]; + } + } + const key = path[path.length - 1]; + if (key === undefined) throw new Error('corpus path should have a final key'); + return { parent, key }; +} + +function setJsonPath(root: unknown, path: readonly (string | number)[], value: unknown): void { + const { parent, key } = jsonPathParent(root, path); + if (typeof key === 'number') { + if (!Array.isArray(parent)) throw new Error('corpus numeric key should address an array'); + parent[key] = value; + return; + } + if (!mutableRecord(parent)) throw new Error('corpus string key should address an object'); + parent[key] = value; +} + +function deleteJsonPath(root: unknown, path: readonly (string | number)[]): void { + const { parent, key } = jsonPathParent(root, path); + if (typeof key !== 'string' || !mutableRecord(parent)) { + throw new Error('corpus delete should address an object field'); + } + delete parent[key]; +} + +function operationString(operation: Record, field: string): string { + const value = operation[field]; + if (typeof value !== 'string') throw new Error(`corpus ${field} should be a string`); + return value; +} + +function operationNumber(operation: Record, field: string): number { + const value = operation[field]; + if (typeof value !== 'number') throw new Error(`corpus ${field} should be a number`); + return value; +} + +function operationPath(operation: Record): Array { + const value = operation.path; + if ( + !Array.isArray(value) || + !value.every((segment) => typeof segment === 'string' || typeof segment === 'number') + ) { + throw new Error('corpus path should contain only string and number segments'); + } + return value; +} + +function materializeCorpusVector(vector: CorpusVector): MaterializedCorpusVector { + const descriptor = structuredClone(rendererCorpus.baseDescriptor); + const decodedEnvelope = structuredClone(envelope) as unknown; + const operation = vector.operation; + const kind = operationString(operation, 'kind'); + let encodedEnvelope: string | undefined; + + switch (kind) { + case 'none': + break; + case 'descriptor-delete': + delete descriptor[operationString(operation, 'field')]; + break; + case 'descriptor-set': + descriptor[operationString(operation, 'field')] = operation.value; + break; + case 'descriptor-repeat': { + const repeated = + operationString(operation, 'unit').repeat(operationNumber(operation, 'count')) + + (typeof operation.suffix === 'string' ? operation.suffix : ''); + descriptor[operationString(operation, 'field')] = repeated; + break; + } + case 'bid-id-repeat': { + const repeated = + operationString(operation, 'unit').repeat(operationNumber(operation, 'count')) + + (typeof operation.suffix === 'string' ? operation.suffix : ''); + descriptor.bidId = repeated; + setJsonPath(decodedEnvelope, ['seatbid', 0, 'bid', 0, 'id'], repeated); + break; + } + case 'dimension': { + const field = operationString(operation, 'field'); + if (field !== 'width' && field !== 'height') { + throw new Error('corpus dimension field should be width or height'); + } + descriptor[field] = operation.value; + setJsonPath( + decodedEnvelope, + ['seatbid', 0, 'bid', 0, field === 'width' ? 'w' : 'h'], + operation.value + ); + break; + } + case 'dimensions': + descriptor.width = operation.width; + descriptor.height = operation.height; + setJsonPath(decodedEnvelope, ['seatbid', 0, 'bid', 0, 'w'], operation.width); + setJsonPath(decodedEnvelope, ['seatbid', 0, 'bid', 0, 'h'], operation.height); + break; + case 'creative-url': { + const value = operationString(operation, 'value'); + descriptor.creativeUrl = value; + setJsonPath(decodedEnvelope, ['seatbid', 0, 'bid', 0, 'ext', 'creativeurl'], value); + break; + } + case 'creative-url-bytes': { + const prefix = 'https://creative.example/'; + const value = prefix + 'a'.repeat(operationNumber(operation, 'bytes') - prefix.length); + descriptor.creativeUrl = value; + setJsonPath(decodedEnvelope, ['seatbid', 0, 'bid', 0, 'ext', 'creativeurl'], value); + break; + } + case 'aax-literal': + encodedEnvelope = operationString(operation, 'value'); + break; + case 'aax-bytes': { + const values = operation.values; + if (!Array.isArray(values) || !values.every((value) => Number.isInteger(value))) { + throw new Error('corpus byte vector should contain integers'); + } + encodedEnvelope = encodeBytes(Uint8Array.from(values as number[])); + break; + } + case 'aax-raw-json': + encodedEnvelope = encodeBytes(new TextEncoder().encode(operationString(operation, 'value'))); + break; + case 'aax-decoded-bytes': { + const serialized = JSON.stringify(decodedEnvelope); + const target = operationNumber(operation, 'bytes'); + if (serialized.length > target) throw new Error('corpus decoded size is below fixture size'); + encodedEnvelope = encodeBytes( + new TextEncoder().encode(serialized + ' '.repeat(target - serialized.length)) + ); + break; + } + case 'aax-raw-price': { + const serialized = JSON.stringify(decodedEnvelope); + const price = operationString(operation, 'value'); + const raw = serialized.replace('"price":1.23', `"price":${price}`); + if (raw === serialized) throw new Error('corpus should replace the fixture price'); + encodedEnvelope = encodeBytes(new TextEncoder().encode(raw)); + break; + } + case 'envelope-set': + setJsonPath(decodedEnvelope, operationPath(operation), operation.value); + break; + case 'envelope-delete': + deleteJsonPath(decodedEnvelope, operationPath(operation)); + break; + case 'duplicate-seat': { + if (!mutableRecord(decodedEnvelope) || !Array.isArray(decodedEnvelope.seatbid)) { + throw new Error('corpus fixture should contain seatbid'); + } + decodedEnvelope.seatbid.push(structuredClone(decodedEnvelope.seatbid[0])); + break; + } + case 'duplicate-bid': { + const seatbid = mutableRecord(decodedEnvelope) ? decodedEnvelope.seatbid : undefined; + const seat = Array.isArray(seatbid) ? seatbid[0] : undefined; + const bids = mutableRecord(seat) ? seat.bid : undefined; + if (!Array.isArray(bids)) throw new Error('corpus fixture should contain a bid array'); + bids.push(structuredClone(bids[0])); + break; + } + default: + throw new Error(`unknown APS renderer corpus operation: ${kind}`); + } + + descriptor.aaxResponse = encodedEnvelope ?? encodeEnvelope(decodedEnvelope); + return { + id: vector.id, + expected: vector.expected, + publisherOrigin: rendererCorpus.publisherOrigin, + descriptor, + }; +} + describe('APS renderer validation', () => { + it('prepares a copied frozen tagged source without retaining projection input', () => { + const input = descriptor(); + const prepared = prepareApsRenderSource(input); + + expect(prepared).toEqual(input); + expect(prepared).not.toBe(input); + expect(Object.isFrozen(prepared)).toBe(true); + input.width = 1; + expect(prepared?.width).toBe(300); + }); + + it('prepares a cached validated source after Object.freeze is poisoned', () => { + const validated = validateApsRenderer(descriptor()); + if (!validated) throw new Error('Expected a validated renderer'); + const originalFreeze = Object.freeze; + let prepared: ReturnType | undefined; + let thrown: unknown; + Object.freeze = function poisonedFreeze() { + throw new Error('poisoned Object.freeze'); + }; + try { + prepared = prepareApsRenderSource(validated); + } catch (error) { + thrown = error; + } finally { + Object.freeze = originalFreeze; + } + + expect(thrown).toBeUndefined(); + expect(prepared).toBe(validated); + expect(Object.isFrozen(prepared)).toBe(true); + }); + + it('matches every shared cross-language contract vector', () => { + for (const vector of rendererCorpus.vectors.map(materializeCorpusVector)) { + const actual = classifyApsRendererV1(vector.descriptor, vector.publisherOrigin); + expect(actual, vector.id).toBe(vector.expected); + } + }); + it('consumes the shared fictional golden envelope and supports an omitted creative ID', () => { const withCreativeId = descriptor(); const withoutCreativeId = descriptor(); @@ -93,19 +345,19 @@ describe('APS renderer validation', () => { ['sibling seat', { seatbid: [...envelope.seatbid, envelope.seatbid[0]] }], [ 'sibling bid', - { seatbid: [{ bid: [...envelope.seatbid[0].bid, envelope.seatbid[0].bid[0]] }] }, + { seatbid: [{ bid: [...envelope.seatbid[0]!.bid, envelope.seatbid[0]!.bid[0]!] }] }, ], [ 'markup', { seatbid: [ - { bid: [{ ...envelope.seatbid[0].bid[0], adm: '' }] }, + { 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', @@ -114,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: [] }, }, ], }, @@ -153,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(); @@ -167,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(); @@ -192,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( @@ -221,285 +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, { - markWinner: vi.fn(), - markRendered: 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 = { markWinner: vi.fn(), markRendered: 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('direct APS rendering', () => { - beforeEach(() => { - document.body.innerHTML = '
existing
'; - }); - - afterEach(() => { - vi.restoreAllMocks(); - document.body.innerHTML = ''; - }); - - 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('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 bootstrap = postMessage.mock.calls[0][0] as { nonce: string }; - const transferredPort = postMessage.mock.calls[0][2]?.[0] as MessagePort | undefined; - expect(bootstrap).toEqual({ nonce: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/) }); - expect(transferredPort).toBeDefined(); - - let settled = false; - void rendered.then(() => { - settled = true; - }); - await Promise.resolve(); - expect(settled).toBe(false); - - await new Promise((resolve) => { - transferredPort!.onmessage = (event) => { - expect(event.data).toEqual({ renderer }); - transferredPort!.postMessage({ - message: 'trusted-server/aps/renderer-ready', - nonce: bootstrap.nonce, - }); - resolve(); - }; - }); - 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 314123c16..995637da3 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({ @@ -98,7 +136,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', FIRST_PARTY_CLICK); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); anchor.setAttribute('href', MUTATED_CLICK); @@ -138,7 +176,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(); @@ -187,7 +225,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); @@ -203,7 +241,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)); @@ -217,6 +255,57 @@ describe('creative/click.ts', () => { } }); + 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('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. @@ -228,7 +317,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(); @@ -239,4 +328,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 48133fb72..cef3195b5 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'; @@ -42,7 +44,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'; @@ -52,4 +54,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 44105571d..80a93eed7 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'; @@ -42,7 +44,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'; @@ -52,4 +54,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..19193fde6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts @@ -0,0 +1,356 @@ +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, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => ({ id, phase: 'critical' as const })), + }; +} + +function catalog(ids: readonly string[]) { + return Object.freeze( + ids.map((id) => + Object.freeze({ + id, + phase: 'critical' 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: 'critical', releaseId: RELEASE_ID, 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..d1ed96cf4 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/datadome/module.test.ts @@ -0,0 +1,132 @@ +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 CRITICAL_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, + criticalSrc: CRITICAL_SRC, + integrations: [{ id: 'datadome', phase: 'critical' }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['datadome']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: undefined, + 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([null, Object.freeze({}), false])('rejects non-absent config %j', async (config) => { + const activate = vi.fn(() => vi.fn()); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + criticalSrc: CRITICAL_SRC, + integrations: [{ id: 'datadome', phase: 'critical' }], + }, + 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..cbcefa8c4 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/didomi/module.test.ts @@ -0,0 +1,83 @@ +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 CRITICAL_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, + criticalSrc: CRITICAL_SRC, + integrations: [{ id: 'didomi', phase: 'critical' }], + }, + 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..02cf37bd4 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/google_tag_manager/module.test.ts @@ -0,0 +1,113 @@ +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 CRITICAL_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, + criticalSrc: CRITICAL_SRC, + integrations: [{ id: 'google_tag_manager', phase: 'critical' }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['google_tag_manager']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: undefined, + 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 2c309f30d..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ /dev/null @@ -1,4778 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; - -import envelope from '../../fixtures/aps-renderer-v1.json'; -import type { AuctionBidData, TsjsApi } from '../../../src/core/types'; - -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; -}; - -function appendResponsiveSlotElement( - id: string, - containerHasLayout: boolean, - elementHidden = false, - elementHasLayout = false, - containerVisible = containerHasLayout -): HTMLDivElement { - const container = document.createElement('div'); - container.id = `${id}-container`; - container.dataset.responsiveSlotTest = 'true'; - container.style.display = containerVisible ? 'block' : 'none'; - container.getBoundingClientRect = () => - ({ - width: containerHasLayout ? 320 : 0, - height: containerHasLayout ? 100 : 0, - }) as DOMRect; - - const element = document.createElement('div'); - element.id = id; - element.style.display = elementHidden ? 'none' : 'block'; - element.getBoundingClientRect = () => - ({ - width: elementHasLayout ? 300 : 0, - height: elementHasLayout ? 250 : 0, - }) as DOMRect; - 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); -} - -type HandoffImplementation = 'bootstrap' | 'bundle'; - -async function installHandoff(implementation: HandoffImplementation): Promise { - if (implementation === 'bootstrap') { - runGptBootstrap(); - return; - } - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); -} - -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-atf-sidebar-2')?.remove(); - document.getElementById('div-size-hydrated')?.remove(); - document.getElementById('ad-header-0-_r_1_')?.remove(); - document.getElementById('div-new-slot')?.remove(); - document.getElementById("ad'prefix-real")?.remove(); - document.querySelectorAll('[data-responsive-slot-test]').forEach((element) => element.remove()); - }); - - function configureOpportunityDiagnostics( - bid: AuctionBidData | undefined, - recordTrustedServerOpportunity: ReturnType - ) { - 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: bid ? { atf_sidebar_ad: bid } : {}, - gptDiagnosticsRecorder: { - recordTrustedServerOpportunity, - } as unknown as TsjsApi['gptDiagnosticsRecorder'], - }; - - return { mockPubads, mockSlot }; - } - - 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 - ); - } - ); - - 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' - ); - }); - - 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' - ); - }); - - 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([]), - }; - // Defining a TS slot installs the slot-handoff refresh wrapper over - // `pubads.refresh`, so assert on the original spy rather than the property. - 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]); - }); - - function configureOpportunityDiagnostics( - bid: AuctionBidData | undefined, - recordTrustedServerOpportunity: ReturnType - ) { - 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: bid ? { atf_sidebar_ad: bid } : {}, - gptDiagnosticsRecorder: { - recordTrustedServerOpportunity, - } as unknown as TsjsApi['gptDiagnosticsRecorder'], - }; - - return { mockPubads, mockSlot }; - } - - 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 - ); - } - ); - - 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' - ); - }); - - 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' - ); - }); - - 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 originalRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([existingSlot]), - addEventListener: vi.fn(), - refresh: originalRefresh, - }; - 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(originalRefresh).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.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: {}, - }; - - const bootstrap = readFileSync( - resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' - ); - window.eval(bootstrap); - (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: {}, - }; - - runGptBootstrap(); - - 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: {}, - }; - - runGptBootstrap(); - - // 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!(); - - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - - 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).toHaveBeenCalledWith([mockSlot]); - - // 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: {}, - }; - - 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().mockReturnValue([]), - }; - 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(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', activeIndexes: [], elementLayoutIndexes: [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: [2], divId: '', selectedIndex: null }, - { implementation: 'bootstrap', activeIndexes: [2], publisherOwned: true, selectedIndex: 2 }, - { implementation: 'bootstrap', activeIndexes: [], selectedIndex: null }, - { implementation: 'bootstrap', activeIndexes: [], elementLayoutIndexes: [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: [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 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( - `ad-responsive-${suffix}`, - (activeIndexes as readonly number[]).includes(index), - (hiddenElementIndexes as readonly number[]).includes(index), - (elementLayoutIndexes as readonly number[]).includes(index), - (visibleContainerIndexes as readonly number[]).includes(index) - ) - ); - const selectedElement = selectedIndex === null ? undefined : elements[selectedIndex]; - 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('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(); - // 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: {}, - }, - ], - }; - }); - - 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!; - } - - async function captureBridgeListener(): Promise<(e: MessageEvent) => unknown> { - 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(); - return bridgeListener!; - } - - it('serves one exact APS dynamic-renderer response without cache fetches or beacons', 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 source = createTrustedSlotIframe(); - 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 descriptors are reusable: GAM can issue repeated - // Universal Creative requests for the same winning ad ID. - expect(portMessages).toHaveLength(2); - 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); - - // 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 bootstrap = rendererPost.mock.calls[0][0] as { nonce: string }; - const transferredPort = rendererPost.mock.calls[0][2]?.[0] as MessagePort | undefined; - expect(transferredPort).toBeDefined(); - await new Promise((resolve) => { - transferredPort!.onmessage = (message) => { - expect(message.data).toEqual({ renderer }); - transferredPort!.postMessage({ - message: 'trusted-server/aps/renderer-ready', - nonce: bootstrap.nonce, - }); - resolve(); - }; - }); - await expect(rendered).resolves.toBeUndefined(); - outerFrame.remove(); - } finally { - delete dynamicWindow.render; - } - beaconSpy.mockRestore(); - }); - - it('resizes only the authenticated collapsed 1x1 creative shell after responding', async () => { - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: 'collapsed-inline-ad-id', - hb_bidder: 'fictional', - hb_pb: '1.23', - adm: '
fictional creative
', - w: 300, - h: 250, - }; - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const slot = document.getElementById('div-header')!; - const selectedFrame = slot.querySelector('iframe')!; - slot.style.width = '1px'; - slot.style.height = '1px'; - selectedFrame.width = '1'; - selectedFrame.height = '1'; - selectedFrame.style.width = '1px'; - selectedFrame.style.height = '1px'; - - const siblingSlot = document.createElement('div'); - siblingSlot.style.width = '1px'; - siblingSlot.style.height = '1px'; - const siblingFrame = document.createElement('iframe'); - siblingFrame.width = '1'; - siblingFrame.height = '1'; - siblingFrame.style.width = '1px'; - siblingFrame.style.height = '1px'; - siblingSlot.appendChild(siblingFrame); - document.body.appendChild(siblingSlot); - - try { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'collapsed-inline-ad-id' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(selectedFrame.style.width).toBe('300px'); - expect(selectedFrame.style.height).toBe('250px'); - expect(slot.style.width).toBe('300px'); - expect(slot.style.height).toBe('250px'); - expect(siblingFrame.style.width).toBe('1px'); - expect(siblingFrame.style.height).toBe('1px'); - } finally { - siblingSlot.remove(); - } - }); - - it('does not resize an authenticated shell whose computed dimensions are not pixels', async () => { - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: 'relative-inline-ad-id', - hb_bidder: 'fictional', - hb_pb: '1.23', - adm: '
fictional creative
', - w: 300, - h: 250, - }; - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const slot = document.getElementById('div-header')!; - const frame = slot.querySelector('iframe')!; - slot.style.width = '1vw'; - slot.style.height = '1vh'; - frame.width = '1'; - frame.height = '1'; - frame.style.width = '1vw'; - frame.style.height = '1vh'; - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'relative-inline-ad-id' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(frame.style.width).toBe('1vw'); - expect(frame.style.height).toBe('1vh'); - expect(slot.style.width).toBe('1vw'); - expect(slot.style.height).toBe('1vh'); - }); - - 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 markWinner = vi.fn(); - const markRendered = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markWinner, - markRendered, - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - 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(markWinner).toHaveBeenCalledTimes(1); - expect(markRendered).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(fetchStub).not.toHaveBeenCalled(); - foreignIframe.remove(); - }); - - it('still serves the APS renderer when markWinner throws', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'throwing-mark-winner-ad-id'; - const markWinner = vi.fn(() => { - throw new Error('fictional markWinner failure'); - }); - const markRendered = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markWinner, - markRendered, - }, - }; - - 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(markWinner).toHaveBeenCalledTimes(1); - expect(markRendered).toHaveBeenCalledTimes(1); - }); - - it('still completes the APS render when markRendered throws', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'throwing-mark-rendered-ad-id'; - const markWinner = vi.fn(); - const markRendered = vi.fn(() => { - throw new Error('fictional markRendered failure'); - }); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markWinner, - markRendered, - }, - }; - - 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(markWinner).toHaveBeenCalledTimes(1); - expect(markRendered).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 firstMarkWinner = vi.fn(); - const firstMarkRendered = vi.fn(); - const secondMarkWinner = vi.fn(); - const secondMarkRendered = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: start, - expiresAt: start + 60_000, - markWinner: firstMarkWinner, - markRendered: firstMarkRendered, - }, - }; - - 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, - markWinner: secondMarkWinner, - markRendered: secondMarkRendered, - }; - sendRequest(); - - expect(portMessages).toHaveLength(2); - expect(stopImmediatePropagation).toHaveBeenCalledTimes(2); - expect(firstMarkWinner).toHaveBeenCalledTimes(1); - expect(firstMarkRendered).toHaveBeenCalledTimes(1); - expect(secondMarkWinner).toHaveBeenCalledTimes(1); - expect(secondMarkRendered).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 }, () => ({ - markWinner: vi.fn(), - markRendered: 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].markWinner).not.toHaveBeenCalled(); - expect(callbacks[capacity].markRendered).not.toHaveBeenCalled(); - expect(entries[`capacity-ad-${capacity}`]).toBeDefined(); - expect(callbacks[0].markWinner).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, - markWinner: vi.fn(), - markRendered: 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, - markWinner: vi.fn(), - markRendered: 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('validates APS data before claiming the Prebid request', 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).not.toHaveBeenCalled(); - 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('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('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 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 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(() => { - throw new Error('port closed'); - }), - }, - ], - 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(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - 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('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 from the 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 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.width).toBe(300); - expect(parsed.height).toBe(250); - 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: {}, - }, - ], - }; - - 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: {}, - }, - ], - }; - - 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: {}, - }, - ], - }; - - 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: {}, - }, - ], - }; - - 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..d092982cd --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/diagnostics_facts.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import type { + GoogletagAdapter, + GoogletagDiagnosticsFact, + GoogletagDiagnosticsObserver, + GoogletagFacade, +} from '../../../src/adapters/googletag'; +import { + activateGptDiagnosticsEventListeners, + activateGptDiagnosticsFactCapture, + createGptDiagnosticsFactBuffer, + projectGptTraceFact, +} 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('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('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 323180e8e..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ /dev/null @@ -1,250 +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'; - -/** - * 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; -} - -// `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; -}; - -function runBootstrap(): void { - // Evaluate in the jsdom global scope, exactly as an inline ')).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..9a215a8ab --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts @@ -0,0 +1,245 @@ +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() { + 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: undefined, + 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 critical 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 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..5a7be04ea --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -0,0 +1,1463 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + publishGptWinner, + publishInitialGptProjection, + startGptSlotOperation, + type GptWinnerPublicationInput, + type GptSlotOperationInput, +} from '../../../src/integrations/gpt/module'; +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 { createReservationService } from '../../../src/services/reservations'; +import type { SlotRequestOutcome } from '../../../src/services/slots'; +import { createTargetingService } from '../../../src/services/targeting'; + +const RELEASE_ID = 'a'.repeat(64); +const RESERVATION_ID = `r1_${'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' | 'cache'; 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' | 'cache'; 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, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => ({ id, phase: 'critical' as const })), + }; +} + +function registration( + id: string, + prepare: IntegrationRegistration['prepare'] +): IntegrationRegistration { + return Object.freeze({ abi: 1, id, phase: 'critical', releaseId: RELEASE_ID, prepare }); +} + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +describe('transactional GPT integration module', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + resetGuardState(); + delete (window as Window & { googletag?: unknown }).googletag; + document.getElementById('critical-slot')?.remove(); + document.getElementById('spa-winner')?.remove(); + }); + + it.each([false, true])( + 'uses only catalog capabilities and conditions diagnostics-only GPT listeners (active=%s)', + async (diagnosticsActive) => { + 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); + }), + }; + (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: vi.fn(), + }; + const providerFacades = new Map>>(); + const protect = vi.fn(() => true); + const bootManifest = Object.freeze({ + version: 1 as const, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'critical' as const }), + Object.freeze({ id: 'gpt', phase: 'critical' 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: 'critical-slot', + outcome: 'no_bid' as const, + }), + ]), + }), + slots: Object.freeze([ + Object.freeze({ + slot: 'critical-slot', + gamUnitPath: '/123/critical-slot', + divId: 'critical-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: 'critical' 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: 'critical' 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({}) : 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 criticalElement = document.createElement('div'); + criticalElement.id = 'critical-slot'; + document.body.appendChild(criticalElement); + expect(registry.register(createRenderRuntimeIntegrationRegistration(RELEASE_ID))).toBe(true); + expect(registry.register(createProductionGptRegistration(RELEASE_ID))).toBe(true); + + const result = await registry.install(callbacks([])); + expect(result.state).toBe('kernel'); + 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; + }; + }; + await vi.waitFor(() => expect(definedSlots).toHaveLength(1)); + const criticalNavigation = gpt.navigation(); + gpt.slots.request({ + intentId: 'critical-request', + navigationGeneration: criticalNavigation.generation, + operation: 'display', + registeredSlotId: 'critical-slot', + requestClass: 'initial', + }); + await vi.waitFor(() => expect(display).toHaveBeenCalledOnce()); + expect(protect).not.toHaveBeenCalled(); + expect(activeMutationObservers.size).toBe(2); + const firstPhysicalSlot = definedSlots[0]; + expect(firstPhysicalSlot).toBeDefined(); + criticalElement.remove(); + const replacementElement = document.createElement('div'); + replacementElement.id = 'critical-slot'; + document.body.appendChild(replacementElement); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(250); + expect(destroySlots).toHaveBeenCalledWith([firstPhysicalSlot]); + expect(definedSlots).toHaveLength(1); + expect(definedSlots[0]).not.toBe(firstPhysicalSlot); + expect(activeMutationObservers.size).toBe(2); + 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([ + 'render', + ]); + const render = providerFacades.get('render.v1') as { + attachPucGamAttemptRegistrar: (registrar: (input: unknown) => boolean) => () => void; + registerRenderer: (type: 'cache', renderer: () => boolean) => () => void; + }; + expect(() => render.attachPucGamAttemptRegistrar(() => true)).toThrow('duplicated'); + expect(() => render.registerRenderer('cache', () => false)).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' }, + rendererReservationId: `r1_${'s'.repeat(22)}`, + renderSource: { + type: 'adm', + version: 1, + adm: '
spa winner
', + 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(2); + 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 }) + ); + + 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(2)); + 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(1); + await later.navigate('/disposed-owner'); + expect(fetchPageBids).toHaveBeenCalledTimes(3); + fetchPageBids.mockRestore(); + slotElement.remove(); + + 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'); + expect(() => render.registerRenderer('cache', () => false)).toThrow('inactive'); + } + ); + + 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 request = vi.fn(() => + 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 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, + }); + 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(); + 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).toHaveBeenCalledTimes(2); + 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: Object.freeze({}), + 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: Object.freeze({}), + 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: Object.freeze({}), 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: Object.freeze({}), + 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), + 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 }); + 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 889c189ea..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -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); - -/** - * 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('does not rerun after a query-only page-bids refresh before load', async () => { - // The RC's SPA route identity includes pathname and query. A query change - // requests fresh page bids and runs adInit for that route, so the deferred - // initial callback must stand down instead of initializing the route twice. - 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.replaceState({}, '', '/?utm_source=newsletter'); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledTimes(1); - expect(ts.navGeneration).toBe(1); - expect(adInit).not.toHaveBeenCalled(); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - }); - - 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('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('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 4cc65c7b9..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ /dev/null @@ -1,625 +0,0 @@ -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 = ''; - delete (window as TestWindow).googletag; - // Remove this test's popstate listener(s) so they do not fire in later tests. - popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler)); - popstateHandlers = []; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('increments navGeneration when a path-and-query navigation is accepted', async () => { - // The deferred initial-adInit bootstrap keys off this counter, so it must - // move in lockstep with the hook's route identity: bumped synchronously for - // each accepted pathname or query change, untouched by identical routes. - 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(2); - - history.pushState({}, '', '/next-page?utm_source=x'); - expect(ts.navGeneration).toBe(2); - 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%3Fedition%3Dfictional', - 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('runs adInit on an empty page-bids response when prior TS state exists', async () => { - // When TS touched slots on a previous navigation, an empty response still - // needs adInit() to sweep the stale TS targeting from those slots. - 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).toHaveBeenCalledTimes(1); - }); - - 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('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('stops orphan recovery before a fast route DOM swap can replay old bids', async () => { - document.body.innerHTML = '
'; - const definedDivs: string[] = []; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - refresh: vi.fn(), - addEventListener: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn((_path: string, _sizes: unknown, divId: string) => { - definedDivs.push(divId); - return { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - clearTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(divId), - getTargeting: vi.fn().mockReturnValue([]), - }; - }), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - display: vi.fn(), - destroySlots: vi.fn(), - }; - // Keep page-bids slower than the orphan observer's 250 ms debounce. - fetchStub.mockReturnValue(new Promise(() => {})); - - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adSlots = [ - { - id: 'ad-header-0', - gam_unit_path: '/123/header', - div_id: 'ad-header-0', - formats: [[728, 90]], - targeting: {}, - }, - ]; - ts.bids = { 'ad-header-0': { hb_adid: 'old-route-ad' } }; - ts.adInit!(); - expect(definedDivs).toEqual(['ad-header-0-_R_old_']); - - history.pushState({}, '', '/new-route'); - document.body.innerHTML = '
'; - await new Promise((resolve) => setTimeout(resolve, 350)); - - // The pending old-route watcher was disconnected synchronously when - // navigation began, so it never rebound or re-requested the old auction. - expect(definedDivs).toEqual(['ad-header-0-_R_old_']); - }); - - 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 2e2ae2d2b..33db076ac 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,6 +1,7 @@ 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'; @@ -63,7 +64,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 +73,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'); @@ -368,6 +379,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', () => { @@ -383,7 +399,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(() => { @@ -408,87 +424,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, - 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; - 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(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(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?.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(() => 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', () => { @@ -559,13 +624,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 675e4f442..76ce64fba 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, @@ -262,7 +274,7 @@ describe('GptDiagnosticsBadgeManager', () => { }) ).toBe('Filled · 728×90\nResponse 276 ms · Render 42 ms\nViewable after 1 s'); expect( - gptDiagnosticsBadgeTextForTest({ + formatGptDiagnosticsBadgeText({ requestNumber: 1, isEmpty: true, incompleteSequence: false, @@ -270,7 +282,7 @@ describe('GptDiagnosticsBadgeManager', () => { }) ).toBe('Empty'); expect( - gptDiagnosticsBadgeTextForTest({ + formatGptDiagnosticsBadgeText({ requestNumber: 1, renderAtMs: 5, incompleteSequence: false, @@ -278,42 +290,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', () => { @@ -336,7 +344,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); @@ -373,7 +381,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); @@ -412,7 +420,7 @@ describe('GptDiagnosticsBadgeManager', () => { MutationObserver: undefined, ResizeObserver: undefined, }), - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); expect(() => { @@ -421,4 +429,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..7d745e00d --- /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('critical 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..ac913e4ea --- /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('critical 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..b7ce96bc5 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,23 +1,13 @@ 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; function fakeStore(): GptDiagnosticsObserverStore { return { @@ -28,411 +18,114 @@ function fakeStore(): GptDiagnosticsObserverStore { 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', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - 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', () => { + it('does not claim GPT observation merely because the diagnostics module activated', () => { 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 observer = new GptDiagnosticsObserver(store); - observer.install(); + observer.start(); + observer.start(); - expect(customPush).toHaveBeenCalledTimes(1); - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); + expect(store.markGptObserved).not.toHaveBeenCalled(); }); - it('normalizes allowed callback facts and forwards every event kind', () => { + 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('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 }); - - 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); - }); - - it('forwards the Ad Manager identifiers GPT reports for the delivered ad', () => { - 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 b89a8f507..8646a7cb2 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; }, @@ -439,7 +449,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; @@ -515,7 +525,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); @@ -544,7 +554,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; }, @@ -570,7 +580,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; }, @@ -604,4 +614,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..74423544c --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/presentation.test.ts @@ -0,0 +1,468 @@ +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: Object.freeze({}), + 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: Object.freeze({}), + 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' }); + }); + 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: Object.freeze({}), + 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: Object.freeze({}), + 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: Object.freeze({}), + 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/store.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts index 4c6721f3a..965c095a2 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 @@ -9,7 +9,6 @@ import { MAX_DIAGNOSTIC_SLOTS, 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'; @@ -29,32 +28,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); @@ -97,8 +70,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({ @@ -132,51 +105,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', () => { @@ -208,9 +150,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, @@ -241,10 +183,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 }); }); @@ -259,7 +201,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); @@ -290,8 +232,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', () => { @@ -304,7 +246,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( @@ -324,11 +266,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', @@ -356,7 +298,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); @@ -388,10 +330,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, @@ -399,14 +341,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'); @@ -429,23 +371,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); @@ -464,8 +406,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', () => { @@ -493,1340 +435,25 @@ 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, - }, - }); - - 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'); - }); - - 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 }); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.responseClass).toBe('unclassified_non_empty'); - expect(cycle.adManager).toBeUndefined(); - }); - - 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: 'competing Prebid and publisher evidence', - direct: undefined, - prebid: true, - publisher: true, - expectedPath: 'competing', - expectedOpportunity: undefined, - }, - { - name: 'competing all source evidence', - direct: 'renderable_candidate', - prebid: true, - publisher: true, - expectedPath: 'competing', - expectedOpportunity: 'renderable_candidate', - }, - ] as const)( - 'attributes $name without inferring demand ownership', - ({ direct, prebid, publisher, expectedPath, expectedOpportunity }) => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('path-slot'); - - if (direct !== undefined) { - store.recordTrustedServerOpportunity(slot, 'auction-slot', direct); - } - if (prebid) store.recordPrebidRefresh([slot]); - if (publisher) store.recordPublisherRefresh([slot]); - store.recordSlotRequested(slot); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.requestPath).toBe(expectedPath); - expect(cycle.trustedServerOpportunity).toBe(expectedOpportunity); - } - ); - - 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; - store.recordSlotRequested(slot); - - const cycles = store.snapshot().slots[0].requests; - expect(cycles).toMatchObject([ - { - requestPath: 'competing', - trustedServerOpportunity: 'renderable_candidate', - }, - { requestPath: 'unattributed' }, - ]); - expect(cycles[1].trustedServerOpportunity).toBeUndefined(); - }); - - 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> = []; + it('announces correctness commits synchronously while coalescing presentation work', () => { + const scheduled: Array<() => void> = []; const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), + now: () => 1, + schedule: (callback) => scheduled.push(callback), }); - const repeated = fakeSlot('repeated-trusted-server-evidence'); - const unconsumed = fakeSlot('unconsumed-trusted-server-evidence'); + const commitListener = vi.fn(); + const presentationListener = vi.fn(); + store.subscribeCommits(commitListener); + store.subscribe(presentationListener); - 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, - }); + store.markGptObserved(); + store.recordSlotRequested(fakeSlot('commit-membership')); - 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*:/ - ); + expect(commitListener).toHaveBeenCalledTimes(2); + expect(presentationListener).not.toHaveBeenCalled(); + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(presentationListener).toHaveBeenCalledOnce(); }); it('returns detached snapshot data', () => { @@ -1835,11 +462,11 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRequested(slot); const first = store.snapshot(); - first.slots[0].requests[0].requestNumber = 999; + 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.slots[0]!.requests[0]!.requestNumber).toBe(1); expect(second.coverage.slotRequested.matched).toBe(1); }); it('ignores malformed publisher refresh inputs without recording intent', () => { @@ -1851,7 +478,7 @@ describe('GptDiagnosticsStore', () => { 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[0]!.requests[0]!.requestPath).toBe('publisher_refresh'); expect(store.snapshot().slots).toHaveLength(1); }); @@ -1911,7 +538,7 @@ describe('GptDiagnosticsStore', () => { now = 1; record(store, slot); - expect(store.snapshot().slots[0].requests[0].incompleteSequence).toBe(true); + expect(store.snapshot().slots[0]!.requests[0]!.incompleteSequence).toBe(true); expect(store.snapshot().callbackIssues).toContainEqual( expect.objectContaining({ kind, disposition: 'matched', reason: 'invalid_event_order' }) ); @@ -1928,8 +555,8 @@ describe('GptDiagnosticsStore', () => { store.recordSlotVisibilityChanged(slot, percentage); const snapshot = store.snapshot(); - expect(snapshot.slots[0].currentVisibilityPercentage).toBeUndefined(); - expect(snapshot.slots[0].maximumVisibilityPercentage).toBeUndefined(); + expect(snapshot.slots[0]!.currentVisibilityPercentage).toBeUndefined(); + expect(snapshot.slots[0]!.maximumVisibilityPercentage).toBeUndefined(); expect(snapshot.callbackIssues).toContainEqual( expect.objectContaining({ kind: 'slotVisibilityChanged', @@ -1949,7 +576,7 @@ describe('GptDiagnosticsStore', () => { store.recordTrustedServerCreativeFailure(4242, 'cache_fetch_failed'); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toBeUndefined(); + expect(store.snapshot().slots[0]!.requests[0]!.trustedServerCreativeFailures).toBeUndefined(); expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_unknown'); }); @@ -1983,7 +610,7 @@ describe('GptDiagnosticsStore', () => { 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'); + 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 55991daf6..6e36df096 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 @@ -11,7 +11,6 @@ import type { GptDiagnosticsRequestPath, GptDiagnosticsResponseClass, GptDiagnosticsSlotExport, - TsjsApi, } from '../../../src/core/types'; describe('GPT diagnostics public types', () => { @@ -59,10 +58,6 @@ describe('GPT diagnostics public types', () => { | 'recordTrustedServerCreativeResponse' | 'recordTrustedServerCreativeFailure' >(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf< - GptDiagnosticsRecorder | undefined - >(); }); it('represents the versioned allowlist schema', () => { @@ -182,7 +177,8 @@ describe('GPT diagnostics public types', () => { >(); expectTypeOf().toEqualTypeOf< 'empty' | 'backfill' | 'reservation' | 'unclassified_non_empty' - >(); expectTypeOf(snapshot).toEqualTypeOf(); + >(); + expectTypeOf(snapshot).toEqualTypeOf(); expectTypeOf().not.toHaveProperty('bidder'); expectTypeOf().not.toHaveProperty('targeting'); expectTypeOf().not.toHaveProperty('price'); 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..f829d89f0 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts @@ -0,0 +1,148 @@ +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 CRITICAL_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 undefined; +}; + +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, + 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, + criticalSrc: CRITICAL_SRC, + integrations: ids.map((id) => ({ id, phase: 'critical' 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, + criticalSrc: CRITICAL_SRC, + integrations: [{ id, phase: 'critical' }], + }, + 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..3408ac40d --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createPermutiveRuntime } from '../../../src/integrations/permutive/module'; + +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('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..2fcd181ca --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/phase-slices.test.ts @@ -0,0 +1,288 @@ +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_CRITICAL_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', + 'critical', + 'always', + ['runtime.v1'], + [ + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + 'trace.presentation.v1', + 'direct.v1', + ], + ], + [ + 'aps', + 'critical', + 'integration:aps', + ['runtime.v1', 'slots.v1', 'render.v1', 'messages.v1', 'trace.v1'], + ['aps.v1'], + ], + ['creative', 'critical', 'creative_guard', ['runtime.v1'], []], + ['datadome', 'critical', 'integration:datadome', ['runtime.v1'], []], + ['didomi', 'critical', 'integration:didomi', ['runtime.v1'], []], + ['google_tag_manager', 'critical', 'integration:google_tag_manager', ['runtime.v1'], []], + [ + 'gpt', + 'critical', + 'integration:gpt', + ['runtime.v1', 'slots.v1', 'auction.v1', 'render.v1', 'messages.v1', 'trace.v1'], + ['gpt.v1', 'gpt.events.v1', 'pbs_cache.baseline.v1'], + ], + [ + 'gpt_diagnostics', + 'critical', + 'gpt_diagnostics_active', + ['runtime.v1', 'gpt.events.v1'], + ['gpt_diag.v1'], + ], + ['lockr', 'critical', 'integration:lockr', ['runtime.v1'], []], + ['osano_consent', 'critical', 'integration:osano', ['runtime.v1'], ['osano_consent.v1']], + [ + 'permutive_context', + 'critical', + 'integration:permutive', + ['runtime.v1'], + ['permutive_context.v1'], + ], + [ + 'sourcepoint_consent', + 'critical', + 'integration:sourcepoint', + ['runtime.v1'], + ['sourcepoint_consent.v1'], + ], + [ + 'prebid', + 'critical', + 'integration:prebid', + ['runtime.v1', 'slots.v1', 'render.v1', 'messages.v1', 'aps.v1?aps'], + ['prebid.v1'], + ], + ['testlight', 'critical', '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 critical 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_CRITICAL_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/index.ts'); + expect( + [...coreSources].some((source) => /(?:browser_test|\/test\/|ForTest)/.test(source)) + ).toBe(false); + expect([...coreSources].some((source) => source.startsWith('src/integrations/'))).toBe(false); + + 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); + } + }); +}); 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 7ef9378a5..000000000 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ /dev/null @@ -1,4385 +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; - bidders?: string[]; - clientSideBidders?: string[]; - excludedGamAdUnitPathSuffixes?: unknown; -} - -interface TestGoogletag { - cmd: { push: (fn: () => void) => void }; - pubads: () => unknown; -} - -interface PrebidTestWindow { - pbjs?: unknown; - tsjs?: 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, - mockMarkWinningBidAsUsed, - mockOnEvent, - mockRemoveAdUnit, - 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; - adUnits: TestAdUnit[]; - setTargetingForGPTAsync?: (adUnitCodes?: string[]) => void; - [key: string]: unknown; - } = { - setConfig: mockSetConfig, - processQueue: mockProcessQueue, - requestBids: mockRequestBids, - registerBidAdapter: mockRegisterBidAdapter, - getUserIdsAsEids: mockGetUserIdsAsEids, - getConfig: mockGetConfig, - markWinningBidAsUsed: mockMarkWinningBidAsUsed, - onEvent: mockOnEvent, - removeAdUnit: mockRemoveAdUnit, - 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, - mockMarkWinningBidAsUsed, - mockOnEvent, - mockRemoveAdUnit, - mockPbjs, - }; -}); - -import { - collectBidders, - getInjectedConfig, - auctionBidsToPrebidBids, - installPrebidNpm, - installRefreshHandler, -} from '../../../src/integrations/prebid/index'; -import type { AuctionBid } from '../../../src/core/auction'; -import { log } from '../../../src/core/log'; -import envelope from '../../fixtures/aps-renderer-v1.json'; -import { GptDiagnosticsObserver } from '../../../src/integrations/gpt_diagnostics/observer'; -import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; - -// 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); - - 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' }, - ]); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual( - expect.objectContaining({ - requestId: 'prebid-request-id', - bidderCode: 'aps', - ad: '', - trustedServerRenderer: renderer, - meta: { - advertiserDomains: ['advertiser.example'], - 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' }] - ); - - 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, []); - - 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); - - 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 as unknown as Record).__tsApsBidResponseListenerInstalled; - }); - - afterEach(() => { - 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 accepted APS descriptors under Prebid generated ad IDs', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - const renderer = apsRenderer(); - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'prebid-generated-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - trustedServerRenderer: renderer, - }); - - const entry = testWindow.tsjs.apsPrebidRenderers['prebid-generated-ad-id']; - expect(entry).toEqual( - expect.objectContaining({ - adUnitCode: 'div-aps', - renderer, - expiresAt: expect.any(Number), - markRendered: expect.any(Function), - markWinner: expect.any(Function), - }) - ); - - entry.markWinner(); - entry.markRendered(); - // markWinner routes through the public markWinningBidAsUsed API, which - // marks the bid as both winning and rendered in one call. - expect(mockMarkWinningBidAsUsed).toHaveBeenCalledWith({ - adId: 'prebid-generated-ad-id', - events: true, - }); - }); - - it('registers APS renderer via requestId when Prebid strips the custom 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' }] - ); - - // 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, markWinner: 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' }] - ); - 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' }] - ); - - // 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', () => { - 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('captures per-bidder params on trustedServer bid', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const trustedServerBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer'); - expect(trustedServerBid).toBeDefined(); - expect(trustedServerBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, - }); - expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual(['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('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('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; - delete testWindow.__tsjs_prebid; - }); - - afterEach(() => { - testWindow.tsjs = undefined; - delete testWindow.googletag; - delete testWindow.__tsjs_prebid; - }); - - 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'] }; - // 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'] }; - 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 clearTargeting = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn((key: string) => { - if (key === 'ts_initial') return ['1']; - if (key === 'zone') return ['homepage']; - return []; - }), - getSizes: vi.fn(() => [ - { getWidth: () => 970, getHeight: () => 250 }, - { getWidth: () => 728, getHeight: () => 90 }, - ]), - clearTargeting, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - const setTargetingForGPTAsync = vi.fn(); - 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(originalRefresh).not.toHaveBeenCalled(); - - const bidsBackHandler = mockRequestBids.mock.calls[0][0].bidsBackHandler; - bidsBackHandler(); - - expect(setTargetingForGPTAsync).toHaveBeenCalled(); - 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) => { - 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(); - 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); - 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; - delete testWindow.__tsjs_prebid; - testWindow.tsjs = undefined; - delete testWindow.googletag; - }); - - afterEach(() => { - delete testWindow.__tsjs_prebid; - testWindow.tsjs = undefined; - delete testWindow.googletag; - }); - - function installGpt(slots: Array>) { - installedGptSlots = slots; - for (const slot of slots) { - if (!slot || typeof slot !== 'object') continue; - 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); - } - - function installPrebidRefreshDiagnostics( - implementation?: (slots: Array>) => void - ) { - const recordPrebidRefresh = vi.fn(implementation); - testWindow.tsjs = { gptDiagnosticsRecorder: { recordPrebidRefresh } }; - return recordPrebidRefresh; - } - - 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'] }; - 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'] }; - 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'] }; - 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('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(undefined, 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]); - 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('consumes all overlapping pending bids for the same ad-unit code', () => { - 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(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => {}, - } as unknown as RequestBidsArg); - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => {}, - } as unknown as RequestBidsArg); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - - deliveryAdIds.set(slot, `example-auction-0-${code}`); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(3); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); - }); - - 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'] }; - - 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'] }; - - 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'] }; - - 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('behaves normally when no client-side bidders are configured', () => { - // No __tsjs_prebid at all — all bidders go server-side - 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('behaves normally when client-side bidders list is empty', () => { - testWindow.__tsjs_prebid = { clientSideBidders: [] }; - - 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'] }; - - 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'] }; - - 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'] }; - - 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'] }; - - 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'] }; - - 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'] }; - - 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'] }; - - 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('disables the integration and leaves pbjs and GPT untouched', async () => { - // Simulate a failed external bundle load: window.pbjs is still the - // head-injected stub with no Prebid.js API. The module captures the - // global at evaluation time, so reset the registry and re-import. - vi.resetModules(); - const barePbjs: { - que: Array<() => void>; - cmd: Array<() => void>; - requestBids?: unknown; - } = { que: [], cmd: [] }; - testWindow.pbjs = barePbjs; - const pubads = { refresh: vi.fn() }; - const cmdPush = vi.fn((callback: () => void) => callback()); - testWindow.googletag = { cmd: { push: cmdPush }, pubads: () => pubads }; - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - await import('../../../src/integrations/prebid/index'); - - // The bail-out is logged loudly. - const hasBailOutError = errorSpy.mock.calls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('has no Prebid.js API')) - ); - expect(hasBailOutError).toBe(true); - - // requestBids is left unwrapped and no adapter registration was attempted. - expect(barePbjs.requestBids).toBeUndefined(); - - // The refresh handler must not install: a wrapped googletag refresh - // would clear TS-applied targeting and then fail to run any auction. - expect(cmdPush).not.toHaveBeenCalled(); - expect( - (pubads as { refresh: unknown; __tsRefreshWrapped?: boolean }).__tsRefreshWrapped - ).toBeUndefined(); - - // The sentinel stays unset so a later successful install can still run. - expect(testWindow.__tsjsPrebidShimInstalled).toBeUndefined(); - - errorSpy.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/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts new file mode 100644 index 000000000..cd7321a09 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -0,0 +1,1869 @@ +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 { + createPrebidRefreshPolicy, + createPrebidIntegrationRegistration, + createPrebidSelectionCoordinator, + createPrebidSyntheticRefreshRunner, + preparePrebidRegisteredRefreshAuction, + publishPrebidBid, + type PrebidBidPublicationInput, + type PreparedTrustedBidV1, +} from '../../../src/integrations/prebid/module'; +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, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => ({ id, phase: 'critical' as const })), + }; +} + +function registration( + id: string, + prepare: IntegrationRegistration['prepare'] +): IntegrationRegistration { + return Object.freeze({ abi: 1, id, phase: 'critical', releaseId: RELEASE_ID, 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 requiredUserIdConfig() { + return Object.freeze({ + clientSideBidders: Object.freeze(['alpha']), + requiredUserIdModules: Object.freeze([ + Object.freeze({ + moduleName: 'sharedIdSystem', + configNames: Object.freeze(['sharedId']), + eidSources: Object.freeze(['sharedid.org']), + }), + ]), + }); +} + +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(() => 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 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: requiredUserIdConfig(), + 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': Object.freeze({ + createAttempt, + navigation, + projection, + registerPucGamAttempt, + reservations, + }), + 'messages.v1': Object.freeze({}), + 'aps.v1': Object.freeze({}), + }), + navigation, + prepareContext: Object.freeze({ + config: requiredUserIdConfig(), + interfaces: Object.freeze({ + 'runtime.v1': Object.freeze({ document }), + 'slots.v1': Object.freeze({}), + 'render.v1': Object.freeze({ + createAttempt, + navigation, + projection, + registerPucGamAttempt, + reservations, + }), + 'messages.v1': Object.freeze({}), + 'aps.v1': Object.freeze({}), + }), + onDispose: (callback: () => void) => preparationDisposers.push(callback), + signal: controller.signal, + }), + registerPucGamAttempt, + reservations, + }); +} + +describe('production Prebid critical registration', () => { + afterEach(() => { + delete (window as unknown as { pbjs?: unknown }).pbjs; + }); + + it('passes exact configured user-ID/EID requirements into artifact admission', 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('incompatible'); + } 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 prospective 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' | 'cache'; 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' | 'cache'; 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..95d0eaae8 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts @@ -0,0 +1,599 @@ +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 { createRenderRuntimeIntegrationRegistration } from '../../../src/integrations/render_runtime/module'; +import { log } from '../../../src/core/log'; +import type { RenderAttempt } from '../../../src/services/render'; + +const RELEASE_ID = 'a'.repeat(64); + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('render_runtime provider', () => { + 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, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'b'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'critical' 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, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'b'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'critical' as const }), + Object.freeze({ id: 'permutive_context', phase: 'critical' 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, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'b'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'critical' 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; + }; + renderDirectCacheAttempt: (input: unknown) => boolean; + resolveCacheAdmAttempt: (input: unknown) => boolean; + 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/v1', 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, + }); + expect(typeof render.renderDirectCacheAttempt).toBe('function'); + expect(typeof render.resolveCacheAdmAttempt).toBe('function'); + 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, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'b'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'critical' 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..8dd20232e --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts @@ -0,0 +1,71 @@ +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 CRITICAL_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, + criticalSrc: CRITICAL_SRC, + integrations: [{ id: SOURCEPOINT_INTEGRATION_ID, phase: 'critical' }], + }, + 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..ae133bc75 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from 'vitest'; + +import { buildFallbackBoot, buildKernelBoot } from '../../src/kernel/fallback'; + +const RELEASE_ID = 'a'.repeat(64); +const TRUSTED_CRITICAL_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; + +function manifest(ids: readonly string[]) { + return { + version: 1 as const, + releaseId: RELEASE_ID, + criticalSrc: `/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=integrations/${id}.min.js?v=${'d'.repeat(64)}`, + } + : { id, phase: 'critical' 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: [], + }, + creative, + diagnostics: { + version: 1, + renderTraceOverlay: diagnostics.renderTraceOverlay, + gpt: { active: diagnostics.gptActive }, + }, + }; +} + +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(buildKernelBoot(RELEASE_ID, manifest([]), boot(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(buildKernelBoot(RELEASE_ID, manifest([]), boot(creative))).toBeUndefined(); + }); + + it.each([ + ['enabled creative without a manifest member', true, []], + ['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( + buildKernelBoot( + RELEASE_ID, + manifest(ids), + boot({ version: 1, enabled, clickGuard: false, renderGuard: false }) + ) + ).toBeUndefined(); + }); + + it('accepts enabled creative with both guards false only with one manifest member', () => { + const accepted = buildKernelBoot( + RELEASE_ID, + manifest(['creative']), + boot({ version: 1, enabled: true, clickGuard: false, renderGuard: false }) + ) as { readonly creative?: unknown } | 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('uses the independently trusted critical source when the manifest field is missing', () => { + const fallback = buildFallbackBoot( + RELEASE_ID, + { + ...boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: [], + }, + }, + TRUSTED_CRITICAL_SRC + ) as { readonly manifest: unknown }; + + expect(fallback.manifest).toEqual({ + version: 1, + releaseId: RELEASE_ID, + criticalSrc: TRUSTED_CRITICAL_SRC, + integrations: [], + }); + }); + + it('uses the independently trusted critical source when the manifest field is malformed', () => { + const fallback = buildFallbackBoot( + RELEASE_ID, + { + ...boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), + manifest: { + version: 1, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'e'.repeat(64)}&publisher=1`, + integrations: [], + }, + }, + TRUSTED_CRITICAL_SRC + ) as { readonly manifest: unknown }; + + expect(fallback.manifest).toEqual({ + version: 1, + releaseId: RELEASE_ID, + criticalSrc: TRUSTED_CRITICAL_SRC, + integrations: [], + }); + }); + + it('refuses to construct a fallback boot without an independently trusted critical source', () => { + expect( + buildFallbackBoot( + RELEASE_ID, + boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), + undefined as never + ) + ).toBeUndefined(); + }); + + it('publishes the exact phase-aware fallback manifest with the accepted critical source', () => { + const acceptedManifest = manifest(['render_runtime', 'diagnostics_presentation']); + const fallback = buildFallbackBoot( + RELEASE_ID, + boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), + acceptedManifest.criticalSrc + ) as { readonly manifest: unknown }; + + expect(fallback.manifest).toEqual({ + version: 1, + releaseId: RELEASE_ID, + criticalSrc: acceptedManifest.criticalSrc, + integrations: [], + }); + expect(Reflect.ownKeys(fallback.manifest as object).sort()).toEqual([ + 'criticalSrc', + 'integrations', + 'releaseId', + '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'] : []), + ]; + + expect( + buildKernelBoot( + RELEASE_ID, + manifest(ids), + boot(disabledCreative, { renderTraceOverlay, gptActive }) + ) + ).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( + buildKernelBoot( + RELEASE_ID, + manifest(ids), + boot(disabledCreative, { renderTraceOverlay, gptActive }) + ) + ).toBeUndefined(); + } + ); + + it('accepts the complete server-shaped phase-aware boot manifest', () => { + const expectedManifest = manifest(['render_runtime']); + const candidate = { + abi: 1, + releaseId: RELEASE_ID, + manifest: expectedManifest, + ...boot(disabledCreative), + }; + + expect(buildKernelBoot(RELEASE_ID, expectedManifest, 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 = { + abi: 1, + releaseId: RELEASE_ID, + manifest: expectedManifest, + ...boot(disabledCreative), + }; + + expect(buildKernelBoot(RELEASE_ID, expectedManifest, candidate) !== undefined).toBe(accepted); + }); + + it('rejects a complete boot whose phase-aware manifest differs from the accepted manifest', () => { + const expectedManifest = manifest(['render_runtime']); + const candidateManifest = { + ...expectedManifest, + criticalSrc: `/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( + buildKernelBoot(RELEASE_ID, manifest([]), { + ...candidate, + diagnostics, + }) + ).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..6c05f3244 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/identity.test.ts @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createBrowserNavigationIdentityIssuer, + createTestNavigationIdentityIssuer, + 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('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('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(mintTestRendererNonce(source, failure)).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(failure.mock.calls).toEqual([ + ['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..9f8a18eb2 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts @@ -0,0 +1,1662 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { BootManifestV1 } from '../../src/core/types'; +import { + createIntegrationRegistry as createIntegrationRegistryOwner, + type IntegrationPrepareContext, + type IntegrationRegistration, + type IntegrationRegistryOptions, +} from '../../src/kernel/integration_registry'; + +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: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }) + ) + ), + }); +} + +function manifest(ids: readonly string[]): BootManifestV1 { + return { + version: 1, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => ({ id, phase: 'critical' as const })), + }; +} + +function registration( + id: string, + hooks: Partial = {} +): IntegrationRegistration { + return { + abi: 1, + id, + phase: 'critical', + releaseId: RELEASE_ID, + prepare: () => ({ activate: () => undefined }), + ...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('accepts only the exact five-field release-bound 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', 'prepare']); + expect(registry.register(exact)).toBe(true); + }); + + it.each([ + ['old three-field ABI', { id: 'gpt', release: RELEASE_ID, prepare: vi.fn() }], + ['missing abi', { id: 'gpt', phase: 'critical', releaseId: RELEASE_ID, prepare: vi.fn() }], + ['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 critical registration to the captured connected core script', () => { + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + document.head.append(criticalScript); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: criticalScript, + }); + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }), + ]), + criticalScript, + 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 critical registration from a %s', + (failure) => { + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + document.head.append(criticalScript); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: criticalScript, + }); + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }), + ]), + criticalScript, + 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') { + criticalScript.remove(); + } else { + criticalScript.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', + '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: 'critical' }; + } + }); + 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 capacity', manifest(Array.from({ length: 21 }, (_, 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 critical 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: 'critical' as const, + releaseId: RELEASE_ID, + 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.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 critical 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 critical consumers', async () => { + const gpt = Object.freeze({ kind: 'gpt' }); + let consumerInterfaces: Readonly> | undefined; + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze(['gpt.v1']), + }), + Object.freeze({ + id: 'prebid', + phase: 'critical' 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 critical capabilities only', async () => { + const gpt = Object.freeze({ kind: 'gpt' }); + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'critical' 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: 'critical' 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: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze(['gpt.v1']), + }), + Object.freeze({ + id: 'prebid', + phase: 'critical' 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, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: () => { + order.push('activate'); + now = activationReturnMs; + }, + }), + }) + ); + + const result = await install(registry, order); + 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), + }); + + await expect( + registry.install({ + activateCore, + publish, + drainPreload: vi.fn(), + }) + ).resolves.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..1e57e3827 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts @@ -0,0 +1,97 @@ +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 CRITICAL_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, + criticalSrc: CRITICAL_SRC, + integrations: [{ id: TEST_INTEGRATION_ID, phase: 'critical' }], + }, + 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(); + }); +}); 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..957144cf6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/phase_loader.test.ts @@ -0,0 +1,533 @@ +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, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${HASH}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'critical' 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('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 critical = document.createElement('script'); + critical.nonce = 'response-nonce'; + const loader = createDeferredPhaseLoader({ + criticalScript: critical, + 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 critical = document.createElement('script'); + const loader = createDeferredPhaseLoader({ + criticalScript: critical, + 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('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({ + criticalScript: 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 critical = 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({ + criticalScript: critical, + 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({ + criticalScript: 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({ + criticalScript: 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: replacement }); + + 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({ + criticalScript: 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({ + criticalScript: 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({ + criticalScript: 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({ + criticalScript: 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({ + criticalScript: 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({ + criticalScript: 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('copies no nonce when the critical script has no nonempty nonce', async () => { + createDeferredPhaseLoader({ + criticalScript: 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..099672df0 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; + +import * as releaseCatalog from '../../src/kernel/release_catalog'; +import { + MAX_CRITICAL_MODULES, + MAX_MANIFEST_MODULES, + MINIMAL_CRITICAL_IDS, + REFERENCE_CRITICAL_IDS, + RELEASE_CATALOG, + selectReleaseCatalog, + validateReleaseCatalog, + type ReleaseCatalogEntry, +} from '../../src/kernel/release_catalog'; + +const EXPECTED = [ + ['render_runtime', 'runtime', 'critical', null, 'always'], + ['aps', 'APS', 'critical', null, 'integration:aps'], + ['creative', 'creative', 'critical', null, 'creative_guard'], + ['datadome', 'DataDome', 'critical', null, 'integration:datadome'], + ['didomi', 'Didomi', 'critical', null, 'integration:didomi'], + ['google_tag_manager', 'GTM/GA', 'critical', null, 'integration:google_tag_manager'], + ['gpt', 'GPT', 'critical', null, 'integration:gpt'], + ['gpt_diagnostics', 'diagnostics', 'critical', null, 'gpt_diagnostics_active'], + ['lockr', 'Lockr', 'critical', null, 'integration:lockr'], + ['osano_consent', 'Osano', 'critical', null, 'integration:osano'], + ['permutive_context', 'Permutive', 'critical', null, 'integration:permutive'], + ['sourcepoint_consent', 'Sourcepoint', 'critical', null, 'integration:sourcepoint'], + ['prebid', 'Prebid', 'critical', null, 'integration:prebid'], + ['testlight', 'Testlight', 'critical', null, 'integration:testlight'], + [ + 'diagnostics_presentation', + 'diagnostics', + 'deferred', + 'first_display_or_idle', + 'diagnostics_presentation', + ], + ['gpt_later', 'GPT', 'deferred', 'first_display_or_idle', 'integration:gpt'], + ['osano_lifecycle', 'Osano', 'deferred', 'first_display_or_idle', 'integration:osano'], + [ + 'permutive_lifecycle', + 'Permutive', + 'deferred', + 'first_display_or_idle', + 'integration:permutive', + ], + ['prebid_later', 'Prebid', 'deferred', 'first_display_or_idle', 'prebid_and_gpt'], + [ + 'sourcepoint_lifecycle', + 'Sourcepoint', + 'deferred', + 'first_display_or_idle', + 'integration:sourcepoint', + ], +] as const; + +describe('canonical release catalog', () => { + it('pins the exact twenty rows, phases, triggers, products, predicates, and order', () => { + expect( + RELEASE_CATALOG.map(({ id, product, phase, trigger, include }) => [ + id, + product, + phase, + trigger, + include, + ]) + ).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'], + ], + ['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_CRITICAL_MODULES).toBe(14); + expect(MAX_MANIFEST_MODULES).toBe(20); + expect('MAX_INTERNAL_DIAGNOSTICS_SUBSCRIPTIONS' in releaseCatalog).toBe(false); + expect(MINIMAL_CRITICAL_IDS).toEqual(['core', 'render_runtime']); + expect(REFERENCE_CRITICAL_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 fifteenCritical = [ + ...RELEASE_CATALOG.slice(0, 14), + { + ...RELEASE_CATALOG[14]!, + phase: 'critical' as const, + trigger: null, + }, + ]; + expect(() => validateReleaseCatalog(fifteenCritical)).toThrow( + /critical 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 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..99e0c607f --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -0,0 +1,2442 @@ +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 { createDiagnosticsPresentationIntegrationRegistration } from '../../src/integrations/gpt_diagnostics/presentation'; +import { createLifecycleIntegrationRegistration } from '../../src/kernel/lifecycle_module'; + +const RELEASE = 'a'.repeat(64); +const TRUSTED_CRITICAL_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + +function installTestCriticalScript(runtimeDocument: Document): void { + if (runtimeDocument.currentScript) return; + const script = runtimeDocument.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL(TRUSTED_CRITICAL_SRC, runtimeDocument.location.origin).href; + runtimeDocument.head.insertBefore(script, null); + Object.defineProperty(runtimeDocument, 'currentScript', { + configurable: true, + value: script, + }); +} + +function createRuntime(options: RuntimeOptions) { + installTestCriticalScript(options.document ?? document); + return createRuntimeOwner(options); +} + +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 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, + criticalSrc: `/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: 'critical' 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 critical 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_CRITICAL_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_CRITICAL_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_CRITICAL_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_CRITICAL_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_CRITICAL_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_CRITICAL_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 critical 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['trustedCriticalSrc'] = TRUSTED_CRITICAL_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({ + abi: 1, + id: 'gpt', + phase: 'critical', + 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 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: 'critical' 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({ + abi: 1, + id: 'render_runtime', + phase: 'critical', + 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 critical 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: 'critical' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze(['direct.v1']), + }), + Object.freeze({ + id: 'gpt_diagnostics', + phase: 'critical' 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({ + abi: 1, + id: 'render_runtime', + phase: 'critical', + 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({ + abi: 1, + id: 'gpt_diagnostics', + phase: 'critical', + 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: 'critical' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze([]), + }), + Object.freeze({ + id: 'gpt_diagnostics', + phase: 'critical' 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({ + abi: 1, + id, + phase: 'critical', + 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 criticalHash = 'c'.repeat(64); + const deferredHash = 'd'.repeat(64); + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${criticalHash}`; + document.head.insertBefore(criticalScript, null); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: criticalScript, + }); + const target: Record = {}; + const deferredPrepare = vi.fn(() => Object.freeze({ activate: () => undefined })); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + document, + manifest: { + version: 1, + releaseId: RELEASE, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${criticalHash}`, + integrations: [ + { id: 'render_runtime', phase: 'critical' }, + { + 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: 'critical' 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({ + abi: 1, + id: 'render_runtime', + phase: 'critical', + 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 !== criticalScript + ); + 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 criticalHash = 'c'.repeat(64); + const deferredHash = 'd'.repeat(64); + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${criticalHash}`; + document.head.insertBefore(criticalScript, null); + let executingScript: HTMLScriptElement | null = criticalScript; + 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 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, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${criticalHash}`, + integrations: [{ id: 'trace_provider', phase: 'critical' }, ...manifestEntries], + }, + knownIntegrationIds: Object.freeze([ + 'trace_provider', + 'optional_gpt_diag_provider', + ...deferredIds, + ]), + catalog: Object.freeze([ + Object.freeze({ + id: 'trace_provider', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze(['trace.v1', 'trace.presentation.v1']), + }), + Object.freeze({ + id: 'optional_gpt_diag_provider', + phase: 'critical' as const, + trigger: 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, + 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, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }) + ), + ]), + boot: { + ...boot(), + 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({ + abi: 1, + id: 'trace_provider', + phase: 'critical', + 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 === criticalScript) 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 = criticalScript; + } + }); + + 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(); + + 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({ + abi: 1, + id: 'gpt', + phase: 'critical', + 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('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({ + abi: 1, + id: 'gpt', + phase: 'critical', + 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({ + abi: 1, + id: 'gpt', + phase: 'critical', + 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, + }); + 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 critical source when the manifest field is missing', async () => { + const criticalSrc = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = new URL(criticalSrc, window.location.origin).href; + document.head.insertBefore(criticalScript, null); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: criticalScript, + }); + 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, + criticalSrc, + integrations: [], + }); + }); + + it('publishes the captured exact critical source when the manifest field is malformed', async () => { + const criticalSrc = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = new URL(criticalSrc, window.location.origin).href; + document.head.insertBefore(criticalScript, null); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: criticalScript, + }); + const candidateManifest = { + version: 1, + releaseId: RELEASE, + criticalSrc: `${criticalSrc}&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, + criticalSrc, + integrations: [], + }); + }); + + it('leaves the namespace unclaimed when no trusted critical 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('self-discards a stale async preparation before activation when a later owner commits', 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 secondRequestAds = 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: secondRequestAds, + }, + }); + + expect(first.start()).toBe(true); + expect( + first.registerIntegration({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare: ({ onDispose }: { onDispose(callback: () => void): void }) => { + onDispose(staleDisposal); + return stalePreparation.then(() => ({ activate: staleModuleActivation })); + }, + }) + ).toBe(true); + const staleInstall = first.install(); + await Promise.resolve(); + + expect(Reflect.deleteProperty(target, '_registerIntegration')).toBe(true); + expect(second.start()).toBe(true); + expect( + second.registerIntegration({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare: () => ({ activate: vi.fn() }), + }) + ).toBe(true); + await expect(second.install()).resolves.toMatchObject({ state: 'kernel' }); + + 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('failed'); + expect(second.state).toBe('kernel'); + expect((target as { requestAds?: unknown }).requestAds).toBe(secondRequestAds); + expect((target as { _internal?: unknown })._internal).toEqual({ + state: 'kernel', + releaseId: RELEASE, + }); + }); + + 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: 'critical', + releaseId: RELEASE, + 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({ + abi: 1, + id: 'gpt', + phase: 'critical', + 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'); + installTestCriticalScript(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: 'critical', + releaseId: 'b'.repeat(64), + prepare: vi.fn(), + }, + ], + ['unknown id', { abi: 1, id: 'aps', phase: 'critical', releaseId: RELEASE, 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 = { + abi: 1, + id: 'gpt', + phase: 'critical', + 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({ + abi: 1, + id: 'gpt', + phase: 'critical', + 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({ + abi: 1, + id: 'gpt', + phase: 'critical', + 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({ + abi: 1, + id: 'gpt', + phase: 'critical', + 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 and settles known, unknown, and aborted slots', 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({ slots: ['known', 'unknown'] })).resolves.toEqual({ + slots: [ + { slot: 'known', path: 'primary', outcome: 'failed', reason: 'abi_mismatch' }, + { 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('snapshots fallback boot before publisher mutation during installation', async () => { + const target = { boot: boot([{ slot: 'initial', outcome: 'no_bid' }]) }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + 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([ + { slot: 'initial', outcome: 'no_bid' }, + ]); + await expect(api.requestAds({ slots: ['initial', 'mutated'] })).resolves.toEqual({ + slots: [ + { slot: 'initial', path: 'primary', outcome: 'failed', reason: 'abi_mismatch' }, + { 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('applies fallback slot collision and combined registry capacity 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(expect.objectContaining({ code: 'slot_collision', unitIndex: 0 })); + + const full = await makeFallback(Array.from({ length: 256 }, (_, index) => `slot-${index}`)); + const capacityError = thrownBy(() => + full({ code: 'overflow', mediaTypes: { banner: { sizes: [[300, 250]] } } }) + ); + expect(capacityError).toMatchObject({ code: 'registry_capacity' }); + expect(Object.prototype.hasOwnProperty.call(capacityError, 'unitIndex')).toBe(false); + }); + + 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 27c189668..da2215351 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,7 @@ let outputDirectory; let bundleCode; let shimCode; let prebidVersion; +let artifactManifest; beforeAll(async () => { outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-artifacts-')); @@ -38,9 +40,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 +62,6 @@ beforeAll(async () => { format: 'iife', dir: outputDirectory, entryFileNames: 'tsjs-prebid.js', - inlineDynamicImports: true, extend: false, name: 'tsjs_prebid', }, @@ -74,137 +77,398 @@ afterAll(() => { }); describe('tsjs-prebid shim artifact', () => { - it('stays Prebid-free: no core markers and an order-of-magnitude size gap', () => { - // The embedded version string is the core marker. Prove it appears in the - // external bundle first so this test fails loudly if the marker rots - // instead of silently passing. + it('stays Prebid-free and uses only the external bundle public API', () => { + // The embedded version string and `_pbjsGlobals` are core markers. Prove + // they appear in the external artifact first so this test fails loudly if + // either marker rots instead of silently passing. expect(bundleCode).toContain(prebidVersion); + expect(bundleCode).toContain('_pbjsGlobals'); expect(shimCode).not.toContain(prebidVersion); + expect(shimCode).not.toContain('_pbjsGlobals'); - // A value-import of 'prebid.js' would multiply the shim size; the shim - // must stay an order of magnitude smaller than Prebid core. + // Bundle size is enforced by the role-correct captured ±5% budget gate. + // These checks prove that Prebid remains external while the shim uses only + // the documented public methods needed by the hard-cutover adapter. expect(bundleCode.length).toBeGreaterThan(200_000); - expect(shimCode.length).toBeLessThan(150_000); + expect(shimCode).toContain('registerBidAdapter'); + expect(shimCode).toContain('getBidResponsesForAdUnitCode'); }); }); 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: [] }; + 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(bundleCode); + 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(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).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.eval( + `window.__conflictingStamp=(function freeze(value){if(value&&typeof value==='object'){Object.getOwnPropertyNames(value).forEach(function(key){freeze(value[key]);});Object.freeze(value);}return value;})(${JSON.stringify(conflictingStamp)});` ); - 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: [ + 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; + + 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.eval( + `window.__exactStamp=(function freeze(value){if(value&&typeof value==='object'){Object.getOwnPropertyNames(value).forEach(function(key){freeze(value[key]);});Object.freeze(value);}return value;})(${JSON.stringify( { - code: 'ad-slot-1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - bids: [{ bidder: 'appnexus', params: { placementId: 1 } }], - }, - ], - timeout: 1000, + 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, }); - const requestUrl = (resource) => - typeof resource === 'string' ? resource : String(resource?.url ?? resource); + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(typeof pageWindow.pbjs.requestBids).toBe('function'); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__exactStamp); + dom.window.close(); + }); - await vi.waitFor( - () => { - expect( - fetchSpy.mock.calls.some(([resource]) => requestUrl(resource).includes('/auction')) - ).toBe(true); - }, - { timeout: 10_000 } + 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(){};});` + ); + pageWindow.eval( + `window.__boundaryStamp=(function freeze(value){if(value&&typeof value==='object'){Object.getOwnPropertyNames(value).forEach(function(key){freeze(value[key]);});Object.freeze(value);}return value;})(${JSON.stringify( + boundaryStamp + )});` ); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__boundaryStamp, + enumerable: false, + writable: false, + configurable: false, + }); + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(pageWindow.pbjs.requestBids).toBe(pageWindow.__fakeRequestBids); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__boundaryStamp); + dom.window.close(); + }); - const [resource, init] = fetchSpy.mock.calls.find(([target]) => - requestUrl(target).includes('/auction') + 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(){};});` ); - 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'); - // The server-side bidder was folded into the trustedServer request - // instead of running client-side. - const trustedServerBid = adUnit.bids.find((bid) => bid.bidder === 'trustedServer'); - expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 1 } }); + pageWindow.eval( + `window.__malformedStamp=(function freeze(value){if(value&&typeof value==='object'){Object.getOwnPropertyNames(value).forEach(function(key){freeze(value[key]);});Object.freeze(value);}return value;})(${JSON.stringify( + malformedStamp + )});` + ); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__malformedStamp, + enumerable: false, + writable: false, + configurable: false, + }); + + 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..48d935289 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts @@ -0,0 +1,659 @@ +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('uses one fetch and applies reversed decisions in 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: 'accepted' }, + { slot: 'slot-a', path: 'primary', outcome: 'no_bid' }, + ], + }); + expect(fetcher).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledWith( + '/auction', + expect.objectContaining({ + method: 'POST', + body: '{"adUnits":[]}', + signal: expect.any(AbortSignal), + }) + ); + expect(attempts.get('slot-b')?.attempt.admitDirectWinner).toHaveBeenCalledOnce(); + 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..81b241033 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -0,0 +1,3336 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createBrowserMessagingAdapter } from '../../src/adapters/messaging'; +import { + createPucBridge, + PUC_DYNAMIC_OWNER, + type PucBridgeOptions, + type PucRenderAttempt, +} from '../../src/services/puc_bridge'; +import type { RenderFailureReason, 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 rendererNonces?: PucBridgeOptions['rendererNonces']; + readonly rendererUrl?: string; + readonly resolveCacheAdm?: PucBridgeOptions['resolveCacheAdm']; + readonly scheduler?: PucBridgeOptions['scheduler']; +} + +function createHarness( + recognize: (reservationId: unknown) => ReservationRecognition, + options: HarnessOptions = {} +) { + let listener: ((event: MessageEvent) => void) | undefined; + const target = { + addEventListener: vi.fn( + (_type: 'message', next: (event: MessageEvent) => void, _capture: true) => { + listener = next; + } + ), + removeEventListener: vi.fn(), + ...(options.messageChannel ? { MessageChannel: options.messageChannel } : {}), + }; + const bridgeOptions: PucBridgeOptions = { + messaging: createBrowserMessagingAdapter(target, { + ...(options.publisherOrigin ? { expectedPublisherOrigin: options.publisherOrigin } : {}), + ...(options.rendererUrl ? { expectedRendererUrl: options.rendererUrl } : {}), + validateApsRenderer: () => true, + }), + reservations: { + claim: options.claim ?? (() => ({ recognized: false }) satisfies ReservationClaimResult), + recognize, + }, + ...(options.mintLifecycleTicket ? { mintLifecycleTicket: options.mintLifecycleTicket } : {}), + ...(options.now ? { now: options.now } : {}), + ...(options.publisherOrigin ? { publisherOrigin: options.publisherOrigin } : {}), + ...(options.resizeCollapsedShell ? { resizeCollapsedShell: options.resizeCollapsedShell } : {}), + ...(options.rendererNonces ? { rendererNonces: options.rendererNonces } : {}), + ...(options.rendererUrl ? { rendererUrl: options.rendererUrl } : {}), + ...(options.resolveCacheAdm ? { resolveCacheAdm: options.resolveCacheAdm } : {}), + ...(options.scheduler ? { scheduler: options.scheduler } : {}), + }; + const bridge = createPucBridge(bridgeOptions); + const dispatch = (event: Record): void => { + if (!listener) throw new Error('Expected the capture listener to be installed synchronously'); + listener(event as unknown as MessageEvent); + }; + return { bridge, dispatch, target }; +} + +function createGamAttempt(kind: 'aps' | 'adm' | 'cache' = '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 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: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + } + : kind === 'adm' + ? { + type: 'adm', + version: 1, + adm: '
fictional creative
', + width: 300, + height: 250, + } + : { + type: 'cache', + version: 1, + cacheId: '12345678-1234-4123-8123-123456789012', + fetchUrl: + 'https://cache.example/pbc/v1/cache?uuid=12345678-1234-4123-8123-123456789012', + 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('installs owner iframe lifecycle handlers before assigning either document source', () => { + const admStart = PUC_DYNAMIC_OWNER.indexOf('const insertAdm'); + const apsStart = PUC_DYNAMIC_OWNER.indexOf('const insertAps'); + const controlStart = PUC_DYNAMIC_OWNER.indexOf('const receiveControl'); + const admOwner = PUC_DYNAMIC_OWNER.slice(admStart, apsStart); + const apsOwner = PUC_DYNAMIC_OWNER.slice(apsStart, controlStart); + + expect(new TextEncoder().encode(PUC_DYNAMIC_OWNER).byteLength).toBeLessThanOrEqual(64 * 1_024); + expect(admStart).toBeGreaterThanOrEqual(0); + expect(apsStart).toBeGreaterThan(admStart); + expect(controlStart).toBeGreaterThan(apsStart); + expect(admOwner.indexOf('next.onload =')).toBeLessThan( + admOwner.indexOf('next.srcdoc = intendedSource;') + ); + expect(admOwner.indexOf('next.onerror =')).toBeLessThan( + admOwner.indexOf('next.srcdoc = intendedSource;') + ); + expect(apsOwner.indexOf('next.onload =')).toBeLessThan( + apsOwner.indexOf('next.src = intendedSource;') + ); + expect(apsOwner.indexOf('next.onerror =')).toBeLessThan( + apsOwner.indexOf('next.src = intendedSource;') + ); + }); + + it('binds owner load and final acceptance to the exact inserted navigation', () => { + const admStart = PUC_DYNAMIC_OWNER.indexOf('const insertAdm'); + const apsStart = PUC_DYNAMIC_OWNER.indexOf('const insertAps'); + const controlStart = PUC_DYNAMIC_OWNER.indexOf('const receiveControl'); + const admOwner = PUC_DYNAMIC_OWNER.slice(admStart, apsStart); + const apsOwner = PUC_DYNAMIC_OWNER.slice(apsStart, controlStart); + + expect(admOwner).toContain('next.parentNode === creativeWindow.document.body'); + expect(admOwner).toContain('next.srcdoc === intendedSource'); + expect(admOwner).toContain('next.getAttribute("src") === null'); + expect(apsOwner).toContain('next.parentNode === creativeWindow.document.body'); + expect(apsOwner).toContain('next.getAttribute("src") === intendedSource'); + expect(apsOwner).toContain('next.contentWindow === intendedWindow'); + 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: '3', + 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: '3', + 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: '3', + 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('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: '3', + 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: '3', + 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', + reason: 'adm_document_no_load', + }, + 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('accepts the optional APS creative id and preserves no-referrer on the owner iframe', 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) {}, + }; + const documentPort = createPort(); + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + 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/v1', + 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', + creativeId: 'creative-1', + }, + }, + }, + ports: [documentPort], + }); + + const frame = document.body.querySelector('iframe'); + expect(frame).not.toBeNull(); + expect(frame?.getAttribute('referrerpolicy')).toBe('no-referrer'); + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }); + await expect(rendered).resolves.toBeUndefined(); + } finally { + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('keeps APS ownership alive after a local frame error until the kernel settles failure', 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: '3', + 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/v1', + 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], + }); + + const frame = document.body.querySelector('iframe'); + expect(frame).not.toBeNull(); + frame?.dispatchEvent(new Event('error')); + const immediate = rendered.then( + () => 'resolved', + () => 'rejected' + ); + await Promise.resolve(); + expect(await Promise.race([immediate, Promise.resolve('pending')])).toBe('pending'); + expect(document.body.querySelector('iframe')).toBeNull(); + expect(documentPort.close).toHaveBeenCalledOnce(); + expect(controlPort.close).not.toHaveBeenCalled(); + + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'failed', + reason: 'runner_no_load', + }, + ports: [], + }); + await expect(rendered).rejects.toThrow('runner_no_load'); + 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: '3', + 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: '3', + 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: '3', + 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: 'cross-origin renderer route', + ownerKind: 'aps', + rendererOverrides: {}, + rendererUrl: 'https://attacker.example/integrations/aps/renderer/v1', + }, + { + caseName: 'semantically invalid renderer descriptor', + ownerKind: 'aps', + rendererOverrides: { tagType: 'native' }, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + }, + { + caseName: 'mismatched declared owner kind', + ownerKind: 'adm', + rendererOverrides: {}, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + }, + ])( + 'refuses an APS owner start with a $caseName', + async ({ ownerKind, rendererOverrides, rendererUrl }) => { + 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: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: ownerKind, + 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, + 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', + ...rendererOverrides, + }, + }, + }, + ports: [documentPort], + }); + const immediate = rendered.then( + () => 'resolved', + () => 'rejected' + ); + + await Promise.resolve(); + expect(await Promise.race([immediate, Promise.resolve('pending')])).toBe('rejected'); + expect(document.body.querySelector('iframe')).toBeNull(); + expect(documentPort.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(JSON.parse(String(port.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'Prebid Response', + adId: RESERVATION_ID, + rendererVersion: '3', + 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: '3', + 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 response = JSON.parse(String(port.postMessage.mock.calls[0]?.[0])); + 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: '3', + 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('cache'); + 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: 'cache', 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('resolves cache privately and sends only the resulting ADM source to the owner', () => { + const gam = createGamAttempt('cache', 1_013); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + let completeResolution: + | (( + source: Readonly<{ adm: string; height: number; type: 'adm'; version: 1; width: number }> + ) => boolean) + | undefined; + const resolveCacheAdm = vi.fn((_attempt, onResolved) => { + completeResolution = onResolved; + return true; + }); + 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 }), + resolveCacheAdm, + } + ); + issueReadyTicket(harness, gam, pucSource); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(resolveCacheAdm).toHaveBeenCalledWith(gam.attempt, expect.any(Function)); + expect(controlRetained.postMessage).not.toHaveBeenCalled(); + expect( + completeResolution?.( + Object.freeze({ + type: 'adm', + version: 1, + adm: '
resolved cache creative
', + width: 300, + height: 250, + }) + ) + ).toBe(true); + expect(controlRetained.postMessage.mock.calls[0]).toEqual([ + { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
resolved cache creative
', + width: 300, + height: 250, + }, + }, + [], + ]); + expect(JSON.stringify(controlRetained.postMessage.mock.calls[0])).not.toContain('cacheId'); + expect( + completeResolution?.( + Object.freeze({ + type: 'adm', + version: 1, + adm: '
duplicate
', + width: 300, + height: 250, + }) + ) + ).toBe(false); + }); + + it('sends exact APS start with one document port and accepts exact document completion', () => { + const gam = createGamAttempt('aps', 1_012); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const documentRetained = createPort(); + const documentTransferred = createPort(); + const channels = [ + { port1: controlRetained, port2: controlTransferred }, + { port1: documentRetained, port2: documentTransferred }, + ]; + let channelIndex = 0; + const issue = vi.fn( + (input: { + readonly attempt: PucRenderAttempt; + readonly port: { readonly close: () => void }; + }) => { + expect(input.attempt.onSettled(() => input.port.close())).toBe(true); + return Object.freeze({ ok: true as const, nonce: 'n1_abcdefghijklmnopqrstuv' }); + } + ); + const consume = vi.fn(() => true); + 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: unknown; + readonly port2: unknown; + + constructor() { + const channel = channels[channelIndex]; + channelIndex += 1; + if (!channel) throw new Error('Unexpected extra MessageChannel'); + this.port1 = channel.port1; + this.port2 = channel.port2; + } + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + publisherOrigin: 'https://publisher.example', + rendererNonces: Object.freeze({ issue, consume }), + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + } + ); + issueReadyTicket(harness, gam, pucSource); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(channelIndex).toBe(2); + expect(issue).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage.mock.calls[0]).toEqual([ + { + message: 'TS APS Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + 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', + }, + }, + }, + [documentTransferred], + ]); + + dispatchPortMessage(documentRetained, { + message: 'TS APS Document Accepted', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + expect(consume).not.toHaveBeenCalled(); + expect(gam.attempt.apsDocumentAccepted).not.toHaveBeenCalled(); + + dispatchPortMessage(documentRetained, { + message: 'TS APS Runner Loaded', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + dispatchPortMessage(documentRetained, { + message: 'TS APS Render Completed', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + dispatchPortMessage(documentRetained, { + message: 'TS APS Render Failed', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + reason: 'runner_failed', + }); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + + // Control and document messages travel over different ports, so delivery order + // is not defined even though the owner posts insertion before handing off. + dispatchPortMessage(controlRetained, { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(gam.attempt.beginApsDocument).toHaveBeenCalledWith(gam.artifact); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); + expect(consume).toHaveBeenCalledOnce(); + expect(gam.attempt.apsDocumentAccepted).toHaveBeenCalledOnce(); + 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(); + expect(documentRetained.close).toHaveBeenCalledOnce(); + expect(controlTransferred.close).not.toHaveBeenCalled(); + expect(documentTransferred.close).not.toHaveBeenCalled(); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); + }); + + it('keeps the first buffered APS failure when a later completion arrives', () => { + const gam = createGamAttempt('aps', 1_016); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const documentRetained = createPort(); + const documentTransferred = createPort(); + const channels = [ + { port1: controlRetained, port2: controlTransferred }, + { port1: documentRetained, port2: documentTransferred }, + ]; + let channelIndex = 0; + const issue = vi.fn( + (input: { + readonly attempt: PucRenderAttempt; + readonly port: { readonly close: () => void }; + }) => { + expect(input.attempt.onSettled(() => input.port.close())).toBe(true); + return Object.freeze({ ok: true as const, nonce: 'n1_abcdefghijklmnopqrstuv' }); + } + ); + const consume = vi.fn(() => true); + 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: unknown; + readonly port2: unknown; + + constructor() { + const channel = channels[channelIndex]; + channelIndex += 1; + if (!channel) throw new Error('Unexpected extra MessageChannel'); + this.port1 = channel.port1; + this.port2 = channel.port2; + } + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + publisherOrigin: 'https://publisher.example', + rendererNonces: Object.freeze({ issue, consume }), + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + } + ); + issueReadyTicket(harness, gam, pucSource); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + dispatchPortMessage(documentRetained, { + message: 'TS APS Document Accepted', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + dispatchPortMessage(documentRetained, { + message: 'TS APS Render Failed', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + reason: 'runner_failed', + }); + dispatchPortMessage(documentRetained, { + message: 'TS APS Render Completed', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + dispatchPortMessage(controlRetained, { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + + expect(consume).toHaveBeenCalledOnce(); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + expect(gam.attempt.fail).toHaveBeenCalledWith('runner_failed'); + expect(controlRetained.postMessage.mock.calls[1]).toEqual([ + { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'failed', + reason: 'runner_failed', + }, + [], + ]); + }); + + it('closes a reentrant APS document channel before issuing nonce authority', () => { + const gam = createGamAttempt('aps', 1_015); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const documentRetained = createPort(); + const documentTransferred = createPort(); + let channelIndex = 0; + const issue = vi.fn(); + 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: unknown; + readonly port2: unknown; + + constructor() { + channelIndex += 1; + if (channelIndex === 1) { + this.port1 = controlRetained; + this.port2 = controlTransferred; + return; + } + this.port1 = documentRetained; + this.port2 = documentTransferred; + gam.attempt.fail('internal_error'); + } + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + publisherOrigin: 'https://publisher.example', + rendererNonces: Object.freeze({ issue, consume: vi.fn() }), + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + } + ); + issueReadyTicket(harness, gam, pucSource); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(channelIndex).toBe(2); + expect(issue).not.toHaveBeenCalled(); + expect(documentRetained.close).toHaveBeenCalledOnce(); + expect(documentTransferred.close).toHaveBeenCalledOnce(); + expect(controlRetained.close).toHaveBeenCalledOnce(); + expect(controlTransferred.close).not.toHaveBeenCalled(); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().attempts).toBe(0); + }); + + 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..ff9893538 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -0,0 +1,4843 @@ +import { describe, expect, it, vi } from 'vitest'; + +import apsEnvelope from '../fixtures/aps-renderer-v1.json'; +import { createBrowserMessagingAdapter, type MessagingAdapter } from '../../src/adapters/messaging'; +import { prepareAdmIframe } from '../../src/core/render'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { createRuntimeSession } from '../../src/kernel/sessions'; +import type { RenderAttemptScope, WinnerContext } from '../../src/kernel/sessions'; +import { + APS_RENDERER_SANDBOX, + APS_RENDERER_V1_PATH, + renderDirectApsAttempt, + resolveApsRendererV1Url, +} from '../../src/integrations/aps/render'; +import { + createCommittedArtifactStore, + createRenderAttempt, + createRendererNonceRegistry, + createSlotOperation, + renderDirectCacheAttempt, + renderDirectAdmAttempt, + resolveCacheAdmAttempt, + resizeCollapsedPucShell, + type CacheAdmSource, + 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')}`; +} + +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 }); +const CACHE_ID = 'f47447a0-b759-4f2f-9887-af458b79b570'; +const CACHE_POLICY = Object.freeze({ + version: 1 as const, + baseUrl: 'https://cache.example:8443/pbc/v1/cache', +}); +const CACHE_SOURCE = Object.freeze({ + type: 'cache' as const, + version: 1 as const, + cacheId: CACHE_ID, + fetchUrl: `${CACHE_POLICY.baseUrl}?uuid=${CACHE_ID}`, + width: 300, + height: 250, +}); + +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 === CACHE_SOURCE) return CACHE_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 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('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('accepts no document-port traffic before the native load handoff', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const nonce = indexedRendererNonce(1); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(transferredRaw.postMessage).not.toHaveBeenCalled(); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_document', + }); + + const frame = document.querySelector('#fictional-slot iframe')!; + frame.dispatchEvent(new Event('load')); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('uses captured native creation instead of a connected iframe returned by a hostile factory', () => { + document.body.innerHTML = + '
'; + const publisherContainer = document.getElementById('publisher-owned')!; + const publisherFrame = publisherContainer.querySelector('iframe')!; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const createElement = vi + .spyOn(document, 'createElement') + .mockReturnValueOnce(publisherFrame as HTMLIFrameElement); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonces = createRendererNonceRegistry(); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(createElement).not.toHaveBeenCalled(); + expect(publisherFrame.parentNode).toBe(publisherContainer); + expect(publisherFrame.title).toBe('publisher frame'); + expect(document.querySelector('#fictional-slot iframe')).not.toBe(publisherFrame); + expect(render.cancel('caller_aborted')).toBe(true); + expect(publisherFrame.parentNode).toBe(publisherContainer); + } finally { + createElement.mockRestore(); + nonces.dispose(); + document.body.innerHTML = ''; + } + }); + + it('ignores a detached poisoned iframe and keeps native source/removal authority', () => { + document.body.innerHTML = + '
'; + const unrelated = document.getElementById('unrelated-publisher-dom')!; + const poisoned = document.createElement('iframe'); + poisoned.title = 'publisher detached frame'; + const forgedSource = Object.freeze({ postMessage: vi.fn() }); + Object.defineProperty(poisoned, 'contentWindow', { + configurable: true, + get: () => forgedSource, + }); + Object.defineProperty(poisoned, 'src', { + configurable: true, + get: () => 'https://publisher.example/lie', + set: vi.fn(), + }); + poisoned.getAttribute = vi.fn(() => 'https://publisher.example/lie'); + poisoned.addEventListener = vi.fn(() => { + throw new Error('publisher listener'); + }); + poisoned.remove = vi.fn(() => unrelated.remove()); + const createElement = vi.spyOn(document, 'createElement').mockReturnValueOnce(poisoned); + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(createElement).not.toHaveBeenCalled(); + expect(poisoned.parentNode).toBeNull(); + expect(poisoned.title).toBe('publisher detached frame'); + const exactFrame = document.querySelector('#fictional-slot iframe')!; + const exactSource = exactFrame.contentWindow!; + const exactPost = vi.spyOn(exactSource, 'postMessage'); + exactFrame.dispatchEvent(new Event('load')); + expect(exactPost).toHaveBeenCalledOnce(); + expect(forgedSource.postMessage).not.toHaveBeenCalled(); + expect(poisoned.remove).not.toHaveBeenCalled(); + expect(unrelated.isConnected).toBe(true); + expect(render.cancel('caller_aborted')).toBe(true); + expect(unrelated.isConnected).toBe(true); + } finally { + createElement.mockRestore(); + nonces.dispose(); + document.body.innerHTML = ''; + } + }); + + it('disposes detached setup resources when listener installation throws before staging', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retained = Object.freeze({ + close: vi.fn(), + listen: vi.fn(() => { + throw new Error('hostile retained listener'); + }), + post: vi.fn(), + }); + const transferred = Object.freeze({ + close: vi.fn(), + listen: vi.fn(), + post: vi.fn(), + }); + const messaging = Object.freeze({ + createChannel: () => Object.freeze({ retained, transferred }), + postWindow: vi.fn(), + installCaptureListener: vi.fn(), + parseProtocolMessage: vi.fn(), + extractTransferredPorts: vi.fn(), + }) as unknown as MessagingAdapter; + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(retained.close).toHaveBeenCalledOnce(); + expect(transferred.close).toHaveBeenCalledOnce(); + expect(document.querySelector('iframe')).toBeNull(); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('does not insert after a pre-append cancellation returns through setup', () => { + document.body.innerHTML = '
'; + const container = document.getElementById('fictional-slot')!; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retained = Object.freeze({ + close: vi.fn(), + listen: vi.fn(() => { + render.cancel('caller_aborted'); + return () => undefined; + }), + post: vi.fn(), + }); + const transferred = Object.freeze({ + close: vi.fn(), + listen: vi.fn(), + post: vi.fn(), + }); + const messaging = Object.freeze({ + createChannel: () => Object.freeze({ retained, transferred }), + postWindow: vi.fn(), + installCaptureListener: vi.fn(), + parseProtocolMessage: vi.fn(), + extractTransferredPorts: vi.fn(), + }) as unknown as MessagingAdapter; + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + const observer = new MutationObserver(() => undefined); + observer.observe(container, { childList: true }); + + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + expect(observer.takeRecords()).toHaveLength(0); + expect(container.children).toHaveLength(0); + expect(retained.close).toHaveBeenCalledOnce(); + expect(transferred.close).toHaveBeenCalledOnce(); + observer.disconnect(); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('binds the inserted renderer window and accepts only exact document-port completion', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
placeholder
'; + const artifacts = createCommittedArtifactStore(); + const render = attempt(owner(), { artifacts }); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const container = document.getElementById('fictional-slot')!; + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const iframe = container.querySelector('iframe'); + expect(iframe).not.toBeNull(); + expect(iframe?.src).toBe( + `${new URL(APS_RENDERER_V1_PATH, window.location.origin).href}#tsaps=${nonce}` + ); + expect(iframe?.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); + expect(iframe?.width).toBe(String(DIRECT_APS_SOURCE.width)); + expect(iframe?.height).toBe(String(DIRECT_APS_SOURCE.height)); + expect(iframe?.style.width).toBe(`${DIRECT_APS_SOURCE.width}px`); + expect(iframe?.style.height).toBe(`${DIRECT_APS_SOURCE.height}px`); + expect(render.snapshot().state).toBe('waiting_for_document'); + + const target = iframe?.contentWindow; + if (!iframe || !target) throw new Error('Expected renderer window'); + const postMessage = vi.spyOn(target, 'postMessage'); + iframe.dispatchEvent(new Event('load')); + expect(postMessage).toHaveBeenCalledWith( + { + version: 1, + nonce, + publisherOrigin: window.location.origin, + renderer: DIRECT_APS_SOURCE, + }, + '*', + [transferredRaw] + ); + + retainedRaw.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: indexedRendererNonce(2), + }); + expect(render.snapshot().state).toBe('waiting_for_document'); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + expect(render.snapshot().state).toBe('waiting_for_aps_completion'); + retainedRaw.emit({ message: 'TS APS Runner Loaded', version: 1, nonce }); + expect(render.snapshot().outcome).toBeUndefined(); + expect(container.querySelector('span')).not.toBeNull(); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(container.querySelector('span')).toBeNull(); + retainedRaw.emit({ + message: 'TS APS Render Failed', + version: 1, + nonce, + reason: 'runner_failed', + }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(iframe.isConnected).toBe(true); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + } finally { + artifacts.dispose(); + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('maps document and APS completion deadlines through the attempt-owned timers', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const makeRender = (id: string, slot: string) => { + const render = attempt(owner(id, slot)); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + return { messaging, render, retainedRaw }; + }; + const first = makeRender(indexedAttemptId(1), 'document-slot'); + const second = makeRender(indexedAttemptId(2), 'runner-slot'); + let draw = 1; + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: first.render, + container: document.getElementById('document-slot')!, + messaging: first.messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + vi.advanceTimersByTime(3_000); + expect(first.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(document.querySelector('#document-slot iframe')).toBeNull(); + + expect( + renderDirectApsAttempt({ + attempt: second.render, + container: document.getElementById('runner-slot')!, + messaging: second.messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const runnerFrame = document.querySelector('#runner-slot iframe')!; + runnerFrame.dispatchEvent(new Event('load')); + second.retainedRaw.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: indexedRendererNonce(2), + }); + second.retainedRaw.emit({ + message: 'TS APS Runner Loaded', + version: 1, + nonce: indexedRendererNonce(2), + }); + vi.advanceTimersByTime(10_000); + expect(second.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'runner_failed', + }); + expect(runnerFrame.isConnected).toBe(false); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it.each([ + ['descriptor_invalid', 'winner_not_renderable'], + ['runner_no_load', 'runner_no_load'], + ['runner_failed', 'runner_failed'], + ] as const)('maps static renderer %s to %s', (rendererReason, attemptReason) => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + document.querySelector('iframe')?.dispatchEvent(new Event('load')); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ + message: 'TS APS Render Failed', + version: 1, + nonce, + reason: rendererReason, + }); + expect(render.snapshot().outcome).toEqual({ outcome: 'failed', reason: attemptReason }); + expect(document.querySelector('iframe')).toBeNull(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('removes and retires the pending frame and channel when caller cancellation wins', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = document.querySelector('iframe')!; + expect(render.cancel('caller_aborted')).toBe(true); + expect(frame.isConnected).toBe(false); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + frame.dispatchEvent(new Event('load')); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('cannot accept a renderer frame removed before its load handoff', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = document.querySelector('iframe')!; + frame.remove(); + frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('cannot accept a renderer whose container ancestor is removed before handoff', () => { + vi.useFakeTimers(); + document.body.innerHTML = + '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const container = document.getElementById('fictional-slot')!; + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe')!; + const target = frame.contentWindow!; + const postMessage = vi.spyOn(target, 'postMessage'); + document.getElementById('publisher-region')!.remove(); + expect(frame.parentNode).toBe(container); + expect(frame.isConnected).toBe(false); + frame.dispatchEvent(new Event('load')); + expect(postMessage).not.toHaveBeenCalled(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('rejects a same-node src navigation before handoff', () => { + vi.useFakeTimers(); + document.body.innerHTML = ''; + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + + const navigationRender = attempt(owner(indexedAttemptId(1), 'navigation-slot')); + expect(navigationRender.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const navigationRetained = browserMessagePort(); + const navigationTransferred = browserMessagePort(); + const navigationMessaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = navigationRetained; + readonly port2 = navigationTransferred; + }, + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: navigationRender, + container: document.getElementById('navigation-slot')!, + messaging: navigationMessaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const navigationFrame = document.querySelector('#navigation-slot iframe')!; + const originalSource = navigationFrame.contentWindow!; + const postMessage = vi.spyOn(originalSource, 'postMessage'); + navigationFrame.src = 'https://attacker.example/replacement'; + navigationFrame.dispatchEvent(new Event('load')); + expect(postMessage).not.toHaveBeenCalled(); + expect(navigationRender.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('does not remove DOM installed reentrantly by accepted-settlement observers', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
placeholder
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const container = document.getElementById('fictional-slot')!; + expect( + render.onSettled((outcome) => { + if (outcome.outcome !== 'accepted') return; + const successor = document.createElement('div'); + successor.id = 'reentrant-successor'; + container.appendChild(successor); + }) + ).toBe(true); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + container.querySelector('iframe')?.dispatchEvent(new Event('load')); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + const duringRenderSuccessor = document.createElement('div'); + duringRenderSuccessor.id = 'during-render-successor'; + container.appendChild(duringRenderSuccessor); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(container.querySelector('span')).toBeNull(); + expect(container.querySelector('#during-render-successor')).not.toBeNull(); + expect(container.querySelector('#reentrant-successor')).not.toBeNull(); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('anchors a synchronous document deadline after insertion and removes the exact frame', () => { + document.body.innerHTML = '
'; + const render = attempt(owner(), { + scheduler: Object.freeze({ + clear: vi.fn(), + set: (callback: () => void) => { + callback(); + return Object.freeze({}); + }, + }), + }); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + const container = document.getElementById('fictional-slot')!; + const observer = new MutationObserver(() => undefined); + observer.observe(container, { childList: true }); + + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + const mutations = observer.takeRecords(); + expect(mutations.some((mutation) => mutation.addedNodes.length === 1)).toBe(true); + expect(mutations.some((mutation) => mutation.removedNodes.length === 1)).toBe(true); + observer.disconnect(); + expect(container.querySelector('iframe')).toBeNull(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('contains a hostile nonce-issuer result and closes both unowned channel endpoints', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const realNonces = createRendererNonceRegistry(); + const nonces = Object.freeze({ + ...realNonces, + issue: () => + Object.freeze( + Object.defineProperty({}, 'ok', { + enumerable: true, + get: () => { + throw new Error('hostile nonce result'); + }, + }) + ), + }) as unknown as typeof realNonces; + let result: boolean | undefined; + let thrown: unknown; + try { + result = renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeUndefined(); + expect(result).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'identity_generation_failed', + }); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + expect(document.querySelector('iframe')).toBeNull(); + realNonces.dispose(); + document.body.innerHTML = ''; + }); + + it('rejects an invalid APS descriptor before creating a channel or mutating the DOM', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const channelConstructor = vi.fn(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: channelConstructor as never, + }); + const nonces = createRendererNonceRegistry(); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(channelConstructor).not.toHaveBeenCalled(); + expect(container.children).toHaveLength(0); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('rejects a publisher origin that is not the exact container document origin', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const channelConstructor = vi.fn(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: channelConstructor as never, + }); + const nonces = createRendererNonceRegistry(); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: 'https://foreign-publisher.example', + }) + ).toBe(false); + expect(channelConstructor).not.toHaveBeenCalled(); + expect(container.children).toHaveLength(0); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('allows HTTPS and loopback HTTP renderer origins but rejects production HTTP', () => { + expect(resolveApsRendererV1Url('https://publisher.example')).toBe( + 'https://publisher.example/integrations/aps/renderer/v1' + ); + expect(resolveApsRendererV1Url('http://localhost:8080')).toBe( + 'http://localhost:8080/integrations/aps/renderer/v1' + ); + expect(resolveApsRendererV1Url('http://127.0.0.1:8080')).toBe( + 'http://127.0.0.1:8080/integrations/aps/renderer/v1' + ); + expect(resolveApsRendererV1Url('http://[::1]:8080')).toBe( + 'http://[::1]:8080/integrations/aps/renderer/v1' + ); + expect(resolveApsRendererV1Url('http://publisher.example')).toBeUndefined(); + }); +}); + +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 corsResponse(body: BodyInit, status = 200): Response { + const response = new Response(body, { status }); + Object.defineProperty(response, 'type', { configurable: true, value: 'cors' }); + return response; +} + +function cacheResponse(body: Uint8Array) { + let delivered = false; + const cancel = vi.fn(async () => undefined); + return { + cancel, + response: Object.freeze({ + body: Object.freeze({ + getReader: () => + Object.freeze({ + cancel, + read: async () => { + if (delivered) return { done: true as const, value: undefined }; + delivered = true; + return { done: false as const, value: body }; + }, + releaseLock: vi.fn(), + }), + }), + ok: true, + type: 'cors' as const, + }) as unknown as Response, + }; +} + +async function insertedCacheFrame( + container: HTMLElement, + render?: RenderAttempt +): Promise { + await vi.waitFor(() => + expect({ + frame: container.querySelector('iframe'), + snapshot: render?.snapshot(), + }).toMatchObject({ + frame: expect.any(HTMLIFrameElement), + }) + ); + const frame = container.querySelector('iframe'); + if (!frame) throw new Error('should insert a cache ADM iframe'); + return frame; +} + +describe('direct cache attempt rendering', () => { + it('uses the exact bounded CORS request and renders validated OpenRTB ADM through the shared constructor', async () => { + document.body.innerHTML = '
'; + const context = Object.freeze({ selectedCpm: 1.25 }); + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, context)).toBe(true); + const container = document.getElementById('fictional-slot')!; + const fetchCache = vi.fn(async () => + corsResponse( + JSON.stringify({ + adm: '
cached
', + w: 300, + h: 250, + price: 999, + id: 'fictional-openrtb-bid', + ext: { ignored: true }, + }) + ) + ); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'rendering_direct' }); + const frame = await insertedCacheFrame(container, render); + + expect(fetchCache).toHaveBeenCalledWith(CACHE_SOURCE.fetchUrl, { + credentials: 'omit', + method: 'GET', + mode: 'cors', + redirect: 'error', + referrer: '', + referrerPolicy: 'no-referrer', + signal: expect.any(AbortSignal), + }); + expect(frame.srcdoc).toContain('data-price="1.25"'); + expect(frame.srcdoc).toContain('${AUCTION_PRICE:B64}'); + expect(frame.srcdoc).not.toContain('999'); + frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + document.body.innerHTML = ''; + }); + + it.each(['basic', 'default', undefined] as const)( + 'rejects every response with non-CORS type %s', + async (responseType) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + + const basicResponse = new Response(JSON.stringify({ adm: '
cached
' })); + Object.defineProperty(basicResponse, 'type', { configurable: true, value: responseType }); + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container, + fetcher: async () => basicResponse, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }) + ); + expect(container.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + } + ); + + it('rejects a basic response even when the cache and publisher origins match', async () => { + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const response = new Response(JSON.stringify({ adm: '
same origin
' })); + Object.defineProperty(response, 'type', { configurable: true, value: 'basic' }); + const onResolved = vi.fn<(source: CacheAdmSource) => boolean>(() => true); + + expect( + resolveCacheAdmAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + fetcher: async () => response, + onResolved, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }) + ); + expect(onResolved).not.toHaveBeenCalled(); + }); + + it('accepts a CORS response when the cache and publisher origins match', async () => { + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const onResolved = vi.fn<(source: CacheAdmSource) => boolean>(() => true); + + expect( + resolveCacheAdmAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + fetcher: async () => corsResponse(JSON.stringify({ adm: '
wrong type
' })), + onResolved, + }) + ).toBe(true); + await vi.waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); + expect(onResolved.mock.calls[0]?.[0]).toMatchObject({ adm: '
wrong type
' }); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'rendering_direct' }); + expect(render.cancel('caller_aborted')).toBe(true); + }); + + it('terminally rejects a foreign direct-cache container before fetching or mutating DOM', () => { + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const foreignContainer = document.implementation.createHTMLDocument().createElement('div'); + foreignContainer.append(document.createTextNode('foreign placeholder')); + const fetcher = vi.fn(); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: foreignContainer, + fetcher, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(fetcher).not.toHaveBeenCalled(); + expect(foreignContainer.querySelector('iframe')).toBeNull(); + expect(foreignContainer.textContent).toBe('foreign placeholder'); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + }); + + it('clears the fetch deadline at the final byte before preparing the ADM frame', async () => { + document.body.innerHTML = '
'; + const clear = vi.fn(); + const render = attempt(owner(), { + scheduler: Object.freeze({ + clear, + set: vi.fn(() => Object.freeze({})), + }), + }); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + const prepareIframe: DirectAdmIframeConstructor = (options) => { + expect(clear).toHaveBeenCalledTimes(1); + return prepareAdmIframe(options); + }; + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container, + fetcher: async () => corsResponse(JSON.stringify({ adm: '
cached
' })), + prepareIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + + const frame = await insertedCacheFrame(container, render); + frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + document.body.innerHTML = ''; + }); + + it('keeps the admitted direct-cache winner context across delayed fetch and later winner changes', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, Object.freeze({ selectedCpm: 2.5 }))).toBe(true); + const container = document.getElementById('fictional-slot')!; + let resolveFetch: ((response: Response) => void) | undefined; + const fetchCache = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const later = attempt(owner(ATTEMPT_TWO, 'later-slot')); + expect(later.admitDirectWinner(CACHE_SOURCE, Object.freeze({ selectedCpm: 8.75 }))).toBe(true); + resolveFetch?.( + corsResponse(JSON.stringify({ adm: '
${AUCTION_PRICE}
', price: 1000 })) + ); + const frame = await insertedCacheFrame(container); + + expect(frame.srcdoc).toContain('
2.5
'); + expect(frame.srcdoc).not.toContain('8.75'); + expect(frame.srcdoc).not.toContain('1000'); + frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + document.body.innerHTML = ''; + }); + + it('does not let the generic direct transition bypass the cache-specific deadline', () => { + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + + expect(render.beginDirect()).toBe(false); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'created' }); + expect(render.cancel('caller_aborted')).toBe(true); + }); + + it.each([ + [ + 'network rejection', + () => Promise.reject(new TypeError('fictional CORS failure')), + 'cache_network_error', + ], + ['HTTP status', () => Promise.resolve(corsResponse('{}', 503)), 'cache_http_error'], + ] as const)('maps %s to the exact typed cache failure', async (_case, fetchResult, reason) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: vi.fn(fetchResult), + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ outcome: 'failed', reason }) + ); + expect(render.snapshot().outcome?.outcome).not.toBe('no_bid'); + document.body.innerHTML = ''; + }); + + it.each([ + ['opaque response', Object.freeze({ body: null, ok: true, type: 'opaque' })], + [ + 'throwing type accessor', + Object.defineProperties(Object.create(null), { + body: { enumerable: true, value: null }, + ok: { enumerable: true, value: true }, + type: { + enumerable: true, + get: () => { + throw new Error('hostile response type'); + }, + }, + }), + ], + [ + 'rejecting body reader', + Object.freeze({ + body: Object.freeze({ + getReader: () => + Object.freeze({ + cancel: vi.fn(), + read: async () => { + throw new Error('fictional stream failure'); + }, + releaseLock: vi.fn(), + }), + }), + ok: true, + type: 'cors', + }), + ], + ] as const)('contains a %s as cache_network_error', async (_case, response) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => response, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }) + ); + expect(document.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + }); + + it('resolves a delayed owner-controlled cache claim without constructing publisher DOM', async () => { + document.body.innerHTML = '
placeholder
'; + const scope = owner(); + const artifacts = createCommittedArtifactStore(); + const render = attempt(scope, { artifacts }); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, CACHE_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + let resolveFetch: ((response: Response) => void) | undefined; + const fetchCache = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + const container = document.getElementById('fictional-slot')!; + const onResolved = vi.fn((_source: unknown) => true); + + expect( + resolveCacheAdmAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + fetcher: fetchCache, + onResolved, + }) + ).toBe(true); + expect(render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_insertion', + }); + expect(fetchCache).toHaveBeenCalledOnce(); + expect(resolveFetch).toBeTypeOf('function'); + resolveFetch?.( + corsResponse(JSON.stringify({ adm: '
${AUCTION_PRICE}
', price: 9000 })) + ); + await vi.waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); + const resolved = onResolved.mock.calls[0]?.[0]; + expect(resolved).toEqual({ + adm: '
1
', + height: 250, + type: 'adm', + version: 1, + width: 300, + }); + expect(Object.isFrozen(resolved)).toBe(true); + expect(render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_insertion', + }); + expect(artifacts.current('fictional-slot')).toBeUndefined(); + expect(container.querySelector('iframe')).toBeNull(); + expect(container.querySelector('span')).not.toBeNull(); + expect(render.cancel('caller_aborted')).toBe(true); + artifacts.dispose(); + document.body.innerHTML = ''; + }); + + it('replaces the PUC cache deadline with the one-second owner-insertion deadline', async () => { + vi.useFakeTimers(); + const scope = owner(); + const render = attempt(scope); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, CACHE_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + let resolveFetch: ((response: Response) => void) | undefined; + const fetcher = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + const onResolved = vi.fn<(source: CacheAdmSource) => boolean>(() => true); + + try { + expect( + resolveCacheAdmAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + fetcher, + onResolved, + }) + ).toBe(true); + await vi.advanceTimersByTimeAsync(4_999); + expect(render.snapshot().outcome).toBeUndefined(); + resolveFetch?.(corsResponse(JSON.stringify({ adm: '
cached
' }))); + for (let index = 0; index < 20 && onResolved.mock.calls.length === 0; index += 1) { + await Promise.resolve(); + } + expect(onResolved).toHaveBeenCalledOnce(); + expect(render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_insertion', + }); + + await vi.advanceTimersByTimeAsync(999); + expect(render.snapshot().outcome).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'owner_insertion_timeout', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('resolves a promoted Prebid cache lease from its captured projection after replacement', async () => { + const scope = owner(); + const service = reservations(); + const render = attempt(scope, { reservations: service }); + const prebidBid = Object.freeze({ cpm: 3.25 }); + const navigation = Object.freeze({ + generation: scope.navigationGeneration, + isCurrent: scope.isCurrent, + onDispose: scope.onDispose, + }); + let currentProjection: Readonly<{ + renderSource: ReservationRenderSource; + winnerContext: WinnerContext; + }> = Object.freeze({ + renderSource: CACHE_SOURCE, + winnerContext: Object.freeze({ selectedCpm: 3.25 }), + }); + expect( + service.registerPrebidLease({ + reservationId: RESERVATION_ID, + slot: scope.slot, + navigation, + auctionId: 'initial-auction', + adUnitCode: scope.slot, + renderSource: currentProjection.renderSource, + winnerContext: currentProjection.winnerContext, + prebidBid, + }) + ).toMatchObject({ ok: true }); + + currentProjection = Object.freeze({ + renderSource: Object.freeze({ + ...CACHE_SOURCE, + cacheId: '00000000-0000-4000-8000-000000000001', + }), + winnerContext: Object.freeze({ selectedCpm: 99 }), + }); + expect(currentProjection.winnerContext.selectedCpm).toBe(99); + expect(render.beginGamClaim()).toBe(true); + expect( + service.promotePrebidSelection({ + reservationId: RESERVATION_ID, + auctionId: 'initial-auction', + adUnitCode: scope.slot, + navigationGeneration: scope.navigationGeneration, + attempt: scope, + prebidBid, + }) + ).toMatchObject({ ok: true }); + const claim = service.claim({ + reservationId: RESERVATION_ID, + slot: scope.slot, + navigationGeneration: scope.navigationGeneration, + attempt: scope, + pucSource: Object.freeze({ owner: 'puc' }), + }); + expect(claim).toMatchObject({ recognized: true, claimed: true }); + expect(render.admitClaimedWinner(claim)).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + + const fetcher = vi.fn(async (_input: string, _init: RequestInit) => + corsResponse(JSON.stringify({ adm: '
${AUCTION_PRICE}
', price: 99 })) + ); + const onResolved = vi.fn<(source: CacheAdmSource) => boolean>(() => true); + expect( + resolveCacheAdmAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + fetcher, + onResolved, + }) + ).toBe(true); + await vi.waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); + + expect(fetcher.mock.calls[0]?.[0]).toBe(CACHE_SOURCE.fetchUrl); + expect(onResolved.mock.calls[0]?.[0]).toMatchObject({ adm: '
3.25
' }); + expect(onResolved.mock.calls[0]?.[0]).not.toMatchObject({ adm: '
99
' }); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'waiting_for_insertion' }); + expect(render.cancel('caller_aborted')).toBe(true); + }); + + it('keeps cache deadline completion private to the resolver capability', () => { + const render = attempt(); + expect('beginCacheFetch' in render).toBe(false); + expect('cacheFetchCompleted' in render).toBe(false); + }); + + it('classifies malformed UTF-8 as an invalid cache response', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const malformed = cacheResponse(new Uint8Array([0xc3, 0x28])); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => malformed.response, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_invalid_response', + }) + ); + expect(document.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + }); + + it.each([ + ['raw markup', '
raw
'], + ['array', JSON.stringify([{ adm: '
wrapped
' }])], + ['primitive', JSON.stringify('creative')], + ['wrapper', JSON.stringify({ bid: { adm: '
wrapped
' } })], + ['empty adm', JSON.stringify({ adm: '' })], + ['width alias', JSON.stringify({ adm: '
alias
', width: 300, height: 250 })], + ['unpaired w', JSON.stringify({ adm: '
unpaired
', w: 300 })], + ['fractional dimensions', JSON.stringify({ adm: '
fractional
', w: 300.5, h: 250 })], + ['out-of-range dimensions', JSON.stringify({ adm: '
large
', w: 4097, h: 250 })], + ['mismatched dimensions', JSON.stringify({ adm: '
wrong
', w: 728, h: 90 })], + ['negative price', JSON.stringify({ adm: '
price
', price: -1 })], + ] as const)('rejects a cache %s response shape', async (_case, body) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: vi.fn(async () => corsResponse(body)), + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_invalid_response', + }) + ); + expect(document.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + }); + + it('keeps captured cache authorities when mutable globals are poisoned after module load', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const response = corsResponse(JSON.stringify({ adm: 7 })); + const nativeGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const descriptorDescriptor = Object.getOwnPropertyDescriptor( + Object, + 'getOwnPropertyDescriptor' + ); + const hasOwnDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, 'hasOwnProperty'); + const finiteDescriptor = Object.getOwnPropertyDescriptor(Number, 'isFinite'); + const integerDescriptor = Object.getOwnPropertyDescriptor(Number, 'isInteger'); + const urlDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'URL'); + const encoderDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'TextEncoder'); + const decoderDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'TextDecoder'); + const abortDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'AbortController'); + + try { + Object.defineProperty(Object, 'getOwnPropertyDescriptor', { + configurable: true, + value: (target: object, name: PropertyKey) => { + const descriptor = Reflect.apply(nativeGetOwnPropertyDescriptor, Object, [target, name]); + return name === 'adm' && descriptor && 'value' in descriptor && descriptor.value === 7 + ? { ...descriptor, value: '
forged
' } + : descriptor; + }, + writable: true, + }); + Object.defineProperty(Object.prototype, 'hasOwnProperty', { + configurable: true, + value: () => false, + writable: true, + }); + Object.defineProperty(Number, 'isFinite', { + configurable: true, + value: () => true, + writable: true, + }); + Object.defineProperty(Number, 'isInteger', { + configurable: true, + value: () => true, + writable: true, + }); + for (const name of ['URL', 'TextEncoder', 'TextDecoder', 'AbortController'] as const) { + Object.defineProperty(globalThis, name, { + configurable: true, + value: class PoisonedAuthority { + constructor() { + throw new Error(`poisoned ${name}`); + } + }, + writable: true, + }); + } + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => response, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + for (let index = 0; index < 20 && render.snapshot().outcome === undefined; index += 1) { + await Promise.resolve(); + } + } finally { + if (descriptorDescriptor) { + Object.defineProperty(Object, 'getOwnPropertyDescriptor', descriptorDescriptor); + } + if (hasOwnDescriptor) { + Object.defineProperty(Object.prototype, 'hasOwnProperty', hasOwnDescriptor); + } + if (finiteDescriptor) Object.defineProperty(Number, 'isFinite', finiteDescriptor); + if (integerDescriptor) Object.defineProperty(Number, 'isInteger', integerDescriptor); + if (urlDescriptor) Object.defineProperty(globalThis, 'URL', urlDescriptor); + if (encoderDescriptor) Object.defineProperty(globalThis, 'TextEncoder', encoderDescriptor); + if (decoderDescriptor) Object.defineProperty(globalThis, 'TextDecoder', decoderDescriptor); + if (abortDescriptor) { + Object.defineProperty(globalThis, 'AbortController', abortDescriptor); + } + } + + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_invalid_response', + }); + expect(document.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + }); + + it('enforces the 512 KiB streamed-body limit before JSON parsing', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const oversized = new Uint8Array(512 * 1024 + 1); + oversized.fill(0x20); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: vi.fn(async () => corsResponse(oversized)), + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_invalid_response', + }) + ); + document.body.innerHTML = ''; + }); + + it('accepts an exact 512 KiB JSON body and bounded ADM', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const prefix = '{"adm":"'; + const suffix = '"}'; + const exactBody = `${prefix}${'x'.repeat(512 * 1024 - prefix.length - suffix.length)}${suffix}`; + expect(new TextEncoder().encode(exactBody)).toHaveLength(512 * 1024); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => corsResponse(exactBody), + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = await insertedCacheFrame(document.getElementById('fictional-slot')!, render); + frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + document.body.innerHTML = ''; + }); + + it('rejects an ADM whose auction-price expansion exceeds 512 KiB', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect( + render.admitDirectWinner(CACHE_SOURCE, Object.freeze({ selectedCpm: Number.MAX_VALUE })) + ).toBe(true); + const body = JSON.stringify({ adm: '${AUCTION_PRICE}'.repeat(25_000) }); + expect(new TextEncoder().encode(body).byteLength).toBeLessThanOrEqual(512 * 1024); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => corsResponse(body), + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_invalid_response', + }) + ); + expect(document.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + }); + + it('cancels an oversized streamed body before publishing a failure', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const oversized = cacheResponse(new Uint8Array(512 * 1024 + 1)); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => oversized.response, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_invalid_response', + }) + ); + + expect(oversized.cancel).toHaveBeenCalledOnce(); + expect(document.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + }); + + it('requires one frozen exact policy and canonical bounded cache source before fetching', () => { + const cases = [ + { + policy: { ...CACHE_POLICY }, + source: CACHE_SOURCE, + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `${CACHE_SOURCE.fetchUrl}&uuid=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `https://other.example/cache?uuid=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `https://user:password@cache.example:8443/pbc/v1/cache?uuid=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `${CACHE_SOURCE.fetchUrl}#fragment`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `https://cache.example:9443/pbc/v1/cache?uuid=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `https://cache.example:8443/pbc/v1/other?uuid=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `https://cache.example:8443/pbc/v1/cache?id=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `https://cache.example:8443/${'x'.repeat(4096)}?uuid=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `${CACHE_SOURCE.fetchUrl}\n`, + }), + }, + ]; + + for (let index = 0; index < cases.length; index += 1) { + document.body.innerHTML = `
`; + const candidate = cases[index]!; + const render = attempt(owner(indexedAttemptId(index), `fictional-slot-${index}`), { + prepareRenderSource: (value) => (value === candidate.source ? candidate.source : undefined), + }); + expect(render.admitDirectWinner(candidate.source, WINNER_CONTEXT)).toBe(true); + const fetchCache = vi.fn(); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: candidate.policy, + container: document.getElementById(`fictional-slot-${index}`)!, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(fetchCache).not.toHaveBeenCalled(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'descriptor_invalid', + }); + } + document.body.innerHTML = ''; + }); + + it.each([-1, 0, 1] as const)( + 'enforces the 4,096-byte canonical fetch URL boundary at delta %s', + async (delta) => { + const query = `?uuid=${CACHE_ID}`; + const prefix = 'https://cache.example/'; + const targetFetchBytes = 4_096 + delta; + const pathLength = + targetFetchBytes - + new TextEncoder().encode(prefix).byteLength - + new TextEncoder().encode(query).byteLength; + const policy = Object.freeze({ + version: 1 as const, + baseUrl: `${prefix}${'x'.repeat(pathLength)}`, + }); + const source = Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `${policy.baseUrl}${query}`, + }); + expect(new TextEncoder().encode(source.fetchUrl)).toHaveLength(targetFetchBytes); + document.body.innerHTML = '
'; + const render = attempt(owner(indexedAttemptId(500 + delta), 'url-boundary-slot'), { + prepareRenderSource: (candidate) => (candidate === source ? source : undefined), + }); + expect(render.admitDirectWinner(source, WINNER_CONTEXT)).toBe(true); + const fetchCache = vi.fn(async () => { + throw new Error('boundary transport stop'); + }); + const started = renderDirectCacheAttempt({ + attempt: render, + cachePolicy: policy, + container: document.getElementById('url-boundary-slot')!, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }); + + if (delta <= 0) { + expect(started).toBe(true); + expect(fetchCache).toHaveBeenCalledOnce(); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }) + ); + } else { + expect(started).toBe(false); + expect(fetchCache).not.toHaveBeenCalled(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'descriptor_invalid', + }); + } + document.body.innerHTML = ''; + } + ); + + it.each(['timeout', 'caller cancellation'] as const)( + 'cancels an active body reader after one chunk on %s and ignores its late chunk', + async (settlement) => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + let resolveRead: ((value: { done: boolean; value: Uint8Array }) => void) | undefined; + const cancel = vi.fn(async () => undefined); + let reads = 0; + const read = vi.fn(() => { + reads += 1; + if (reads === 1) { + return Promise.resolve({ + done: false, + value: new TextEncoder().encode('{"adm":"first chunk'), + }); + } + return new Promise<{ done: boolean; value: Uint8Array }>((resolve) => { + resolveRead = resolve; + }); + }); + const response = Object.freeze({ + body: Object.freeze({ + getReader: () => + Object.freeze({ + cancel, + read, + releaseLock: vi.fn(), + }), + }), + ok: true, + type: 'cors' as const, + }) as unknown as Response; + + try { + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => response, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + for (let index = 0; index < 5 && read.mock.calls.length < 2; index += 1) { + await Promise.resolve(); + } + expect(read).toHaveBeenCalledTimes(2); + + if (settlement === 'timeout') await vi.advanceTimersByTimeAsync(5_000); + else expect(render.cancel('caller_aborted')).toBe(true); + + expect(cancel).toHaveBeenCalledOnce(); + expect(render.snapshot().outcome).toEqual( + settlement === 'timeout' + ? { outcome: 'failed', reason: 'cache_network_error' } + : { outcome: 'cancelled', reason: 'caller_aborted' } + ); + resolveRead?.({ done: false, value: new TextEncoder().encode('{"adm":"late"}') }); + await Promise.resolve(); + await Promise.resolve(); + expect(document.querySelector('iframe')).toBeNull(); + expect(cancel).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + document.body.innerHTML = ''; + } + } + ); + + it('aborts the cache request after five seconds and makes late work inert', async () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + let signal: AbortSignal | undefined; + let resolveFetch: ((response: Response) => void) | undefined; + const fetchCache = vi.fn((_input: string, init: RequestInit) => { + signal = init.signal as AbortSignal; + return new Promise((resolve) => { + resolveFetch = resolve; + }); + }); + + try { + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.advanceTimersByTimeAsync(4_999); + expect(signal?.aborted).toBe(false); + expect(render.snapshot().outcome).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(signal?.aborted).toBe(true); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }); + + resolveFetch?.(corsResponse(JSON.stringify({ adm: '
late
' }))); + await vi.runAllTimersAsync(); + expect(document.querySelector('iframe')).toBeNull(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }); + } finally { + vi.useRealTimers(); + document.body.innerHTML = ''; + } + }); + + it('aborts on caller cancellation and ignores a late cache response', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + let signal: AbortSignal | undefined; + let resolveFetch: ((response: Response) => void) | undefined; + const fetchCache = vi.fn((_input: string, init: RequestInit) => { + signal = init.signal as AbortSignal; + return new Promise((resolve) => { + resolveFetch = resolve; + }); + }); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(render.cancel('caller_aborted')).toBe(true); + expect(signal?.aborted).toBe(true); + resolveFetch?.(corsResponse(JSON.stringify({ adm: '
late
' }))); + await Promise.resolve(); + await Promise.resolve(); + expect(document.querySelector('iframe')).toBeNull(); + expect(render.snapshot().outcome).toEqual({ outcome: 'cancelled', reason: 'caller_aborted' }); + document.body.innerHTML = ''; + }); +}); + +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, 'puc'); + 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))).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, 'puc'))).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)); + break; + case 'waiting_for_aps_completion': + render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginApsDocument(artifact(scope)); + 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 => { + const kind = render.snapshot().state === 'waiting_for_insertion' ? 'puc' : 'direct_iframe'; + 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, kind)); + case 'begin_adm': + return render.beginAdm(artifact(render, kind)); + 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); + 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)); + 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)); + 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)); + 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('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('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); + 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..fa34691dd --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -0,0 +1,2255 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { parseCacheFetchPolicyV1 } from '../../src/core/config'; +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'; + +const CACHE_ID = '123e4567-e89b-42d3-a456-426614174000'; + +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 cacheSource() { + return { + type: 'cache', + version: 1, + cacheId: CACHE_ID, + fetchUrl: `https://cache.example/render?uuid=${CACHE_ID}`, + 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) { + const cachePolicy = parseCacheFetchPolicyV1({ + version: 1, + baseUrl: 'https://cache.example/render', + }); + if (!cachePolicy) throw new Error('Expected cache policy'); + return createReservationService({ + now: readNow, + prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), + }); +} + +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.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, ADM, or cache source without retaining projection input', () => { + const { navigation } = runtimeNavigation(); + const sources = [apsSource(), admSource(), cacheSource()]; + + 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 cache 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: cacheSource(), + 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 cache lease to remain claimable'); + } + expect( + service.consumeClaim(selected, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext, + }) + ).toEqual({ renderSource: cacheSource(), 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 cache source and immutable context after registration input mutation', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + const source = cacheSource(); + 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 cache 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..3a94ca5a6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -0,0 +1,4637 @@ +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'; + +function createNavigation(): NavigationSession { + return createRuntimeWithNavigation().navigation; +} + +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; + } = {} +) { + 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(), + 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: true, + }), + 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', + 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.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('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()); + + 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('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({ + disposeCommittedArtifact, + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + 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'); + + 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({ + disposeCommittedArtifact, + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(5_000); + + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); + 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('rejects display batches at the type and runtime boundaries before mutation', () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const displayBatch = [ + { + intentId: 'valid-before-display', + navigationGeneration: navigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'slot', + requestClass: 'primary', + }, + { + intentId: 'display-batch', + navigationGeneration: navigation.generation, + operation: 'display' as const, + registeredSlotId: 'slot', + requestClass: 'primary', + }, + ] as const; + const compileOnly = (): void => { + // @ts-expect-error requestBatch is refresh-only; single request retains display support. + service.requestBatch(displayBatch); + }; + expect(compileOnly).toBeTypeOf('function'); + const inventory = service.snapshotForTest(); + const runtimeRequestBatch = service.requestBatch as unknown as ( + inputs: readonly object[] + ) => unknown; + + expect(runtimeRequestBatch(displayBatch)).toEqual([]); + expect(service.snapshotForTest()).toEqual(inventory); + expect(harness.display).not.toHaveBeenCalled(); + 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({ + disposeCommittedArtifact, + googletag: harness.adapter, + }); + const navigation = createNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + 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'); + + 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', + 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..022abf5d3 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -0,0 +1,775 @@ +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('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..dd3995e39 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,25 +1,26 @@ 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'; 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', @@ -30,8 +31,16 @@ describe('Beacon Guard', () => { }); 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', () => { @@ -130,7 +139,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,6 +155,171 @@ 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 = { diff --git a/crates/trusted-server-js/lib/test/shared/origin.test.ts b/crates/trusted-server-js/lib/test/shared/origin.test.ts new file mode 100644 index 000000000..b5951c973 --- /dev/null +++ b/crates/trusted-server-js/lib/test/shared/origin.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { trustedDocumentHttpOrigin, trustedHttpOrigin } from '../../src/shared/origin'; + +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' + ); + }); + + 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(''); + }); +}); + +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('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/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index acb591cdc..b445204b0 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -1,19 +1,36 @@ 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_RUNTIME_CATALOG_V1__: JSON.stringify( + RELEASE_CATALOG.map(({ id, phase, trigger, consumes, provides }) => ({ + id, + phase, + trigger, + 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,6 +38,15 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, + // 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', + ], // Run tests in the main thread to avoid spawning // child processes/workers, which are blocked in this sandbox. threads: false, diff --git a/crates/trusted-server-js/src/bundle.rs b/crates/trusted-server-js/src/bundle.rs index 7ddc060ab..cd8687c1f 100644 --- a/crates/trusted-server-js/src/bundle.rs +++ b/crates/trusted-server-js/src/bundle.rs @@ -6,6 +6,67 @@ 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, + /// Sole TSJS kernel artifact. + Core, + /// Catalogued critical or deferred integration module. + Integration, +} + +/// Fixed catalog phase for one integration module. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TsjsModulePhase { + /// Parser-blocking server-composed first-display module. + Critical, + /// 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 critical modules. +pub const MAX_CRITICAL_MODULES: usize = GENERATED_MAX_CRITICAL_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 GPT bootstrap fallback proposal. +#[must_use] +#[inline] +pub const fn gpt_bootstrap_fallback_bundle() -> &'static str { + GPT_BOOTSTRAP_FALLBACK +} + /// Return the JS bundle content for a given module ID (e.g., "core", "prebid"). #[must_use] #[inline] @@ -17,7 +78,46 @@ 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 != "bootstrap") + .map(|artifact| artifact.id) + .collect() +} + +/// 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 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. @@ -64,19 +164,97 @@ pub fn concatenated_hash(ids: &[&str]) -> String { #[must_use] #[inline] pub fn single_module_hash(id: &str) -> Option { - module_bundle(id).map(|content| { - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - encode(hasher.finalize()) - }) + TSJS_ARTIFACTS + .iter() + .find(|artifact| artifact.role == "integration" && artifact.id == id) + .map(|artifact| artifact.hash.to_owned()) } fn module_map() -> &'static HashMap<&'static str, &'static str> { static MAP: OnceLock> = OnceLock::new(); MAP.get_or_init(|| { - TSJS_MODULES + TSJS_ARTIFACTS .iter() - .map(|module| (module.id, module.bundle)) + .filter(|artifact| artifact.role != "bootstrap") + .map(|artifact| (artifact.id, artifact.bundle)) .collect() }) } + +fn public_metadata(artifact: &TsjsGeneratedArtifactMeta) -> TsjsArtifactMetadata { + TsjsArtifactMetadata { + id: artifact.id, + role: match artifact.role { + "bootstrap" => TsjsArtifactRole::Bootstrap, + "core" => TsjsArtifactRole::Core, + "integration" => TsjsArtifactRole::Integration, + _ => unreachable!("generated artifact role should be validated"), + }, + phase: artifact.phase.map(|phase| match phase { + "critical" => TsjsModulePhase::Critical, + "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::*; + + #[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_CRITICAL_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::Critical)); + assert_eq!(metadata[13].id, "testlight"); + assert_eq!(metadata[13].phase, Some(TsjsModulePhase::Critical)); + 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_artifact_inventory_includes_bootstrap_core_and_catalog_once() { + let artifacts = all_artifact_metadata(); + + assert_eq!(artifacts.len(), 22); + assert_eq!(artifacts[0].id, "bootstrap"); + assert_eq!(artifacts[0].role, TsjsArtifactRole::Bootstrap); + assert_eq!(artifacts[1].id, "core"); + assert_eq!(artifacts[1].role, TsjsArtifactRole::Core); + assert!( + artifacts[2..] + .iter() + .all(|artifact| artifact.role == TsjsArtifactRole::Integration) + ); + } +} diff --git a/crates/trusted-server-js/src/lib.rs b/crates/trusted-server-js/src/lib.rs index 2c816b154..e369c1f80 100644 --- a/crates/trusted-server-js/src/lib.rs +++ b/crates/trusted-server-js/src/lib.rs @@ -6,5 +6,8 @@ pub mod bundle; pub use bundle::{ - all_module_ids, concatenate_modules, concatenated_hash, module_bundle, single_module_hash, + MAX_CRITICAL_MODULES, MAX_MANIFEST_MODULES, TsjsArtifactMetadata, TsjsArtifactRole, + TsjsModulePhase, all_artifact_metadata, all_integration_ids, all_integration_metadata, + all_module_ids, concatenate_modules, concatenated_hash, gpt_bootstrap_fallback_bundle, + integration_metadata, module_bundle, release_id, single_module_hash, }; diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 850c68332..e5e883b9b 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -156,7 +156,7 @@ sequenceDiagram %% === Creative Rendering === rect rgb(239,246,255) Note over Client,Mock: Creative Rendering - Client->>Client: Validate renderer descriptor
Create opaque sandbox iframe
Load /integrations/aps/renderer + Client->>Client: Validate renderer descriptor
Create opaque sandbox iframe
Load /integrations/aps/renderer/v1 Note right of Client: Fragment-bound nonce and one-time acknowledgement
No allow-same-origin on the outer frame deactivate Client end @@ -710,16 +710,16 @@ environment overrides to apply; see #### `[integrations.aps]` -| Field | Type | Default | Description | -| ------------------------ | ------ | ----------------------------- | ----------------------------------------------------------------- | -| `enabled` | bool | `false` | Enable APS provider | -| `account_id` | string | — | APS account ID (required; `pub_id` is an alias) | -| `endpoint` | string | Built-in APS OpenRTB endpoint | Optional APS OpenRTB endpoint override | -| `timeout_ms` | u32 | `800` | Request timeout | -| `debug` | bool | `false` | Include the raw APS HTTP exchange in `/auction` provider metadata | -| `inventory_domain` | string | — | Override `site.domain` for APS-authorized inventory | -| `inventory_page_origin` | string | — | HTTPS origin paired with `inventory_domain` for `site.page` | -| `allow_script_creatives` | bool | `false` | Admit script bids before APS candidate reduction | +| Field | Type | Default | Description | +| ------------------------ | ----------------- | ----------------------------- | ----------------------------------------------------------------- | +| `enabled` | bool | `false` | Enable APS provider | +| `account_id` | string or integer | — | APS account ID (required) | +| `endpoint` | string | Built-in APS OpenRTB endpoint | Optional APS OpenRTB endpoint override | +| `timeout_ms` | u32 | `800` | Request timeout | +| `debug` | bool | `false` | Include the raw APS HTTP exchange in `/auction` provider metadata | +| `inventory_domain` | string | — | Override `site.domain` for APS-authorized inventory | +| `inventory_page_origin` | string | — | HTTPS origin paired with `inventory_domain` for `site.page` | +| `allow_script_creatives` | bool | `false` | Admit script bids before APS candidate reduction | #### `[integrations.adserver_mock]` diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index e0a61c07f..abe663a6d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -662,11 +662,9 @@ 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. ::: @@ -1066,6 +1064,12 @@ apply when the integration section exists in `trusted-server.toml`. | `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side (see [Prebid docs](/guide/integrations/prebid#client-side-bidders)) | | `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | +APS is configured exclusively under `[integrations.aps]`. `aps` entries in +`bidders` or `client_side_bidders` are logged and removed case-insensitively so +an upgrade does not prevent Trusted Server from starting. Remove those entries +from operator configuration; this guard prevents APS demand from reaching +Prebid Server or the client-side Prebid bundle. + **Example**: ```toml diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index 6708a4fcb..40592f2c5 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -100,8 +100,9 @@ runtime's click guard recovers mutated clicks there via a GET One capability is unavailable in that context: **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 ``/`